diff --git a/src/components/layout/Navbar.astro b/src/components/layout/Navbar.astro
index 7b5454a5fd..55662430d4 100644
--- a/src/components/layout/Navbar.astro
+++ b/src/components/layout/Navbar.astro
@@ -21,6 +21,7 @@ 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 NavbarSocialInteraction from "./NavbarSocialInteraction.astro";
import NavMenuPanel from "./NavMenuPanel.astro";
const className = Astro.props.class;
@@ -193,9 +194,11 @@ if (isLocalSrcLogo) {
)}
+
+
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;
---
-
+
+
diff --git a/src/config/profileConfig.ts b/src/config/profileConfig.ts
index 23fdd3e9a4..0cf9442ede 100644
--- a/src/config/profileConfig.ts
+++ b/src/config/profileConfig.ts
@@ -20,13 +20,37 @@ 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",
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: 3000,
+ qrCode: "https://example.com/assets/wechat_qrcode.png",
+ qrCodeAlt: "微信二维码",
+ },
+ }, */
{
name: "GitHub",
icon: "fa7-brands:github",
diff --git a/src/types/profileConfig.ts b/src/types/profileConfig.ts
index d17289af2d..81d628d7a8 100644
--- a/src/types/profileConfig.ts
+++ b/src/types/profileConfig.ts
@@ -1,11 +1,21 @@
+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..9d1df0d32c
--- /dev/null
+++ b/src/utils/navbar-social-interaction.ts
@@ -0,0 +1,175 @@
+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;
+ openDelayMs: number;
+ secondsRemaining: number;
+ }
+ | { kind: "failure"; copyText: string }
+ | { kind: "complete"; copyText: string; openDelayMs: number };
+
+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;
+ }
+
+ 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,
+ });
+
+ intervalId = dependencies.setInterval(() => {
+ if (secondsRemaining <= 0) return;
+ secondsRemaining -= 1;
+ if (secondsRemaining > 0) {
+ dependencies.notify({
+ kind: "success",
+ copyText: options.copyText,
+ openDelayMs: options.openDelayMs,
+ secondsRemaining,
+ });
+ }
+ }, 1000);
+
+ timeoutId = dependencies.setTimeout(() => {
+ clearTimers();
+ dependencies.notify({
+ kind: "complete",
+ copyText: options.copyText,
+ openDelayMs: options.openDelayMs,
+ });
+ dependencies.open(options.href);
+ }, options.openDelayMs);
+ }
+
+ return { activate, cancel };
+}