Browse Source

first commit

main
betaqi 3 months ago
commit
5f1e8720d3
  1. 10
      .gitignore
  2. 76
      global.types/auto-imports.d.ts
  3. 39
      global.types/components.d.ts
  4. 16
      index.html
  5. 45
      package.json
  6. 3318
      pnpm-lock.yaml
  7. BIN
      public/favicon.ico
  8. 14
      src/App.vue
  9. 6
      src/assets/styles/global.module.scss
  10. 56
      src/assets/styles/main.css
  11. 77
      src/assets/styles/mixins.scss
  12. 74
      src/assets/styles/theme-variables.css
  13. 31
      src/assets/styles/widge-variables.css
  14. 198
      src/components/ContextMenu.vue
  15. 178
      src/components/apps/Search.vue
  16. 125
      src/components/apps/widget.vue
  17. 299
      src/components/dialogs/iconSetting.vue
  18. 62
      src/components/drawerSetting/iconSetting.vue
  19. 87
      src/components/drawerSetting/index.vue
  20. 72
      src/components/drawerSetting/widgetSetting.vue
  21. 67
      src/components/wallpaper.vue
  22. 52
      src/composables/useContextMenu.ts
  23. 14
      src/main.ts
  24. 48
      src/router/index.ts
  25. 120
      src/schema/iconSchema.ts
  26. 38
      src/schema/wallpaperSchema.ts
  27. 76
      src/stores/icon.ts
  28. 45
      src/stores/wallpaper.ts
  29. 121
      src/stores/widget.ts
  30. 7
      src/utils/index.ts
  31. 73
      src/utils/menuConfig.ts
  32. 52
      src/utils/types.ts
  33. 119
      src/views/home/index.vue
  34. 24
      tsconfig.app.json
  35. 17
      tsconfig.json
  36. 21
      tsconfig.node.json
  37. 48
      uno.config.ts
  38. 63
      vite.config.ts

10
.gitignore vendored

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

76
global.types/auto-imports.d.ts vendored

@ -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')
}

39
global.types/components.d.ts vendored

@ -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']
}
}

16
index.html

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

45
package.json

@ -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"
}
}

3318
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

14
src/App.vue

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

6
src/assets/styles/global.module.scss

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
@use './variables.scss';
// 导出变量
:export {
namespace: $namespace;
elNamespace: $elNamespace;
}

56
src/assets/styles/main.css

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

77
src/assets/styles/mixins.scss

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

74
src/assets/styles/theme-variables.css

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

31
src/assets/styles/widge-variables.css

@ -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; /* 壁纸模糊 */
}

198
src/components/ContextMenu.vue

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

178
src/components/apps/Search.vue

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

125
src/components/apps/widget.vue

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

299
src/components/dialogs/iconSetting.vue

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

62
src/components/drawerSetting/iconSetting.vue

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

87
src/components/drawerSetting/index.vue

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

72
src/components/drawerSetting/widgetSetting.vue

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

67
src/components/wallpaper.vue

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

52
src/composables/useContextMenu.ts

@ -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,
}
}

14
src/main.ts

@ -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')

48
src/router/index.ts

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

120
src/schema/iconSchema.ts

@ -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',
},
],
},
}

38
src/schema/wallpaperSchema.ts

@ -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: '%',
},
],
},
}

76
src/stores/icon.ts

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

45
src/stores/wallpaper.ts

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

121
src/stores/widget.ts

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
import type { IWidget, WidgetIconVO } from '@/utils/types'
import { defineStore } from 'pinia'
export const useWidgetStore = defineStore('widget', () => {
const widgetList = ref<IWidget[]>([
{
id: 'music_player',
name: '音乐播放器',
iconName: '🎵',
iconColor: '#FF5733',
addr: 'addr1',
type: 'icon',
iconType: 'textIcon',
size: { width: 1, height: 1 },
},
{
id: 'calendar',
name: '日历',
iconName: '📅',
iconColor: '#33A1FF',
addr: 'addr2',
type: 'icon',
iconType: 'textIcon',
size: { width: 1, height: 1 },
},
{
id: 'news_feed',
name: '新闻快讯',
iconName: '📰',
iconColor: '#FF33A1',
addr: 'addr3',
type: 'icon',
iconType: 'textIcon',
size: { width: 2, height: 2 },
},
{
id: 'fitness_tracker',
name: '健身追踪',
iconName: '🏃',
iconColor: '#33FF57',
addr: 'addr4',
type: 'icon',
iconType: 'textIcon',
size: { width: 1, height: 2 },
},
{
id: 'photo_album',
name: '相册',
iconName: '📷',
iconColor: '#FFC300',
addr: 'addr5',
type: 'icon',
iconType: 'textIcon',
size: { width: 2, height: 1 },
},
{
id: 'weather_forecast',
name: '天气预报',
iconName: '⛅',
iconColor: '#C70039',
addr: 'addr6',
type: 'icon',
iconType: 'textIcon',
size: { width: 1, height: 1 },
},
])
function findWidgetById(id: string) {
const widget = widgetList.value.find(w => w.id === id)
if (!widget) {
new Error(`widget ${id} not found`)
}
return widget as IWidget
}
const getWidgetBg = (widget: IWidget) => {
if (widget.iconType === 'uploadIcon') {
return { backgroundImage: `url(${widget.iconImage})` }
} else {
return { backgroundColor: widget.iconColor }
}
}
function createWidget() {
widgetList.value.push({
id: 'clock2',
name: '时钟',
iconName: '时钟',
iconColor: '#000000',
addr: '',
type: 'icon',
iconType: 'textIcon',
size: {
width: 2,
height: 2,
},
})
}
function layoutWidget(widget: IWidget, size: { width: number; height: number }) {
const findWidget = findWidgetById(widget.id)
if (findWidget) {
findWidget.size = size
}
}
const onEditIcon = (icon: WidgetIconVO) => {
const widget = findWidgetById(icon.id)
debugger
if (widget) {
widget.name = icon.name
widget.iconColor = icon.iconColor
widget.addr = icon.addr
widget.iconName = icon.iconName
widget.iconImage = icon.iconImage
widget.iconType = icon.iconType
}
}
return { widgetList, getWidgetBg, createWidget, onEditIcon, layoutWidget }
})

7
src/utils/index.ts

@ -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)',
]

73
src/utils/menuConfig.ts

@ -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] || [])]
},
}

52
src/utils/types.ts

@ -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,
}

119
src/views/home/index.vue

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

24
tsconfig.app.json

@ -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/*"
]
},
}
}

17
tsconfig.json

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
],
"compilerOptions": {
"types": [
"node"
],
"moduleResolution": "node"
}
}

21
tsconfig.node.json

@ -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"
]
}
}

48
uno.config.ts

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

63
vite.config.ts

@ -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…
Cancel
Save