commit
5f1e8720d3
38 changed files with 5788 additions and 0 deletions
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
.DS_Store |
||||
.vite-ssg-dist |
||||
.vite-ssg-temp |
||||
*.local |
||||
dist |
||||
dist-ssr |
||||
node_modules |
||||
.idea/ |
||||
*.log |
||||
cypress/downloads |
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable */ |
||||
/* prettier-ignore */ |
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {} |
||||
declare global { |
||||
const EffectScope: typeof import('vue')['EffectScope'] |
||||
const computed: typeof import('vue')['computed'] |
||||
const createApp: typeof import('vue')['createApp'] |
||||
const customRef: typeof import('vue')['customRef'] |
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] |
||||
const defineComponent: typeof import('vue')['defineComponent'] |
||||
const effectScope: typeof import('vue')['effectScope'] |
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance'] |
||||
const getCurrentScope: typeof import('vue')['getCurrentScope'] |
||||
const h: typeof import('vue')['h'] |
||||
const inject: typeof import('vue')['inject'] |
||||
const isProxy: typeof import('vue')['isProxy'] |
||||
const isReactive: typeof import('vue')['isReactive'] |
||||
const isReadonly: typeof import('vue')['isReadonly'] |
||||
const isRef: typeof import('vue')['isRef'] |
||||
const markRaw: typeof import('vue')['markRaw'] |
||||
const nextTick: typeof import('vue')['nextTick'] |
||||
const onActivated: typeof import('vue')['onActivated'] |
||||
const onBeforeMount: typeof import('vue')['onBeforeMount'] |
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] |
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] |
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] |
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] |
||||
const onDeactivated: typeof import('vue')['onDeactivated'] |
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured'] |
||||
const onMounted: typeof import('vue')['onMounted'] |
||||
const onRenderTracked: typeof import('vue')['onRenderTracked'] |
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered'] |
||||
const onScopeDispose: typeof import('vue')['onScopeDispose'] |
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch'] |
||||
const onUnmounted: typeof import('vue')['onUnmounted'] |
||||
const onUpdated: typeof import('vue')['onUpdated'] |
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] |
||||
const provide: typeof import('vue')['provide'] |
||||
const reactive: typeof import('vue')['reactive'] |
||||
const readonly: typeof import('vue')['readonly'] |
||||
const ref: typeof import('vue')['ref'] |
||||
const resolveComponent: typeof import('vue')['resolveComponent'] |
||||
const shallowReactive: typeof import('vue')['shallowReactive'] |
||||
const shallowReadonly: typeof import('vue')['shallowReadonly'] |
||||
const shallowRef: typeof import('vue')['shallowRef'] |
||||
const toRaw: typeof import('vue')['toRaw'] |
||||
const toRef: typeof import('vue')['toRef'] |
||||
const toRefs: typeof import('vue')['toRefs'] |
||||
const toValue: typeof import('vue')['toValue'] |
||||
const triggerRef: typeof import('vue')['triggerRef'] |
||||
const unref: typeof import('vue')['unref'] |
||||
const useAttrs: typeof import('vue')['useAttrs'] |
||||
const useCssModule: typeof import('vue')['useCssModule'] |
||||
const useCssVars: typeof import('vue')['useCssVars'] |
||||
const useId: typeof import('vue')['useId'] |
||||
const useLink: typeof import('vue-router')['useLink'] |
||||
const useModel: typeof import('vue')['useModel'] |
||||
const useRoute: typeof import('vue-router')['useRoute'] |
||||
const useRouter: typeof import('vue-router')['useRouter'] |
||||
const useSlots: typeof import('vue')['useSlots'] |
||||
const useTemplateRef: typeof import('vue')['useTemplateRef'] |
||||
const watch: typeof import('vue')['watch'] |
||||
const watchEffect: typeof import('vue')['watchEffect'] |
||||
const watchPostEffect: typeof import('vue')['watchPostEffect'] |
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect'] |
||||
} |
||||
// for type re-export
|
||||
declare global { |
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' |
||||
import('vue') |
||||
} |
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable */ |
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {} |
||||
|
||||
/* prettier-ignore */ |
||||
declare module 'vue' { |
||||
export interface GlobalComponents { |
||||
AppSearch: typeof import('../src/components/apps/Search.vue')['default'] |
||||
ContextMenu: typeof import('./../src/components/ContextMenu.vue')['default'] |
||||
ContextMenuContainer: typeof import('./../src/components/ContextMenuContainer.vue')['default'] |
||||
DrawerSetting: typeof import('./../src/components/drawerSetting/index.vue')['default'] |
||||
IconSetting: typeof import('./../src/components/dialogs/iconSetting.vue')['default'] |
||||
NButton: typeof import('naive-ui')['NButton'] |
||||
NCard: typeof import('naive-ui')['NCard'] |
||||
NColorPicker: typeof import('naive-ui')['NColorPicker'] |
||||
NDrawer: typeof import('naive-ui')['NDrawer'] |
||||
NDrawerContent: typeof import('naive-ui')['NDrawerContent'] |
||||
NInput: typeof import('naive-ui')['NInput'] |
||||
NInputGroup: typeof import('naive-ui')['NInputGroup'] |
||||
NMenu: typeof import('naive-ui')['NMenu'] |
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider'] |
||||
NModal: typeof import('naive-ui')['NModal'] |
||||
NModalProvider: typeof import('naive-ui')['NModalProvider'] |
||||
NSlider: typeof import('naive-ui')['NSlider'] |
||||
NSwitch: typeof import('naive-ui')['NSwitch'] |
||||
NText: typeof import('naive-ui')['NText'] |
||||
NUpload: typeof import('naive-ui')['NUpload'] |
||||
NUploadDragger: typeof import('naive-ui')['NUploadDragger'] |
||||
RouterLink: typeof import('vue-router')['RouterLink'] |
||||
RouterView: typeof import('vue-router')['RouterView'] |
||||
Search: typeof import('./../src/components/apps/Search.vue')['default'] |
||||
Wallpaper: typeof import('./../src/components/wallpaper.vue')['default'] |
||||
Widget: typeof import('./../src/components/apps/widget.vue')['default'] |
||||
WidgetSetting: typeof import('./../src/components/drawerSetting/widgetSetting.vue')['default'] |
||||
} |
||||
} |
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html> |
||||
<html lang=""> |
||||
|
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<link rel="icon" href="/favicon.ico"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Vite App</title> |
||||
</head> |
||||
|
||||
<body> |
||||
<div id="app"></div> |
||||
<script type="module" src="/src/main.ts"></script> |
||||
</body> |
||||
|
||||
</html> |
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
{ |
||||
"name": "vue-project", |
||||
"version": "0.0.0", |
||||
"private": true, |
||||
"type": "module", |
||||
"scripts": { |
||||
"dev": "vite", |
||||
"build": "run-p type-check \"build-only {@}\" --", |
||||
"preview": "vite preview", |
||||
"build-only": "vite build", |
||||
"type-check": "vue-tsc --build" |
||||
}, |
||||
"dependencies": { |
||||
"@headlessui/vue": "^1.7.23", |
||||
"@unocss/reset": "^66.0.0", |
||||
"gsap": "^3.12.7", |
||||
"naive-ui": "^2.41.0", |
||||
"pinia": "^3.0.1", |
||||
"vue": "^3.5.13", |
||||
"vue-cropper": "^1.1.4", |
||||
"vue-draggable-plus": "^0.6.0", |
||||
"vue-router": "^4.5.0" |
||||
}, |
||||
"devDependencies": { |
||||
"@iconify/json": "^2.2.310", |
||||
"@tsconfig/node22": "^22.0.0", |
||||
"@types/node": "^22.13.5", |
||||
"@unocss/preset-icons": "^66.0.0", |
||||
"@unocss/preset-rem-to-px": "^66.0.0", |
||||
"@vitejs/plugin-vue": "^5.2.1", |
||||
"@vitejs/plugin-vue-jsx": "^4.1.1", |
||||
"@vue/tsconfig": "^0.7.0", |
||||
"npm-run-all2": "^7.0.2", |
||||
"sass": "^1.85.0", |
||||
"typescript": "~5.7.3", |
||||
"unocss": "^66.0.0", |
||||
"unocss-preset-scalpel": "^1.2.7", |
||||
"unplugin-auto-import": "^19.1.0", |
||||
"unplugin-icons": "^22.1.0", |
||||
"unplugin-vue-components": "^28.4.0", |
||||
"vite": "^6.1.0", |
||||
"vite-plugin-vue-devtools": "^7.7.2", |
||||
"vue-tsc": "^2.2.2" |
||||
} |
||||
} |
After Width: | Height: | Size: 4.2 KiB |
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts"> |
||||
import wallpaper from '@/components/wallpaper.vue' |
||||
</script> |
||||
|
||||
<template> |
||||
<n-modal-provider> |
||||
<n-message-provider> |
||||
<RouterView /> |
||||
</n-message-provider> |
||||
</n-modal-provider> |
||||
<wallpaper /> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"></style> |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
@use './variables.scss'; |
||||
// 导出变量 |
||||
:export { |
||||
namespace: $namespace; |
||||
elNamespace: $elNamespace; |
||||
} |
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
@import './theme-variables.css'; |
||||
@import './widge-variables.css'; |
||||
@import 'vue-cropper/dist/index.css'; |
||||
@import '@unocss/reset/tailwind-compat.css'; |
||||
html, |
||||
body { |
||||
@apply w-full h-full; |
||||
} |
||||
|
||||
#app { |
||||
@apply wh-full relative; |
||||
z-index: 1; |
||||
} |
||||
.borderBlack { |
||||
@apply border border-black/10; |
||||
} |
||||
.vsc-controller, |
||||
.n-color-picker-trigger__value { |
||||
display: none; |
||||
} |
||||
input, |
||||
textarea { |
||||
user-select: auto; |
||||
} |
||||
input, |
||||
textarea { |
||||
background-color: transparent; |
||||
font-family: inherit; |
||||
outline: none; |
||||
border-width: initial; |
||||
border-style: none; |
||||
border-color: initial; |
||||
border-image: initial; |
||||
} |
||||
|
||||
button, |
||||
input, |
||||
select, |
||||
optgroup, |
||||
textarea { |
||||
font: inherit; |
||||
font-feature-settings: inherit; |
||||
font-variation-settings: inherit; |
||||
letter-spacing: inherit; |
||||
color: inherit; |
||||
opacity: 1; |
||||
background-color: #0000; |
||||
border-radius: 0; |
||||
} |
||||
button, |
||||
.n-input, |
||||
[type='button'], |
||||
[type='reset'], |
||||
[type='submit'] { |
||||
border-radius: 6px; |
||||
} |
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
@use 'sass:math'; |
||||
|
||||
// 命名空间 |
||||
$namespace: v; |
||||
// el命名空间 |
||||
$elNamespace: el; |
||||
$W-scale-ratio-1440: math.div(1440, 1920); |
||||
$H-scale-ratio-900: math.div(900, 1080); |
||||
|
||||
$W-scale-ratio-2560: math.div(2560, 1920); |
||||
$H-scale-ratio-1440: math.div(1440, 1080); |
||||
|
||||
@mixin ResponsiveW($base-width) { |
||||
width: $base-width + px !important; |
||||
|
||||
@media only screen and (max-width: 1440px) { |
||||
width: calc($W-scale-ratio-1440 * #{$base-width}px) !important; |
||||
} |
||||
|
||||
@media only screen and (min-width: 2560px) { |
||||
width: calc($W-scale-ratio-2560 * #{$base-width}px) !important; |
||||
} |
||||
} |
||||
|
||||
@mixin ResponsiveH($base-height) { |
||||
height: $base-height + px; |
||||
|
||||
@media only screen and (max-width: 1440px) { |
||||
height: calc($H-scale-ratio-900 * #{$base-height}px); |
||||
} |
||||
|
||||
@media only screen and (min-width: 2560px) { |
||||
height: calc($H-scale-ratio-1440 * #{$base-height}px); |
||||
} |
||||
} |
||||
|
||||
@mixin ResponsiveFz($base-font-size) { |
||||
font-size: $base-font-size + px; |
||||
|
||||
@media only screen and (max-width: 1440px) { |
||||
font-size: calc($W-scale-ratio-1440 * #{$base-font-size}px); |
||||
} |
||||
|
||||
@media only screen and (min-width: 2560px) { |
||||
font-size: calc($W-scale-ratio-2560 * #{$base-font-size}px); |
||||
} |
||||
} |
||||
|
||||
@mixin ResponsivePadding($top, $right, $bottom, $left) { |
||||
padding: $top + px $right + px $bottom + px $left + px; |
||||
|
||||
@media only screen and (max-width: 1440px) { |
||||
padding: calc($H-scale-ratio-900 * #{$top}px) calc($W-scale-ratio-1440 * #{$right}px) |
||||
calc($H-scale-ratio-900 * #{$bottom}px) calc($W-scale-ratio-1440 * #{$left}px); |
||||
} |
||||
|
||||
@media only screen and (min-width: 2560px) { |
||||
padding: calc($H-scale-ratio-1440 * #{$top}px) calc($W-scale-ratio-2560 * #{$right}px) |
||||
calc($H-scale-ratio-1440 * #{$bottom}px), |
||||
calc($W-scale-ratio-2560 * #{$left}px); |
||||
} |
||||
} |
||||
|
||||
@mixin ResponsiveMargin($top, $right, $bottom, $left) { |
||||
margin: $top + px $right + px $bottom + px $left + px; |
||||
|
||||
@media only screen and (max-width: 1440px) { |
||||
margin: calc($H-scale-ratio-900 * #{$top}px) calc($W-scale-ratio-1440 * #{$right}px) |
||||
calc($H-scale-ratio-900 * #{$bottom}px) calc($W-scale-ratio-1440 * #{$left}px); |
||||
} |
||||
|
||||
@media only screen and (min-width: 2560px) { |
||||
margin: calc($H-scale-ratio-1440 * #{$top}px) calc($W-scale-ratio-2560 * #{$right}px) |
||||
calc($H-scale-ratio-1440 * #{$bottom}px), |
||||
calc($W-scale-ratio-2560 * #{$left}px); |
||||
} |
||||
} |
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
html[data-theme='dark'] { |
||||
--bg: #0d0d0d; |
||||
--text-color: #ffffff; |
||||
--layout-header-bg: #1b1d23; |
||||
--layout-menu-bg: #1b1d23; |
||||
--menu-active-bg: #2c342c; |
||||
--menu-hover-bg: #272b35; |
||||
--warp-bg: #212327; |
||||
--table-header-bg: #3b3d40; |
||||
--table-header-text-color: #cdcecf; |
||||
--pagination-bg: #101115; |
||||
--pagination-border-color: rgba(255, 255, 255, 0.13); |
||||
--select-header--text-color: #ffffff; |
||||
--station-card-bg: #23272f; |
||||
--station-card-border-color: #4b5361; |
||||
--station-header-bg: #363a40; |
||||
--station-header-text-color: #fff; |
||||
--station-info-val-text: #fff; |
||||
--label-color: #c7c8cb; |
||||
--text-desc: #8d9095; |
||||
--mask-bg: #333; |
||||
--icon-color: #aaa; |
||||
--icon-hover-color: #606266; |
||||
} |
||||
|
||||
html[data-theme='light'] { |
||||
--bg: #f1f2f6; |
||||
--text-color: #4d4d4d; |
||||
--layout-header-bg: #ffffff; |
||||
--layout-menu-bg: #fff; |
||||
--menu-active-bg: #f5fcee; |
||||
--menu-hover-bg: #f5f5f5; |
||||
--warp-bg: #ffffff; |
||||
--table-header-bg: #e8e9ee; |
||||
--table-header-text-color: #030303; |
||||
--pagination-bg: transparent; |
||||
--pagination-border-color: rgba(217, 217, 217, 1); |
||||
--select-header--text-color: #666; |
||||
--station-card-bg: #ffffff; |
||||
--station-card-border-color: #e9e9e9; |
||||
--station-header-bg: #f1f1f1; |
||||
--station-header-text-color: #030303; |
||||
--station-info-val-text: #4d4d4d; |
||||
--label-color: #4d4d4d; |
||||
--text-desc: #a8abb2; |
||||
--mask-bg: #f5f5f5; |
||||
--icon-color: #aaa; |
||||
--icon-hover-color: #606266; |
||||
} |
||||
|
||||
html.dark { |
||||
--el-bg-color: #212327; |
||||
--el-border-color: rgba(255, 255, 255, 0.13); |
||||
--el-button-divide-border-color: rgba(255, 255, 255, 0.13); |
||||
--el-bg-color-overlay: #212327; |
||||
--el-text-color-regular: #c7c8cb; |
||||
--el-color-primary: #619925; |
||||
--el-color-primary-light-3: rgb(78.1, 141.8, 46.6); |
||||
--el-color-primary-light-5: rgb(61.5, 107, 39); |
||||
--el-color-primary-light-7: rgb(44.9, 72.2, 31.4); |
||||
--el-color-primary-light-8: rgb(36.6, 54.8, 27.6); |
||||
--el-color-primary-light-9: rgb(28.3, 37.4, 23.8); |
||||
--el-color-primary-dark-2: rgb(133.4, 206.2, 97.4); |
||||
} |
||||
|
||||
html.light { |
||||
--el-color-primary: #619925; |
||||
--el-color-primary-light-3: rgb(148.6, 212.3, 117.1); |
||||
--el-color-primary-light-5: rgb(179, 224.5, 156.5); |
||||
--el-color-primary-light-7: rgb(209.4, 236.7, 195.9); |
||||
--el-color-primary-light-8: rgb(224.6, 242.8, 215.6); |
||||
--el-color-primary-light-9: rgb(239.8, 248.9, 235.3); |
||||
--el-color-primary-dark-2: rgb(82.4, 155.2, 46.4); |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
:root { |
||||
/* Widget 图标相关 */ |
||||
--icon-radius: 16px; /* 图标圆角 */ |
||||
--icon-size: 68px; /* 图标大小 */ |
||||
--icon-opacity: 1; /* 图标透明度 */ |
||||
--icon-grid-width: 900px; /*图标box最大宽度*/ |
||||
|
||||
--icon-gap: 27px; /* 图标间距 */ |
||||
|
||||
--icon-name: block; /* 图标名称 */ |
||||
--icon-nameSize: 14px; /* 图标名称大小 */ |
||||
--icon-nameColor: #fff; /* 图标名称颜色 */ |
||||
|
||||
/* 时间相关 */ |
||||
--time-size: 58px; /* 时间大小 */ |
||||
--time-font: Orbitron; /* 时间字体 */ |
||||
--time-color: #cccccc; /* 时间颜色 */ |
||||
--time-fontWeight: 400; /* 时间字体粗细 */ |
||||
--time-month: none; /* 是否显示农历 */ |
||||
--time-week: inline; /* 是否显示星期 */ |
||||
--time-lunar: none; /* 是否显示农历 */ |
||||
--time-sec: none; /* 是否显示秒 */ |
||||
|
||||
/* 侧边栏相关 */ |
||||
--sidebar-width: 42px; /* 侧边栏宽度 */ |
||||
--sidebar-opacity: 0.1; /* 侧边栏透明度 */ |
||||
|
||||
/* 壁纸相关 */ |
||||
--wall-mask: 0; /* 壁纸遮罩 */ |
||||
--wall-blur: 0px; /* 壁纸模糊 */ |
||||
} |
@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
|
||||
<template> |
||||
<Teleport to="body"> |
||||
<div |
||||
ref="menuRef" |
||||
class="context-menu borderBlack" |
||||
:style="{ |
||||
left: adjustedPosition.x + 'px', |
||||
top: adjustedPosition.y + 'px', |
||||
display: isVisible ? 'block' : 'none', |
||||
}" |
||||
> |
||||
<div |
||||
v-for="item in menuItems" |
||||
:key="item.id" |
||||
class="menu-item" |
||||
:class="{ hover: !item.options }" |
||||
@click.stop.prevent="handleMenuClick(item)" |
||||
> |
||||
<div class="items-center flex gap-4"> |
||||
<div :class="item.icon"></div> |
||||
{{ item.label }} |
||||
</div> |
||||
<p v-if="item.options" class="contextmenu-layout"> |
||||
<span |
||||
v-for="option in item.options" |
||||
:key="option.id" |
||||
class="item-option" |
||||
:class="{ active: option.active }" |
||||
@click.stop="handleMenuClick(option)" |
||||
> |
||||
{{ option.label }} |
||||
</span> |
||||
</p> |
||||
</div> |
||||
</div> |
||||
</Teleport> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import type { IContextMenu } from '@/utils/types' |
||||
import gsap from 'gsap' |
||||
|
||||
interface Props { |
||||
isVisible: boolean |
||||
position: { x: number; y: number } |
||||
menuItems: IContextMenu[] |
||||
} |
||||
|
||||
const props = defineProps<Props>() |
||||
const emit = defineEmits(['update:show', 'select', 'close']) |
||||
|
||||
const menuRef = ref<HTMLElement | null>(null) |
||||
const menuSize = ref({ width: 0, height: 0 }) |
||||
const isVisible = ref(false) |
||||
|
||||
// 计算调整后的位置 |
||||
const adjustedPosition = computed(() => { |
||||
const { x, y } = props.position |
||||
const { width, height } = menuSize.value |
||||
const windowWidth = window.innerWidth |
||||
const windowHeight = window.innerHeight |
||||
|
||||
let adjustedX = x |
||||
let adjustedY = y |
||||
|
||||
// 检查右边界 |
||||
if (x + width > windowWidth) { |
||||
adjustedX = x - width |
||||
} |
||||
|
||||
// 检查下边界 |
||||
if (y + height > windowHeight) { |
||||
adjustedY = y - height |
||||
} |
||||
|
||||
// 确保不会超出左边界和上边界 |
||||
adjustedX = Math.max(0, adjustedX) |
||||
adjustedY = Math.max(0, adjustedY) |
||||
|
||||
return { x: adjustedX, y: adjustedY } |
||||
}) |
||||
|
||||
// 监听位置变化 |
||||
watch( |
||||
() => props.position, |
||||
async () => { |
||||
if (props.isVisible) { |
||||
// 显示菜单但设置为透明 |
||||
isVisible.value = true |
||||
await nextTick() |
||||
|
||||
// 更新菜单尺寸 |
||||
if (menuRef.value) { |
||||
menuSize.value = { |
||||
width: menuRef.value.offsetWidth, |
||||
height: menuRef.value.offsetHeight, |
||||
} |
||||
} |
||||
|
||||
// 每次位置变化时重新执行动画 |
||||
gsap.fromTo( |
||||
menuRef.value, |
||||
{ |
||||
opacity: 0, |
||||
scale: 0.6, |
||||
transformOrigin: '0 0', |
||||
}, |
||||
{ |
||||
opacity: 1, |
||||
scale: 1, |
||||
duration: 0.3, |
||||
ease: 'elastic.out(1, 0.75)', |
||||
} |
||||
) |
||||
} |
||||
}, |
||||
{ deep: true } |
||||
) |
||||
|
||||
// 监听显示状态只处理隐藏动画 |
||||
watch( |
||||
() => props.isVisible, |
||||
async newVal => { |
||||
if (!newVal) { |
||||
// 执行隐藏动画 |
||||
await gsap.to(menuRef.value, { |
||||
opacity: 0, |
||||
scale: 0.6, |
||||
duration: 0.2, |
||||
ease: 'power2.in', |
||||
onComplete: () => { |
||||
isVisible.value = false |
||||
}, |
||||
}) |
||||
} |
||||
} |
||||
) |
||||
|
||||
const handleMenuClick = async (item: IContextMenu) => { |
||||
emit('select', item) |
||||
onClose() |
||||
} |
||||
|
||||
const onClose = () => { |
||||
emit('close') |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" scoped> |
||||
.context-menu { |
||||
@apply fixed rounded-md shadow w-142 p-4 user-select-none backdrop-blur-12 z-999; |
||||
background: rgba(255, 255, 255, 0.85); |
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
.menu-item { |
||||
@apply cursor-pointer w-full h-auto p-y-5 p-x-4 border-radius-6 line-height-26 font-size-14 |
||||
transition-all-400 text-gray-800; |
||||
|
||||
[class^='i-'] { |
||||
@apply text-gray-500 font-size-14; |
||||
} |
||||
|
||||
.contextmenu-layout { |
||||
@apply p-y-2 gap-4 flex flex-wrap font-size-12; |
||||
|
||||
.item-option { |
||||
@apply h-24 overflow-hidden line-height-24 min-w-36 |
||||
p-x-10 |
||||
bg-gray-04 border-radius-6 |
||||
text-center cursor-pointer |
||||
flex items-center justify-center |
||||
transition-all-400; |
||||
|
||||
background-color: rgba(0, 0, 0, 0.04); |
||||
color: rgba(0, 0, 0, 0.7); |
||||
|
||||
&:hover { |
||||
background-color: rgba(0, 0, 0, 0.08); |
||||
color: rgba(0, 0, 0, 0.95); |
||||
} |
||||
&.active { |
||||
background-color: rgba(66, 133, 244, 0.08); |
||||
@apply text-green-500; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&.hover:hover { |
||||
background-color: rgba(66, 133, 244, 0.08); |
||||
@apply text-green-500; |
||||
|
||||
[class^='i-'] { |
||||
@apply text-green-500; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
|
||||
<template> |
||||
<div class="app-serach-wrap"> |
||||
<div class="app-serach-box-wrap"> |
||||
<div class="app-serach-box relative"> |
||||
<form class="input-box relative flex items-center" @submit.prevent="onSearch"> |
||||
<div id="selectEngine" class="select relative"> |
||||
<Menu as="div" class="relative inline-block wh-full"> |
||||
<MenuButton class="wh-full"> |
||||
<div id="selectEngine" class="select"> |
||||
<img |
||||
class="search-icon" |
||||
:src="curSeImg" |
||||
style="width: 20px; height: 20px" |
||||
/> |
||||
</div> |
||||
</MenuButton> |
||||
<transition |
||||
enter-active-class="transition duration-100 ease-out" |
||||
enter-from-class="transform scale-95 opacity-0" |
||||
enter-to-class="transform scale-100 opacity-100" |
||||
leave-active-class="transition duration-75 ease-in" |
||||
leave-from-class="transform scale-100 opacity-100" |
||||
leave-to-class="transform scale-95 opacity-0" |
||||
> |
||||
<MenuItems class="select-menu absolute w-600 p-10 m-t-4"> |
||||
<VueDraggable |
||||
class="verflow-hidden w-full z-0 items-center origin-top flex gap-10 action" |
||||
v-model="seList" |
||||
:animation="300" |
||||
> |
||||
<template v-for="se in seList" :key="se.title"> |
||||
<MenuItem v-slot="{ close }"> |
||||
<div |
||||
class="w-64 items-center justify-center" |
||||
@click="onSeSelect(se, close)" |
||||
> |
||||
<div class="se-item-icon"> |
||||
<img :src="se.src" class="w-24 h-24" /> |
||||
</div> |
||||
<div class="text-center color-#222 text-12px"> |
||||
{{ se.title }} |
||||
</div> |
||||
</div> |
||||
</MenuItem> |
||||
</template> |
||||
</VueDraggable> |
||||
</MenuItems> |
||||
</transition> |
||||
</Menu> |
||||
</div> |
||||
<input |
||||
id="searchInput" |
||||
autocomplete="off" |
||||
class="se-input w-full h-ful f14 bg-transparent leading-5" |
||||
maxlength="220" |
||||
placeholder="输入搜索内容" |
||||
type="text" |
||||
/> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import { VueDraggable } from 'vue-draggable-plus' |
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue' |
||||
const seList = [ |
||||
{ |
||||
title: '百度', |
||||
src: 'https://files.codelife.cc/itab/search/baidu.svg', |
||||
url: 'https://www.baidu.com/s?wd=', |
||||
}, |
||||
{ |
||||
title: '哔哩哔哩', |
||||
src: 'https://files.codelife.cc/itab/search/bilibili.svg', |
||||
url: 'https://search.bilibili.com/all?keyword=', |
||||
}, |
||||
{ |
||||
title: '必应', |
||||
src: 'https://files.codelife.cc/itab/search/bing.svg', |
||||
url: 'https://www.bing.com/search?q=', |
||||
}, |
||||
{ |
||||
title: 'Google', |
||||
src: 'https://files.codelife.cc/itab/search/google.svg', |
||||
url: 'https://www.google.com/search?q=', |
||||
}, |
||||
] |
||||
|
||||
const currentSe = ref<any>(seList[0].url) |
||||
|
||||
const curSeImg = computed(() => seList.find(se => se.url === currentSe.value)?.src) |
||||
|
||||
function onSeSelect(se: any, close: () => void) { |
||||
currentSe.value = se.url |
||||
close() |
||||
} |
||||
|
||||
function onSearch(e: any) { |
||||
const searchInput = ( |
||||
document.getElementById('searchInput') as HTMLInputElement |
||||
).value.trim() |
||||
|
||||
if (!searchInput) return |
||||
const searchUrl = `${currentSe.value}${encodeURIComponent(searchInput)}` |
||||
|
||||
window.open(searchUrl, '_blank') |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" scoped> |
||||
.app-serach-wrap { |
||||
@apply h-90 w-full p-x-26; |
||||
.app-serach-box-wrap { |
||||
max-width: var(--icon-grid-width, 1350px); |
||||
transform: translateY(calc(50% - 4px)); |
||||
@apply mx-auto; |
||||
} |
||||
} |
||||
.app-serach-box { |
||||
max-width: 600px; |
||||
margin: 3vh auto 20px; |
||||
.input-box { |
||||
backdrop-filter: blur(12px); |
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 10px 3px; |
||||
z-index: 1; |
||||
height: 36px; //height: var(--search-height); |
||||
background-color: rgba(255, 255, 255, 0.2); // var(--search-bgColor); |
||||
color: #222; // var(--d-main); |
||||
border-radius: 6px; //var(--search-radius); |
||||
transition: background 0.2s; |
||||
&:hover { |
||||
background-color: rgba(255, 255, 255, 0.7); |
||||
} |
||||
} |
||||
.select { |
||||
background-color: initial; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
height: 100%; |
||||
min-width: 50px; |
||||
max-width: 50px; |
||||
position: relative; |
||||
transition: 0.2s; |
||||
.select-icon { |
||||
width: 20px; |
||||
height: 20px; |
||||
display: inline-block; |
||||
vertical-align: middle; |
||||
} |
||||
.close { |
||||
transition: transform 0.2s; |
||||
} |
||||
|
||||
.select-menu { |
||||
background-color: rgba(255, 255, 255, 0.9); |
||||
ackdrop-filter: blur(8px); |
||||
box-shadow: 0 0 10px 3px #00000029; |
||||
border-radius: 6px; |
||||
} |
||||
.se-item-icon { |
||||
color: rgba(255, 255, 255, 0.8); |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
line-height: 30px; |
||||
margin: 0 auto; |
||||
height: 36px; |
||||
width: 36px; |
||||
border-radius: 8px; |
||||
background-color: #fff; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
|
||||
<template> |
||||
<div class="app-widget"> |
||||
<div class="app-widget-grid"> |
||||
<VueDraggable class="widget-grid" v-model="widgetList" :animation="300"> |
||||
<template v-for="item in widgetList" :key="item.id"> |
||||
<div |
||||
:class="['widget-item', `icon-size-${item.size.width}x${item.size.height}`]" |
||||
:style="getWidgetBg(item)" |
||||
@contextmenu.stop.prevent="showWidgetMenu($event, item)" |
||||
> |
||||
<div class="widget-item-icon"> |
||||
<span class="icon-txt" :style="getIconTextStyle(item)">{{ |
||||
item.iconName |
||||
}}</span> |
||||
</div> |
||||
<div class="widget-item-name">{{ item.name }}</div> |
||||
</div> |
||||
</template> |
||||
</VueDraggable> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import { storeToRefs } from 'pinia' |
||||
import { useIconStore } from '@/stores/icon' |
||||
import { useWidgetStore } from '@/stores/widget' |
||||
import { VueDraggable } from 'vue-draggable-plus' |
||||
import type { IWidget } from '@/utils/types' |
||||
|
||||
defineProps<{ |
||||
showWidgetMenu: (event: MouseEvent, widget: IWidget) => void |
||||
}>() |
||||
|
||||
const widgetStore = useWidgetStore() |
||||
const { widgetList } = storeToRefs(widgetStore) |
||||
const { getWidgetBg } = widgetStore |
||||
|
||||
const iconStore = useIconStore() |
||||
const { getIconTextStyle } = iconStore |
||||
</script> |
||||
|
||||
<style scoped lang="scss"> |
||||
.app-widget { |
||||
flex: 1 1 0%; |
||||
.app-widget-grid { |
||||
max-width: var(--icon-grid-width, 1350px); |
||||
@apply h-full mx-auto p-x-45; |
||||
} |
||||
.widget-grid { |
||||
padding: 3vh 0; |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fill, var(--icon-size)); |
||||
grid-template-rows: repeat(auto-fill, var(--icon-size)); |
||||
justify-content: center; |
||||
gap: var(--icon-gap) var(--icon-gap); |
||||
|
||||
.widget-item { |
||||
border-radius: var(--icon-radius); |
||||
opacity: var(--icon-opacity); |
||||
grid-column: span 1; |
||||
grid-row: span 1; |
||||
background-size: cover; |
||||
transition: width 0.2s ease-in-out, height 0.2s ease-in-out; |
||||
|
||||
.widget-item-icon { |
||||
@apply flex w-full h-full text-white items-center whitespace-nowrap relative; |
||||
.icon-txt { |
||||
@apply absolute left-1/2 font-weight-500 font-size-22 line-height-1; |
||||
transform-origin: 0 center; |
||||
transform: scale(0.94) translateX(-50%); |
||||
transition: transform 0.2s ease-in-out; |
||||
} |
||||
} |
||||
|
||||
&.sortable-ghost { |
||||
opacity: 0.2; |
||||
background-color: rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
&.icon-size-1x1 { |
||||
height: calc(var(--icon-size)); |
||||
width: calc(var(--icon-size)); |
||||
grid-column: span 1; |
||||
grid-row: span 1; |
||||
} |
||||
|
||||
&.icon-size-1x2 { |
||||
height: calc(var(--icon-size) * 2 + var(--icon-gap)); |
||||
width: calc(var(--icon-size) * 1); |
||||
grid-column: span 1; |
||||
grid-row: span 2; |
||||
} |
||||
|
||||
&.icon-size-2x1 { |
||||
height: calc(var(--icon-size)); |
||||
width: calc(var(--icon-size) * 2 + var(--icon-gap)); |
||||
grid-row: span 1; |
||||
grid-column: span 2; |
||||
} |
||||
|
||||
&.icon-size-2x2 { |
||||
height: calc(var(--icon-size) * 2 + var(--icon-gap)); |
||||
width: calc(var(--icon-size) * 2 + var(--icon-gap)); |
||||
grid-column: span 2; |
||||
grid-row: span 2; |
||||
} |
||||
|
||||
&.icon-size-4x2 { |
||||
height: calc(var(--icon-size) * 2 + var(--icon-gap)); |
||||
width: calc(var(--icon-size) * 4 + var(--icon-gap) * 3); |
||||
grid-column: span 4; |
||||
grid-row: span 2; |
||||
} |
||||
} |
||||
|
||||
.widget-item-name { |
||||
font-size: var(--icon-nameSize); |
||||
color: var(--icon-nameColor); |
||||
display: var(--icon-name); |
||||
@apply text-center fw-500 w-full overflow-hidden text-ellipsis whitespace-nowrap; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,299 @@
@@ -0,0 +1,299 @@
|
||||
<template> |
||||
<n-modal |
||||
v-model:show="showModal" |
||||
preset="dialog" |
||||
title="编辑图标" |
||||
style="width: 600px" |
||||
:show-icon="false" |
||||
draggable |
||||
> |
||||
<n-card :bordered="false" size="huge" role="dialog"> |
||||
<div class="icon-form"> |
||||
<div class="item"> |
||||
<label for="addr" class="label">地址</label> |
||||
<n-input-group class="flex-1"> |
||||
<n-input |
||||
id="addr" |
||||
v-model:value="iconForm.addr" |
||||
placeholder="请输入图标地址" |
||||
/> |
||||
<!-- <n-button type="primary" @click="onGetIcon"> 获取图标 </n-button> --> |
||||
</n-input-group> |
||||
</div> |
||||
<div class="item"> |
||||
<label for="name" class="label">名称</label> |
||||
<n-input |
||||
class="flex-1" |
||||
id="name" |
||||
v-model:value="iconForm.name" |
||||
placeholder="请输入图标名称" |
||||
/> |
||||
</div> |
||||
<div class="item"> |
||||
<label for="color" class="label">图标颜色</label> |
||||
<n-color-picker |
||||
id="color" |
||||
v-model:value="iconForm.iconColor" |
||||
:swatches="swatches" |
||||
/> |
||||
</div> |
||||
<div class="item"> |
||||
<label for="text" class="label">图标文字</label> |
||||
<n-input |
||||
class="flex-1" |
||||
id="text" |
||||
v-model:value="iconForm.iconName" |
||||
placeholder="请输入图标文字" |
||||
:maxlength="iconNameMaxLength" |
||||
/> |
||||
</div> |
||||
<div class="flex gap-x-2 w-full m-l-140"> |
||||
<div |
||||
class="icon-type" |
||||
:class="iconForm.iconType === 'textIcon' ? 'active' : ''" |
||||
> |
||||
<div class="icon-preview"> |
||||
<div class="icon" :style="{ backgroundColor: iconForm.iconColor }"> |
||||
<span class="icon-txt" :style="getIconTextStyle(iconForm.iconName)">{{ |
||||
iconForm.iconName |
||||
}}</span> |
||||
</div> |
||||
</div> |
||||
<div class="preview-text">文字图标</div> |
||||
</div> |
||||
<div |
||||
class="icon-type" |
||||
:class="iconForm.iconType === 'uploadIcon' ? 'active' : ''" |
||||
> |
||||
<div class="icon-preview" @click="onUploadIcon"> |
||||
<div class="icon-upload"> |
||||
<img v-if="iconForm.iconImage" :src="iconForm.iconImage" alt="预览" /> |
||||
<div v-else class="i-mingcute:close-line rotate-45"></div> |
||||
<input type="file" accept="image/*" ref="uploadInput" class="hidden" /> |
||||
</div> |
||||
</div> |
||||
<div class="preview-text">上传图标</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<template #footer> |
||||
<n-button type="primary" class="m-l-70 w-120" @click="onEditIcon"> |
||||
确定 |
||||
</n-button> |
||||
</template> |
||||
</n-card> |
||||
</n-modal> |
||||
<n-modal |
||||
v-model:show="showUploadModal" |
||||
title="上传图标" |
||||
:show-icon="false" |
||||
:title-style="{ |
||||
justifyContent: 'center', |
||||
fontWeight: '700', |
||||
}" |
||||
preset="dialog" |
||||
style="width: auto" |
||||
> |
||||
<div class="w-260 h-260 p-0"> |
||||
<vueCropper |
||||
ref="cropperRef" |
||||
:img="previewUrl" |
||||
:outputSize="cropperOption.size" |
||||
:outputType="cropperOption.outputType" |
||||
:fixed="cropperOption.fixed" |
||||
:autoCrop="cropperOption.autoCrop" |
||||
:autoCropWidth="cropperOption.autoCropWidth" |
||||
:autoCropHeight="cropperOption.autoCropHeight" |
||||
/> |
||||
</div> |
||||
<n-button type="primary" class="m-t-10 m-l-70 w-120" @click="handleCroppedImage"> |
||||
确定 |
||||
</n-button> |
||||
</n-modal> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import type { IWidget, WidgetIconVO } from '@/utils/types' |
||||
import { useModal, useMessage } from 'naive-ui' |
||||
import { swatches } from '@/utils' |
||||
import { VueCropper } from 'vue-cropper' |
||||
|
||||
const iconNameMaxLength = 6 |
||||
const message = useMessage() |
||||
const showModal = defineModel({ default: false }) |
||||
const emit = defineEmits<{ |
||||
editIcon: [WidgetIconVO] |
||||
}>() |
||||
const iconForm = ref<WidgetIconVO>({ |
||||
id: '', |
||||
addr: '', |
||||
iconName: '', |
||||
name: '', |
||||
iconColor: '#000000', |
||||
iconType: 'textIcon', |
||||
iconImage: '', |
||||
}) |
||||
|
||||
function onEditIcon() { |
||||
emit('editIcon', iconForm.value) |
||||
showModal.value = false |
||||
} |
||||
|
||||
const getIconTextStyle = (name: string) => { |
||||
const baseScale = 0.94 |
||||
const containerWidth = 64 |
||||
const span = document.createElement('span') |
||||
span.style.fontSize = '22px' |
||||
span.style.visibility = 'hidden' |
||||
span.style.fontWeight = '500' |
||||
span.innerText = name |
||||
document.body.appendChild(span) |
||||
const textWidth = span.offsetWidth |
||||
document.body.removeChild(span) |
||||
const scale = containerWidth / (textWidth + 10) |
||||
return { |
||||
transform: `scale(${scale > baseScale ? baseScale : scale}) translateX(-50%)`, |
||||
} |
||||
} |
||||
|
||||
async function onGetIcon() { |
||||
const url = new URL('https://api.codelife.cc/website/info') |
||||
url.searchParams.append('lang', 'cn') |
||||
url.searchParams.append('url', encodeURIComponent(iconForm.value.addr)) |
||||
// accept:application/json, text/plain, */* |
||||
|
||||
const res = await fetch(url.toString(), { |
||||
method: 'GET', |
||||
headers: { |
||||
'Content-Type': 'application/json, text/plain, */*', |
||||
accept: 'application/json, text/plain, */*', |
||||
}, |
||||
}) |
||||
const data = await res.json() |
||||
console.log(data) |
||||
} |
||||
|
||||
function openIconSetting(widget: IWidget) { |
||||
iconForm.value.id = widget.id |
||||
iconForm.value.addr = widget.addr || '' |
||||
iconForm.value.name = widget.name |
||||
iconForm.value.iconColor = widget.iconColor |
||||
iconForm.value.iconName = widget.iconName |
||||
iconForm.value.iconType = widget.iconType |
||||
iconForm.value.iconImage = widget.iconImage |
||||
showModal.value = true |
||||
} |
||||
|
||||
const showUploadModal = ref(false) |
||||
const uploadInput = ref<HTMLInputElement | null>(null) |
||||
const previewUrl = ref<string>('') |
||||
|
||||
const cropperRef = ref<InstanceType<typeof VueCropper> | null>(null) |
||||
const cropperOption = ref({ |
||||
size: 1, |
||||
outputType: 'webp', |
||||
fixed: true, |
||||
full: true, |
||||
autoCrop: true, |
||||
autoCropWidth: 260, |
||||
autoCropHeight: 260, |
||||
}) |
||||
|
||||
// 获取裁剪后的图片 |
||||
function getCroppedImage() { |
||||
return new Promise<string>(resolve => { |
||||
cropperRef.value?.getCropData((data: string) => { |
||||
resolve(data) |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
// 处理裁剪后的图片 |
||||
async function handleCroppedImage() { |
||||
try { |
||||
const croppedData = await getCroppedImage() |
||||
// 更新预览图片 |
||||
if (previewUrl.value) { |
||||
URL.revokeObjectURL(previewUrl.value) |
||||
} |
||||
previewUrl.value = croppedData |
||||
iconForm.value.iconImage = croppedData |
||||
showUploadModal.value = false |
||||
} catch (error) { |
||||
message.error('裁剪图片失败') |
||||
} |
||||
} |
||||
|
||||
function onUploadIcon() { |
||||
iconForm.value.iconType = 'uploadIcon' |
||||
uploadInput.value?.click() |
||||
if (uploadInput.value) { |
||||
uploadInput.value.onchange = (e: Event) => { |
||||
const target = e.target as HTMLInputElement |
||||
const file = target.files?.[0] |
||||
if (file) { |
||||
// 检查文件类型 |
||||
if (!file.type.startsWith('image/')) { |
||||
message.error('请上传图片文件') |
||||
return |
||||
} |
||||
|
||||
// 创建预览URL |
||||
if (previewUrl.value) { |
||||
URL.revokeObjectURL(previewUrl.value) |
||||
} |
||||
previewUrl.value = URL.createObjectURL(file) |
||||
showUploadModal.value = true |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
defineExpose({ |
||||
openIconSetting, |
||||
}) |
||||
</script> |
||||
|
||||
<style scoped lang="scss"> |
||||
.icon-form { |
||||
@apply flex items-center flex-col gap-y-3; |
||||
.item { |
||||
@apply flex w-full items-center; |
||||
.label { |
||||
@apply w-70; |
||||
} |
||||
:deep(.n-color-picker) { |
||||
@apply w-70; |
||||
} |
||||
} |
||||
.icon-type { |
||||
@apply flex flex-col items-center justify-center gap-y-1; |
||||
.icon-preview { |
||||
@apply w-64 h-64 font-size-22 border border-solid border-gray-300 cursor-pointer; |
||||
border-radius: var(--icon-radius); |
||||
overflow: hidden; |
||||
&:hover { |
||||
box-shadow: 0 0 12px 3px #0003; |
||||
} |
||||
.icon { |
||||
@apply relative w-full h-full flex items-center justify-center whitespace-nowrap; |
||||
.icon-txt { |
||||
transform-origin: 0 center; |
||||
@apply absolute left-1/2 font-500 text-5.5 line-height-1 text-white; |
||||
} |
||||
} |
||||
.icon-upload { |
||||
@apply w-full h-full flex items-center justify-center; |
||||
} |
||||
} |
||||
.preview-text { |
||||
@apply text-3.5; |
||||
} |
||||
} |
||||
.active { |
||||
.preview-text { |
||||
@apply text-green; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
|
||||
<template> |
||||
<div class="w-full h-full p-y-10 p-l-10 p-r-20 flex flex-col gap-y-10px"> |
||||
<n-card |
||||
v-for="(section, sectionIndex) in iconSchema" |
||||
:key="sectionIndex" |
||||
:title="section.title" |
||||
hoverable |
||||
class="border-radius-6" |
||||
> |
||||
<div |
||||
v-for="(config, configIndex) in section.configs" |
||||
:key="configIndex" |
||||
class="flex justify-between items-center gap-col-12px p-x-12 h-36" |
||||
> |
||||
<span>{{ config.label }}</span> |
||||
<!-- {{ config.value }} --> |
||||
|
||||
<template v-if="config.type === 'slider' && config.value === 'iconRadius'"> |
||||
<n-slider |
||||
class="flex-1" |
||||
v-model:value="values[config.value]" |
||||
v-bind="config.props" |
||||
:max="iconRadiusMax" |
||||
/> |
||||
</template> |
||||
|
||||
<n-slider |
||||
v-else-if="config.type === 'slider'" |
||||
class="flex-1" |
||||
v-model:value="values[config.value]" |
||||
v-bind="config.props" |
||||
/> |
||||
<n-switch |
||||
size="small" |
||||
v-else-if="config.type === 'switch'" |
||||
v-model:value="values[config.value]" |
||||
/> |
||||
<n-color-picker |
||||
v-else-if="config.type === 'colorPicker'" |
||||
v-model:value="values[config.value]" |
||||
style="width: 28px; height: 28px" |
||||
:swatches="swatches" |
||||
/> |
||||
<span v-if="config.unit">{{ values[config.value] }}{{ config.unit }}</span> |
||||
</div> |
||||
</n-card> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import { useIconStore } from '@/stores/icon' |
||||
import iconSchema from '@/schema/iconSchema' |
||||
import { storeToRefs } from 'pinia' |
||||
import { swatches } from '@/utils' |
||||
|
||||
const iconStore = useIconStore() |
||||
const { values } = storeToRefs(iconStore) |
||||
|
||||
const iconRadiusMax = computed(() => { |
||||
return values.value?.iconSize / 2 |
||||
}) |
||||
</script> |
@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
|
||||
<template> |
||||
<div class="w-full h-full p-y-10 p-l-10 p-r-20 flex flex-col gap-y-10px"> |
||||
<n-card |
||||
v-for="(section, sectionIndex) in wallpaperSchema" |
||||
:key="sectionIndex" |
||||
:title="section.title" |
||||
hoverable |
||||
class="border-radius-6" |
||||
> |
||||
<div |
||||
v-for="(config, configIndex) in section.configs" |
||||
:key="configIndex" |
||||
class="flex justify-between items-center gap-col-12px p-x-12 line-height-36" |
||||
> |
||||
<span class="w-56">{{ config.label }}</span> |
||||
|
||||
<n-slider |
||||
v-if="config.type === 'slider'" |
||||
class="flex-1" |
||||
v-model:value="values[config.value]" |
||||
v-bind="config.props" |
||||
/> |
||||
|
||||
<n-upload |
||||
v-if="config.type === 'imgUpload'" |
||||
multiple |
||||
directory-dnd |
||||
:max="1" |
||||
@before-upload="beforeUpload" |
||||
> |
||||
<n-upload-dragger> |
||||
<div class="flex flex-col items-center justify-center gap-y-10px p-t-14px"> |
||||
<div class="i-mingcute:pic-ai-fill text-size-24px"></div> |
||||
<n-text class="text-size-14px"> 点击或者拖动上传壁纸 </n-text> |
||||
</div> |
||||
</n-upload-dragger> |
||||
</n-upload> |
||||
|
||||
<span v-if="config.unit">{{ values[config.value] }}{{ config.unit }}</span> |
||||
</div> |
||||
</n-card> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import wallpaperSchema from '@/schema/wallpaperSchema' |
||||
import { useWallpaperStore } from '@/stores/wallpaper' |
||||
import { useMessage, type UploadFileInfo } from 'naive-ui' |
||||
import { storeToRefs } from 'pinia' |
||||
import { ref } from 'vue' |
||||
|
||||
const message = useMessage() |
||||
const wallpaperStore = useWallpaperStore() |
||||
const { values } = storeToRefs(wallpaperStore) |
||||
|
||||
const imageUrl = ref<string | null>(null) |
||||
|
||||
async function beforeUpload(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) { |
||||
if ( |
||||
!['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes( |
||||
data.file.file?.type || '' |
||||
) |
||||
) { |
||||
message.error('只能上传png、jpeg、jpg、webp格式的图片文件,请重新上传') |
||||
return false |
||||
} |
||||
|
||||
// const reader = new FileReader() |
||||
// reader.onload = (e: ProgressEvent<FileReader>) => { |
||||
// imageUrl.value = e.target?.result as string |
||||
// wallpaperStore.setWallpaperImg(imageUrl.value) |
||||
// message.success('上传成功') |
||||
// } |
||||
// reader.readAsDataURL(data.file.file as File) |
||||
wallpaperStore.setWallpaperImg(data.file.file as File) |
||||
|
||||
return false |
||||
} |
||||
</script> |
||||
|
||||
<style scoped lang="scss"> |
||||
.uploaded-image { |
||||
max-width: 100%; |
||||
height: auto; |
||||
margin-top: 20px; |
||||
} |
||||
</style> |
@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
|
||||
<template> |
||||
<n-drawer |
||||
v-model:show="isShowDrawer" |
||||
:width="502" |
||||
placement="right" |
||||
class="drawer-setting" |
||||
:show-mask="false" |
||||
> |
||||
<n-drawer-content title="设置"> |
||||
<div class="flex h-full w-full"> |
||||
<n-menu :options="menuOptions" class="h-full w-130" v-model:value="currentMenu" /> |
||||
<div class="flex-1 bg-[#f1f0f5]"> |
||||
<IconSetting v-if="currentMenu === 'icon'" /> |
||||
<WallpaperSetting v-if="currentMenu === 'wallpaper'" /> |
||||
</div> |
||||
</div> |
||||
</n-drawer-content> |
||||
</n-drawer> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import type { MenuOption } from 'naive-ui' |
||||
import IconSetting from './iconSetting.vue' |
||||
import WallpaperSetting from './index.vue' |
||||
const isShowDrawer = defineModel({ default: false }) |
||||
|
||||
const props = defineProps() |
||||
|
||||
const currentMenu = ref('icon') |
||||
|
||||
const menuOptions: MenuOption[] = [ |
||||
{ |
||||
label: '图标', |
||||
key: 'icon', |
||||
icon: () => h('div', { class: 'i-mingcute:pic-ai-line font-size-16' }), |
||||
}, |
||||
{ |
||||
label: '壁纸', |
||||
key: 'wallpaper', |
||||
icon: () => h('div', { class: 'i-mingcute:layout-6-line font-size-16' }), |
||||
}, |
||||
] |
||||
|
||||
function openDrawer(menu: string) { |
||||
isShowDrawer.value = true |
||||
currentMenu.value = menu |
||||
} |
||||
|
||||
defineExpose({ |
||||
openDrawer, |
||||
}) |
||||
</script> |
||||
|
||||
<style lang="scss"> |
||||
.drawer-setting { |
||||
.n-drawer-body-content-wrapper { |
||||
padding: 0 !important; |
||||
} |
||||
.n-layout-scroll-container { |
||||
width: 100%; |
||||
} |
||||
.n-menu-item-content__icon { |
||||
margin-right: 0px !important; |
||||
} |
||||
.n-menu-item-content { |
||||
padding-left: 16px !important; |
||||
} |
||||
.n-card-header { |
||||
padding: 8px 12px !important; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
<template> |
||||
<!-- 插入到 body 中 --> |
||||
<Teleport to="body"> |
||||
<div |
||||
class="wallpaper" |
||||
:style="{ backgroundImage: isVideo ? 'none' : `url(${values.wallpaperImg})` }" |
||||
> |
||||
<video class="wallpaper-video" autoplay loop muted v-if="isVideo"> |
||||
<source |
||||
src="https://files.codelife.cc/itab/defaultWallpaper/videos/84.mp4" |
||||
type="video/mp4" |
||||
/> |
||||
<img |
||||
src="https://files.codelife.cc/itab/defaultWallpaper/videos/84.jpg?x-oss-process=image/resize,limit_0,m_fill,w_1920,h_1080/quality,q_93/format,webp" |
||||
alt="Background video poster" |
||||
/> |
||||
</video> |
||||
</div> |
||||
</Teleport> |
||||
</template> |
||||
<script setup lang="ts"> |
||||
import { useWallpaperStore } from '@/stores/wallpaper' |
||||
import { storeToRefs } from 'pinia' |
||||
const wallpaperStore = useWallpaperStore() |
||||
const { values } = storeToRefs(wallpaperStore) |
||||
|
||||
const isVideo = computed(() => !values.value?.wallpaperImg) |
||||
</script> |
||||
<style scoped lang="scss"> |
||||
.wallpaper { |
||||
position: absolute; |
||||
left: 0; |
||||
top: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
transition: background 0.3s, transform 0.3s, height 0.3s; |
||||
background-size: cover; |
||||
background-position: 50%; |
||||
z-index: 0; |
||||
|
||||
.wallpaper-video { |
||||
position: relative; |
||||
height: 100%; |
||||
width: 100%; |
||||
object-fit: cover; |
||||
transition: 0.3s; |
||||
background-size: cover; |
||||
background-repeat: no-repeat; |
||||
background-position: center; |
||||
} |
||||
} |
||||
|
||||
// .wallpaper.change { |
||||
// transform: scale(1.1); |
||||
// } |
||||
// .wallpaper:after { |
||||
// content: ''; |
||||
// position: absolute; |
||||
// left: 0; |
||||
// top: 0; |
||||
// width: 100%; |
||||
// height: 100%; |
||||
// backdrop-filter: blur(var(--wall-blur)); |
||||
// -webkit-backdrop-filter: blur(var(--wall-blur)); |
||||
// background-color: rgba(0, 0, 0, var(--wall-mask)); |
||||
// } |
||||
</style> |
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
import { menuConfigs } from '@/utils/menuConfig' |
||||
import type { IContextMenu, IWidget } from '@/utils/types' |
||||
|
||||
export function useContextMenuManager() { |
||||
const activeMenuType = ref<'global' | 'widget' | null>(null) |
||||
const position = ref({ x: 0, y: 0 }) |
||||
const menuItems = ref<IContextMenu[]>([]) |
||||
const currentWidget = ref<IWidget | null>(null) |
||||
|
||||
function showGlobalMenu(event: MouseEvent) { |
||||
activeMenuType.value = 'global' |
||||
position.value = { x: event.clientX, y: event.clientY } |
||||
menuItems.value = menuConfigs.global.items |
||||
currentWidget.value = null |
||||
} |
||||
|
||||
function showWidgetMenu(event: MouseEvent, widget: IWidget) { |
||||
activeMenuType.value = 'widget' |
||||
position.value = { x: event.clientX, y: event.clientY } |
||||
menuItems.value = menuConfigs.widget(widget.type) |
||||
const layoutMenuItem = menuItems.value.find(item => item.id === 'layout') |
||||
if (layoutMenuItem && layoutMenuItem.options) { |
||||
layoutMenuItem.options = layoutMenuItem.options.map(option => ({ |
||||
...option, |
||||
active: option.id === `${widget.size.width}x${widget.size.height}`, |
||||
})) |
||||
} |
||||
|
||||
currentWidget.value = widget |
||||
} |
||||
|
||||
function hideAllMenus() { |
||||
activeMenuType.value = null |
||||
} |
||||
|
||||
const isGlobalMenuVisible = computed(() => activeMenuType.value === 'global') |
||||
const isWidgetMenuVisible = computed(() => activeMenuType.value === 'widget') |
||||
const visible = computed(() => isGlobalMenuVisible.value || isWidgetMenuVisible.value) |
||||
|
||||
return { |
||||
isGlobalMenuVisible, |
||||
isWidgetMenuVisible, |
||||
currentWidget, |
||||
activeMenuType, |
||||
visible, |
||||
position, |
||||
menuItems, |
||||
showGlobalMenu, |
||||
showWidgetMenu, |
||||
hideAllMenus, |
||||
} |
||||
} |
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
import './assets/styles/main.css' |
||||
import 'virtual:uno.css' |
||||
import { createApp } from 'vue' |
||||
import { createPinia } from 'pinia' |
||||
|
||||
import App from './App.vue' |
||||
import router from './router' |
||||
|
||||
const app = createApp(App) |
||||
|
||||
app.use(createPinia()) |
||||
app.use(router) |
||||
|
||||
app.mount('#app') |
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router' |
||||
|
||||
export const defaultRouter = [ |
||||
// {
|
||||
// path: '/',
|
||||
// name: 'dashboard',
|
||||
// component: () => import('@/views/layout/index.vue'),
|
||||
// meta: {
|
||||
// title: '首页',
|
||||
// isShow: true,
|
||||
// icon: 'i-mage:file-2',
|
||||
// },
|
||||
// children: [
|
||||
// {
|
||||
// path: 'home',
|
||||
// name: 'home',
|
||||
// component: () => import('@/views/home/index.vue'),
|
||||
// meta: {
|
||||
// title: '首页1',
|
||||
// isShow: true,
|
||||
// icon: 'i-mage:file-2',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// path: 'dan',
|
||||
// name: '单独',
|
||||
// component: () => import('@/views/home/index.vue'),
|
||||
// meta: {
|
||||
// title: '单独1',
|
||||
// isShow: true,
|
||||
// icon: 'i-logos-vue',
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{ |
||||
path: '/', |
||||
name: 'home', |
||||
component: () => import('@/views/home/index.vue'), |
||||
}, |
||||
] |
||||
|
||||
const router = createRouter({ |
||||
history: createWebHistory(import.meta.env.BASE_URL), |
||||
routes: defaultRouter, |
||||
}) |
||||
|
||||
export default router |
@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
|
||||
export default { |
||||
icon: { |
||||
title: '图标', |
||||
type: 'icon', |
||||
configs: [ |
||||
{ |
||||
label: '图标大小', |
||||
type: 'slider', |
||||
value: 'iconSize', |
||||
defaultValue: 68, |
||||
props: { |
||||
min: 50, |
||||
max: 130, |
||||
step: 2, |
||||
}, |
||||
cssVar: '--icon-size', |
||||
unit: 'px', |
||||
}, |
||||
{ |
||||
label: '图标圆角', |
||||
type: 'slider', |
||||
value: 'iconRadius', |
||||
defaultValue: 16, |
||||
props: { |
||||
min: 0, |
||||
max: 100, |
||||
step: 1, |
||||
}, |
||||
cssVar: '--icon-radius', |
||||
unit: 'px', |
||||
}, |
||||
{ |
||||
label: '不透明度', |
||||
type: 'slider', |
||||
value: 'iconOpacity', |
||||
defaultValue: 100, |
||||
|
||||
props: { |
||||
min: 0, |
||||
max: 100, |
||||
step: 1, |
||||
}, |
||||
cssVar: '--icon-opacity', |
||||
unit: '%', |
||||
}, |
||||
], |
||||
}, |
||||
space: { |
||||
title: '间距', |
||||
type: 'space', |
||||
configs: [ |
||||
{ |
||||
label: '行间距', |
||||
type: 'slider', |
||||
value: 'iconRowSpace', |
||||
unit: 'px', |
||||
defaultValue: 27, |
||||
props: { |
||||
min: 0, |
||||
max: 100, |
||||
step: 1, |
||||
}, |
||||
cssVar: '--icon-gap', |
||||
}, |
||||
], |
||||
}, |
||||
name: { |
||||
title: '名称', |
||||
type: 'name', |
||||
configs: [ |
||||
{ |
||||
label: '名称', |
||||
type: 'switch', |
||||
value: 'nameTextShow', |
||||
defaultValue: true, |
||||
cssVar: '--icon-name', |
||||
props: {}, |
||||
}, |
||||
{ |
||||
label: '名称颜色', |
||||
type: 'colorPicker', |
||||
value: 'nameColor', |
||||
defaultValue: '#fff', |
||||
cssVar: '--icon-nameColor', |
||||
}, |
||||
{ |
||||
label: '字体大小', |
||||
type: 'slider', |
||||
value: 'nameSize', |
||||
defaultValue: 14, |
||||
props: { |
||||
min: 12, |
||||
max: 30, |
||||
step: 1, |
||||
}, |
||||
cssVar: '--icon-nameSize', |
||||
unit: 'px', |
||||
}, |
||||
], |
||||
}, |
||||
iconBox: { |
||||
title: '图标最大宽度', |
||||
type: 'iconBox', |
||||
configs: [ |
||||
{ |
||||
label: '最大宽度', |
||||
type: 'slider', |
||||
value: 'iconGridWidth', |
||||
defaultValue: 900, |
||||
props: { |
||||
min: 900, |
||||
max: 2000, |
||||
step: 10, |
||||
}, |
||||
cssVar: '--icon-grid-width', |
||||
unit: 'px', |
||||
}, |
||||
], |
||||
}, |
||||
} |
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
export default { |
||||
wallpaper: { |
||||
title: '壁纸', |
||||
type: 'wallpaper', |
||||
configs: [ |
||||
{ |
||||
label: '壁纸', |
||||
type: 'imgUpload', |
||||
value: 'wallpaperImg', |
||||
defaultValue: '', |
||||
}, |
||||
{ |
||||
label: '模糊度', |
||||
type: 'slider', |
||||
value: 'wallpaperBlur', |
||||
defaultValue: 0, |
||||
props: { |
||||
min: 0, |
||||
max: 40, |
||||
step: 1, |
||||
}, |
||||
unit: '%', |
||||
}, |
||||
{ |
||||
label: '遮罩浓度', |
||||
type: 'slider', |
||||
value: 'wallpaperMask', |
||||
defaultValue: 0, |
||||
props: { |
||||
min: 0, |
||||
max: 100, |
||||
step: 1, |
||||
}, |
||||
unit: '%', |
||||
}, |
||||
], |
||||
}, |
||||
} |
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia' |
||||
import iconSchema from '@/schema/iconSchema' |
||||
import type { IWidget } from '@/utils/types' |
||||
|
||||
interface Values { |
||||
[key: string]: any |
||||
} |
||||
|
||||
export const useIconStore = defineStore('icon', () => { |
||||
const values = reactive<Values>({}) |
||||
|
||||
// 初始化响应式变量
|
||||
const allConfigs = [ |
||||
...iconSchema.icon.configs, |
||||
...iconSchema.space.configs, |
||||
...iconSchema.name.configs, |
||||
...iconSchema.iconBox.configs, |
||||
] |
||||
|
||||
allConfigs.forEach(config => { |
||||
values[config.value] = config.defaultValue |
||||
}) |
||||
|
||||
const setNameColor = (value: string) => { |
||||
values.nameColor = value |
||||
} |
||||
|
||||
// 统一处理 CSS 变量映射
|
||||
allConfigs.forEach(config => { |
||||
if (config.cssVar) { |
||||
watch( |
||||
() => values[config.value], |
||||
newValue => { |
||||
if (config.value === 'nameTextShow') { |
||||
document.documentElement.style.setProperty( |
||||
config.cssVar, |
||||
newValue ? 'block' : 'none' |
||||
) |
||||
} else { |
||||
document.documentElement.style.setProperty( |
||||
config.cssVar, |
||||
`${newValue}${config?.unit ?? ''}` |
||||
) |
||||
} |
||||
} |
||||
) |
||||
} |
||||
}) |
||||
|
||||
const getIconTextStyle = (widget: IWidget) => { |
||||
const baseScale = 0.94 |
||||
const text = widget.iconName |
||||
const { width } = widget.size |
||||
if (width === 1) { |
||||
const containerWidth = width * values.iconSize |
||||
const span = document.createElement('span') |
||||
span.style.fontSize = '22px' |
||||
span.style.visibility = 'hidden' |
||||
span.style.fontWeight = '500' |
||||
span.innerText = text |
||||
document.body.appendChild(span) |
||||
const textWidth = span.offsetWidth |
||||
document.body.removeChild(span) |
||||
const scale = containerWidth / (textWidth + 10) |
||||
return { |
||||
transform: `scale(${scale > baseScale ? baseScale : scale}) translateX(-50%)`, |
||||
} |
||||
} else { |
||||
return { |
||||
transform: `scale(${baseScale}) translateX(-50%)`, |
||||
} |
||||
} |
||||
} |
||||
|
||||
return { values, setNameColor, getIconTextStyle } |
||||
}) |
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia' |
||||
import iconSchema from '@/schema/wallpaperSchema' |
||||
|
||||
interface Values { |
||||
[key: string]: any |
||||
} |
||||
|
||||
export const useWallpaperStore = defineStore('wallpaper', () => { |
||||
const values = reactive<Values>({}) |
||||
|
||||
// 初始化响应式变量
|
||||
const allConfigs = [...iconSchema.wallpaper.configs] |
||||
|
||||
allConfigs.forEach(config => { |
||||
values[config.value] = config.defaultValue |
||||
}) |
||||
|
||||
function setWallpaperImg(imgUrl: Blob) { |
||||
values.wallpaperImg = URL.createObjectURL(imgUrl) |
||||
} |
||||
|
||||
// 统一处理 CSS 变量映射
|
||||
allConfigs.forEach(config => { |
||||
if (config?.cssVar) { |
||||
watch( |
||||
() => values[config.value], |
||||
newValue => { |
||||
if (config.value === 'nameTextShow') { |
||||
document.documentElement.style.setProperty( |
||||
config.cssVar, |
||||
newValue ? 'block' : 'none' |
||||
) |
||||
} else { |
||||
document.documentElement.style.setProperty( |
||||
config.cssVar, |
||||
`${newValue}${config.unit}` |
||||
) |
||||
} |
||||
} |
||||
) |
||||
} |
||||
}) |
||||
|
||||
return { values, setWallpaperImg } |
||||
}) |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
export const swatches = [ |
||||
'rgb(18, 160, 88)', |
||||
'rgb(22, 129, 255)', |
||||
'rgb(251, 190, 35)', |
||||
'rgb(252, 69, 72)', |
||||
'rgb(0, 0, 0)', |
||||
] |
@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
|
||||
import type { IContextMenu, MenuOption, WidgetMenuConfig } from './types' |
||||
|
||||
const rootMenuConfig: IContextMenu[] = [ |
||||
{ |
||||
id: 'add', |
||||
label: '添加组件', |
||||
icon: 'i-mingcute:add-circle-line', |
||||
event: 'ADD_WIDGET', |
||||
}, |
||||
{ |
||||
id: 'wallpaper', |
||||
label: '更换壁纸', |
||||
icon: 'i-mingcute:pic-ai-line', |
||||
event: 'CHANGE_WALLPAPER', |
||||
}, |
||||
{ |
||||
id: 'setting', |
||||
label: '设置', |
||||
icon: 'i-mingcute:settings-5-line', |
||||
event: 'SETTING', |
||||
}, |
||||
] |
||||
|
||||
const layoutOptions: MenuOption[] = [ |
||||
{ id: '1x1', label: '1 x 1', event: 'LAYOUT_WIDGET_1X1' }, |
||||
{ id: '2x1', label: '2 x 1', event: 'LAYOUT_WIDGET_2X1' }, |
||||
{ id: '1x2', label: '1 x 2', event: 'LAYOUT_WIDGET_1X2' }, |
||||
{ id: '2x2', label: '2 x 2', event: 'LAYOUT_WIDGET_2X2' }, |
||||
{ id: '4x2', label: '4 x 2', event: 'LAYOUT_WIDGET_4X2' }, |
||||
] |
||||
|
||||
const widgetMenuConfig: WidgetMenuConfig = { |
||||
icon: [ |
||||
{ |
||||
id: 'icon-open-tab', |
||||
label: '在新标签页打开', |
||||
event: 'ICON_OPEN_TAB', |
||||
icon: 'i-mingcute:share-3-line', |
||||
}, |
||||
{ |
||||
id: 'layout', |
||||
label: '布局', |
||||
event: 'LAYOUT_WIDGET', |
||||
icon: 'i-mingcute:layout-6-line', |
||||
options: layoutOptions, |
||||
}, |
||||
{ |
||||
id: 'edit-icon', |
||||
label: '编辑图标', |
||||
icon: 'i-mingcute:edit-4-line', |
||||
event: 'EDIT_ICON', |
||||
}, |
||||
{ |
||||
id: 'delete', |
||||
label: '删除', |
||||
icon: 'i-mingcute:delete-2-line', |
||||
event: 'DELETE_WIDGET', |
||||
}, |
||||
], |
||||
} |
||||
|
||||
export const menuConfigs = { |
||||
global: { |
||||
items: rootMenuConfig, |
||||
}, |
||||
|
||||
widget(type: string) { |
||||
const commonItems: IContextMenu[] = [] |
||||
const typeSpecificItems = widgetMenuConfig |
||||
|
||||
return [...commonItems, ...(typeSpecificItems[type] || [])] |
||||
}, |
||||
} |
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
// 菜单选项类型
|
||||
interface MenuOption { |
||||
id: string |
||||
label: string |
||||
event: string |
||||
active?: boolean |
||||
} |
||||
|
||||
// 菜单项类型
|
||||
interface IContextMenu { |
||||
id: string |
||||
label: string |
||||
event?: string |
||||
icon?: string |
||||
options?: MenuOption[] |
||||
} |
||||
|
||||
interface IWidget { |
||||
id: string |
||||
name: string |
||||
iconName: string |
||||
iconColor: string |
||||
addr: string |
||||
type: string |
||||
iconType: WidgetIconType |
||||
size: { |
||||
width: number |
||||
height: number |
||||
} |
||||
|
||||
iconImage?: string |
||||
config?: Record<string, any> // 不同组件的特定配置
|
||||
} |
||||
|
||||
type WidgetIconType = 'uploadIcon' | 'textIcon' |
||||
type WidgetIconVO = Pick< |
||||
IWidget, |
||||
'id' | 'addr' | 'name' | 'iconColor' | 'iconName' | 'iconImage' | 'iconType' |
||||
> |
||||
|
||||
type WidgetMenuConfig = { |
||||
[key: string]: IContextMenu[] |
||||
} |
||||
|
||||
export type { |
||||
IContextMenu, |
||||
MenuOption, |
||||
IWidget, |
||||
WidgetMenuConfig, |
||||
WidgetIconVO, |
||||
WidgetIconType, |
||||
} |
@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
|
||||
<template> |
||||
<div |
||||
class="app-main" |
||||
@contextmenu.prevent="showGlobalMenu($event)" |
||||
@click="hideAllMenus" |
||||
> |
||||
<div class="app-header"></div> |
||||
<Search></Search> |
||||
<Widget :show-widget-menu="showWidgetMenu"></Widget> |
||||
</div> |
||||
|
||||
<ContextMenu |
||||
:is-visible="visible" |
||||
:position="position" |
||||
:menu-items="menuItems" |
||||
@select="handleMenuSelect" |
||||
@close="hideAllMenus" |
||||
/> |
||||
<IconSetting ref="iconSettingRef" @edit-icon="onEditIcon" /> |
||||
<DrawerSetting ref="SettingDrawerRef" /> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import ContextMenu from '@/components/ContextMenu.vue' |
||||
import Search from '@/components/apps/Search.vue' |
||||
import Widget from '@/components/apps/widget.vue' |
||||
import type { IContextMenu, IWidget } from '../../utils/types' |
||||
import { useContextMenuManager } from '@/composables/useContextMenu' |
||||
import DrawerSetting from '@/components/drawerSetting/widgetSetting.vue' |
||||
import IconSetting from '@/components/dialogs/iconSetting.vue' |
||||
import { storeToRefs } from 'pinia' |
||||
import { useWidgetStore } from '@/stores/widget' |
||||
|
||||
const widgetStore = useWidgetStore() |
||||
const { createWidget, onEditIcon, layoutWidget } = widgetStore |
||||
|
||||
const { |
||||
visible, |
||||
position, |
||||
menuItems, |
||||
hideAllMenus, |
||||
showGlobalMenu, |
||||
showWidgetMenu, |
||||
currentWidget, |
||||
activeMenuType, |
||||
} = useContextMenuManager() |
||||
|
||||
const handleMenuSelect = (item: IContextMenu) => { |
||||
if (!activeMenuType.value) return |
||||
if (activeMenuType.value === 'global') { |
||||
globalMenuSelect(item) |
||||
} |
||||
if (activeMenuType.value === 'widget') { |
||||
widgetMenuSelect(item) |
||||
} |
||||
} |
||||
|
||||
function globalMenuSelect(item: IContextMenu) { |
||||
switch (item.event) { |
||||
case 'ADD_WIDGET': |
||||
createWidget() |
||||
break |
||||
case 'CHANGE_WALLPAPER': |
||||
openDrawerSetting('wallpaper') |
||||
break |
||||
case 'SETTING': |
||||
openDrawerSetting() |
||||
break |
||||
} |
||||
} |
||||
|
||||
function widgetMenuSelect(item: IContextMenu) { |
||||
if (!currentWidget.value) { |
||||
new Error(`currentWidget is null`) |
||||
return |
||||
} |
||||
|
||||
if (item.event?.includes('LAYOUT_WIDGET')) { |
||||
const size = item.id.split('x').map(Number) |
||||
layoutWidget(currentWidget.value, { |
||||
width: size[0], |
||||
height: size[1], |
||||
}) |
||||
} |
||||
|
||||
switch (item.event) { |
||||
case 'EDIT_ICON': |
||||
openIconSetting(currentWidget.value) |
||||
break |
||||
case 'ICON_OPEN_TAB': |
||||
openIconTab(currentWidget.value) |
||||
break |
||||
} |
||||
} |
||||
|
||||
const iconSettingRef = ref<InstanceType<typeof IconSetting>>() |
||||
const SettingDrawerRef = ref<InstanceType<typeof DrawerSetting>>() |
||||
|
||||
function openDrawerSetting(menu: string = 'icon') { |
||||
SettingDrawerRef.value?.openDrawer(menu) |
||||
} |
||||
|
||||
function openIconSetting(widget: IWidget) { |
||||
iconSettingRef.value?.openIconSetting(widget) |
||||
} |
||||
|
||||
function openIconTab(widget: IWidget) { |
||||
window.open(widget.addr, '_blank') |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" scoped> |
||||
.app-main { |
||||
@apply flex flex-col wh-full user-select-none; |
||||
.app-header { |
||||
@apply h-[3vh]; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
{ |
||||
"extends": "@vue/tsconfig/tsconfig.dom.json", |
||||
"include": [ |
||||
"src/**/*", |
||||
"src/**/*.ts", |
||||
"src/**/*.vue", |
||||
"src/**/**/*.vue", |
||||
"src/**/**/*.ts", |
||||
"global.types/**/*.d.ts", |
||||
"*/*.d.ts", |
||||
], |
||||
"exclude": [ |
||||
"src/**/__tests__/*" |
||||
], |
||||
"compilerOptions": { |
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", |
||||
"baseUrl": ".", |
||||
"paths": { |
||||
"@/*": [ |
||||
"./src/*" |
||||
] |
||||
}, |
||||
} |
||||
} |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
{ |
||||
"files": [], |
||||
"references": [ |
||||
{ |
||||
"path": "./tsconfig.node.json" |
||||
}, |
||||
{ |
||||
"path": "./tsconfig.app.json" |
||||
} |
||||
], |
||||
"compilerOptions": { |
||||
"types": [ |
||||
"node" |
||||
], |
||||
"moduleResolution": "node" |
||||
} |
||||
} |
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
{ |
||||
"extends": "@tsconfig/node22/tsconfig.json", |
||||
"include": [ |
||||
"vite.config.*", |
||||
"vitest.config.*", |
||||
"cypress.config.*", |
||||
"nightwatch.conf.*", |
||||
"playwright.config.*", |
||||
"eslint.config.*" |
||||
], |
||||
"compilerOptions": { |
||||
"composite": true, // 启用复合项目 |
||||
"noEmit": true, |
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", |
||||
"module": "ESNext", |
||||
"moduleResolution": "Bundler", |
||||
"types": [ |
||||
"node" |
||||
] |
||||
} |
||||
} |
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
import presetRemToPx from '@unocss/preset-rem-to-px' |
||||
import { presetScalpel } from 'unocss-preset-scalpel' |
||||
import { |
||||
defineConfig, |
||||
presetAttributify, |
||||
presetIcons, |
||||
presetTypography, |
||||
presetWind3, |
||||
presetWebFonts, |
||||
transformerDirectives, |
||||
transformerVariantGroup, |
||||
} from 'unocss' |
||||
|
||||
export default defineConfig({ |
||||
shortcuts: [], |
||||
theme: { |
||||
colors: {}, |
||||
}, |
||||
content: { |
||||
pipeline: { |
||||
include: [ |
||||
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, |
||||
'src/**/*.{js,ts}', |
||||
'src/router/index.ts', |
||||
'src/views/home/utils/menuConfig.ts', |
||||
], |
||||
}, |
||||
}, |
||||
rules: [['wh-full', { width: '100%', height: '100%' }]], |
||||
presets: [ |
||||
presetRemToPx(), |
||||
presetScalpel(), |
||||
presetWind3(), |
||||
presetAttributify({ |
||||
prefix: 'uno-', |
||||
prefixedOnly: true, |
||||
}), |
||||
presetIcons({ |
||||
scale: 1.2, |
||||
warn: true, |
||||
}), |
||||
presetTypography(), |
||||
presetWebFonts({ |
||||
fonts: {}, |
||||
}), |
||||
], |
||||
transformers: [transformerDirectives(), transformerVariantGroup()], |
||||
}) |
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
import { fileURLToPath, URL } from 'node:url' |
||||
import UnoCSS from 'unocss/vite' |
||||
import { defineConfig } from 'vite' |
||||
import vue from '@vitejs/plugin-vue' |
||||
import vueDevTools from 'vite-plugin-vue-devtools' |
||||
import AutoImport from 'unplugin-auto-import/vite' |
||||
import Components from 'unplugin-vue-components/vite' |
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' |
||||
import vueJsx from '@vitejs/plugin-vue-jsx' |
||||
import Icons from 'unplugin-icons/vite' |
||||
// Vite 配置文件
|
||||
export default defineConfig({ |
||||
plugins: [ |
||||
vue(), |
||||
vueJsx(), |
||||
vueDevTools(), |
||||
UnoCSS(), |
||||
Icons({ |
||||
autoInstall: true, |
||||
}), |
||||
AutoImport({ |
||||
imports: ['vue', 'vue-router'], |
||||
resolvers: [NaiveUiResolver()], |
||||
dts: 'global.types/auto-imports.d.ts', |
||||
}), |
||||
Components({ |
||||
resolvers: [NaiveUiResolver()], |
||||
dts: 'global.types/components.d.ts', |
||||
}), |
||||
], |
||||
resolve: { |
||||
alias: { |
||||
'@': fileURLToPath(new URL('./src', import.meta.url)), |
||||
}, |
||||
}, |
||||
|
||||
build: { |
||||
minify: 'terser', |
||||
terserOptions: { |
||||
compress: { |
||||
drop_console: true, |
||||
drop_debugger: true, |
||||
}, |
||||
}, |
||||
}, |
||||
css: { |
||||
preprocessorOptions: { |
||||
scss: { |
||||
additionalData: '@use "@/assets/styles/mixins.scss" as *;', |
||||
}, |
||||
}, |
||||
}, |
||||
|
||||
// 开发服务器配置
|
||||
server: { |
||||
// 启动时自动打开浏览器
|
||||
open: true, |
||||
// 开发服务器端口
|
||||
port: 4005, |
||||
// 允许局域网访问
|
||||
host: true, |
||||
}, |
||||
}) |
Loading…
Reference in new issue