u
This commit is contained in:
1 parent
48dac36685
commit
d17b49cdd9
18 files changed
+524
-332
No files matched your search
@@ -5,4 +5,5 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestDB(c *router.Context) {
|
func TestDB(c *router.Context) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ao/ao-design": "workspace:*",
|
"@ao/ao-design": "workspace:*",
|
||||||
|
"@floating-ui/vue": "^1.1.9",
|
||||||
"@iconify/vue": "5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"vue": "^3.5.18"
|
"vue": "^3.5.18"
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
@use './input/index.scss' as *;
|
@use './input/index.scss' as *;
|
||||||
@use './textarea/index.scss' as *;
|
@use './textarea/index.scss' as *;
|
||||||
@use './group/index.scss' as *;
|
@use './group/index.scss' as *;
|
||||||
@use "./popup/tooltip.scss" as *;
|
@use "./popup/index.scss" as *;
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@use '../_styles/_variables.scss' as *;
|
||||||
|
|
||||||
|
$popup-prefix: #{$prefix}-popup;
|
||||||
|
|
||||||
|
.#{$popup-prefix} {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -1,158 +1,15 @@
|
|||||||
// Tooltip.tsx
|
import { defineComponent } from 'vue'
|
||||||
import type { PropType } from 'vue'
|
import { getPrefixCls } from '../_utils/getPrefixCls'
|
||||||
import { cloneVNode, defineComponent, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
|
|
||||||
interface Position {
|
|
||||||
top: number
|
|
||||||
left: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Tooltip',
|
name: 'Popup',
|
||||||
props: {
|
|
||||||
content: { type: String, required: true },
|
|
||||||
placement: { type: String as PropType<'top' | 'bottom' | 'left' | 'right'>, default: 'top' },
|
|
||||||
trigger: { type: String as PropType<'hover' | 'click'>, default: 'hover' },
|
|
||||||
offset: { type: Number, default: 6 },
|
|
||||||
},
|
|
||||||
setup(props, { slots }) {
|
setup(props, { slots }) {
|
||||||
const visible = ref(false)
|
const prefixCls = getPrefixCls('popup')
|
||||||
const targetEl = ref<HTMLElement | null>(null)
|
|
||||||
const tooltipEl = ref<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const createTooltip = () => {
|
return () => (
|
||||||
const el = document.createElement('div')
|
<div class={prefixCls}>
|
||||||
el.className = `tooltip tooltip-${props.placement}`
|
{/* {slots?.default()} */}
|
||||||
|
</div>
|
||||||
const contentEl = document.createElement('div')
|
)
|
||||||
contentEl.className = 'tooltip-content'
|
|
||||||
contentEl.textContent = props.content
|
|
||||||
|
|
||||||
const arrowEl = document.createElement('div')
|
|
||||||
arrowEl.className = 'tooltip-arrow'
|
|
||||||
|
|
||||||
el.appendChild(contentEl)
|
|
||||||
el.appendChild(arrowEl)
|
|
||||||
|
|
||||||
// 初始状态
|
|
||||||
el.style.position = 'absolute'
|
|
||||||
el.style.top = '0'
|
|
||||||
el.style.left = '0'
|
|
||||||
el.style.opacity = '0'
|
|
||||||
el.style.visibility = 'hidden'
|
|
||||||
el.style.pointerEvents = 'none'
|
|
||||||
|
|
||||||
document.body.appendChild(el)
|
|
||||||
tooltipEl.value = el
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTargetDom = (): HTMLElement | null => {
|
|
||||||
if (!targetEl.value)
|
|
||||||
return null
|
|
||||||
let dom = targetEl.value
|
|
||||||
if ('$el' in dom)
|
|
||||||
dom = (dom as any).$el
|
|
||||||
return dom instanceof HTMLElement ? dom : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
const tooltip = tooltipEl.value
|
|
||||||
const target = getTargetDom()
|
|
||||||
if (!tooltip || !target)
|
|
||||||
return
|
|
||||||
|
|
||||||
const rect = target.getBoundingClientRect()
|
|
||||||
const tooltipRect = tooltip.getBoundingClientRect()
|
|
||||||
const scrollX = window.scrollX || window.pageXOffset
|
|
||||||
const scrollY = window.scrollY || window.pageYOffset
|
|
||||||
|
|
||||||
let top = 0
|
|
||||||
let left = 0
|
|
||||||
const offset = props.offset
|
|
||||||
|
|
||||||
switch (props.placement) {
|
|
||||||
case 'top':
|
|
||||||
top = rect.top + scrollY - tooltipRect.height - offset
|
|
||||||
left = rect.left + scrollX + rect.width / 2 - tooltipRect.width / 2
|
|
||||||
break
|
|
||||||
case 'bottom':
|
|
||||||
top = rect.bottom + scrollY + offset
|
|
||||||
left = rect.left + scrollX + rect.width / 2 - tooltipRect.width / 2
|
|
||||||
break
|
|
||||||
case 'left':
|
|
||||||
top = rect.top + scrollY + rect.height / 2 - tooltipRect.height / 2
|
|
||||||
left = rect.left + scrollX - tooltipRect.width - offset
|
|
||||||
break
|
|
||||||
case 'right':
|
|
||||||
top = rect.top + scrollY + rect.height / 2 - tooltipRect.height / 2
|
|
||||||
left = rect.right + scrollX + offset
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
tooltip.style.top = `${top}px`
|
|
||||||
tooltip.style.left = `${left}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = () => {
|
|
||||||
const tooltip = tooltipEl.value
|
|
||||||
if (!tooltip || !getTargetDom())
|
|
||||||
return
|
|
||||||
|
|
||||||
tooltip.style.visibility = 'visible'
|
|
||||||
tooltip.style.pointerEvents = 'auto'
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
updatePosition()
|
|
||||||
tooltip.style.opacity = '1'
|
|
||||||
tooltip.style.transform = 'scale(1)'
|
|
||||||
})
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const hide = () => {
|
|
||||||
const tooltip = tooltipEl.value
|
|
||||||
if (!tooltip)
|
|
||||||
return
|
|
||||||
tooltip.style.opacity = '0'
|
|
||||||
tooltip.style.transform = 'scale(0.95)'
|
|
||||||
tooltip.style.pointerEvents = 'none'
|
|
||||||
tooltip.style.visibility = 'hidden'
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = () => (visible.value ? hide() : show())
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
createTooltip()
|
|
||||||
|
|
||||||
const onUpdate = () => {
|
|
||||||
if (visible.value)
|
|
||||||
updatePosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', onUpdate)
|
|
||||||
window.addEventListener('resize', onUpdate)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('scroll', onUpdate)
|
|
||||||
window.removeEventListener('resize', onUpdate)
|
|
||||||
if (tooltipEl.value && tooltipEl.value.parentNode) {
|
|
||||||
tooltipEl.value.parentNode.removeChild(tooltipEl.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const child = slots.default?.()[0]
|
|
||||||
if (!child)
|
|
||||||
return null
|
|
||||||
|
|
||||||
return cloneVNode(child, {
|
|
||||||
ref: targetEl,
|
|
||||||
onMouseenter: props.trigger === 'hover' ? show : undefined,
|
|
||||||
onMouseleave: props.trigger === 'hover' ? hide : undefined,
|
|
||||||
onClick: props.trigger === 'click' ? toggle : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
@use '../_styles/_variables.scss' as *;
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
z-index: 9999;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #333;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
|
|
||||||
transition:
|
|
||||||
opacity 0.2s ease,
|
|
||||||
transform 0.2s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95);
|
|
||||||
visibility: hidden;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-arrow {
|
|
||||||
position: absolute;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background: #fff;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tooltip-top .tooltip-arrow,
|
|
||||||
&.tooltip-bottom .tooltip-arrow {
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tooltip-left .tooltip-arrow,
|
|
||||||
&.tooltip-right .tooltip-arrow {
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tooltip-top .tooltip-arrow {
|
|
||||||
bottom: -4px;
|
|
||||||
}
|
|
||||||
&.tooltip-bottom .tooltip-arrow {
|
|
||||||
top: -4px;
|
|
||||||
}
|
|
||||||
&.tooltip-left .tooltip-arrow {
|
|
||||||
right: -4px;
|
|
||||||
}
|
|
||||||
&.tooltip-right .tooltip-arrow {
|
|
||||||
left: -4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,6 @@ export default defineComponent({
|
|||||||
icon={props.icon}
|
icon={props.icon}
|
||||||
size={props.size}
|
size={props.size}
|
||||||
spin
|
spin
|
||||||
rotate={props.rotate}
|
|
||||||
/>
|
/>
|
||||||
{props.tip && <span class={`${prefixCls}-tip`}>{props.tip}</span>}
|
{props.tip && <span class={`${prefixCls}-tip`}>{props.tip}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+50
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@ao/ao-design':
|
'@ao/ao-design':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:packages/ao-design
|
version: link:packages/ao-design
|
||||||
|
'@floating-ui/vue':
|
||||||
|
specifier: ^1.1.9
|
||||||
|
version: 1.1.9(vue@3.5.19(typescript@5.8.3))
|
||||||
'@iconify/vue':
|
'@iconify/vue':
|
||||||
specifier: 5.0.0
|
specifier: 5.0.0
|
||||||
version: 5.0.0(vue@3.5.19(typescript@5.8.3))
|
version: 5.0.0(vue@3.5.19(typescript@5.8.3))
|
||||||
@@ -510,6 +513,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10':
|
||||||
|
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||||
|
|
||||||
|
'@floating-ui/vue@1.1.9':
|
||||||
|
resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -2292,6 +2307,17 @@ packages:
|
|||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
vue-demi@0.14.10:
|
||||||
|
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
vue-eslint-parser@10.2.0:
|
vue-eslint-parser@10.2.0:
|
||||||
resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==}
|
resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -2768,6 +2794,26 @@ snapshots:
|
|||||||
'@eslint/core': 0.15.2
|
'@eslint/core': 0.15.2
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.3
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10': {}
|
||||||
|
|
||||||
|
'@floating-ui/vue@1.1.9(vue@3.5.19(typescript@5.8.3))':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.4
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
vue-demi: 0.14.10(vue@3.5.19(typescript@5.8.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
@@ -4751,6 +4797,10 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
|
vue-demi@0.14.10(vue@3.5.19(typescript@5.8.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.19(typescript@5.8.3)
|
||||||
|
|
||||||
vue-eslint-parser@10.2.0(eslint@9.34.0):
|
vue-eslint-parser@10.2.0(eslint@9.34.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
|
|||||||
@@ -1,28 +1,65 @@
|
|||||||
<script setup lang="ts">
|
<script setup>
|
||||||
function setDark() {
|
import { arrow, offset, useFloating } from '@floating-ui/vue'
|
||||||
document.documentElement.setAttribute('theme-mode', 'dark')
|
import { ref } from 'vue'
|
||||||
document.body.style.backgroundColor = '#242424'
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLight() {
|
const reference = ref(null) // 按钮
|
||||||
document.documentElement.setAttribute('theme-mode', 'light')
|
const floating = ref(null) // tooltip
|
||||||
document.body.style.backgroundColor = '#ffffff'
|
const floatingArrow = ref(null)
|
||||||
}
|
|
||||||
|
// 使用 useFloating 计算浮动位置
|
||||||
|
const { floatingStyles, middlewareData } = useFloating(reference, floating, {
|
||||||
|
middleware: [offset(8), arrow({ element: floatingArrow })],
|
||||||
|
placement: 'bottom',
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div style="height: 100vh;width: 100%;display: flex;align-items: center;flex-direction: column;justify-content: center;gap:16px">
|
<div style="padding: 100px;">
|
||||||
<div style="display: flex; gap: 8px;">
|
<APopup />
|
||||||
<AButton status="primary" size="medium" @click="setDark">
|
<button
|
||||||
黑色主题
|
ref="reference" style="width: 200px;"
|
||||||
</AButton>
|
>
|
||||||
|
Button
|
||||||
|
</button>
|
||||||
|
|
||||||
<AButton status="primary" size="medium" @click="setLight">
|
<div ref="floating" class="floating" :style="floatingStyles">
|
||||||
白色主题
|
Tooltip
|
||||||
</AButton>
|
<div
|
||||||
|
ref="floatingArrow"
|
||||||
|
class="arrow"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left:
|
||||||
|
middlewareData.arrow?.x != null
|
||||||
|
? `${middlewareData.arrow.x}px`
|
||||||
|
: '',
|
||||||
|
top:
|
||||||
|
middlewareData.arrow?.y != null
|
||||||
|
? `${middlewareData.arrow.y}px`
|
||||||
|
: '',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<APopup content="123456" trigger="hover">
|
|
||||||
<AButton>测试</AButton>
|
|
||||||
</APopup>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* tooltip 样式 */
|
||||||
|
.floating {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); /* 添加阴影 */
|
||||||
|
}
|
||||||
|
/* 箭头 */
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,115 +1,105 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>按钮示例</title>
|
<title>Popup with Arrow</title>
|
||||||
<!-- 这里使用 Font Awesome CDN,你可以换成自己的图标库 -->
|
<style>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
body {
|
||||||
<style>
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触发按钮 */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; /* 内部水平排列 */
|
padding: 10px 20px;
|
||||||
align-items: center; /* 垂直居中 */
|
background-color: #3498db;
|
||||||
justify-content: center; /* 水平居中 */
|
color: white;
|
||||||
gap: 4px; /* 图标和文字间距 */
|
border: none;
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f4f5f5;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background-color: #eaeaea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
background-color: #dcdcdc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px; /* 控制图标大小 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- 图标在左 -->
|
|
||||||
<button class="btn">
|
|
||||||
<i class="fas fa-check btn-icon"></i>
|
|
||||||
<span class="btn-text">按钮文字</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 图标在右 -->
|
|
||||||
<button class="btn">
|
|
||||||
<span class="btn-text">按钮文字</span>
|
|
||||||
<i class="fas fa-arrow-right btn-icon"></i>
|
|
||||||
</button>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>按钮示例</title>
|
|
||||||
<!-- 这里使用 Font Awesome CDN,你可以换成自己的图标库 -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
|
||||||
<style>
|
|
||||||
.btn {
|
|
||||||
display: inline-flex; /* 内部水平排列 */
|
|
||||||
align-items: center; /* 垂直居中 */
|
|
||||||
justify-content: center; /* 水平居中 */
|
|
||||||
gap: 4px; /* 图标和文字间距 */
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #f4f5f5;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
/* Popup 基础样式 */
|
||||||
background-color: #eaeaea;
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
display: none;
|
||||||
|
z-index: 100;
|
||||||
|
max-width: 200px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active {
|
/* 箭头公共样式 */
|
||||||
background-color: #dcdcdc;
|
.popup::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
/* 箭头方向 */
|
||||||
display: inline-flex;
|
.popup.top::after {
|
||||||
align-items: center;
|
bottom: -8px;
|
||||||
justify-content: center;
|
left: 50%;
|
||||||
font-size: 16px; /* 控制图标大小 */
|
transform: translateX(-50%);
|
||||||
|
border-width: 8px 8px 0 8px;
|
||||||
|
border-color: #333 transparent transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-text {
|
.popup.bottom::after {
|
||||||
display: inline-block;
|
top: -8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-width: 0 8px 8px 8px;
|
||||||
|
border-color: transparent transparent #333 transparent;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- 图标在左 -->
|
|
||||||
<button class="btn">
|
|
||||||
<i class="fas fa-check btn-icon"></i>
|
|
||||||
<span class="btn-text">按钮文字</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 图标在右 -->
|
.popup.left::after {
|
||||||
<button class="btn">
|
top: 50%;
|
||||||
<span class="btn-text">按钮文字</span>
|
right: -8px;
|
||||||
<i class="fas fa-arrow-right btn-icon"></i>
|
transform: translateY(-50%);
|
||||||
</button>
|
border-width: 8px 0 8px 8px;
|
||||||
</body>
|
border-color: transparent transparent transparent #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup.right::after {
|
||||||
|
top: 50%;
|
||||||
|
left: -8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-width: 8px 8px 8px 0;
|
||||||
|
border-color: transparent #333 transparent transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<button class="btn" id="btn">Hover me</button>
|
||||||
|
|
||||||
|
<div class="popup top" id="popup">这是一个带箭头的 Popup!</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const btn = document.getElementById('btn');
|
||||||
|
const popup = document.getElementById('popup');
|
||||||
|
|
||||||
|
// 鼠标悬停显示
|
||||||
|
btn.addEventListener('mouseenter', () => {
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
popup.style.display = 'block';
|
||||||
|
// 设置 Popup 位置(示例:顶部)
|
||||||
|
popup.style.left = rect.left + rect.width / 2 - popup.offsetWidth / 2 + 'px';
|
||||||
|
popup.style.top = rect.top - popup.offsetHeight - 10 + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.addEventListener('mouseleave', () => {
|
||||||
|
popup.style.display = 'none';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
target/
|
||||||
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
|
!**/src/main/**/target/
|
||||||
|
!**/src/test/**/target/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea/modules.xml
|
||||||
|
.idea/jarRepositories.xml
|
||||||
|
.idea/compiler.xml
|
||||||
|
.idea/libraries/
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
### Eclipse ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
build/
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
### Mac OS ###
|
||||||
|
.DS_Store
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Environment-dependent path to Maven home directory
|
||||||
|
/mavenHomeManager.xml
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.email</groupId>
|
||||||
|
<artifactId>Email-Netty</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>8</maven.compiler.source>
|
||||||
|
<maven.compiler.target>8</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.netty</groupId>
|
||||||
|
<artifactId>netty-all</artifactId>
|
||||||
|
<version>4.1.106.Final</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package com.email;
|
||||||
|
|
||||||
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
|
import io.netty.channel.SimpleChannelInboundHandler;
|
||||||
|
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public class ImapClientHandler extends SimpleChannelInboundHandler<String> {
|
||||||
|
|
||||||
|
private final String username;
|
||||||
|
private final String password;
|
||||||
|
|
||||||
|
private enum State { LOGIN, SELECT, FETCH, DONE }
|
||||||
|
private State state = State.LOGIN;
|
||||||
|
|
||||||
|
private int tagCounter = 1;
|
||||||
|
private StringBuilder headerBuffer = new StringBuilder();
|
||||||
|
private StringBuilder bodyBuffer = new StringBuilder();
|
||||||
|
private boolean inHeader = true;
|
||||||
|
private String boundary = null;
|
||||||
|
|
||||||
|
private boolean inAttachment = false;
|
||||||
|
private String currentFilename = null;
|
||||||
|
|
||||||
|
public ImapClientHandler(String username, String password) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nextTag() {
|
||||||
|
return "a" + (tagCounter++);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void channelActive(ChannelHandlerContext ctx) {
|
||||||
|
System.out.println("Connected to IMAP server...");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void channelRead0(ChannelHandlerContext ctx, String line) {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.isEmpty()) return;
|
||||||
|
|
||||||
|
System.out.println("S: " + line);
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case LOGIN:
|
||||||
|
if (line.startsWith("* OK")) {
|
||||||
|
ctx.writeAndFlush(nextTag() + " LOGIN " + username + " " + password + "\r\n");
|
||||||
|
} else if (line.contains("OK LOGIN")) {
|
||||||
|
state = State.SELECT;
|
||||||
|
ctx.writeAndFlush(nextTag() + " SELECT INBOX\r\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SELECT:
|
||||||
|
if (line.contains("OK [READ-WRITE]") || line.contains("OK [READ-ONLY]")) {
|
||||||
|
state = State.FETCH;
|
||||||
|
ctx.writeAndFlush(nextTag() + " FETCH 1:* (BODY.PEEK[])\r\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FETCH:
|
||||||
|
if (line.contains("OK FETCH completed")) {
|
||||||
|
state = State.DONE;
|
||||||
|
System.out.println("All emails fetched!");
|
||||||
|
ctx.close();
|
||||||
|
} else if (line.startsWith("*") && line.contains("FETCH")) {
|
||||||
|
// 新邮件开始
|
||||||
|
headerBuffer.setLength(0);
|
||||||
|
bodyBuffer.setLength(0);
|
||||||
|
inHeader = true;
|
||||||
|
inAttachment = false;
|
||||||
|
currentFilename = null;
|
||||||
|
boundary = null;
|
||||||
|
} else {
|
||||||
|
if (inHeader) {
|
||||||
|
headerBuffer.append(line).append("\r\n");
|
||||||
|
if (line.isEmpty()) { // header结束
|
||||||
|
inHeader = false;
|
||||||
|
|
||||||
|
// 解析邮件标题
|
||||||
|
Matcher subjectMatcher = Pattern.compile("(?i)^Subject: (.+)$", Pattern.MULTILINE).matcher(headerBuffer);
|
||||||
|
if (subjectMatcher.find()) {
|
||||||
|
String subject = subjectMatcher.group(1).trim();
|
||||||
|
System.out.println("Email Subject: " + subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 boundary
|
||||||
|
Matcher m = Pattern.compile("boundary=\"([^\"]+)\"").matcher(headerBuffer);
|
||||||
|
if (m.find()) boundary = m.group(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// body部分
|
||||||
|
if (boundary != null && line.contains(boundary)) {
|
||||||
|
// attachment结束
|
||||||
|
if (inAttachment && currentFilename != null) {
|
||||||
|
saveAttachment(bodyBuffer.toString(), currentFilename);
|
||||||
|
System.out.println("Attachment saved: " + currentFilename);
|
||||||
|
bodyBuffer.setLength(0);
|
||||||
|
inAttachment = false;
|
||||||
|
currentFilename = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 检测附件开始
|
||||||
|
Matcher m = Pattern.compile("Content-Disposition: attachment; filename=\"([^\"]+)\"").matcher(line);
|
||||||
|
if (m.find()) {
|
||||||
|
currentFilename = m.group(1);
|
||||||
|
inAttachment = true;
|
||||||
|
bodyBuffer.setLength(0);
|
||||||
|
} else if (inAttachment) {
|
||||||
|
bodyBuffer.append(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DONE:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveAttachment(String base64Data, String filename) {
|
||||||
|
try {
|
||||||
|
byte[] data = Base64.getDecoder().decode(base64Data);
|
||||||
|
try (OutputStream os = new FileOutputStream(filename)) {
|
||||||
|
os.write(data);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
||||||
|
cause.printStackTrace();
|
||||||
|
ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.email;
|
||||||
|
|
||||||
|
import io.netty.bootstrap.Bootstrap;
|
||||||
|
import io.netty.channel.*;
|
||||||
|
import io.netty.channel.nio.NioEventLoopGroup;
|
||||||
|
import io.netty.channel.socket.SocketChannel;
|
||||||
|
import io.netty.channel.socket.nio.NioSocketChannel;
|
||||||
|
import io.netty.handler.codec.LineBasedFrameDecoder;
|
||||||
|
import io.netty.handler.codec.string.StringDecoder;
|
||||||
|
import io.netty.handler.ssl.SslContext;
|
||||||
|
import io.netty.handler.ssl.SslContextBuilder;
|
||||||
|
|
||||||
|
public class NettyImapClient {
|
||||||
|
|
||||||
|
private final String host;
|
||||||
|
private final int port;
|
||||||
|
private final String username;
|
||||||
|
private final String password;
|
||||||
|
|
||||||
|
public NettyImapClient(String host, int port, String username, String password) {
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() throws Exception {
|
||||||
|
EventLoopGroup group = new NioEventLoopGroup();
|
||||||
|
SslContext sslCtx = SslContextBuilder.forClient().build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Bootstrap b = new Bootstrap();
|
||||||
|
b.group(group)
|
||||||
|
.channel(NioSocketChannel.class)
|
||||||
|
.handler(new ChannelInitializer<SocketChannel>() {
|
||||||
|
@Override
|
||||||
|
protected void initChannel(SocketChannel ch) {
|
||||||
|
ChannelPipeline p = ch.pipeline();
|
||||||
|
p.addLast(sslCtx.newHandler(ch.alloc(), host, port));
|
||||||
|
p.addLast(new LineBasedFrameDecoder(8192));
|
||||||
|
p.addLast(new StringDecoder());
|
||||||
|
p.addLast(new ImapClientHandler(username, password));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ChannelFuture f = b.connect(host, port).sync();
|
||||||
|
f.channel().closeFuture().sync();
|
||||||
|
} finally {
|
||||||
|
group.shutdownGracefully();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
String host = "imap.qq.com"; // IMAP服务器
|
||||||
|
int port = 993; // SSL端口
|
||||||
|
String user = "717406575@qq.com";
|
||||||
|
String pass = "dhqesrwzgblrbcac"; // 授权码
|
||||||
|
|
||||||
|
new NettyImapClient(host, port, user, pass).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user