From 905288bf7e59ab6bf881808fa1d1f4d82fdb9770 Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Tue, 21 Jul 2026 03:06:47 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DnavBar=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B9=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/navBarConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/navBarConfig.ts b/src/config/navBarConfig.ts index 99942057dd..81c1a43f5a 100644 --- a/src/config/navBarConfig.ts +++ b/src/config/navBarConfig.ts @@ -42,10 +42,10 @@ const getDynamicNavBarConfig = (): NavBarConfig => { url: "#", icon: "material-symbols:group", children: [ - // 相册 + // 友链 LinkPresets.Friends, - // 追番 + // 留言 LinkPresets.Guestbook, ], }); From 2a7401ebbdbb0f432dd9d7fb579589e14c0ba904 Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Sun, 26 Jul 2026 03:56:35 +0800 Subject: [PATCH 2/8] feat(types/utils): add social link interaction types and positioning/controller utilities --- src/types/profileConfig.ts | 23 +++- src/utils/navbar-social-interaction.ts | 162 +++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 src/utils/navbar-social-interaction.ts diff --git a/src/types/profileConfig.ts b/src/types/profileConfig.ts index d17289af2d..7b22df3e9d 100644 --- a/src/types/profileConfig.ts +++ b/src/types/profileConfig.ts @@ -1,11 +1,22 @@ +export type ProfileLinkInteraction = { + qrCode?: string; + qrCodeAlt?: string; + copyText?: string; + openDelayMs?: number; +}; + +export type ProfileLink = { + name: string; + url: string; + icon: string; + showName?: boolean; + interaction?: ProfileLinkInteraction; +}; + export type ProfileConfig = { avatar?: string; name: string; bio?: string; - links: { - name: string; - url: string; - icon: string; - showName?: boolean; - }[]; + links: ProfileLink[]; }; + diff --git a/src/utils/navbar-social-interaction.ts b/src/utils/navbar-social-interaction.ts new file mode 100644 index 0000000000..6045be0eb5 --- /dev/null +++ b/src/utils/navbar-social-interaction.ts @@ -0,0 +1,162 @@ +export type Rect = { + top: number; + right: number; + bottom: number; + left: number; + width: number; + height: number; +}; + +export type Size = { + width: number; + height: number; +}; + +export type QrPopoverPlacement = { + placement: "top" | "bottom"; + top: number; + left: number; +}; + +export type DelayedLinkOptions = { + copyText: string; + href: string; + openDelayMs: number; +}; + +export type DelayedLinkNotification = + | { kind: "success"; copyText: string; secondsRemaining: number } + | { kind: "failure"; copyText: string } + | { kind: "complete"; copyText: string }; + +export type DelayedLinkDependencies = { + copy: (text: string) => Promise; + notify: (notification: DelayedLinkNotification) => void; + open: (href: string) => void; + setTimeout: (callback: () => void, delay: number) => number; + clearTimeout: (id: number) => void; + setInterval: (callback: () => void, delay: number) => number; + clearInterval: (id: number) => void; +}; + +export type DelayedLinkController = { + activate: (options: DelayedLinkOptions) => Promise; + cancel: () => void; +}; + +export function calculateQrPopoverPosition( + trigger: Rect, + popover: Size, + viewport: Size, + gap = 8, + margin = 12, +): QrPopoverPlacement { + const spaceBelow = viewport.height - trigger.bottom; + const spaceAbove = trigger.top; + const placement = + spaceBelow < popover.height + gap && spaceAbove > spaceBelow + ? "top" + : "bottom"; + const top = + placement === "top" + ? trigger.top - popover.height - gap + : trigger.bottom + gap; + const centeredLeft = trigger.left + (trigger.width - popover.width) / 2; + const maxLeft = Math.max(margin, viewport.width - popover.width - margin); + const left = Math.min(Math.max(centeredLeft, margin), maxLeft); + + return { placement, top, left }; +} + +export async function copyWithFallback( + primary: (() => Promise) | undefined, + fallback: () => boolean, +): Promise { + if (primary) { + try { + await primary(); + return true; + } catch { + // Continue with legacy clipboard fallback. + } + } + + try { + return fallback(); + } catch { + return false; + } +} + +export function createDelayedLinkController( + dependencies: DelayedLinkDependencies, +): DelayedLinkController { + let timeoutId: number | undefined; + let intervalId: number | undefined; + let activationId = 0; + + function clearTimers(): void { + if (timeoutId !== undefined) { + dependencies.clearTimeout(timeoutId); + timeoutId = undefined; + } + if (intervalId !== undefined) { + dependencies.clearInterval(intervalId); + intervalId = undefined; + } + } + + function cancel(): void { + activationId += 1; + clearTimers(); + } + + async function activate(options: DelayedLinkOptions): Promise { + activationId += 1; + const currentActivation = activationId; + clearTimers(); + + const copied = await dependencies.copy(options.copyText); + if (currentActivation !== activationId) return; + + if (!copied) { + dependencies.notify({ kind: "failure", copyText: options.copyText }); + return; + } + + let secondsRemaining = Math.max(1, Math.ceil(options.openDelayMs / 1000)); + dependencies.notify({ + kind: "success", + copyText: options.copyText, + secondsRemaining, + }); + + intervalId = dependencies.setInterval(() => { + if (secondsRemaining <= 0) return; + secondsRemaining -= 1; + if (secondsRemaining > 0) { + dependencies.notify({ + kind: "success", + copyText: options.copyText, + secondsRemaining, + }); + } else { + dependencies.notify({ + kind: "complete", + copyText: options.copyText, + }); + } + }, 1000); + + timeoutId = dependencies.setTimeout(() => { + clearTimers(); + dependencies.notify({ + kind: "complete", + copyText: options.copyText, + }); + dependencies.open(options.href); + }, options.openDelayMs); + } + + return { activate, cancel }; +} From 6554f011363ea6855f43a99e5e96ced428c082aa Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Sun, 26 Jul 2026 03:56:45 +0800 Subject: [PATCH 3/8] feat(components): add NavbarSocialInteraction component and mount in Navbar --- src/components/layout/Navbar.astro | 4 + .../layout/NavbarSocialInteraction.astro | 289 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/components/layout/NavbarSocialInteraction.astro diff --git a/src/components/layout/Navbar.astro b/src/components/layout/Navbar.astro index 7b5454a5fd..678a8f8155 100644 --- a/src/components/layout/Navbar.astro +++ b/src/components/layout/Navbar.astro @@ -22,6 +22,8 @@ import { isHomePage } from "@/utils/layout-utils"; import { url } from "@/utils/url-utils"; import DropdownMenu from "./DropdownMenu.astro"; import NavMenuPanel from "./NavMenuPanel.astro"; +import NavbarSocialInteraction from "./NavbarSocialInteraction.astro"; + const className = Astro.props.class; @@ -193,9 +195,11 @@ if (isLocalSrcLogo) { )} + + From e36b7ea7ff7d8344fed1737639d72a6758032cd7 Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Sun, 26 Jul 2026 03:56:53 +0800 Subject: [PATCH 4/8] feat(profile): enhance Profile widget to support social interaction popovers --- src/components/widget/Profile.astro | 169 ++++++++++++++++++---------- 1 file changed, 112 insertions(+), 57 deletions(-) diff --git a/src/components/widget/Profile.astro b/src/components/widget/Profile.astro index fa002b43c9..013aff0e9c 100644 --- a/src/components/widget/Profile.astro +++ b/src/components/widget/Profile.astro @@ -12,7 +12,7 @@ const { style } = Astro.props; const className = Astro.props.class; --- -
+
- {profileConfig.links.length > 1 && profileConfig.links.map(item => - { - const showName = item.showName; - const className = showName - ? "btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95" - : "btn-regular rounded-lg h-10 w-10 active:scale-90"; + {profileConfig.links.map(item => { + const showName = item.showName; + const className = showName + ? "btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95 flex items-center" + : "btn-regular rounded-lg h-10 w-10 active:scale-90 flex items-center justify-center"; + const targetUrl = item.interaction?.copyText ? item.url : (item.url.startsWith("mailto:") ? "#" : item.url); - if (item.url.startsWith("mailto:")) { - const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64"); - return - - {showName && item.name} - - } else { - return + if (item.url.startsWith("mailto:") && !item.interaction?.copyText) { + const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64"); + return ( +
+ + + {showName && item.name} + + {item.interaction?.qrCode && ( + + )} +
+ ); + } + + return ( +
+ {showName && item.name} - } - } - )} - {profileConfig.links.length == 1 && (function(item){ - if (item.url.startsWith("mailto:")) { - const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64"); - return - - {item.name} - - } else { - return - - {item.name} - - } - })(profileConfig.links[0])} + {item.interaction?.qrCode && ( + + )} +
+ ); + })}
+ + From c5a99ff005cb860a607dd85be294152f2a311de3 Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Sun, 26 Jul 2026 03:57:00 +0800 Subject: [PATCH 5/8] docs(config): add interaction usage comments and configuration templates --- src/config/profileConfig.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config/profileConfig.ts b/src/config/profileConfig.ts index 23fdd3e9a4..b0a635354e 100644 --- a/src/config/profileConfig.ts +++ b/src/config/profileConfig.ts @@ -20,6 +20,12 @@ export const profileConfig: ProfileConfig = { // 如果想使用尚未包含相应的图标集,则需要安装它 // `pnpm add @iconify-json/` // showName: true 时显示图标和名称,false 时只显示图标 + // interaction 可配置悬停/点击交互选项: + // - qrCode: 悬停/聚焦时弹出的二维码图片路径 (如 "/assets/qr-code/wechat.png") + // - qrCodeAlt: 二维码图片的 Alt 描述文本 + // - copyText: 点击时自动复制到剪贴板的文本 (如微信号/QQ号) + // - openDelayMs: 点击后延迟打开外链的时间(毫秒,默认 0) + links: [ { name: "qq", From 9b7141b77f63101e482289368404a941b4638222 Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Mon, 27 Jul 2026 00:00:46 +0800 Subject: [PATCH 6/8] docs: update profile config --- src/config/profileConfig.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/config/profileConfig.ts b/src/config/profileConfig.ts index b0a635354e..70ca4e9d17 100644 --- a/src/config/profileConfig.ts +++ b/src/config/profileConfig.ts @@ -32,7 +32,25 @@ export const profileConfig: ProfileConfig = { icon: "fa7-brands:qq", url: "https://qm.qq.com/q/ZGsFa8qX2G", showName: false, +/* interaction: { + copyText: "qq群:xxxx 网站:xxxx 欢迎扩列~", + openDelayMs: 0, + qrCode: "https://example.com/assets/qq_qrcode.png", + qrCodeAlt: "QQ 二维码", + }, */ }, +/* { + name: "微信", + icon: "fa7-brands:weixin", + url: "weixin://", + showName: false, + interaction: { + copyText: "微信:xxx 网站:xxxx 欢迎扩列~", + openDelayMs: 1500, + qrCode: "https://example.com/assets/wechat_qrcode.png", + qrCodeAlt: "微信二维码", + }, + }, */ { name: "GitHub", icon: "fa7-brands:github", @@ -52,4 +70,4 @@ export const profileConfig: ProfileConfig = { showName: false, }, ], -}; +}; \ No newline at end of file From 4b46b5fbe49f53013613e98403e998180029cf00 Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Mon, 27 Jul 2026 00:33:49 +0800 Subject: [PATCH 7/8] fix(components): use window.open for external links and set default 3s delay for wechat --- .../layout/NavbarSocialInteraction.astro | 18 ++++++++++----- src/config/profileConfig.ts | 2 +- src/utils/navbar-social-interaction.ts | 22 +++++++++++++------ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/components/layout/NavbarSocialInteraction.astro b/src/components/layout/NavbarSocialInteraction.astro index f9448db629..c947ea98c2 100644 --- a/src/components/layout/NavbarSocialInteraction.astro +++ b/src/components/layout/NavbarSocialInteraction.astro @@ -72,14 +72,18 @@ import { Icon } from "astro-icon/components"; const announcementKey = `${notification.kind}:${notification.copyText}`; if (notification.kind === "success") { - message.textContent = `微信号 ${notification.copyText} 已复制,${notification.secondsRemaining} 秒后将尝试打开微信(${notification.secondsRemaining})`; + const delaySeconds = (notification.openDelayMs / 1000).toFixed(1); + message.textContent = `已复制 "${notification.copyText}",${delaySeconds} 秒后尝试打开应用`; if (live && live.dataset.announcement !== announcementKey) { - live.textContent = `微信号 ${notification.copyText} 已复制,3 秒后将尝试打开微信`; + live.textContent = message.textContent; live.dataset.announcement = announcementKey; } setToastVisible(true); } else if (notification.kind === "complete") { - message.textContent = `微信号 ${notification.copyText} 已复制,正在尝试打开微信...`; + message.textContent = + notification.openDelayMs > 0 + ? `已复制 "${notification.copyText}",正在尝试打开应用...` + : `已复制 "${notification.copyText}" 并正在跳转...`; if (live) { live.textContent = message.textContent; live.dataset.announcement = announcementKey; @@ -88,7 +92,7 @@ import { Icon } from "astro-icon/components"; if (toastAutoHideTimer !== undefined) { window.clearTimeout(toastAutoHideTimer); } - toastAutoHideTimer = window.setTimeout(() => setToastVisible(false), 1200); + toastAutoHideTimer = window.setTimeout(() => setToastVisible(false), 1500); } else { message.textContent = `无法自动复制,请手动复制:${notification.copyText}`; if (live) { @@ -138,7 +142,11 @@ import { Icon } from "astro-icon/components"; copy: copyText, notify: showNotification, open: (href) => { - window.location.href = href; + if (href.startsWith("http://") || href.startsWith("https://")) { + window.open(href, "_blank", "noopener,noreferrer"); + } else { + window.location.href = href; + } }, setTimeout: (callback, delay) => window.setTimeout(callback, delay), clearTimeout: (id) => window.clearTimeout(id), diff --git a/src/config/profileConfig.ts b/src/config/profileConfig.ts index 70ca4e9d17..7bd57354b0 100644 --- a/src/config/profileConfig.ts +++ b/src/config/profileConfig.ts @@ -46,7 +46,7 @@ export const profileConfig: ProfileConfig = { showName: false, interaction: { copyText: "微信:xxx 网站:xxxx 欢迎扩列~", - openDelayMs: 1500, + openDelayMs: 3000, qrCode: "https://example.com/assets/wechat_qrcode.png", qrCodeAlt: "微信二维码", }, diff --git a/src/utils/navbar-social-interaction.ts b/src/utils/navbar-social-interaction.ts index 6045be0eb5..2a4fd929c6 100644 --- a/src/utils/navbar-social-interaction.ts +++ b/src/utils/navbar-social-interaction.ts @@ -25,9 +25,9 @@ export type DelayedLinkOptions = { }; export type DelayedLinkNotification = - | { kind: "success"; copyText: string; secondsRemaining: number } + | { kind: "success"; copyText: string; openDelayMs: number; secondsRemaining: number } | { kind: "failure"; copyText: string } - | { kind: "complete"; copyText: string }; + | { kind: "complete"; copyText: string; openDelayMs: number }; export type DelayedLinkDependencies = { copy: (text: string) => Promise; @@ -124,10 +124,21 @@ export function createDelayedLinkController( return; } + if (options.openDelayMs <= 0) { + dependencies.notify({ + kind: "complete", + copyText: options.copyText, + openDelayMs: 0, + }); + dependencies.open(options.href); + return; + } + let secondsRemaining = Math.max(1, Math.ceil(options.openDelayMs / 1000)); dependencies.notify({ kind: "success", copyText: options.copyText, + openDelayMs: options.openDelayMs, secondsRemaining, }); @@ -138,13 +149,9 @@ export function createDelayedLinkController( dependencies.notify({ kind: "success", copyText: options.copyText, + openDelayMs: options.openDelayMs, secondsRemaining, }); - } else { - dependencies.notify({ - kind: "complete", - copyText: options.copyText, - }); } }, 1000); @@ -153,6 +160,7 @@ export function createDelayedLinkController( dependencies.notify({ kind: "complete", copyText: options.copyText, + openDelayMs: options.openDelayMs, }); dependencies.open(options.href); }, options.openDelayMs); From 5504bafa8dae83f630f8b0a3df654b7dee7567d8 Mon Sep 17 00:00:00 2001 From: sonder <2734165908@qq.com> Date: Mon, 27 Jul 2026 00:36:14 +0800 Subject: [PATCH 8/8] style: format code and sort imports with Biome --- src/components/layout/Navbar.astro | 3 +-- src/config/profileConfig.ts | 6 +++--- src/types/profileConfig.ts | 1 - src/utils/navbar-social-interaction.ts | 7 ++++++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/layout/Navbar.astro b/src/components/layout/Navbar.astro index 678a8f8155..55662430d4 100644 --- a/src/components/layout/Navbar.astro +++ b/src/components/layout/Navbar.astro @@ -21,9 +21,8 @@ import { getFallbackFormat, getImageFormats } from "@/utils/image-utils"; import { isHomePage } from "@/utils/layout-utils"; import { url } from "@/utils/url-utils"; import DropdownMenu from "./DropdownMenu.astro"; -import NavMenuPanel from "./NavMenuPanel.astro"; import NavbarSocialInteraction from "./NavbarSocialInteraction.astro"; - +import NavMenuPanel from "./NavMenuPanel.astro"; const className = Astro.props.class; diff --git a/src/config/profileConfig.ts b/src/config/profileConfig.ts index 7bd57354b0..0cf9442ede 100644 --- a/src/config/profileConfig.ts +++ b/src/config/profileConfig.ts @@ -32,14 +32,14 @@ export const profileConfig: ProfileConfig = { icon: "fa7-brands:qq", url: "https://qm.qq.com/q/ZGsFa8qX2G", showName: false, -/* interaction: { + /* interaction: { copyText: "qq群:xxxx 网站:xxxx 欢迎扩列~", openDelayMs: 0, qrCode: "https://example.com/assets/qq_qrcode.png", qrCodeAlt: "QQ 二维码", }, */ }, -/* { + /* { name: "微信", icon: "fa7-brands:weixin", url: "weixin://", @@ -70,4 +70,4 @@ export const profileConfig: ProfileConfig = { showName: false, }, ], -}; \ No newline at end of file +}; diff --git a/src/types/profileConfig.ts b/src/types/profileConfig.ts index 7b22df3e9d..81d628d7a8 100644 --- a/src/types/profileConfig.ts +++ b/src/types/profileConfig.ts @@ -19,4 +19,3 @@ export type ProfileConfig = { bio?: string; links: ProfileLink[]; }; - diff --git a/src/utils/navbar-social-interaction.ts b/src/utils/navbar-social-interaction.ts index 2a4fd929c6..9d1df0d32c 100644 --- a/src/utils/navbar-social-interaction.ts +++ b/src/utils/navbar-social-interaction.ts @@ -25,7 +25,12 @@ export type DelayedLinkOptions = { }; export type DelayedLinkNotification = - | { kind: "success"; copyText: string; openDelayMs: number; secondsRemaining: number } + | { + kind: "success"; + copyText: string; + openDelayMs: number; + secondsRemaining: number; + } | { kind: "failure"; copyText: string } | { kind: "complete"; copyText: string; openDelayMs: number };