Commit 2781f66

HPCesia <me@hpcesia.com>
2025-04-06 12:52:46
refactor(aside): migrate recent comments card to Vue
1 parent 63e0eb1
src/components/aside/recent-comments/Artalk.ts
@@ -1,71 +1,58 @@
-import {
-  cleanCommentHtml,
-  cleanPlaceholders,
-  createCommentItem,
-  getTemplate,
-} from './utils.ts';
+import type { CommentData, CommentProvider } from './types';
+import { cleanCommentHtml } from './utils';
 import { asideConfig, commentConfig } from '@/config';
 
-async function setup() {
-  const artalkConfig = commentConfig.artalk!;
-  const commentCount = asideConfig.recentComment.count;
-
-  const apiCommentUrl = new URL(
-    `api/v2/stats/latest_comments?limit=${commentCount}`,
-    artalkConfig.serverURL
-  ).toString();
-  const apiConfigUrl = new URL(`api/v2/conf`, artalkConfig.serverURL).toString();
-
-  const responseComment = fetch(apiCommentUrl, {
-    method: 'GET',
-  });
-  const responseConfig = fetch(apiConfigUrl, {
-    method: 'GET',
-  });
-  if (!(await responseComment).ok) {
-    throw new Error('Failed to fetch recent comments');
-  }
-  if (!(await responseConfig).ok) {
-    throw new Error('Failed to fetch comment config');
-  }
-
-  const commentData: {
-    id: number;
-    nick: string;
-    content_marked: string;
-    date: string;
-    email_encrypted: string;
-    page_url: string;
-  }[] = (await (await responseComment).json()).data;
-  const configData: {
-    gravatar: {
-      mirror: string;
-      params: string;
+export const ArtalkProvider: CommentProvider = {
+  async setup() {
+    const artalkConfig = commentConfig.artalk!;
+    const commentCount = asideConfig.recentComment.count;
+
+    const apiCommentUrl = new URL(
+      `api/v2/stats/latest_comments?limit=${commentCount}`,
+      artalkConfig.serverURL
+    ).toString();
+    const apiConfigUrl = new URL(`api/v2/conf`, artalkConfig.serverURL).toString();
+
+    const responseComment = fetch(apiCommentUrl, {
+      method: 'GET',
+    });
+    const responseConfig = fetch(apiConfigUrl, {
+      method: 'GET',
+    });
+    if (!(await responseComment).ok) {
+      throw new Error('Failed to fetch recent comments');
+    }
+    if (!(await responseConfig).ok) {
+      throw new Error('Failed to fetch comment config');
+    }
+
+    const commentData: {
+      id: number;
+      nick: string;
+      content_marked: string;
+      date: string;
+      email_encrypted: string;
+      page_url: string;
+    }[] = (await (await responseComment).json()).data;
+    const configData: {
+      gravatar: {
+        mirror: string;
+        params: string;
+      };
+    } = (await (await responseConfig).json()).frontend_conf;
+
+    const getAvatarUrl = (email: string) => {
+      return `${configData.gravatar.mirror}${email}?${configData.gravatar.params}`;
     };
-  } = (await (await responseConfig).json()).frontend_conf;
-
-  const getAvatarUrl = (email: string) => {
-    return `${configData.gravatar.mirror}${email}?${configData.gravatar.params}`;
-  };
-
-  const { container, template } = getTemplate()!;
-  if (container && !template) {
-    // 说明已经加载完毕, 模板被删除了
-    return;
-  }
-
-  commentData.forEach((item) =>
-    createCommentItem(container, template!, {
-      avatarUrl: getAvatarUrl(item.email_encrypted),
-      commentContent: cleanCommentHtml(item.content_marked),
-      commentUrl: `${item.page_url}#atk-comment-${item.id}`,
-      author: item.nick,
-      time: new Date(item.date),
-    })
-  );
-
-  cleanPlaceholders(container, template!);
-}
 
-document.addEventListener('astro:page-load', setup);
-await setup();
+    return commentData.map(
+      (item): CommentData => ({
+        avatarUrl: getAvatarUrl(item.email_encrypted),
+        commentContent: cleanCommentHtml(item.content_marked),
+        commentUrl: `${item.page_url}#atk-comment-${item.id}`,
+        author: item.nick,
+        time: new Date(item.date),
+      })
+    );
+  },
+};
src/components/aside/recent-comments/Twikoo.ts
@@ -1,53 +1,40 @@
-import {
-  cleanCommentHtml,
-  cleanPlaceholders,
-  createCommentItem,
-  getTemplate,
-} from './utils.ts';
+import type { CommentData, CommentProvider } from './types';
+import { cleanCommentHtml } from './utils';
 import { asideConfig, commentConfig } from '@/config';
 import { loadScript } from '@/scripts/utils';
 import { CDN } from '@constants/cdn';
 
 const twikooConfig = commentConfig.twikoo!;
 
-async function setup() {
-  const waitTwikoo = () => {
-    if (typeof twikoo === 'undefined') {
-      setTimeout(waitTwikoo, 100);
+export const TwikooProvider: CommentProvider = {
+  async setup() {
+    const waitTwikoo = () => {
+      if (typeof twikoo === 'undefined') {
+        setTimeout(waitTwikoo, 100);
+      }
+    };
+
+    // 判断当前页面是否已经加载了 Twikoo 的脚本
+    // 如果没有加载,则动态加载
+    const twikooWrapper = document.getElementById('twikoo-wrap');
+    if (!twikooWrapper) {
+      await loadScript(CDN.twikoo);
     }
-  };
-
-  // 判断当前页面是否已经加载了 Twikoo 的脚本
-  // 如果没有加载,则动态加载
-  const twikooWrapper = document.getElementById('twikoo-wrap');
-  if (!twikooWrapper) {
-    await loadScript(CDN.twikoo);
-  }
-  waitTwikoo();
-
-  const recentComments = await twikoo.getRecentComments({
-    envId: twikooConfig.envId,
-    pageSize: asideConfig.recentComment.count,
-  });
-
-  const { container, template } = getTemplate()!;
-  if (container && !template) {
-    // 说明已经加载完毕, 模板被删除了
-    return;
-  }
-
-  recentComments.forEach((comment) =>
-    createCommentItem(container, template!, {
-      avatarUrl: comment.avatar,
-      commentContent: cleanCommentHtml(comment.comment),
-      commentUrl: `${comment.url}#${comment.id}`,
-      author: comment.nick,
-      time: new Date(comment.created),
-    })
-  );
-
-  cleanPlaceholders(container, template!);
-}
-
-document.addEventListener('astro:page-load', setup);
-await setup();
+    waitTwikoo();
+
+    const recentComments = await twikoo.getRecentComments({
+      envId: twikooConfig.envId,
+      pageSize: asideConfig.recentComment.count,
+    });
+
+    return recentComments.map(
+      (comment): CommentData => ({
+        avatarUrl: comment.avatar,
+        commentContent: cleanCommentHtml(comment.comment),
+        commentUrl: `${comment.url}#${comment.id}`,
+        author: comment.nick,
+        time: new Date(comment.created),
+      })
+    );
+  },
+};
src/components/aside/recent-comments/types.ts
@@ -0,0 +1,11 @@
+export interface CommentData {
+  avatarUrl: string;
+  commentContent: string;
+  commentUrl: string;
+  author: string;
+  time: Date;
+}
+
+export interface CommentProvider {
+  setup: () => Promise<CommentData[]>;
+}
src/components/aside/recent-comments/utils.ts
@@ -1,21 +1,6 @@
-import { siteConfig } from '@/config';
 import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
 
-export function getTemplate() {
-  const recentCommentsCard = document.getElementById('recent-comments-card');
-  if (!recentCommentsCard) {
-    console.error('Recent comments card not found');
-    return;
-  }
-  const recentCommentsList = recentCommentsCard.querySelector('ul')!;
-  const recentCommentTemplate = recentCommentsList.querySelector('template');
-  return {
-    container: recentCommentsList,
-    template: recentCommentTemplate,
-  };
-}
-
 export function cleanCommentHtml(htmlString: string) {
   return htmlString
     .replaceAll(/<img.*?src="(.*?)"?[^>]+>/gi, i18n(I18nKey.commentReplaceImage)!)
@@ -26,47 +11,3 @@ export function cleanCommentHtml(htmlString: string) {
     .replaceAll(/<pre><code[^>]+?>.*?<\/pre>/gis, i18n(I18nKey.commentReplaceCode)!)
     .replaceAll(/<[^>]+>/g, '');
 }
-
-export function createCommentItem(
-  container: HTMLUListElement,
-  template: HTMLTemplateElement,
-  data: {
-    avatarUrl: string;
-    commentContent: string;
-    commentUrl: string;
-    author: string;
-    time: Date;
-  }
-) {
-  const item = template.content.firstElementChild!.cloneNode(true) as HTMLLIElement;
-  const avatarLinkEl = item.querySelector('a.avatar')!;
-  const avatarWrapper = avatarLinkEl.querySelector('div')!;
-  const avatarImg = new Image();
-  const comment = item.querySelector('a.line-clamp-2')!;
-  const time = item.querySelector('time')!;
-
-  avatarLinkEl.setAttribute('href', data.commentUrl);
-  comment.setAttribute('href', data.commentUrl);
-
-  avatarImg.src = data.avatarUrl;
-  avatarImg.alt = data.author;
-  avatarWrapper.appendChild(avatarImg);
-
-  comment.innerHTML = data.commentContent;
-  time.setAttribute('datetime', data.time.toISOString());
-  time.innerText = data.time.toLocaleDateString(siteConfig.lang.replace('_', '-'), {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-  });
-
-  container.appendChild(item);
-}
-
-export function cleanPlaceholders(container: HTMLUListElement, template: HTMLTemplateElement) {
-  template.remove();
-  const placeholders = container.querySelectorAll('.comment-placeholder');
-  placeholders.forEach((placeholder) => {
-    placeholder.remove();
-  });
-}
src/components/aside/recent-comments/Waline.ts
@@ -1,61 +1,48 @@
-import {
-  cleanCommentHtml,
-  cleanPlaceholders,
-  createCommentItem,
-  getTemplate,
-} from './utils.ts';
+import type { CommentData, CommentProvider } from './types';
+import { cleanCommentHtml } from './utils';
 import { asideConfig, commentConfig } from '@/config';
 
-async function setup() {
-  const walineConfig = commentConfig.waline!;
-  const commentCount = asideConfig.recentComment.count;
-  const apiUrl = `${walineConfig.serverURL}/api/comment?type=recent&count=${commentCount}`;
+export const WalineProvider: CommentProvider = {
+  async setup() {
+    const walineConfig = commentConfig.waline!;
+    const commentCount = asideConfig.recentComment.count;
+    const apiUrl = `${walineConfig.serverURL}/api/comment?type=recent&count=${commentCount}`;
 
-  const response = await fetch(apiUrl, {
-    method: 'GET',
-  });
-  if (!response.ok) {
-    throw new Error('Failed to fetch recent comments');
-  }
+    const response = await fetch(apiUrl, {
+      method: 'GET',
+    });
+    if (!response.ok) {
+      throw new Error('Failed to fetch recent comments');
+    }
 
-  const data: {
-    nick: string;
-    sticky: 0 | 1;
-    status: string;
-    link: string;
-    comment: string;
-    url: string;
-    user_id: string;
-    objectId: string;
-    browser: string;
-    os: string;
-    type: string;
-    label: string;
-    avatar: string;
-    orig: string;
-    addr: string;
-    like: number;
-    time: number;
-  }[] = (await response.json()).data;
+    const data: {
+      nick: string;
+      sticky: 0 | 1;
+      status: string;
+      link: string;
+      comment: string;
+      url: string;
+      user_id: string;
+      objectId: string;
+      browser: string;
+      os: string;
+      type: string;
+      label: string;
+      avatar: string;
+      orig: string;
+      addr: string;
+      like: number;
+      time: number;
+    }[] = (await response.json()).data;
 
-  const { container, template } = getTemplate()!;
-  if (container && !template) {
-    // 说明已经加载完毕, 模板被删除了
-    return;
-  }
-
-  data.forEach((item) =>
-    createCommentItem(container, template!, {
-      avatarUrl: item.avatar,
-      commentContent: cleanCommentHtml(item.comment),
-      commentUrl: `${item.url}#${item.objectId}`,
-      author: item.nick,
-      time: new Date(item.time),
-    })
-  );
-
-  cleanPlaceholders(container, template!);
-}
-
-document.addEventListener('astro:page-load', setup);
-await setup();
+    return data.map(
+      (item): CommentData => ({
+        avatarUrl: item.avatar,
+        commentContent: cleanCommentHtml(item.comment),
+        commentUrl: `${item.url}#${item.objectId}`,
+        author: item.nick,
+        time: new Date(item.time),
+      })
+    );
+  },
+};
src/components/aside/RecentCommentsCard.vue
@@ -0,0 +1,99 @@
+<script setup lang="ts">
+import { ArtalkProvider } from './recent-comments/Artalk';
+import { TwikooProvider } from './recent-comments/Twikoo';
+import { WalineProvider } from './recent-comments/Waline';
+import type { CommentData } from './recent-comments/types';
+import { asideConfig, commentConfig, siteConfig } from '@/config';
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
+import { onMounted, ref } from 'vue';
+
+const comments = ref<CommentData[]>([]);
+const loading = ref(true);
+
+// 根据评论系统类型加载不同的评论数据
+async function loadComments() {
+  const provider = (() => {
+    switch (commentConfig.provider) {
+      case 'twikoo':
+        return TwikooProvider;
+      case 'waline':
+        return WalineProvider;
+      case 'artalk':
+        return ArtalkProvider;
+      default:
+        throw new Error(
+          `Unsupported comment provider: '${commentConfig.provider}' for recent comments`
+        );
+    }
+  })();
+
+  try {
+    comments.value = await provider.setup();
+  } catch (error) {
+    console.error('Failed to load recent comments:', error);
+    comments.value = [];
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(() => {
+  loadComments();
+});
+</script>
+
+<template>
+  <div id="recent-comments-card" class="card border-base-300 bg-base-200/25 border">
+    <div class="card-body px-4 py-2">
+      <div class="card-title">
+        {{ i18n(I18nKey.recentComments) }}
+      </div>
+      <ul class="list">
+        <template v-if="!loading">
+          <li v-for="comment in comments" :key="comment.commentUrl" class="list-row px-0">
+            <a class="avatar" :href="comment.commentUrl">
+              <div class="w-16 min-w-16 rounded-md">
+                <img :src="comment.avatarUrl" :alt="comment.author" />
+              </div>
+            </a>
+            <div class="flex w-full flex-col justify-between">
+              <a
+                :href="comment.commentUrl"
+                class="hover:text-primary line-clamp-2 w-full overflow-clip"
+                v-html="comment.commentContent"
+              ></a>
+              <time :datetime="comment.time.toISOString()" class="text-base-content/60 text-xs">
+                {{
+                  comment.time.toLocaleDateString(siteConfig.lang.replace('_', '-'), {
+                    year: 'numeric',
+                    month: '2-digit',
+                    day: '2-digit',
+                  })
+                }}
+              </time>
+            </div>
+          </li>
+        </template>
+        <template v-else>
+          <li
+            v-for="n in asideConfig.recentComment.count"
+            :key="n"
+            class="list-row comment-placeholder px-0"
+          >
+            <a class="avatar">
+              <div class="skeleton w-16 min-w-16 rounded-md" />
+            </a>
+            <div class="flex w-full flex-col justify-between">
+              <div class="flex flex-col gap-2">
+                <div class="skeleton h-4 w-full" />
+                <div class="skeleton h-4 w-[100%-2rem]" />
+              </div>
+              <div class="skeleton h-4 w-10" />
+            </div>
+          </li>
+        </template>
+      </ul>
+    </div>
+  </div>
+</template>
src/components/aside/ResentCommentsCard.astro
@@ -1,59 +0,0 @@
----
-import { asideConfig, commentConfig } from '@/config';
-import I18nKey from '@i18n/I18nKey';
-import { i18n } from '@i18n/translation';
----
-
-<div id="recent-comments-card" class="card border-base-300 bg-base-200/25 border">
-  <div class="card-body px-4 py-2">
-    <div class="card-title">
-      {i18n(I18nKey.recentComments)}
-    </div>
-    <ul class="list">
-      <template>
-        <li class="list-row px-0">
-          <a class="avatar">
-            <div class="w-16 min-w-16 rounded-md"></div>
-          </a>
-          <div class="flex w-full flex-col justify-between">
-            <a class="hover:text-primary line-clamp-2 w-full overflow-clip"></a>
-            <time class="text-base-content/60 text-xs"></time>
-          </div>
-        </li>
-      </template>
-      {
-        Array.from({ length: asideConfig.recentComment.count }).map(() => (
-          <li class="list-row comment-placeholder px-0">
-            <a class="avatar">
-              <div class="skeleton w-16 min-w-16 rounded-md" />
-            </a>
-            <div class="flex w-full flex-col justify-between">
-              <div class="flex flex-col gap-2">
-                <div class="skeleton h-4 w-full" />
-                <div class="skeleton h-4 w-[100%-2rem]" />
-              </div>
-              <div class="skeleton h-4 w-10" />
-            </div>
-          </li>
-        ))
-      }
-    </ul>
-  </div>
-</div>
-
-{
-  (() => {
-    switch (commentConfig.provider) {
-      case 'twikoo':
-        return <script src="recent-comments/Twikoo.ts" />;
-      case 'waline':
-        return <script src="recent-comments/Waline.ts" />;
-      case 'artalk':
-        return <script src="recent-comments/Artalk.ts" />;
-      default:
-        throw new Error(
-          `Unsupported comment provider: '${commentConfig.provider}' for recent comments`
-        );
-    }
-  })()
-}
src/pages/[...page].astro
@@ -1,7 +1,7 @@
 ---
 import { asideConfig, commentConfig, siteConfig } from '@/config';
 import ProfileCard from '@components/aside/ProfileCard.astro';
-import ResentCommentsCard from '@components/aside/ResentCommentsCard.astro';
+import ResentCommentsCard from '@components/aside/RecentCommentsCard.vue';
 import SiteInfoCard from '@components/aside/SiteInfoCard.astro';
 import CategoryBar from '@components/misc/CategoryBar.astro';
 import PostsPage from '@components/PostsPage.astro';
@@ -36,6 +36,10 @@ const categories = await getCategories();
     <SiteInfoCard />
   </Fragment>
   <Fragment slot="aside-sticky">
-    {commentConfig.enable && asideConfig.recentComment.enable && <ResentCommentsCard />}
+    {
+      commentConfig.enable && asideConfig.recentComment.enable && (
+        <ResentCommentsCard client:visible />
+      )
+    }
   </Fragment>
 </MainLayout>