feat(analytics): 添加点赞统计功能并修复页面URL处理

- 在admin.ts中添加LikeStats相关类型定义和API接口
- 在AnalyticsVisitView.vue中添加点赞排行榜展示
- 修改CWDComments.js中postUrl的生成逻辑,使用origin+pathname代替href
This commit is contained in:
anghunk
2026-01-22 18:47:43 +08:00
parent 26e1ab5c61
commit d42fe6629f
3 changed files with 117 additions and 9 deletions

View File

@@ -111,6 +111,17 @@ export type DomainListResponse = {
domains: string[];
};
export type LikeStatsItem = {
pageSlug: string;
pageTitle: string | null;
pageUrl: string | null;
likes: number;
};
export type LikeStatsResponse = {
items: LikeStatsItem[];
};
export async function loginAdmin(name: string, password: string): Promise<string> {
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
const key = res.data.key;
@@ -273,3 +284,7 @@ export function fetchVisitPages(domain?: string, order?: 'pv' | 'latest'): Promi
export function fetchDomainList(): Promise<DomainListResponse> {
return get<DomainListResponse>('/admin/stats/domains');
}
export function fetchLikeStats(): Promise<LikeStatsResponse> {
return get<LikeStatsResponse>('/admin/likes/stats');
}

View File

@@ -95,12 +95,55 @@
</div>
<div class="domain-cell domain-cell-url">
<a
v-if="item.postUrl"
:href="item.postUrl"
v-if="item.postSlug"
:href="item.postSlug"
target="_blank"
rel="noreferrer"
>
{{ item.postUrl }}
{{ item.postSlug }}
</a>
<span v-else>-</span>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<h3 class="card-title">点赞页面排行榜</h3>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else-if="likeStatsItems.length === 0" class="page-hint">暂无点赞数据</div>
<div v-else class="domain-table-wrapper">
<div class="domain-table">
<div class="domain-table-header">
<div class="domain-cell domain-cell-rank">排名</div>
<div class="domain-cell domain-cell-title">页面标题</div>
<div class="domain-cell domain-cell-like">点赞数</div>
<div class="domain-cell domain-cell-url">页面地址</div>
</div>
<div
v-for="(item, index) in likeStatsItems"
:key="item.pageSlug"
class="domain-table-row"
>
<div class="domain-cell domain-cell-rank">
{{ index + 1 }}
</div>
<div class="domain-cell domain-cell-title">
{{ item.pageTitle || item.pageSlug }}
</div>
<div class="domain-cell domain-cell-like">
{{ item.likes }}
</div>
<div class="domain-cell domain-cell-url">
<a
v-if="item.pageSlug"
:href="item.pageSlug"
target="_blank"
rel="noreferrer"
>
{{ item.pageSlug }}
</a>
<span v-else>-</span>
</div>
@@ -120,6 +163,8 @@ import {
fetchVisitPages,
type VisitOverviewResponse,
type VisitPageItem,
fetchLikeStats,
type LikeStatsItem,
} from "../api/admin";
const loading = ref(false);
@@ -133,6 +178,7 @@ const overview = ref<VisitOverviewResponse>({
monthPv: 0,
last30Days: [],
});
const items = ref<VisitPageItem[]>([]);
const visitTab = ref<"pv" | "latest">("pv");
const visitTabStorageKey = "cwd-analytics-visit-tab";
@@ -140,6 +186,7 @@ const visitTabStorageKey = "cwd-analytics-visit-tab";
const injectedDomainFilter = inject<Ref<string> | null>("domainFilter", null);
const domainFilter = injectedDomainFilter ?? ref("");
const last30Days = ref<{ date: string; total: number }[]>([]);
const likeStatsItems = ref<LikeStatsItem[]>([]);
const toastMessage = ref("");
const toastType = ref<"success" | "error">("success");
@@ -213,6 +260,20 @@ function getVisitOrderParam(): "pv" | "latest" | undefined {
return undefined;
}
function filterLikeStatsByDomain(list: LikeStatsItem[], domain: string | undefined): LikeStatsItem[] {
if (!domain) {
return list;
}
return list.filter((item) => {
const source = item.pageUrl || item.pageSlug;
const d = extractDomain(source);
if (!d) {
return false;
}
return d === domain;
});
}
function loadVisitTabFromStorage() {
if (typeof window === "undefined") {
return;
@@ -243,9 +304,10 @@ async function loadData() {
try {
const domain = domainFilter.value || undefined;
const order = getVisitOrderParam();
const [overviewRes, pagesRes] = await Promise.all([
const [overviewRes, pagesRes, likeStatsRes] = await Promise.all([
fetchVisitOverview(domain),
fetchVisitPages(domain, order),
fetchLikeStats(),
]);
overview.value = {
totalPv: overviewRes.totalPv,
@@ -257,7 +319,10 @@ async function loadData() {
? overviewRes.last30Days
: [],
};
items.value = pagesRes.items || [];
const likeItemsRaw = Array.isArray(likeStatsRes.items) ? likeStatsRes.items : [];
likeStatsItems.value = filterLikeStatsByDomain(likeItemsRaw, domain);
const pageItems = Array.isArray(pagesRes.items) ? pagesRes.items : [];
items.value = pageItems;
last30Days.value = Array.isArray(overviewRes.last30Days)
? overviewRes.last30Days
: [];
@@ -282,7 +347,8 @@ async function loadVisitPagesOnly() {
const domain = domainFilter.value || undefined;
const order = getVisitOrderParam();
const pagesRes = await fetchVisitPages(domain, order);
items.value = pagesRes.items || [];
const pageItems = Array.isArray(pagesRes.items) ? pagesRes.items : [];
items.value = pageItems;
} catch (e: any) {
const msg = e.message || "加载访问统计数据失败";
error.value = msg;
@@ -514,6 +580,21 @@ watch(domainFilter, () => {
text-align: center;
}
.domain-cell-rank {
flex: 0 0 60px;
text-align: center;
}
.domain-cell-like {
flex: 0 0 80px;
text-align: center;
}
.domain-cell-like-rate {
flex: 0 0 90px;
text-align: center;
}
.domain-cell-time {
flex: 0 0 170px;
}
@@ -539,6 +620,18 @@ watch(domainFilter, () => {
width: 100px;
}
.domain-cell-rank {
width: 60px;
}
.domain-cell-like {
width: 80px;
}
.domain-cell-like-rate {
width: 90px;
}
.domain-cell-time {
width: 150px;
}

View File

@@ -23,7 +23,7 @@ export class CWDComments {
* 以下字段由组件自动推导或从后端读取,无需通过 config 传入:
* - postSlugwindow.location.origin + window.location.pathname
* - postTitledocument.title 或 postSlug
* - postUrlwindow.location.href
* - postUrlwindow.location.origin + window.location.pathname
* - avatarPrefix/adminEmail/adminBadge通过 /api/config/comments 接口获取
*/
constructor(config) {
@@ -35,7 +35,7 @@ export class CWDComments {
this.config.postTitle = document.title || this.config.postSlug;
}
if (typeof window !== 'undefined') {
this.config.postUrl = window.location.href;
this.config.postUrl = window.location.origin + window.location.pathname;
}
this.hostElement = this._resolveElement(config.el);
this.shadowRoot = null;
@@ -522,7 +522,7 @@ export class CWDComments {
this.config.postTitle = document.title || this.config.postSlug;
}
if (typeof window !== 'undefined') {
this.config.postUrl = window.location.href;
this.config.postUrl = window.location.origin + window.location.pathname;
}
// 更新主题