Compare commits

..

No commits in common. 'main' and 'develop' have entirely different histories.

@ -1,11 +1,5 @@
import {createQuery} from '../utils/api/hooks';
// ReferrerHistoryItem表示单条来源历史记录的结构
// - date: 日期字符串(通常为 ISO 格式)
// - signups: 该日期来自该来源的注册数
// - source: 来源名称,若未知则为 null
// - paid_conversions: 付费转化次数(例如成功订阅)
// - mrr: 该来源在该日期贡献的月度经常性收入Monthly Recurring Revenue
export type ReferrerHistoryItem = {
date: string,
signups: number,
@ -14,26 +8,17 @@ export type ReferrerHistoryItem = {
mrr: number
};
// ReferrerHistoryResponseType后端返回的历史数据响应结构包含 stats 数组
export interface ReferrerHistoryResponseType {
stats: ReferrerHistoryItem[];
}
// dataType用于 createQuery 的标识字符串(用于缓存/调试/metrics 等)
const dataType = 'ReferrerHistoryResponseType';
// useReferrerHistoryHook / 查询,获取来源历史时间序列数据
// - 请求路径GET /stats/referrers/
// - 返回类型ReferrerHistoryResponseType
// 使用场景:给增长图表、来源折线图或列表提供数据
export const useReferrerHistory = createQuery<ReferrerHistoryResponseType>({
dataType,
path: '/stats/referrers/'
});
// useTopSourcesGrowth查询“Top sources growth”数据来源增长趋势
// - 请求路径GET /stats/top-sources-growth
// - 注意dataType 字符串与上面不同,以便区分缓存条目或响应处理
export const useTopSourcesGrowth = createQuery<ReferrerHistoryResponseType>({
dataType: 'TopSourcesGrowthResponseType',
path: '/stats/top-sources-growth'

@ -1,31 +1,22 @@
import {Meta, createQuery, createQueryWithId} from '../utils/api/hooks';
/*
- / TypeScript Types HookcreateQuery/createQueryWithId
- Hook /stats/* /使
*/
// Types
/* ----------------------------- Types ----------------------------- */
/* TopContentItem热门内容条目的结构用于 Top content 列表) */
export type TopContentItem = {
pathname: string; // 内容路径(如 /post/slug
visits: number; // 访问次数
title?: string; // 标题(可选,若爬取或关联到文章)
post_uuid?: string; // 文章 UUID可选
post_id?: string; // 文章 ID可选
post_type?: string; // 内容类型post/page 等,可选)
url_exists?: boolean; // 链接是否有效(可选)
pathname: string;
visits: number;
title?: string;
post_uuid?: string;
post_id?: string;
post_type?: string;
url_exists?: boolean;
}
/* TopContentResponseType热门内容接口的返回类型包含 stats 数组与分页元信息 */
export type TopContentResponseType = {
stats: TopContentItem[];
meta: Meta;
}
/* MemberStatusItem会员统计按日期的结构用于会员数量历史 */
export type MemberStatusItem = {
date: string;
paid: number;
@ -35,7 +26,6 @@ export type MemberStatusItem = {
paid_canceled: number;
}
/* MemberCountHistoryResponseType会员数量历史接口返回类型包含统计数组和 totals */
export type MemberCountHistoryResponseType = {
stats: MemberStatusItem[];
meta: {
@ -47,7 +37,6 @@ export type MemberCountHistoryResponseType = {
};
}
/* TopPostStatItem热门文章统计项含成员/收益等指标 */
export type TopPostStatItem = {
post_id: string;
attribution_url: string;
@ -61,13 +50,11 @@ export type TopPostStatItem = {
url_exists?: boolean;
};
/* TopPostsStatsResponseType热门文章统计接口返回类型 */
export type TopPostsStatsResponseType = {
stats: TopPostStatItem[];
meta: Meta;
};
/* PostReferrerStatItem单篇文章的来源统计项来源名称及贡献的成员/收益) */
export type PostReferrerStatItem = {
source: string;
referrer_url?: string;
@ -81,7 +68,6 @@ export type PostReferrersResponseType = {
meta: Meta;
};
/* PostGrowthStatItem单篇文章按日期的增长统计项成员增长/付费等) */
export type PostGrowthStatItem = {
post_id: string;
free_members: number;
@ -94,7 +80,6 @@ export type PostGrowthStatsResponseType = {
meta: Meta;
};
/* MRR经常性收入相关类型历史项与总计结构 */
export type MrrHistoryItem = {
date: string;
mrr: number;
@ -116,7 +101,6 @@ export type MrrHistoryResponseType = {
};
};
/* Newsletter / 邮件简报相关类型(用于统计打开/点击/发送量等) */
export type NewsletterStatItem = {
post_id: string;
post_title: string;
@ -133,10 +117,9 @@ export type NewsletterStatsResponseType = {
meta: Meta;
};
/* NewsletterSubscriberStats按日期累计订阅数结构 */
export type NewsletterSubscriberValue = {
date: string;
value: number; // 该日期的累计订阅者数量
value: number; // Cumulative subscriber count for this date
};
export type NewsletterSubscriberStats = {
@ -148,7 +131,6 @@ export type NewsletterSubscriberStatsResponseType = {
stats: NewsletterSubscriberStats[];
};
/* PostStats文章在不同维度打开、访客、成员变动等的统计结构 */
export interface PostStats {
id: string;
recipient_count: number | null;
@ -164,7 +146,6 @@ export type PostStatsResponseType = {
stats: PostStats[];
};
/* TopPostViewsStats热门文章浏览量详情类型 */
export type TopPostViewsStats = {
post_id: string;
title: string;
@ -187,7 +168,7 @@ export type TopPostViewsResponseType = {
stats: TopPostViewsStats[];
};
/* Subscription stats订阅相关按日期统计类型用于订阅/取消/计数等) */
// Types for subscription stats
export type SubscriptionStatItem = {
date: string;
tier: string;
@ -212,12 +193,8 @@ export type SubscriptionStatsResponseType = {
};
};
/* ----------------------------- Requests (Hooks) ----------------------------- */
// Requests
/*
dataType createQuery
hook /stats/*
*/
const dataType = 'TopContentResponseType';
const memberCountHistoryDataType = 'MemberCountHistoryResponseType';
const topPostsStatsDataType = 'TopPostsStatsResponseType';
@ -230,63 +207,50 @@ const mrrHistoryDataType = 'MrrHistoryResponseType';
const topPostViewsDataType = 'TopPostViewsResponseType';
const subscriptionStatsDataType = 'SubscriptionStatsResponseType';
/* useTopContent获取热门内容列表GET /stats/top-content/),返回 TopContentResponseType */
export const useTopContent = createQuery<TopContentResponseType>({
dataType,
path: '/stats/top-content/'
});
/* useMemberCountHistory获取会员数量历史GET /stats/member_count/ */
export const useMemberCountHistory = createQuery<MemberCountHistoryResponseType>({
dataType: memberCountHistoryDataType,
path: '/stats/member_count/'
});
/* useTopPostsStats获取热门文章相关统计GET /stats/top-posts/ */
export const useTopPostsStats = createQuery<TopPostsStatsResponseType>({
dataType: topPostsStatsDataType,
path: '/stats/top-posts/'
});
/* usePostReferrers按文章 ID 获取该文章的来源统计GET /stats/posts/:id/top-referrers */
export const usePostReferrers = createQueryWithId<PostReferrersResponseType>({
dataType: postReferrersDataType,
path: id => `/stats/posts/${id}/top-referrers`
});
/* usePostGrowthStats按文章 ID 获取该文章的增长时间序列GET /stats/posts/:id/growth */
export const usePostGrowthStats = createQueryWithId<PostGrowthStatsResponseType>({
dataType: postGrowthStatsDataType,
path: id => `/stats/posts/${id}/growth`
});
/* useMrrHistory获取 MRR 历史GET /stats/mrr/ */
export const useMrrHistory = createQuery<MrrHistoryResponseType>({
dataType: mrrHistoryDataType,
path: '/stats/mrr/'
});
/* useSubscriptionStats获取订阅统计GET /stats/subscriptions/ */
export const useSubscriptionStats = createQuery<SubscriptionStatsResponseType>({
dataType: subscriptionStatsDataType,
path: '/stats/subscriptions/'
});
/* usePostStats按文章 ID 获取多维度文章统计GET /stats/posts/:id/stats/ */
export const usePostStats = createQueryWithId<PostStatsResponseType>({
dataType: 'PostStatsResponseType',
path: id => `/stats/posts/${id}/stats/`
});
/* useTopPostsViews获取按浏览量排序的文章列表GET /stats/top-posts-views/ */
export const useTopPostsViews = createQuery<TopPostViewsResponseType>({
dataType: topPostViewsDataType,
path: '/stats/top-posts-views/'
});
/* ----------------------------- Newsletter hooks ----------------------------- */
/* 支持基于参数查询的 newsletter hooks包含包装函数以接收 newsletterId 等参数) */
export interface NewsletterStatsSearchParams {
newsletterId?: string;
date_from?: string;
@ -305,7 +269,7 @@ export const useNewsletterStats = createQuery<NewsletterStatsResponseType>({
dataType: newsletterStatsDataType,
path: '/stats/newsletter-stats/',
defaultSearchParams: {
// 空的默认参数,会在调用时由 hook 填充
// Empty default params, will be filled by the hook
}
});
@ -313,7 +277,7 @@ export const useNewsletterBasicStats = createQuery<NewsletterStatsResponseType>(
dataType: newsletterStatsDataType,
path: '/stats/newsletter-basic-stats/',
defaultSearchParams: {
// 空的默认参数,会在调用时由 hook 填充
// Empty default params, will be filled by the hook
}
});
@ -321,11 +285,11 @@ export const useNewsletterClickStats = createQuery<NewsletterStatsResponseType>(
dataType: newsletterStatsDataType,
path: '/stats/newsletter-click-stats/',
defaultSearchParams: {
// 空的默认参数,会在调用时由 hook 填充
// Empty default params, will be filled by the hook
}
});
/* useNewsletterStatsByNewsletterId包装器接受 newsletterId 与额外选项并构建搜索参数 */
// Hook wrapper to accept a newsletterId parameter
export const useNewsletterStatsByNewsletterId = (newsletterId?: string, options: Partial<NewsletterStatsSearchParams> = {}, queryOptions: {enabled?: boolean} = {}) => {
const searchParams: Record<string, string> = {};
@ -333,7 +297,7 @@ export const useNewsletterStatsByNewsletterId = (newsletterId?: string, options:
searchParams.newsletter_id = newsletterId;
}
// 添加其它可选参数(日期范围、排序、限制等)
// Add any additional search params
if (options.date_from) {
searchParams.date_from = options.date_from;
}
@ -354,11 +318,11 @@ export const useSubscriberCount = createQuery<NewsletterSubscriberStatsResponseT
dataType: newsletterSubscriberStatsDataType,
path: '/stats/subscriber-count/',
defaultSearchParams: {
// 空的默认参数,会在调用时由 hook 填充
// Empty default params, will be filled by the hook
}
});
/* useSubscriberCountByNewsletterId包装器构建 newsletter_id 与日期参数后调用 useSubscriberCount */
// Hook wrapper to accept a newsletterId parameter
export const useSubscriberCountByNewsletterId = (newsletterId?: string, options: Partial<SubscriberCountSearchParams> = {}) => {
const searchParams: Record<string, string> = {};
@ -366,7 +330,7 @@ export const useSubscriberCountByNewsletterId = (newsletterId?: string, options:
searchParams.newsletter_id = newsletterId;
}
// 可选的日期范围参数
// Add any additional search params
if (options.date_from) {
searchParams.date_from = options.date_from;
}

@ -1,66 +1,46 @@
import {Post} from '../api/posts';
/**
*
* - true post.email_only 'sent'
* Determines if a post is email-only (newsletter only, not published to the web)
*/
export function isEmailOnly(post: Post): boolean {
return Boolean(post.email_only) && post.status === 'sent';
}
/**
*
* - true 'published'
* Determines if a post is published-only (web only, no email sent)
*/
export function isPublishedOnly(post: Post): boolean {
return post.status === 'published' && !hasBeenEmailed(post);
}
/**
*
* - true 'published'
* Determines if a post is both published and emailed
*/
export function isPublishedAndEmailed(post: Post): boolean {
return post.status === 'published' && hasBeenEmailed(post);
}
/**
*
*
* - sent published email
* - email status !== 'failed' email_count>0
*
*
* Determines if a post has been sent as an email
* Based on the logic from admin-x-framework/src/utils/post-utils.ts
*/
function hasBeenEmailed(post: Post): boolean {
const isPublished = post.status === 'published';
const isSent = post.status === 'sent';
const hasEmail = Boolean(post.email);
const validEmailStatus = post.email?.status !== 'failed'; // 邮件状态不为 failed 则视为有效
const hasEmailCount = typeof post.email?.email_count === 'number' && post.email.email_count > 0; // 有发送计数
// 只有在已发送或已发布且存在 email 信息,且 email 状态有效或有发送次数时,
// 才认为文章“已被邮件发送/记录为邮件发送过”
const validEmailStatus = post.email?.status !== 'failed';
const hasEmailCount = typeof post.email?.email_count === 'number' && post.email.email_count > 0;
return (isSent || isPublished)
&& hasEmail
&& (validEmailStatus || hasEmailCount);
}
/**
*
*
* - showEmailMetrics: opens/clicks
* - showWebMetrics: views/visitors
* - showMemberGrowth: / membersTrackSources
*
*
* - (isEmailOnly) ->
* - (isPublishedOnly) ->
* - (isPublishedAndEmailed) ->
* -
* Gets the appropriate metrics to display based on post type and settings
*/
export function getPostMetricsToDisplay(post: Post, settings?: {membersTrackSources?: boolean}) {
// settings.membersTrackSources 如果未配置,默认取 true显示会员增长来源
const showMemberGrowth = settings?.membersTrackSources ?? true;
if (isEmailOnly(post)) {
@ -87,7 +67,7 @@ export function getPostMetricsToDisplay(post: Post, settings?: {membersTrackSour
};
}
// 默认回退:显示网站指标并隐藏邮件指标
// Default fallback
return {
showEmailMetrics: false,
showWebMetrics: true,
@ -96,8 +76,6 @@ export function getPostMetricsToDisplay(post: Post, settings?: {membersTrackSour
}
/**
*
* - formatPostUrl(post)
* - shouldShowTopSources(post, settings)
* - deriveDefaultTimeRange(post)
* Post type information with computed properties
*/

@ -1,8 +1,4 @@
/**
* Source domain mapping for favicons
* - favicon
* - Reddit -> reddit.com便 URL
*/
// Source domain mapping for favicons
export const SOURCE_DOMAIN_MAP: Record<string, string> = {
Reddit: 'reddit.com',
'www.reddit.com': 'reddit.com',
@ -43,11 +39,7 @@ export const SOURCE_DOMAIN_MAP: Record<string, string> = {
newsletter: 'static.ghost.org'
};
/**
* SOURCE_NORMALIZATION_MAP
* - /
* - referrer 便 facebook www.facebook.com
*/
// Comprehensive source normalization mapping
export const SOURCE_NORMALIZATION_MAP = new Map<string, string>([
// Social Media Consolidation
['facebook', 'Facebook'],
@ -149,10 +141,9 @@ export const SOURCE_NORMALIZATION_MAP = new Map<string, string>([
]);
/**
* normalizeSource
* - source
* - source null 'Direct' 访
* - 使 SOURCE_NORMALIZATION_MAP
* Normalize source names to consistent display names
* @param source - Raw source string from referrer data
* @returns Normalized source name or 'Direct' for empty/null sources
*/
export function normalizeSource(source: string | null): string {
if (!source || source === '') {
@ -164,11 +155,7 @@ export function normalizeSource(source: string | null): string {
return SOURCE_NORMALIZATION_MAP.get(lowerSource) || source;
}
/**
* extractDomain
* - URL www.
* - URL null
*/
// Helper function to extract domain from URL
export const extractDomain = (url: string): string | null => {
try {
const domain = new URL(url.startsWith('http') ? url : `https://${url}`).hostname;
@ -184,11 +171,6 @@ export interface ExtendSourcesOptions {
mode: 'visits' | 'growth';
}
/**
* extendSourcesWithPercentages
* - mode 'visits' 访 (visits / totalVisitors)
* - growth growth /
*/
export function extendSourcesWithPercentages({
processedData,
totalVisitors,
@ -204,11 +186,7 @@ export function extendSourcesWithPercentages({
}));
};
/**
* isDomainOrSubdomain
* - sourceDomain siteDomain blog.example.com example.com
* - Direct
*/
// Helper function to check if a domain is the same or a subdomain
export const isDomainOrSubdomain = (sourceDomain: string, siteDomain: string): boolean => {
// Exact match
if (sourceDomain === siteDomain) {
@ -219,20 +197,7 @@ export const isDomainOrSubdomain = (sourceDomain: string, siteDomain: string): b
return sourceDomain.endsWith(`.${siteDomain}`);
};
/**
* getFaviconDomain
* - source//URLsiteUrl
* - {domain, isDirectTraffic}
* -
* 1. source -> null domainisDirectTraffic = false
* 2. source 'Direct' -> isDirectTraffic = true
* 3. siteUrl source ->
* 4. SOURCE_DOMAIN_MAP -> favicon/
* 5. source
* 6. null domain
*
* - domain favicon URL https://<domain>)。
*/
// Helper function to get favicon domain and determine if it's direct traffic
export const getFaviconDomain = (source: string | number | undefined, siteUrl?: string): {domain: string | null, isDirectTraffic: boolean} => {
if (!source || typeof source !== 'string') {
return {domain: null, isDirectTraffic: false};
@ -265,7 +230,7 @@ export const getFaviconDomain = (source: string | number | undefined, siteUrl?:
return {domain: mappedDomain, isDirectTraffic: false};
}
// If not in mapping, check if it's already a domain (basic domain regex)
// If not in mapping, check if it's already a domain
const isDomain = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(source);
if (isDomain) {
// Clean up the domain by removing www. prefix
@ -277,12 +242,7 @@ export const getFaviconDomain = (source: string | number | undefined, siteUrl?:
return {domain: null, isDirectTraffic: false};
};
/* ----------------------------- Data Structures ----------------------------- */
/**
* BaseSourceData
* - visits/growth
*/
// Base interface for all source data types
export interface BaseSourceData {
source?: string | number;
visits?: number;
@ -292,11 +252,7 @@ export interface BaseSourceData {
[key: string]: unknown;
}
/**
* ProcessedSourceData
* -
* - displayName(iconSrc)
*/
// Processed source data with pre-computed display values
export interface ProcessedSourceData {
source: string;
visits: number;
@ -319,21 +275,6 @@ export interface ProcessSourcesOptions {
defaultSourceIconUrl: string;
}
/**
* processSources
* - data
* - visits / growth
* - Direct
* - Direct favicon URL
* - mode (visits | growth)
*
*
* - data: null
* - mode: 'visits' 访'growth' /
* - siteUrl: direct traffic
* - siteIcon: URL Direct
* - defaultSourceIconUrl: faviconDomain 使
*/
export function processSources({
data,
mode,
@ -370,14 +311,12 @@ export function processSources({
} else {
// Keep other sources as-is
const sourceKey = String(item.source);
// 若有 faviconDomain 则使用 favicon 服务拼接图标地址,否则使用默认图标
const iconSrc = faviconDomain
? `https://www.faviconextractor.com/favicon/${faviconDomain}?larger=true`
: defaultSourceIconUrl;
const linkUrl = faviconDomain ? `https://${faviconDomain}` : undefined;
if (sourceMap.has(sourceKey)) {
// 已存在则累加数值(用于合并相同来源)
const existing = sourceMap.get(sourceKey)!;
existing.visits += visits;
if (mode === 'growth') {
@ -386,7 +325,6 @@ export function processSources({
existing.mrr = (existing.mrr || 0) + (item.mrr || 0);
}
} else {
// 新建处理项
const processedItem: ProcessedSourceData = {
source: sourceKey,
visits,
@ -442,7 +380,7 @@ export function processSources({
return bScore - aScore;
});
} else {
// Sort by visits(降序)
// Sort by visits
return result.sort((a, b) => b.visits - a.visits);
}
}

@ -1,16 +1,9 @@
/**
* utils/post-helpers
* isEmailOnly / isPublishedOnly / isPublishedAndEmailed / getPostMetricsToDisplay
* email
*/
import {isEmailOnly, isPublishedOnly, isPublishedAndEmailed, getPostMetricsToDisplay} from '../../../src/utils/post-helpers';
import {Post} from '../../../src/api/posts';
describe('post-helpers', () => {
describe('isEmailOnly', () => {
it('returns true for email-only posts with sent status', () => {
// email_only 且 status 为 sent -> 认为是仅邮件发送的文章
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -24,7 +17,6 @@ describe('post-helpers', () => {
});
it('returns false for email-only posts with published status', () => {
// 即便 email_only 为 true但 status 为 published -> 不是仅邮件(已发布也在线)
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -38,7 +30,6 @@ describe('post-helpers', () => {
});
it('returns false for non-email-only posts with sent status', () => {
// status 为 sent 但 email_only 为 false -> 不是仅邮件
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -52,7 +43,6 @@ describe('post-helpers', () => {
});
it('returns false when email_only is undefined', () => {
// 未设置 email_only -> 默认为 false
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -65,7 +55,6 @@ describe('post-helpers', () => {
});
it('returns false for draft posts even with email_only true', () => {
// 草稿状态即使标记为 email_only 也不认为是已发送的仅邮件文章
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -81,7 +70,6 @@ describe('post-helpers', () => {
describe('isPublishedOnly', () => {
it('returns true for published posts without email', () => {
// 已发布且没有 email 信息 -> 仅在网站发布
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -94,7 +82,6 @@ describe('post-helpers', () => {
});
it('returns false for published posts with email', () => {
// 已发布且包含有效 email 信息 -> 既发布又发邮件,不是仅发布
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -112,7 +99,6 @@ describe('post-helpers', () => {
});
it('returns true for published posts with failed email', () => {
// 已发布但 email 状态为 failed 且无发送记录 -> 仍视为仅发布(邮件失败不计)
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -130,7 +116,6 @@ describe('post-helpers', () => {
});
it('returns false for draft posts', () => {
// 草稿不属于已发布
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -143,7 +128,6 @@ describe('post-helpers', () => {
});
it('returns false for sent posts', () => {
// sent 状态表示仅邮件或已发送邮件,不视为仅发布
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -158,7 +142,6 @@ describe('post-helpers', () => {
describe('isPublishedAndEmailed', () => {
it('returns true for published posts with valid email', () => {
// 已发布且有有效 email非 failed 或有发送量) -> 同时为发布与发邮件
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -176,7 +159,6 @@ describe('post-helpers', () => {
});
it('returns false for published posts without email', () => {
// 已发布但没有 email 信息 -> 不是同时发布与发邮件
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -189,7 +171,6 @@ describe('post-helpers', () => {
});
it('returns false for sent posts with email', () => {
// sent 状态不是 published即便有 email 也不满足 publishedAndEmailed
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -207,7 +188,6 @@ describe('post-helpers', () => {
});
it('returns false for published posts with failed email', () => {
// 已发布但 email 状态为 failed 且无发送量 -> 不认为已成功发邮件
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -225,7 +205,6 @@ describe('post-helpers', () => {
});
it('returns true for published posts with failed status but positive email_count', () => {
// 虽然 email.status 为 failed但如果有正的 email_count历史发送记录仍视为已发邮件
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -245,7 +224,6 @@ describe('post-helpers', () => {
describe('getPostMetricsToDisplay', () => {
it('returns correct metrics for email-only posts', () => {
// email-only 且 sent -> 只显示邮件相关指标showEmailMetrics
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -263,7 +241,6 @@ describe('post-helpers', () => {
});
it('returns correct metrics for published-only posts', () => {
// 仅发布(无 email -> 只显示网站相关指标
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -280,7 +257,6 @@ describe('post-helpers', () => {
});
it('returns correct metrics for published and emailed posts', () => {
// 同时发布并成功发邮件 -> 显示邮件与网站指标
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -302,7 +278,6 @@ describe('post-helpers', () => {
});
it('returns default metrics for draft posts', () => {
// 草稿或未定义状态 -> 默认显示网站指标(邮件指标为 false
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -319,7 +294,6 @@ describe('post-helpers', () => {
});
it('returns default metrics for scheduled posts', () => {
// scheduled 与 draft 类似,使用默认策略
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -336,7 +310,6 @@ describe('post-helpers', () => {
});
it('returns default metrics for sent posts without email_only flag', () => {
// sent 状态但未标记为 email_only -> 仍显示网站指标(兼容历史/未标记场景)
const post: Post = {
id: '1',
url: 'http://example.com/post',
@ -354,7 +327,6 @@ describe('post-helpers', () => {
});
it('returns default metrics for undefined status', () => {
// 未定义 status -> 使用函数默认返回值(兼容缺省字段)
const post: Post = {
id: '1',
url: 'http://example.com/post',

@ -8,27 +8,13 @@ import {useBrowseIncomingRecommendations, useBrowseRecommendations} from '@trygh
import {useReferrerHistory} from '@tryghost/admin-x-framework/api/referrers';
import {useRouting} from '@tryghost/admin-x-framework/routing';
/**
* Recommendations
* - Growth -> Recommendations Tab
* 1) Your recommendations
* 2) Recommending you/
*
*
* - 使 useBrowseRecommendations / useBrowseIncomingRecommendations
* - useReferrerHistory IncomingRecommendationList
* - "Add recommendation" modal
* - showMore isLoading / stats
*/
const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
// Setting group 保存/提交控制TopLevelGroup 提供的上下文)
const {
saveState,
handleSave
} = useSettingGroup();
/* ---------------- Fetch "Your recommendations" ---------------- */
// 分页获取当前站点的 recommendations带 counts 的简要信息)
// Fetch "Your recommendations"
const {data: {meta: recommendationsMeta, recommendations} = {}, isLoading: areRecommendationsLoading, hasNextPage, fetchNextPage} = useBrowseRecommendations({
searchParams: {
include: 'count.clicks,count.subscribers',
@ -36,7 +22,7 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
limit: '5'
},
// 翻页策略:先加载 5然后以 100 为步长加载全部(避免使用危险的 'all'
// We first load 5, then load 100 at a time (= show all, but without using the dangerous 'all' limit)
getNextPageParams: (lastPage, otherParams) => {
if (!lastPage.meta) {
return;
@ -57,21 +43,19 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
keepPreviousData: true
});
// 为 RecommendationList 提供的 showMore 对象(分页展示/加载)
const showMoreRecommendations: ShowMoreData = {
hasMore: !!hasNextPage,
loadMore: fetchNextPage
};
/* ---------------- Fetch "Recommending you" ---------------- */
// 获取推荐你到其它站点/来源的条目
// Fetch "Recommending you", including stats
const {data: {recommendations: incomingRecommendations, meta: incomingRecommendationsMeta} = {}, isLoading: areIncomingRecommendationsLoading, hasNextPage: hasIncomingRecommendationsNextPage, fetchNextPage: fetchIncomingRecommendationsNextPage} = useBrowseIncomingRecommendations({
searchParams: {
limit: '5',
order: 'created_at desc'
},
// 同上:分页策略,先 5 后 100
// We first load 5, then load 100 at a time (= show all, but without using the dangerous 'all' limit)
getNextPageParams: (lastPage, otherParams) => {
if (!lastPage.meta) {
return;
@ -92,7 +76,6 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
keepPreviousData: true
});
// 从 referrers API 获取来源历史/统计,用于在 IncomingRecommendationList 中展示来源信息
const {data: {stats} = {}, isLoading: areStatsLoading} = useReferrerHistory({});
const showMoreMentions: ShowMoreData = {
@ -100,10 +83,9 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
loadMore: fetchIncomingRecommendationsNextPage
};
// 默认选中 "Your recommendations" Tab
// Select "Your recommendations" by default
const [selectedTab, setSelectedTab] = useState('your-recommendations');
// Tab 配置:包含计数、加载状态以及传入子组件的 props
const tabs = [
{
id: 'your-recommendations',
@ -120,45 +102,38 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
];
const groupDescription = (
// 分组说明(显示在 TopLevelGroup 中)
<>Recommend any publication that your audience will find valuable, and find out when others are recommending you.</>
);
/* ---------------- Add new recommendation 操作 ---------------- */
// Add a new recommendation
const {updateRoute} = useRouting();
const openAddNewRecommendationModal = () => {
// 跳转到新增推荐的路由(由路由处理打开 modal/页面)
updateRoute('recommendations/add');
};
// 顶部按钮(大屏显示)
const buttons = (
<Button className='mt-[-5px] hidden md:!visible md:!block' color='clear' label='Add recommendation' size='sm' onClick={() => {
openAddNewRecommendationModal();
}} />
);
/* ---------------- Render ---------------- */
return (
<TopLevelGroup
beta={true} // 标记为 beta 功能
customButtons={buttons} // 自定义右上按钮
description={groupDescription} // 分组描述
keywords={keywords} // 搜索关键词(来自父级配置)
navid='recommendations' // 导航 id用于侧边栏高亮/跳转)
saveState={saveState} // 保存状态(来自 useSettingGroup
testId='recommendations' // 用于 e2e 测试定位
beta={true}
customButtons={buttons}
description={groupDescription}
keywords={keywords}
navid='recommendations'
saveState={saveState}
testId='recommendations'
title="Recommendations"
onSave={handleSave} // 点击保存时的回调
onSave={handleSave}
>
{/* 小屏时显示的添加按钮(在 TopLevelGroup 内部隐藏) */}
<div className='flex justify-center rounded border border-green px-4 py-2 md:hidden'>
<Button color='light-grey' label='Add recommendation' link onClick={() => {
openAddNewRecommendationModal();
}} />
</div>
{/* Tab 视图:切换 Your recommendations / Recommending you */}
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
</TopLevelGroup>
);

@ -80,12 +80,11 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
const onClose = () => {
updateRoute('/');
};
//主题上传预处理函数
const onThemeUpload = async (file: File) => {
const themeFileName = file?.name.replace(/\.zip$/, '');
const existingThemeNames = themes.map(t => t.name);
if (isDefaultOrLegacyTheme({name: themeFileName})) {//防止覆盖默认主题
if (isDefaultOrLegacyTheme({name: themeFileName})) {
NiceModal.show(ConfirmationModal, {
title: 'Upload failed',
cancelLabel: 'Cancel',
@ -100,7 +99,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
confirmModal?.remove();
}
});
} else if (existingThemeNames.includes(themeFileName)) {//处理主题覆盖确认
} else if (existingThemeNames.includes(themeFileName)) {
NiceModal.show(ConfirmationModal, {
title: 'Overwrite theme',
prompt: (
@ -115,17 +114,19 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
okColor: 'red',
onOk: async (confirmModal) => {
setUploading(true);
// this is to avoid the themes array from returning the overwritten theme.
// find index of themeFileName in existingThemeNames and remove from the array
const index = existingThemeNames.indexOf(themeFileName);
themes.splice(index, 1);
await handleThemeUpload({file, onActivate: onClose});
setUploading(false);
setCurrentTab('installed');
confirmModal?.remove();
}
});
} else {//正常的主题
} else {
setCurrentTab('installed');
handleThemeUpload({file, onActivate: onClose});
}
@ -274,7 +275,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
</div>
</>);
};
//负责界面渲染
const ThemeModalContent: React.FC<ThemeModalContentProps> = ({
currentTab,
onSelectTheme,
@ -447,6 +448,7 @@ const ChangeThemeModal: React.FC<ChangeThemeModalProps> = ({source, themeRef}) =
const performInstallation = async () => {
let title = 'Success';
let prompt = <></>;
// default theme can't be installed, only activated
if (isDefaultOrLegacyTheme(selectedTheme)) {
title = 'Activate theme';
@ -461,26 +463,33 @@ const ChangeThemeModal: React.FC<ChangeThemeModalProps> = ({source, themeRef}) =
} finally {
setInstalling(false);
}
if (!data) {
return;
}
const newlyInstalledTheme = data.themes[0];
title = 'Success';
prompt = <>
<strong>{newlyInstalledTheme.name}</strong> has been successfully installed.
</>;
if (!newlyInstalledTheme.active) {
prompt = <>
{prompt}{' '}
Do you want to activate it now?
</>;
}
if (newlyInstalledTheme.gscan_errors?.length || newlyInstalledTheme.warnings?.length) {
const hasErrors = newlyInstalledTheme.gscan_errors?.length;
title = `Installed with ${hasErrors ? 'errors' : 'warnings'}`;
prompt = <>
The theme <strong>&quot;{newlyInstalledTheme.name}&quot;</strong> was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}.
</>;
if (!newlyInstalledTheme.active) {
prompt = <>
{prompt}

@ -4,40 +4,10 @@ import {formatNumber, formatPercentage, formatQueryDate, getRangeDates} from '@t
import {getSymbol} from '@tryghost/admin-x-framework';
import {useMemo} from 'react';
/**
* DiffDirection
* - updownsame
*/
// Type for direction values
export type DiffDirection = 'up' | 'down' | 'same';
/**
* calculateTotals
* - memberData mrrData /MRR
* -
* - memberData: free/paid/comped
* - mrrData: MRR date/mrr/currency
* - dateFrom: YYYY-MM-DD
* - memberCountTotals: totals meta
*
*
* - 0/(same)
* - totalMembers 使 memberCountTotals memberData
* - percentChanges: >1
* - MRR
* - "from beginning" 0YTD
* - from-beginning使 MRR
* - 0 MRR>0 100%
*
*
* {
* totalMembers: number,
* freeMembers: number,
* paidMembers: number,
* mrr: number,
* percentChanges: { total, free, paid, mrr },
* directions: { total, free, paid, mrr }
* }
*/
// Calculate totals from member data
const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem[], dateFrom: string, memberCountTotals?: {paid: number; free: number; comped: number}) => {
if (!memberData.length) {
return {
@ -60,18 +30,18 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
};
}
// 使用后端 meta 的 totals如果存在否则从时间序列取最后一个点作为当前值
// Use current totals from API meta if available (like Ember), otherwise use latest time series data
const currentTotals = memberCountTotals || memberData[memberData.length - 1];
const latest = memberData.length > 0 ? memberData[memberData.length - 1] : {free: 0, paid: 0, comped: 0};
const latestMrr = mrrData.length > 0 ? mrrData[mrrData.length - 1] : {mrr: 0};
// 计算总会员数(免费 + 付费 + comped
// Calculate total members using current totals (like Ember dashboard)
const totalMembers = currentTotals.free + currentTotals.paid + currentTotals.comped;
const totalMrr = latestMrr.mrr;
// 默认百分比与方向
// Calculate percentage changes if we have enough data
const percentChanges = {
total: '0%',
free: '0%',
@ -86,8 +56,8 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
mrr: 'same' as DiffDirection
};
// 只有在成员时间序列存在 >=2 个点时,才计算与第一天的百分比变化
if (memberData.length > 1) {
// Get first day in range
const first = memberData[0];
const firstTotal = first.free + first.paid + first.comped;
@ -113,43 +83,43 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
}
}
// MRR 的百分比变化需要更复杂的边界处理,考虑到 YTD 或缺失数据点情况
if (mrrData.length > 1) {
// 选择范围内第一个真正的点(实际数据点,而非人为添加的边界点)
// Find the first ACTUAL data point within the selected date range (not synthetic boundary points)
const actualStartDate = moment(dateFrom).format('YYYY-MM-DD');
const firstActualPoint = mrrData.find(point => moment(point.date).isSameOrAfter(actualStartDate));
// 判断是否为从“起始/年初”开始的范围(例如 YTD这会将缺失起点视作 0
// Check if this is a "from beginning" range (like YTD) vs a recent range
const isFromBeginningRange = moment(dateFrom).isSame(moment().startOf('year'), 'day') ||
moment(dateFrom).year() < moment().year();
let firstMrr = 0;
if (firstActualPoint) {
// Check if the first actual point is exactly at the start date
if (moment(firstActualPoint.date).isSame(actualStartDate, 'day')) {
// 起点恰好落在范围开始
firstMrr = firstActualPoint.mrr;
} else {
// 起点在范围之后
// First actual point is later than start date
if (isFromBeginningRange) {
// 对于 YTD 等从年初/更早开始的范围,假设从 0 开始增长
// For YTD/beginning ranges, assume started from 0
firstMrr = 0;
} else {
// 对于近期范围,采用范围外最近的 MRR 值(平滑承接)
// For recent ranges, use the most recent MRR before the range
// This should be the same as current MRR (flat line scenario)
firstMrr = totalMrr;
}
}
} else if (isFromBeginningRange) {
// 范围内无数据且属于从起始范围 -> 起点为 0
// No data points in range, and it's a from-beginning range
firstMrr = 0;
} else {
// 范围内无数据且为近期范围 -> 使用当前 MRR没有变化
// No data points in recent range, carry forward current MRR
firstMrr = totalMrr;
}
if (firstMrr >= 0) { // 允许 0 作为有效起点
if (firstMrr >= 0) { // Allow 0 as a valid starting point
const mrrChange = firstMrr === 0
? (totalMrr > 0 ? 100 : 0) // 起点0且有增长 -> 视作 100% 增长
? (totalMrr > 0 ? 100 : 0) // If starting from 0, any positive value is 100% increase
: ((totalMrr - firstMrr) / firstMrr) * 100;
percentChanges.mrr = formatPercentage(mrrChange / 100);
@ -167,25 +137,15 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
};
};
/**
* formatChartData
* - memberData mrrData 线
* - member mrr
* - 使last-known 线
* - value()freepaidcompedmrrformattedValuelabel
*
*
* - 便
*/
// Format chart data
const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem[]) => {
// 确保时间序列按日期升序
// Ensure data is sorted by date
const sortedMemberData = [...memberData].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const sortedMrrData = [...mrrData].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const memberDates = sortedMemberData.map(item => item.date);
const mrrDates = sortedMrrData.map(item => item.date);
// 合并去重后的所有日期并按时间排序
const allDates = [...new Set([...memberDates, ...mrrDates])].sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
let lastMemberItem: MemberStatusItem | null = null;
@ -195,7 +155,6 @@ const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
const mrrMap = new Map(sortedMrrData.map(item => [item.date, item]));
return allDates.map((date) => {
// 若该日期存在 member/mrr 项,则更新 last-known
const currentMemberItem = memberMap.get(date);
if (currentMemberItem) {
lastMemberItem = currentMemberItem;
@ -206,7 +165,6 @@ const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
lastMrrItem = currentMrrItem;
}
// 使用最近已知的值向前填充缺失数据
const free = lastMemberItem?.free ?? 0;
const paid = lastMemberItem?.paid ?? 0;
const comped = lastMemberItem?.comped ?? 0;
@ -226,40 +184,18 @@ const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
paid_subscribed: paidSubscribed,
paid_canceled: paidCanceled,
formattedValue: formatNumber(value),
label: 'Total members' // 注意:如需根据视图切换 label可在上层处理
label: 'Total members' // Consider if label needs update based on data type?
};
});
};
/**
* useGrowthStats(range)
* - Hook range 1, 7, 30
* -
* - isLoading: API
* - memberData: member 2
* - mrrData: MRR /
* - dateFrom / endDate: 使
* - totals: calculateTotals + +
* - chartData: formatChartData
* - subscriptionData: signups/cancellations
* - selectedCurrency / currencySymbol:
*
*
* - 使 getRangeDates(range) startDate / endDate
* - range===1
* - member 便->
* - mrrData
* - meta.totals MRR currency
* - dateFrom dateTo
* - subscriptionData
* - signups/cancellationsreduce
*/
export const useGrowthStats = (range: number) => {
// 使用 Shade 提供的时区感知工具计算起止日期
// Calculate date range using Shade's timezone-aware getRangeDates
const {startDate, endDate} = useMemo(() => getRangeDates(range), [range]);
const dateFrom = formatQueryDate(startDate);
// memberData 请求:对于单日需要从昨天开始以便生成两个点
// Fetch member count history from API
// For single day ranges, we need at least 2 days of data to show a proper delta
const memberDataStartDate = range === 1 ? moment(dateFrom).subtract(1, 'day').format('YYYY-MM-DD') : dateFrom;
const {data: memberCountResponse, isLoading: isMemberCountLoading} = useMemberCountHistory({
@ -268,35 +204,35 @@ export const useGrowthStats = (range: number) => {
}
});
// MRR 历史(全币种)——后续会基于 meta 选择最佳币种
const {data: mrrHistoryResponse, isLoading: isMrrLoading} = useMrrHistory();
// 订阅事件(用于真实的 signups / cancellations
// Fetch subscription stats for real subscription events
const {data: subscriptionStatsResponse, isLoading: isSubscriptionLoading} = useSubscriptionStats();
/**
* memberData memo
* - stats response.stats
* - range===1 EOD startOfToday EOD startOfTomorrow
* 便
*/
// Process member data with stable reference
const memberData = useMemo(() => {
let rawData: MemberStatusItem[] = [];
// Check the structure of the response and extract data
if (memberCountResponse?.stats) {
rawData = memberCountResponse.stats;
} else if (Array.isArray(memberCountResponse)) {
// If response is directly an array
rawData = memberCountResponse;
}
// For single day (Today), ensure we have two data points for a proper line
if (range === 1 && rawData.length >= 2) {
const yesterdayData = rawData[rawData.length - 2]; // 昨日 EOD
const todayData = rawData[rawData.length - 1]; // 今日 EOD
// We should have yesterday's data and today's data
const yesterdayData = rawData[rawData.length - 2]; // Yesterday's EOD counts
const todayData = rawData[rawData.length - 1]; // Today's EOD counts
const startOfToday = moment(dateFrom).format('YYYY-MM-DD');
const startOfTomorrow = moment(dateFrom).add(1, 'day').format('YYYY-MM-DD');
const startOfToday = moment(dateFrom).format('YYYY-MM-DD'); // 6/26
const startOfTomorrow = moment(dateFrom).add(1, 'day').format('YYYY-MM-DD'); // 6/27
// 构造两个点:昨天的数据点标记为 startOfToday今天的数据点标记为 startOfTomorrow
// Create two data points:
// 1. Yesterday's EOD count attributed to start of today (6/26)
// 2. Today's EOD count attributed to start of tomorrow (6/27)
const startPoint = {
...yesterdayData,
date: startOfToday
@ -313,21 +249,13 @@ export const useGrowthStats = (range: number) => {
return rawData;
}, [memberCountResponse, range, dateFrom]);
/**
* mrrData memo
* - meta.totals currency currency
* - dateFrom dateFromendDate/endOfDay
* - dateFrom
* -
* - range 使 end-of-today
*/
const {mrrData, selectedCurrency} = useMemo(() => {
const dateFromMoment = moment(dateFrom);
// 单日范围时,使用当日结束时间作为检查边界
// For "Today" range (1 day), use end of today to match visitor data behavior
const dateToMoment = range === 1 ? moment().endOf('day') : moment().startOf('day');
if (mrrHistoryResponse?.stats && mrrHistoryResponse?.meta?.totals) {
// 从 meta.totals 中选取 mrr 最大的 currency与 Dashboard 一致的选择逻辑)
// Select the currency with the highest total MRR value (same logic as Dashboard)
const totals = mrrHistoryResponse.meta.totals;
let currentMax = totals[0];
if (!currentMax) {
@ -342,19 +270,17 @@ export const useGrowthStats = (range: number) => {
const useCurrency = currentMax.currency;
// 筛选出所选货币的数据
// Filter MRR data to only include the selected currency
const currencyFilteredData = mrrHistoryResponse.stats.filter(d => d.currency === useCurrency);
// 只保留在 dateFrom 及之后的点(范围内部数据)
const filteredData = currencyFilteredData.filter((item) => {
return moment(item.date).isSameOrAfter(dateFromMoment);
});
// allData 用于查找范围外最近的点(向前回填)
const allData = [...currencyFilteredData].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const result = [...filteredData];
// 确保起点存在:若缺失则尝试取范围外最近的点并把它插入为 dateFrom
// Always ensure we have a data point at the start of the range
const hasStartPoint = result.some(item => moment(item.date).isSame(dateFromMoment, 'day'));
if (!hasStartPoint) {
const mostRecentBeforeRange = allData.find((item) => {
@ -369,10 +295,11 @@ export const useGrowthStats = (range: number) => {
}
}
// 确保终点存在:若缺失则把当前 result 中最近的值复制为结束点
// Always ensure we have a data point at the end of the range
const endDateToCheck = range === 1 ? moment().startOf('day') : dateToMoment;
const hasEndPoint = result.some(item => moment(item.date).isSame(endDateToCheck, 'day'));
if (!hasEndPoint && result.length > 0) {
// Use the most recent value in our result set
const sortedResult = [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const mostRecentValue = sortedResult[0];
@ -382,7 +309,6 @@ export const useGrowthStats = (range: number) => {
});
}
// 最终按时间升序返回
const finalResult = result.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return {mrrData: finalResult, selectedCurrency: useCurrency};
@ -390,32 +316,26 @@ export const useGrowthStats = (range: number) => {
return {mrrData: [], selectedCurrency: 'usd'};
}, [mrrHistoryResponse, dateFrom, range]);
// totalsData计算摘要数字使用 calculateTotals
// Calculate totals
const totalsData = useMemo(() => calculateTotals(memberData, mrrData, dateFrom, memberCountResponse?.meta?.totals), [memberData, mrrData, dateFrom, memberCountResponse?.meta?.totals]);
// chartData将时间序列格式化为图表友好的连续数据formatChartData
// Format chart data
const chartData = useMemo(() => formatChartData(memberData, mrrData), [memberData, mrrData]);
// 货币符号(用于显示 MRR 时的单位)
// Get currency symbol
const currencySymbol = useMemo(() => {
return getSymbol(selectedCurrency);
}, [selectedCurrency]);
// 综合加载状态(任一基础请求在加载则返回 true
const isLoading = useMemo(() => isMemberCountLoading || isMrrLoading || isSubscriptionLoading, [isMemberCountLoading, isMrrLoading, isSubscriptionLoading]);
/**
* subscriptionData
* - subscriptionStatsResponse.stats signups cancellations
* - [dateFrom, endDate]
* -
*/
// Process subscription data for real subscription events (like Ember dashboard)
const subscriptionData = useMemo(() => {
if (!subscriptionStatsResponse?.stats) {
return [];
}
// 按日期合并merge by date
// Merge subscription stats by date (like Ember's mergeStatsByDate)
const mergedByDate = subscriptionStatsResponse.stats.reduce((acc, current) => {
const dateKey = current.date;
@ -433,11 +353,11 @@ export const useGrowthStats = (range: number) => {
return acc;
}, {} as Record<string, {date: string; signups: number; cancellations: number}>);
// 转数组并按日期排序
// Convert to array and sort by date
const subscriptionArray = Object.values(mergedByDate).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
// 过滤到请求范围
// Filter to requested date range
const dateFromMoment = moment(dateFrom);
const dateToMoment = moment(endDate);
return subscriptionArray.filter((item) => {
@ -446,7 +366,6 @@ export const useGrowthStats = (range: number) => {
});
}, [subscriptionStatsResponse, dateFrom, endDate]);
// 最终返回:供组件/页面直接消费的所有字段
return {
isLoading,
memberData,

@ -7,20 +7,6 @@ import {getPostStatusText} from '@tryghost/admin-x-framework/utils/post-utils';
import {useAppContext, useNavigate} from '@tryghost/admin-x-framework';
import {useGlobalData} from '@src/providers/GlobalDataProvider';
/**
* TopPosts.tsx
*
* - "Top posts"
* - Web visitors / Newsletter / New members
* - analytics
*
*
* - appSettings web analyticsemail opens/clicksmembers tracking
* - tooltip
* - EmptyIndicator
*/
/* Tooltip 组件:鼠标悬停时显示每个帖子的多个指标(例如 unique visitors / opens / clicks / new members */
interface PostlistTooptipProps {
title?: string;
metrics?: Array<{
@ -38,11 +24,6 @@ const PostListTooltip:React.FC<PostlistTooptipProps> = ({
}) => {
return (
<>
{/*
Tooltip
- 使 group-hover/tooltip / hover
-
*/}
<div className={
cn('pointer-events-none absolute bottom-[calc(100%+2px)] left-1/2 z-50 min-w-[160px] -translate-x-1/2 rounded-md bg-background p-3 text-sm opacity-0 shadow-md transition-all group-hover/tooltip:bottom-[calc(100%+12px)] group-hover/tooltip:opacity-100', className)
}>
@ -63,7 +44,6 @@ const PostListTooltip:React.FC<PostlistTooptipProps> = ({
);
};
/* 类型定义Top posts 卡片接收的数据结构 */
interface TopPostsData {
stats?: TopPostViewsStats[];
}
@ -73,31 +53,24 @@ interface TopPostsProps {
isLoading: boolean;
}
/**
* TopPosts
* - topPostsData.stats: TopPostViewsStatsviews, members, sent_count, opened_count
* - isLoading: SkeletonTable
*/
const TopPosts: React.FC<TopPostsProps> = ({
topPostsData,
isLoading
}) => {
const navigate = useNavigate();
const {range} = useGlobalData(); // 全局时间范围(用于标题 "Top posts (Last 7 days)"
const {appSettings} = useAppContext(); // 全局应用设置,用于决定显示哪些指标
const {range} = useGlobalData();
const {appSettings} = useAppContext();
// 根据设置决定是否展示对应指标
const showWebAnalytics = appSettings?.analytics.webAnalytics; // 是否显示 Unique visitors
const showClickTracking = appSettings?.analytics.emailTrackClicks; // 是否显示点击率clicks
const showOpenTracking = appSettings?.analytics.emailTrackOpens; // 是否显示打开率opens
// Show open rate if newsletters are enabled and email tracking is enabled
const showWebAnalytics = appSettings?.analytics.webAnalytics;
const showClickTracking = appSettings?.analytics.emailTrackClicks;
const showOpenTracking = appSettings?.analytics.emailTrackOpens;
// metricClass共享的样式类用于右侧每个指标的显示图标 + 值)
const metricClass = 'flex items-center justify-end gap-1 rounded-md px-2 py-1 font-mono text-gray-800 hover:bg-muted-foreground/10 group-hover:text-foreground';
return (
<Card className='group/card w-full lg:col-span-2' data-testid='top-posts-card'>
<CardHeader>
{/* 标题包含时间范围描述getPeriodText 会基于 range 返回如 "this week" */}
<CardTitle className='flex items-baseline justify-between font-medium leading-snug text-muted-foreground'>
Top posts {getPeriodText(range)}
</CardTitle>
@ -105,52 +78,39 @@ const TopPosts: React.FC<TopPostsProps> = ({
</CardHeader>
<CardContent>
{isLoading ?
/* 加载中展示骨架表格,替代真实条目 */
<SkeletonTable className='mt-6' />
:
<>
{
/* 列表渲染:遍历 topPostsData.stats每一项渲染为一行 */
topPostsData?.stats?.map((post: TopPostViewsStats) => {
return (
<div key={post.post_id} className='group relative flex w-full items-start justify-between gap-5 border-t border-border/50 py-4 before:absolute before:-inset-x-4 before:inset-y-0 before:z-0 before:hidden before:rounded-md before:bg-accent before:opacity-80 before:content-[""] first:!border-border hover:cursor-pointer hover:border-transparent hover:before:block md:items-center dark:before:bg-accent/50 [&+div]:hover:border-transparent'>
{/* 左侧:封面缩略 + 标题/作者/发布日期/状态,点击整块跳转到文章 Analytics 概览 */}
<div className='z-10 flex min-w-[160px] grow items-start gap-4 md:items-center lg:min-w-[320px]' onClick={() => {
// 跨应用导航到文章分析主页面Overview
navigate(`/posts/analytics/${post.post_id}`, {crossApp: true});
}}>
{post.feature_image ?
/* 若存在 feature_image使用背景图展示缩略 */
<div className='hidden aspect-[16/10] w-[80px] shrink-0 rounded-sm bg-cover bg-center sm:!visible sm:!block lg:w-[100px]' style={{
backgroundImage: `url(${post.feature_image})`
}}></div>
:
/* 否则使用占位组件 */
<FeatureImagePlaceholder className='hidden aspect-[16/10] w-[80px] shrink-0 group-hover:bg-muted-foreground/10 sm:!visible sm:!flex lg:w-[100px]' />
}
<div className='flex flex-col'>
{/* 标题(最多两行) */}
<span className='line-clamp-2 text-lg font-semibold leading-[1.35em]'>{post.title}</span>
{/* 作者与发布时间 */}
<span className='text-sm text-muted-foreground'>
By {post.authors} &ndash; {formatDisplayDate(post.published_at)}
</span>
{/* 文章状态(例如 Draft / Published / Scheduled */}
<span className='text-sm text-muted-foreground'>
{getPostStatusText(post)}
</span>
</div>
</div>
{/* 右侧:各指标列(按设置显示不同列),每个指标区域支持点击跳转并包含 Tooltip */}
<div className='z-10 flex flex-col items-end justify-center gap-0.5 text-sm md:flex-row md:items-center md:justify-end md:gap-3'>
{/* Web analytics 列Unique visitors */}
{showWebAnalytics &&
<div className='group/tooltip relative flex w-[66px] lg:w-[92px]' data-testid='statistics-visitors' onClick={(e) => {
e.stopPropagation(); // 阻止外层行点击,避免同时触发导航
e.stopPropagation();
navigate(`/posts/analytics/${post.post_id}/web`, {crossApp: true});
}}>
{/* Tooltip展示 Unique visitors 的详细数值 */}
<PostListTooltip
metrics={[
{
@ -167,29 +127,27 @@ const TopPosts: React.FC<TopPostsProps> = ({
</div>
</div>
}
{/* Newsletter 列:如果有 sent_count 字段展示邮件相关指标sent / opens / clicks */}
{post.sent_count !== null &&
<div className='group/tooltip relative flex w-[66px] lg:w-[92px]' onClick={(e) => {
e.stopPropagation();
navigate(`/posts/analytics/${post.post_id}/newsletter`, {crossApp: true});
}}>
{/* Tooltip根据 appSettings 显示 sent / opens / clicks */}
<PostListTooltip
className={`${!appSettings?.analytics.membersTrackSources ? 'left-auto right-0 translate-x-0' : ''}`}
metrics={[
// Always show sent
{
icon: <LucideIcon.Send className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Sent',
metric: formatNumber(post.sent_count || 0)
},
// 仅在启用 open tracking 时展示 Open 数
// Only show opens if open tracking is enabled
...(showOpenTracking ? [{
icon: <LucideIcon.MailOpen className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Opens',
metric: formatNumber(post.opened_count || 0)
}] : []),
// 仅在启用 click tracking 时展示 Click 数
// Only show clicks if click tracking is enabled
...(showClickTracking ? [{
icon: <LucideIcon.MousePointer className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Clicks',
@ -200,10 +158,8 @@ const TopPosts: React.FC<TopPostsProps> = ({
/>
<div className={metricClass}>
{(() => {
// 在展示区域根据追踪设置选择显示内容:
// - 优先显示 open rate若启用
// - 否则若启用 click tracking 则显示 click rate
// - 否则回退显示已发送数sent
// If clicks and opens are enabled, show open rate %
// If clicks are disabled but opens enabled, show open rate %
if (showOpenTracking) {
return (
<>
@ -212,6 +168,7 @@ const TopPosts: React.FC<TopPostsProps> = ({
</>
);
} else if (showClickTracking) {
// If open rate is disabled but clicks enabled, show click rate %
return (
<>
<LucideIcon.MousePointer className='text-muted-foreground group-hover:text-foreground' size={16} strokeWidth={1.5} />
@ -219,6 +176,7 @@ const TopPosts: React.FC<TopPostsProps> = ({
</>
);
} else {
// If both are disabled, show sent count
return (
<>
<LucideIcon.Send className='text-muted-foreground group-hover:text-foreground' size={16} strokeWidth={1.5} />
@ -230,14 +188,11 @@ const TopPosts: React.FC<TopPostsProps> = ({
</div>
</div>
}
{/* Members 列:若启用 membersTrackSources则展示新增会员数free / paid */}
{appSettings?.analytics.membersTrackSources &&
<div className='group/tooltip relative flex w-[66px] lg:w-[92px]' data-testid='statistics-members' onClick={(e) => {
e.stopPropagation();
navigate(`/posts/analytics/${post.post_id}/growth`, {crossApp: true});
}}>
{/* Tooltip展示 Free / Paid 新增会员Paid 仅在站点启用付费会员时显示) */}
<PostListTooltip
className='left-auto right-0 translate-x-0'
metrics={[
@ -246,6 +201,7 @@ const TopPosts: React.FC<TopPostsProps> = ({
label: 'Free',
metric: post.free_members > 0 ? `+${formatNumber(post.free_members)}` : '0'
},
// Only show paid members if paid members are enabled
...(appSettings?.paidMembersEnabled ? [{
icon: <LucideIcon.CreditCard className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Paid',
@ -265,7 +221,6 @@ const TopPosts: React.FC<TopPostsProps> = ({
);
})
}
{/* 无数据状态:若 stats 为空则展示 EmptyIndicator 提示 */}
{(!topPostsData?.stats || topPostsData.stats.length === 0) && (
<EmptyIndicator
className='w-full pb-10'

@ -1,97 +1,65 @@
import { Locator, Page } from '@playwright/test';
// 导入父类AdminPage该类继承了管理员页面的通用属性和方法如页面导航、通用元素等
import { AdminPage } from '../AdminPage';
import {Locator, Page} from '@playwright/test';
import {AdminPage} from '../AdminPage';
/**
* "网站流量分析"
*
*/
export class AnalyticsWebTrafficPage extends AdminPage {
// 总浏览量标签页的定位器(用于切换到总浏览量视图)
// 总浏览数 / 总访问量 选项卡的定位器
readonly totalViewsTab: Locator;
// 唯一访客数标签页的定位器(用于切换到唯一访客视图)
// 唯一访客数 选项卡的定位器
readonly totalUniqueVisitorsTab: Locator;
// 页面中流量图表(折线图/柱状图)的容器定位器
// 私有属性,仅在类内部使用(通过方法暴露交互能力)
// 页面中展示流量折线/图表的容器定位器(使用 data-testid
private readonly webGraph: Locator;
// "热门内容"统计卡片的定位器(包含内容访问量排名
// “Top content” 卡片及其内部的选项卡定位器Posts & pages / Posts / Pages
readonly topContentCard: Locator;
// "热门内容"卡片中的"文章和页面"标签按钮(切换显示所有内容类型)
readonly postsAndPagesButton: Locator;
// "热门内容"卡片中的"文章"标签按钮仅显示文章的访问排名exact确保精确匹配文本
readonly postsButton: Locator;
// "热门内容"卡片中的"页面"标签按钮(仅显示页面的访问排名)
readonly pagesButton: Locator;
// "热门来源"统计卡片的定位器(显示流量来源渠道排名,如搜索引擎、外部链接等
// “Top sources” 卡片的定位器(显示来源统计
public readonly topSourcesCard: Locator;
/**
*
* @param page PlaywrightPage
*/
constructor(page: Page) {
// 调用父类构造函数传递page对象
super(page);
// 当前页面的URL路径用于页面导航或验证是否在目标页面
// 页面对应的 hash 路由,用于 goto() 等导航判断
this.pageUrl = '/ghost/#/analytics/web';
// 初始化标签页定位器使用角色定位role="tab"),更符合页面语义和可访问性
this.totalViewsTab = page.getByRole('tab', { name: 'Total views' });
this.totalUniqueVisitorsTab = page.getByRole('tab', { name: 'Unique visitors' });
// 使用可访问性角色定位选项卡(便于稳定定位)
this.totalViewsTab = page.getByRole('tab', {name: 'Total views'});
this.totalUniqueVisitorsTab = page.getByRole('tab', {name: 'Unique visitors'});
// 初始化图表定位器使用data-testid属性开发者为测试预留的标识定位更稳定
// 使用 data-testid 定位图表容器,便于直接读取文本或存在性检查
this.webGraph = page.getByTestId('web-graph');
// 初始化热门内容卡片及内部标签:通过卡片容器定位子元素,缩小查找范围提高效率
// Top content 卡片及内部按钮定位
this.topContentCard = page.getByTestId('top-content-card');
this.postsAndPagesButton = this.topContentCard.getByRole('tab', { name: 'Posts & pages' });
this.postsButton = this.topContentCard.getByRole('tab', { name: 'Posts', exact: true });
this.pagesButton = this.topContentCard.getByRole('tab', { name: 'Pages', exact: true });
this.postsAndPagesButton = this.topContentCard.getByRole('tab', {name: 'Posts & pages'});
this.postsButton = this.topContentCard.getByRole('tab', {name: 'Posts', exact: true});
this.pagesButton = this.topContentCard.getByRole('tab', {name: 'Pages', exact: true});
// 初始化热门来源卡片定位器
this.topSourcesCard = page.getByTestId('top-sources-card');
}
/**
*
*
* @returns null
*/
// 返回 webGraph 的文本内容(可用于断言图表上方的汇总数或提示文本)
async totalViewsContent() {
return await this.webGraph.textContent();
}
/**
* "唯一访客"
*
* @returns null
*/
// 返回“Unique visitors”选项卡的文本内容通常包含数字或标签
async totalUniqueVisitorsContent() {
return await this.totalUniqueVisitorsTab.textContent();
}
/**
* "总浏览量"
*
*/
// 切换到“Total views”选项卡模拟用户点击
async viewTotalViews() {
await this.totalViewsTab.click();
}
/**
* "唯一访客"访
* 访
*/
// 切换到“Unique visitors”选项卡模拟用户点击
async viewTotalUniqueVisitors() {
await this.totalUniqueVisitorsTab.click();
}0 vcf
}
/**
* totalViewsContent
* 使return
*/
// 读取 webGraph 的文本内容(方法名表示读取图表内容)
async viewWebGraphContent() {
await this.webGraph.textContent();
}

@ -1,42 +1,28 @@
import { Locator, Page } from '@playwright/test';
// 导入管理员页面基类,继承通用的管理员页面属性和方法(如页面导航、基础元素操作)
import { AdminPage } from '../../AdminPage';
import {Locator, Page} from '@playwright/test';
import {AdminPage} from '../../AdminPage';
/**
* PostAnalyticsGrowthPage
* Growth
* e2e
* PostAnalyticsGrowthPage
* "Growth增长"
* e2e
*/
export class PostAnalyticsGrowthPage extends AdminPage {
// 成员统计卡片的容器定位器
// 说明通过data-testid定位这是开发者为测试预留的标识定位稳定性高不易受UI变动影响
// 用途:用于获取成员相关统计信息(如免费成员数、付费成员数等)的容器元素
// 成员统计卡片的容器定位器(使用 data-testid便于稳定定位
readonly membersCard: Locator;
// 成员卡片内的“View member”按钮定位器
// 说明在membersCard容器内通过角色button和名称View member定位确保按钮唯一
// 用途:点击后跳转到成员管理页面,查看该文章相关的成员详情
// 成员卡片内的“View member”按钮用于导航到 Members 页面查看详情
readonly viewMemberButton: Locator;
// 流量来源Top sources卡片的容器定位器
// 说明通过data-testid定位用于获取访问来源相关数据如直接访问、搜索引擎、外部链接等
// Top sources流量来源卡片的容器定位器
readonly topSourcesCard: Locator;
/**
*
* @param page PlaywrightPage
*/
constructor(page: Page) {
// 调用父类AdminPage的构造函数传入page对象以继承基础功能
super(page);
// 初始化成员卡片定位器匹配data-testid="members-card"的元素
// 通过 data-testid 定位 members 卡片(包含免费/付费成员数等摘要)
this.membersCard = this.page.getByTestId('members-card');
// 在 members 卡片内定位名为 'View member' 的按钮(用于点击查看成员列表)
this.viewMemberButton = this.membersCard.getByRole('button', {name: 'View member'});
// 初始化“View member”按钮定位器在成员卡片内查找角色为button、名称为View member的元素
this.viewMemberButton = this.membersCard.getByRole('button', { name: 'View member' });
// 初始化流量来源卡片定位器匹配data-testid="top-sources-card"的元素
// 定位 top sources 卡片(显示访问来源,如直接、搜索、社交等)
this.topSourcesCard = this.page.getByTestId('top-sources-card');
}
}
}

@ -1,102 +1,89 @@
import { Locator, Page } from '@playwright/test';
// 导入管理员页面基类,所有管理后台页面均继承此类,可复用通用属性和方法(如页面导航)
import { AdminPage } from '../../AdminPage';
import {Locator, Page} from '@playwright/test';
import {AdminPage} from '../../AdminPage';
/**
* GrowthSection
* Growth
* View more
* GrowthSection
* Growth
* View more
*/
class GrowthSection extends AdminPage {
// Growth区块的容器定位器最外层元素通过data-testid定位稳定性高
// Growth 卡片容器定位器(使用 data-testid
readonly card: Locator;
// Growth区块内的“View more”按钮定位器用于点击查看更详细的增长数据
// Growth 卡片内的“View more”按钮定位器
readonly viewMoreButton: Locator;
/**
* Growth
* @param page PlaywrightPage
*/
constructor(page: Page) {
super(page); // 调用父类构造函数,继承页面上下文
super(page);
// 通过data-testid="growth"定位区块容器(开发预留的测试标识,不易变更)
// 通过 data-testid 定位 growth 卡片,便于稳定选择
this.card = this.page.getByTestId('growth');
// 在容器内定位“View more”按钮按角色+名称定位,符合页面语义
this.viewMoreButton = this.card.getByRole('button', { name: 'View more' });
// 在卡片内查找名为 'View more' 的按钮(用于查看更详细的增长数据
this.viewMoreButton = this.card.getByRole('button', {name: 'View more'});
}
}
/**
* WebPerformanceSection
* Web performance
* 访
* WebPerformanceSection
* Web performance
* 访View more
*/
class WebPerformanceSection extends AdminPage {
// Web performance区块的容器定位器
// Web performance 卡片容器定位器
readonly card: Locator;
// 区块内显示“唯一访客数”的元素定位器(核心数据展示元素)
// 卡片内显示“unique visitors唯一访客”的元素定位器
readonly uniqueVisitors: Locator;
// 区块内的“View more”按钮定位器用于查看更详细的网站表现数据
// 卡片内的“View more”按钮定位器
readonly viewMoreButton: Locator;
/**
* Web performance
* @param page PlaywrightPage
*/
constructor(page: Page) {
super(page); // 调用父类构造函数
super(page);
// 通过data-testid="web-performance"定位区块容器
// 使用 data-testid 定位 web-performance 卡片
this.card = this.page.getByTestId('web-performance');
// 在容器内通过data-testid定位唯一访客数元素精确指向数据展示区域
// 在卡片内定位显示唯一访客数的元素
this.uniqueVisitors = this.card.getByTestId('unique-visitors');
// 在容器内定位“View more”按钮
this.viewMoreButton = this.card.getByRole('button', { name: 'View more' });
// 在卡片内定位“View more”按钮用于查看流量详情
this.viewMoreButton = this.card.getByRole('button', {name: 'View more'});
}
}
/**
* PostAnalyticsPage
*
* Overview/Web traffic/Growth访
* PostAnalyticsPage
* Overview / Web traffic / Growth
* growthSection, webPerformanceSection
*/
export class PostAnalyticsPage extends AdminPage {
// 顶部视图切换按钮:用于在不同分析视图间切换
readonly overviewButton: Locator; // “概览”视图按钮
readonly webTrafficButton: Locator; // “网站流量”视图按钮
readonly growthButton: Locator; // “增长”视图按钮
// 页面上方的导航/选项按钮Overview、Web traffic、Growth
readonly overviewButton: Locator;
readonly webTrafficButton: Locator;
readonly growthButton: Locator;
// 子区块实例:通过组合子区块类,实现对页面各部分的精细化操作
readonly growthSection: GrowthSection; // 增长区块实例
readonly webPerformanceSection: WebPerformanceSection; // 网站表现区块实例
// 区块对象,分别封装 Growth 和 Web Performance 子区域的定位器/操作
readonly growthSection: GrowthSection;
readonly webPerformanceSection: WebPerformanceSection;
/**
*
* @param page PlaywrightPage
*/
constructor(page: Page) {
super(page); // 调用父类构造函数
// 当前页面的路由地址(用于页面导航或验证是否在目标页面
super(page);
// 设置此页面对应的路由(用于 goto() 或页面断言)
this.pageUrl = '/ghost/#/analytics';
// 初始化顶部视图切换按钮(按角色+名称定位,确保点击目标准确)
this.overviewButton = this.page.getByRole('button', { name: 'Overview' });
this.webTrafficButton = this.page.getByRole('button', { name: 'Web traffic' });
this.growthButton = this.page.getByRole('button', { name: 'Growth' });
// 使用可访问性角色定位顶部视图切换按钮,便于稳定点击
this.overviewButton = this.page.getByRole('button', {name: 'Overview'});
this.webTrafficButton = this.page.getByRole('button', {name: 'Web traffic'});
this.growthButton = this.page.getByRole('button', {name: 'Growth'});
// 实例化子区块对象传入当前page实例以共享页面上下文
// 初始化子区块页面对象,传入同一 page 实例以共享上下文
this.growthSection = new GrowthSection(page);
this.webPerformanceSection = new WebPerformanceSection(page);
}
/**
*
* webPerformanceSection
*
* waitForPageLoad
* webPerformanceSection
*
*/
async waitForPageLoad() {
// 等待网站表现区块容器处于可见状态({state: 'visible'}为可见性检查)
await this.webPerformanceSection.card.waitFor({ state: 'visible' });
await this.webPerformanceSection.card.waitFor({state: 'visible'});
}
}
}

@ -1,60 +1,31 @@
import { Page } from '@playwright/test';
// 导入管理员页面基类,继承管理员页面的通用属性和方法(如页面导航、基础元素等)
import { AdminPage } from '../../AdminPage';
import {Page} from '@playwright/test';
import {AdminPage} from '../../AdminPage';
/**
* POM
*
*
* - Web Traffic
* - e2e
*
*
* 访访
* PostAnalyticsWebTrafficPage
* --------------------------
* Playwright Web Traffic
*
*
* - AdminPage 便
* - Top sourcesTop content
* 便/ getTotalViews(), clickTopSourcesTab()
*
* TODO
* - this.webGraph = page.getByTestId('post-web-graph');
* - async getTotalViews() { return await this.webGraphLocator.textContent(); }
* - async selectTimeRange(range: '7d'|'30d'|'90d') { ... }
*
* 便 e2e
*/
export class PostAnalyticsWebTrafficPage extends AdminPage {
/**
*
* @param page PlaywrightPage
*/
constructor(page: Page) {
// 调用父类AdminPage的构造函数传递page对象以继承基础功能
super(page);
// TODO当前为占位说明实际使用时需补充以下内容
// 1. 页面URL如有固定路由用于验证是否在目标页面或直接导航
// 示例this.pageUrl = '/ghost/#/analytics/post/123'; // 假设123为文章ID
// TODO: 在这里初始化文章级 Web 流量页面的定位器,例如:
// this.pageUrl = '/ghost/#/editor/analytics/post/...'; // 如有需要可设置具体路由
//
// 2. 元素定位器根据页面实际DOM结构补充
// - 流量图表容器(如折线图/柱状图)
// - 总浏览量/唯一访客数等统计数据元素
// - 流量来源Top sources卡片及内部元素
// - 热门内容Top content相关元素
// - 时间范围切换控件如7天/30天/90天
//
// 示例定位器仅为参考需根据实际测试ID或属性调整
// this.trafficGraph = page.getByTestId('post-traffic-graph'); // 文章流量图表
// this.totalViewsStat = page.getByTestId('post-total-views'); // 总浏览量统计
// this.timeRangeSelector = page.getByRole('combobox', { name: 'Time range' }); // 时间范围选择器
// this.topSourcesList = page.getByTestId('post-top-sources-list'); // 流量来源列表
// 示例(占位):
// this.webGraph = page.getByTestId('post-web-graph');
// this.topSourcesCard = page.getByTestId('post-top-sources-card');
}
/**
*
*
* 1.
* async getTotalViews(): Promise<string | null> {
* return await this.totalViewsStat.textContent();
* }
*
* 2. 30
* async setTimeRange(range: '7d' | '30d' | '90d'): Promise<void> {
* await this.timeRangeSelector.selectOption(range);
* }
*
* 3.
* async hasSource(sourceName: string): Promise<boolean> {
* return await this.topSourcesList.getByText(sourceName).isVisible();
* }
*/
}
}

@ -1,68 +1,55 @@
// 导入Playwright测试工具test用于定义测试用例结构expect用于结果断言验证
// 从项目自定义helpers中导入playwright工具包可能包含项目特有的配置或辅助函数
import { test, expect } from '../../../../helpers/playwright';
// 导入所需的页面对象类(每个类对应管理后台的一个页面/功能模块)
import {test, expect} from '../../../../helpers/playwright';
import {
AnalyticsOverviewPage, // 分析概览页面(展示所有内容的分析入口)
PostAnalyticsPage, // 文章分析主页面(包含概览、流量、增长等多个视图切换)
PostAnalyticsGrowthPage, // 文章分析的“增长”子页面(专注于用户增长和流量来源数据)
MembersPage // 成员管理页面(展示所有用户成员信息)
AnalyticsOverviewPage,
PostAnalyticsPage,
PostAnalyticsGrowthPage,
MembersPage
} from '../../../../helpers/pages/admin';
// 定义测试套件对Ghost管理后台中“文章分析-增长”页面的测试集合
// test.describe用于将相关测试用例分组增强代码可读性和维护性
// 测试套件Ghost 管理后台 - 文章分析Post Analytics- Growth增长
test.describe('Ghost Admin - Post Analytics - Growth', () => {
// 前置钩子函数:每个测试用例执行前自动运行
// 作用:创建统一的测试前置条件,确保所有用例从相同初始状态开始
test.beforeEach(async ({ page }) => {
// 1. 实例化分析概览页面对象(获取该页面的元素操作能力)
// 每个测试开始前的准备工作:导航并打开目标文章的分析页面,再点击 Growth 选项
test.beforeEach(async ({page}) => {
const analyticsOverviewPage = new AnalyticsOverviewPage(page);
// 2. 导航到分析概览页面(内部封装了页面跳转和加载完成的验证)
await analyticsOverviewPage.goto();
// 3. 在概览页面中点击最新文章的“analytics”按钮
// latestPost是AnalyticsOverviewPage中定义的“最新文章”元素对象包含其专属的analyticsButton
// 在概览页点击最新文章的 analytics 按钮,进入文章分析面板
await analyticsOverviewPage.latestPost.analyticsButton.click();
// TODO优化提示——未来可调整为无需等待页面完全加载即可点击growth链接提升测试效率
// 4. 实例化文章分析主页面对象(当前处于文章分析的概览视图)
// TODO 注释保留:理想情况下不应需等待页面完全加载即可点击 growth 链接
const postAnalyticsPage = new PostAnalyticsPage(page);
// 5. 等待文章分析页面加载完成(内部通过等待关键元素出现来确认页面就绪,避免操作过早导致失败
// 等待文章分析页面加载完成(确保元素可交互
await postAnalyticsPage.waitForPageLoad();
// 6. 点击“Growth”按钮切换到增长数据视图进入当前测试套件的目标页面
// 点击 Growth 按钮,进入增长视图
await postAnalyticsPage.growthButton.click();
});
// 测试用例1验证空数据时成员卡片的显示内容
test('empty members card', async ({ page }) => {
// 实例化文章分析的增长页面对象(获取当前页面的元素操作能力)
// 测试:空成员卡片应显示 Free members 字样并且数量为 0
test('empty members card', async ({page}) => {
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 断言1成员卡片应包含“Free members”文本验证标签显示正确
// 断言 members 卡片包含“Free members”标签
await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('Free members');
// 断言2成员卡片应包含“0”验证空数据场景下数量显示正确
// 断言成员数量显示为 0空数据场景
await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('0');
});
// 测试用例2验证空成员场景下点击“查看成员”的跳转结果
test('empty members card - view member', async ({ page }) => {
// 实例化增长页面对象
// 测试:在空成员场景点击“查看成员”应跳转到 Members 页面并显示无匹配结果
test('empty members card - view member', async ({page}) => {
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 点击增长页面上的“查看成员”按钮(模拟用户查看详情的操作
// 点击“查看成员”按钮(应导航到 Members 列表
await postAnalyticsPageGrowthPage.viewMemberButton.click();
// 实例化成员管理页面对象(跳转后的目标页面)
const membersPage = new MembersPage(page);
// 断言:成员页面应显示“无成员匹配”的提示文本(验证空数据跳转后的状态正确)
// 断言 Members 页面显示“无成员匹配”的提示文本
await expect(membersPage.body).toContainText('No members match');
});
// 测试用例3验证Top sources卡片在无数据时的提示文本
test('empty top sources card', async ({ page }) => {
// 实例化增长页面对象
// 测试Top sources 卡片在无数据时显示“无来源数据”提示
test('empty top sources card', async ({page}) => {
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 断言顶部来源卡片应包含“No sources data available”验证无数据提示正确
// 断言 top sources 卡片包含“No sources data available”
await expect(postAnalyticsPageGrowthPage.topSourcesCard).toContainText('No sources data available');
});
});
});

@ -1,12 +1,9 @@
// 导入Playwright测试核心工具test用于定义测试用例expect用于用于断言验证结果
// 从项目helpers目录导入导入playwright工具包封装含项目自定义的测试配置或工具函数
import { test, expect } from '../../../../helpers/playwright';
// 导入所需的页面对象类(对应管理后台的不同页面/组件)
import {test, expect} from '../../../../helpers/playwright';
import {
AnalyticsOverviewPage, // 分析概览页面(入口页面)
PostAnalyticsPage, // 文章分析主页面Overview视图
PostAnalyticsGrowthPage, // 文章分析的Growth详情页面
PostAnalyticsWebTrafficPage // 文章分析的Web Traffic详情页面
AnalyticsOverviewPage,
PostAnalyticsPage,
PostAnalyticsGrowthPage,
PostAnalyticsWebTrafficPage
} from '../../../../helpers/pages/admin';
/**
@ -15,66 +12,53 @@ import {
* /
*/
// 定义测试套件Ghost管理后台 - 文章分析 - Overview概览视图的测试
// 所有相关测试用例都归类在此套件下,便于管理和理解测试范围
test.describe('Ghost Admin - Post Analytics - Overview', () => {
// 前置操作:每个测试用例执行前都会运行的代码(初始化测试环境)
test.beforeEach(async ({ page }) => {
// 1. 实例化分析概览页面对象(进入分析模块的入口页面)
// 在每个测试前都执行:导航到 Analytics 概览并打开最新文章的 analytics 面板
test.beforeEach(async ({page}) => {
const analyticsOverviewPage = new AnalyticsOverviewPage(page);
// 2. 导航到分析概览页面(内部实现页面跳转和加载验证)
await analyticsOverviewPage.goto();
// 3. 在概览页面中点击“最新文章”的analytics按钮进入该文章的分析面板
// (模拟用户从概览页查看单篇文章详情分析的操作流程)
// 在概览页点击“最新文章”的 analytics 按钮,进入文章分析面板
await analyticsOverviewPage.latestPost.analyticsButton.click();
});
// 测试用例1验证概览页面存在三个主要选项卡确保页面结构完整性
test('empty page with all tabs', async ({ page }) => {
// 实例化文章分析主页面对象当前处于Overview视图
// 验证概览页面存在三个主要选项卡Overview / Web traffic / Growth
test('empty page with all tabs', async ({page}) => {
const postAnalyticsPage = new PostAnalyticsPage(page);
// 断言三个视图切换按钮Overview/Web traffic/Growth都应可见
// 验证页面基本结构完整,用户可正常切换不同分析视图
// 三个切换按钮都应可见,确保页面结构完整
await expect(postAnalyticsPage.overviewButton).toBeVisible();
await expect(postAnalyticsPage.webTrafficButton).toBeVisible();
await expect(postAnalyticsPage.growthButton).toBeVisible();
});
// 测试用例2验证从Overview的Web performance区块点击"View more"的跳转和空数据提示
test('empty page - overview - web performance - view more', async ({ page }) => {
// 实例化文章分析主页面对象
// 在 Overview -> Web performance 区块点击 "View more" 应进入 Web traffic 视图并显示无访问提示
test('empty page - overview - web performance - view more', async ({page}) => {
const postAnalyticsPage = new PostAnalyticsPage(page);
// 点击Web performance区块的“View more”按钮预期跳转到Web traffic详情页
// 点击 Web performance 区块的 “View more” 按钮,进入流量详情页
await postAnalyticsPage.webPerformanceSection.viewMoreButton.click();
// 实例化Web traffic详情页面对象跳转后的目标页面
const postAnalyticsWebTrafficPage = new PostAnalyticsWebTrafficPage(page);
// 断言空数据场景下Web traffic页面应显示指定提示文本验证空状态展示正确
// 在空数据场景下web traffic 页面应包含“No visitors in the last 30 days”提示
await expect(postAnalyticsWebTrafficPage.body).toContainText('No visitors in the last 30 days');
});
// 测试用例3验证Overview视图中Growth区块的空数据展示成员数相关
test('empty page - overview - growth', async ({ page }) => {
// 实例化文章分析主页面对象
// 验证 Growth 区块在空数据情况下显示“Free members” 并且数量为 0
test('empty page - overview - growth', async ({page}) => {
const postAnalyticsPage = new PostAnalyticsPage(page);
// 断言1Growth区块应包含“Free members”标签验证成员类型标签正确
await expect(postAnalyticsPage.growthSection.card).toContainText('Free members');
// 断言2Growth区块应显示成员数量为0验证空数据时数量展示正确
await expect(postAnalyticsPage.growthSection.card).toContainText('0');
});
// 测试用例4验证从Overview的Growth区块点击"View more"的跳转和空数据提示
test('empty page - overview - growth - view more', async ({ page }) => {
// 实例化文章分析主页面对象
// 在 Overview -> Growth 区块点击 "View more" 应进入 Growth 详情并显示无来源数据提示
test('empty page - overview - growth - view more', async ({page}) => {
const postAnalyticsPage = new PostAnalyticsPage(page);
// 点击Growth区块的“View more”按钮预期跳转到Growth详情页
// 点击 Growth 卡片的“View more”按钮进入增长详情页
await postAnalyticsPage.growthSection.viewMoreButton.click();
// 实例化Growth详情页面对象跳转后的目标页面
const postAnalyticsGrowthPage = new PostAnalyticsGrowthPage(page);
// 断言空数据场景下Top sources卡片应显示指定提示文本验证空状态展示正确
// 在空数据场景下top sources 卡片应展示“No sources data available”提示
await expect(postAnalyticsGrowthPage.topSourcesCard).toContainText('No sources data available');
});
});
});

@ -1,69 +1,39 @@
{{! 标签编辑/新建页面的主模板,同时支持新建标签和编辑已有标签的功能 }}
{{! section标签语义化HTML元素用于划分页面独立区域 }}
{{! gh-canvasGhost CMS的核心样式类定义页面基础布局宽度、边距等保证后台界面风格统一 }}
{{! 标签编辑/新建页面的主模板 }}
<section class="gh-canvas">
{{! form标签语义化表单容器虽未绑定提交事件但用于包裹所有表单元素便于后续扩展验证逻辑 }}
{{! mb15自定义样式类设置margin-bottom:15px与下方删除按钮保持间距 }}
<form class="mb15">
{{! 页面头部区域:包含面包屑导航、页面标题和操作按钮 }}
{{! GhCanvasHeaderGhost的通用头部组件封装了后台页面的标准化头部布局 }}
{{! gh-canvas-header为头部组件添加的样式类控制内边距和边框等细节 }}
{{! 页面头部区域:包含面包屑导航、标题和操作按钮 }}
<GhCanvasHeader class="gh-canvas-header">
<div class="flex flex-column">
{{! 面包屑导航:显示用户当前位置,提供返回上级页面的入口 }}
{{! gh-canvas-breadcrumb面包屑导航样式类控制文字大小、间距和颜色 }}
{{! 面包屑导航:显示当前位置,点击"Tags"可返回标签列表页 }}
<div class="gh-canvas-breadcrumb">
{{! LinkToEmber框架的路由链接组件用于页面间跳转 }}
{{! @route="tags":指定跳转的路由为标签列表页 }}
{{! data-test-link="tags-back":测试标识,供自动化测试工具定位该元素 }}
<LinkTo @route="tags" data-test-link="tags-back">
Tags
</LinkTo>
{{! svg-jar引入SVG图标组件"arrow-right-small"是图标名称 }}
{{! 作用:作为面包屑导航的分隔符,增强视觉层级 }}
{{svg-jar "arrow-right-small"}} {{! 箭头图标,分隔面包屑导航项 }}
{{! if条件渲染根据标签状态动态显示文本 }}
{{! this.tag.isNew组件逻辑中的属性判断标签是否为新创建未保存 }}
{{! 新建标签时显示"New tag",编辑时显示"Edit tag" }}
{{svg-jar "arrow-right-small"}} {{! 箭头图标,分隔面包屑 }}
{{! 根据标签状态显示"New tag"或"Edit tag" }}
{{if this.tag.isNew "New tag" "Edit tag"}}
</div>
{{! 页面标题:明确当前页面的核心内容 }}
{{! gh-canvas-title标题样式类通常为大字号、粗体突出显示页面主题 }}
{{! data-test-screen-title测试标识用于验证页面标题是否正确渲染 }}
{{! 页面标题:新建时显示"New tag",编辑时显示标签名称 }}
<h2 class="gh-canvas-title" data-test-screen-title>
{{! 动态标题:新建时显示"New tag",编辑时显示标签名称 }}
{{if this.tag.isNew "New tag" this.tag.name}}
</h2>
</div>
{{! 操作按钮区域:集中展示页面的核心功能按钮 }}
{{! view-actionsGhost的操作按钮容器样式类通常位于页面右上角 }}
{{! 操作按钮区域 }}
<section class="view-actions">
{{! view-actions-bottom-row控制按钮行的布局水平排列、间距等 }}
<div class="view-actions-bottom-row">
{{! 查看标签按钮:在新窗口打开标签的前端页面,方便预览效果 }}
{{! href={{this.tagURL}}this.tagURL是组件逻辑计算的标签访问URL }}
{{! target="_blank":在新窗口打开链接 }}
{{! rel="noopener noreferrer":安全属性,防止新窗口劫持原页面 }}
{{! gh-btn基础按钮样式gh-btn-icon-right图标在文字右侧的样式gh-btn-action-icon辅助操作按钮样式灰色 }}
{{! 查看标签按钮:点击在新窗口打开标签页面(仅编辑时有效) }}
<a
href={{this.tagURL}}
target="_blank"
rel="noopener noreferrer"
class="gh-btn gh-btn-icon-right gh-btn-action-icon"
>
{{! 按钮文本+图标:"View"提示功能,箭头图标表示外部链接 }}
<span>View{{svg-jar "arrow-top-right"}}</span> {{! 包含"View"文本和外部链接图标 }}
</a>
{{! 保存按钮:用于提交标签数据(新建或更新) }}
{{! GhTaskButtonGhost的任务按钮组件专门绑定异步任务支持加载状态显示 }}
{{! @task={{this.saveTask}}绑定组件中的saveTask异步任务负责保存标签数据到服务器 }}
{{! @type="button"指定按钮类型为button避免触发表单默认提交 }}
{{! gh-btn-primary主要操作按钮样式通常为蓝色突出显示核心功能 }}
{{! @data-test-button="save":测试标识,用于验证保存按钮功能 }}
{{! on-key "cmd+s"绑定快捷键按下Command+SMac或Ctrl+SWindows触发保存 }}
{{! 保存按钮使用任务按钮组件关联保存任务支持cmd+s快捷键 }}
<GhTaskButton
@task={{this.saveTask}}
@type="button"
@ -75,20 +45,13 @@
</section>
</GhCanvasHeader>
{{! 标签表单组件:负责渲染标签的具体编辑字段(名称、颜色、描述等) }}
{{! Tags::TagForm自定义组件路径为app/components/tags/tag-form }}
{{! @tag={{this.model}}通过参数传递标签数据模型Ember Data对象表单基于此渲染和更新数据 }}
{{! 标签表单组件:传入当前标签模型,用于编辑或新建标签的具体内容 }}
<Tags::TagForm @tag={{this.model}} />
</form>
{{! 删除按钮:仅在编辑现有标签时显示(新建标签无删除意义) }}
{{! unless条件渲染等同于"如果不满足条件则渲染"this.tag.isNew为false时显示即编辑状态 }}
{{! 删除按钮:仅在编辑现有标签时显示 }}
{{#unless this.tag.isNew}}
<div>
{{! 按钮类型为button避免触发表单提交 }}
{{! gh-btn-red红色按钮样式视觉警示用户这是危险操作删除不可恢复 }}
{{! on "click" this.confirmDeleteTag点击事件绑定触发删除确认流程通常弹出模态框 }}
{{! data-test-button="delete-tag":测试标识,用于验证删除按钮功能 }}
<button
type="button"
class="gh-btn gh-btn-red gh-btn-icon"

@ -250,7 +250,8 @@ const controller = {
},
async query(frame) {
let model = await postsService.editPost(frame, {
eventHandler: (event, dto) => { // 事件处理器,根据文章状态变更智能处理缓存
// 事件处理器,根据文章状态变更智能处理缓存
eventHandler: (event, dto) => {
const cacheInvalidate = getCacheHeaderFromEventString(event, dto);
if (cacheInvalidate === true) {
frame.setHeader('X-Cache-Invalidate', '/*'); // 失效所有缓存

@ -180,4 +180,4 @@ const controller = {
}
};
module.exports = controller;
module.exports = controller;

@ -40,7 +40,7 @@ class DatabaseStateManager {
async getState() { //获得当前数据库状态
let state = states.READY;
try {
await this.knexMigrator.isDatabaseOK(); //await 起等待作用,如果有问题,它会率先等异步操作结束在进行
await this.knexMigrator.isDatabaseOK();
return state;
} catch (error) { //对错误状态进行处理
// CASE: database has not yet been initialized

@ -14,8 +14,8 @@ const {
TABLES_ALLOWLIST,
SETTING_KEYS_BLOCKLIST
} = require('./table-lists');
//单表导出函数 会在后面被调用
const exportTable = function exportTable(tableName, options) {
const exportTable = function exportTable(tableName, options) {//单表导出函数
if (TABLES_ALLOWLIST.includes(tableName) ||
(options.include && _.isArray(options.include) && options.include.indexOf(tableName) !== -1)) {
const query = (options.transacting || db.knex)(tableName);
@ -23,8 +23,8 @@ const exportTable = function exportTable(tableName, options) {
return query.select();
}
};
//数据过滤函数,移除黑名单中的设置项
const getSettingsTableData = function getSettingsTableData(settingsData) {
const getSettingsTableData = function getSettingsTableData(settingsData) { //数据过滤函数,移除黑名单中的设置项
return settingsData && settingsData.filter((setting) => {
return !SETTING_KEYS_BLOCKLIST.includes(setting.key);
});
@ -35,7 +35,7 @@ const doExport = async function doExport(options) {//导出主函数
try {
const tables = await commands.getTables(options.transacting);
//并行导出所有表的数据
const tableData = await sequence(tables.map(tableName => async () => {
return exportTable(tableName, options);
}));
@ -50,7 +50,7 @@ const doExport = async function doExport(options) {//导出主函数
}
};
tables.forEach((name, i) => {//导出数据到导出的结构之中,设置信息,普通的表格信息
tables.forEach((name, i) => {
if (name === 'settings') {
exportData.data[name] = getSettingsTableData(tableData[i]);
} else {

@ -12,32 +12,29 @@ ImageHandler = {
directories: ['images', 'content'],
loadFile: function (files, baseDir) {
const store = storage.getStorage('images'); // 获取图像存储适配器
const store = storage.getStorage('images');
const baseDirRegex = baseDir ? new RegExp('^' + baseDir + '/') : new RegExp('');
// 创建Ghost静态文件URL前缀的正则表达式
const imageFolderRegexes = _.map(store.staticFileURLPrefix.split('/'), function (dir) {
return new RegExp('^' + dir + '/');
});
// normalize the directory structure
files = _.map(files, function (file) {
const noBaseDir = file.name.replace(baseDirRegex, ''); // 移除基础目录
const noBaseDir = file.name.replace(baseDirRegex, '');
let noGhostDirs = noBaseDir;
// 移除Ghost特定的目录前缀
_.each(imageFolderRegexes, function (regex) {
noGhostDirs = noGhostDirs.replace(regex, '');
});
file.originalPath = noBaseDir; // 保存原始路径(不含基础目录)
file.name = noGhostDirs; // 标准化后的文件名
file.originalPath = noBaseDir;
file.name = noGhostDirs;
file.targetDir = path.join(config.getContentPath('images'), path.dirname(noGhostDirs));
return file;
});
return Promise.all(files.map(function (image) {
//构建新的url路径
return store.getUniqueFileName(image, image.targetDir).then(function (targetFilename) {
image.newPath = urlUtils.urlJoin('/', urlUtils.getSubdir(), store.staticFileURLPrefix,
path.relative(config.getContentPath('images'), targetFilename));
@ -48,4 +45,4 @@ ImageHandler = {
}
};
module.exports = ImageHandler;
module.exports = ImageHandler;

@ -1,155 +1,108 @@
const _ = require('lodash');
const fs = require('fs-extra');
const moment = require('moment');
// 正则表达式用于匹配Markdown文件中的不同部分
const featuredImageRegex = /^(!\[]\(([^)]*?)\)\s+)(?=#)/; // 匹配特色图片:以 ![alt](url) 格式开头,后面跟着 #
const titleRegex = /^#\s?([\w\W]*?)(?=\n)/; // 匹配标题:以 # 开头,直到换行符
const statusRegex = /(published|draft)-/; // 匹配状态published- 或 draft-
const dateRegex = /(\d{4}-\d{2}-\d{2})-/; // 匹配日期YYYY-MM-DD 格式
const featuredImageRegex = /^(!\[]\(([^)]*?)\)\s+)(?=#)/;
const titleRegex = /^#\s?([\w\W]*?)(?=\n)/;
const statusRegex = /(published||draft)-/;
const dateRegex = /(\d{4}-\d{2}-\d{2})-/;
let processDateTime;
let processFileName;
let processMarkdownFile;
let MarkdownHandler;
/**
* 处理日期时间将文件名中的日期时间转换为可导入的Date对象
* @param {Object} post - 文章对象
* @param {string} datetime - 日期时间字符串 (格式: YYYY-MM-DD-HH-mm)
* @returns {Object} 更新后的文章对象
*/
// Takes a date from the filename in y-m-d-h-m form, and converts it into a Date ready to import
processDateTime = function (post, datetime) {
const format = 'YYYY-MM-DD-HH-mm';
// 将日期时间字符串转换为时间戳UTC时间
datetime = moment.utc(datetime, format).valueOf();
// 根据文章状态设置发布时间或创建时间
if (post.status && post.status === 'published') {
post.published_at = datetime; // 已发布文章设置发布时间
post.published_at = datetime;
} else {
post.created_at = datetime; // 草稿文章设置创建时间
post.created_at = datetime;
}
return post;
};
/**
* 处理文件名从中提取文章状态日期和slug信息
* @param {string} filename - 文件名不含扩展名
* @returns {Object} 包含文章基本信息的对象
*/
processFileName = function (filename) {
let post = {};
let name = filename.split('.')[0]; // 移除文件扩展名
let name = filename.split('.')[0];
let match;
// 解析文章状态published 或 draft
// Parse out the status
match = name.match(statusRegex);
if (match) {
post.status = match[1]; // 提取状态
name = name.replace(match[0], ''); // 从文件名中移除状态部分
post.status = match[1];
name = name.replace(match[0], '');
}
// 解析日期
// Parse out the date
match = name.match(dateRegex);
if (match) {
name = name.replace(match[0], ''); // 从文件名中移除日期部分
// 默认设置为中午12点并处理日期时间
name = name.replace(match[0], '');
// Default to middle of the day
post = processDateTime(post, match[1] + '-12-00');
}
// 设置slug和默认标题使用处理后的文件名
post.slug = name;
post.title = name;
return post;
};
/**
* 处理Markdown文件内容提取特色图片标题和正文
* @param {string} filename - 文件名
* @param {string} content - 文件内容
* @returns {Object} 完整的文章对象
*/
processMarkdownFile = function (filename, content) {
// 首先从文件名中提取基本信息
const post = processFileName(filename);
let match;
// 统一换行符为Unix风格
content = content.replace(/\r\n/gm, '\n');
// 解析标题前的特色图片
// parse out any image which appears before the title
match = content.match(featuredImageRegex);
if (match) {
content = content.replace(match[1], ''); // 从内容中移除图片标记
post.image = match[2]; // 保存图片URL
content = content.replace(match[1], '');
post.image = match[2];
}
// 解析一级标题作为文章标题
// try to parse out a heading 1 for the title
match = content.match(titleRegex);
if (match) {
content = content.replace(titleRegex, ''); // 从内容中移除标题标记
post.title = match[1]; // 保存标题内容
content = content.replace(titleRegex, '');
post.title = match[1];
}
// 移除开头多余的换行符
content = content.replace(/^\n+/, '');
// 保存处理后的Markdown正文
post.markdown = content;
return post;
};
/**
* Markdown处理器 - 主要模块
* 用于处理Markdown文件的导入和解析
*/
MarkdownHandler = {
type: 'data', // 处理器类型
extensions: ['.md', '.markdown'], // 支持的文件扩展名
contentTypes: ['application/octet-stream', 'text/plain'], // 支持的内容类型
directories: [], // 处理的目录
/**
* 加载并处理多个Markdown文件
* @param {Array} files - 文件对象数组
* @param {string} startDir - 起始目录用于路径处理
* @returns {Promise} 返回包含处理结果的Promise
*/
type: 'data',
extensions: ['.md', '.markdown'],
contentTypes: ['application/octet-stream', 'text/plain'],
directories: [],
loadFile: function (files, startDir) {
// 创建正则表达式用于移除起始目录前缀
const startDirRegex = startDir ? new RegExp('^' + startDir + '/') : new RegExp('');
const posts = []; // 存储处理后的文章
const ops = []; // 存储文件读取操作
const posts = [];
const ops = [];
// 遍历所有文件
_.each(files, function (file) {
// 对每个文件创建读取和处理操作
ops.push(fs.readFile(file.path).then(function (content) {
// 规范化文件名(移除起始目录前缀)
// normalize the file name
file.name = file.name.replace(startDirRegex, '');
// 跳过已删除的文章(文件名以"deleted"开头)
// don't include deleted posts
if (!/^deleted/.test(file.name)) {
// 处理Markdown文件并添加到文章列表
posts.push(processMarkdownFile(file.name, content.toString()));
}
}));
});
// 等待所有文件处理完成
return Promise.all(ops).then(function () {
// 返回标准格式的数据
return {
meta: {}, // 元数据(当前为空)
data: {posts: posts} // 文章数据
};
return {meta: {}, data: {posts: posts}};
});
}
};
// 导出Markdown处理器模块
module.exports = MarkdownHandler;
module.exports = MarkdownHandler;

@ -127,7 +127,6 @@ class ImportManager {
/**
* Get an array of all the mime types for which we have handlers
* @returns {string[]} contentTypes 所有支持的文件MIME类型数组
* 获取导入管理器的所有支持类型
*/
getContentTypes() {
return _.union(_.flatMap(this.handlers, 'contentTypes'), defaults.contentTypes);
@ -620,4 +619,4 @@ class ImportManager {
/**
* @typedef {Object} ImportResult
*/
module.exports = new ImportManager();
module.exports = new ImportManager();

@ -1,144 +1,88 @@
// 导入基础模块
const ghostBookshelf = require('./base');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
// 定义错误消息
const messages = {
labelNotFound: 'Label not found.'
};
// 定义变量
let Label;
let Labels;
/**
* Label 模型 - 标签数据模型
* 用于管理系统中的标签功能
*/
Label = ghostBookshelf.Model.extend({
// 数据库表名
tableName: 'labels',
// 动作收集配置用于记录CRUD操作
actionsCollectCRUD: true,
actionsResourceType: 'label',
/**
* 触发变更事件
* @param {string} event - 事件类型
* @param {Object} options - 选项参数
*/
emitChange: function emitChange(event, options) {
const eventToTrigger = 'label' + '.' + event;
ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
},
/**
* 创建后钩子函数
*/
onCreated: function onCreated(model, options) {
ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);
// 触发添加事件
model.emitChange('added', options);
},
/**
* 更新后钩子函数
*/
onUpdated: function onUpdated(model, options) {
ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments);
// 触发编辑事件
model.emitChange('edited', options);
},
/**
* 删除后钩子函数
*/
onDestroyed: function onDestroyed(model, options) {
ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);
// 触发删除事件
model.emitChange('deleted', options);
},
/**
* 保存前钩子函数 - 在保存到数据库前执行
*/
onSaving: function onSaving(newLabel, attr, options) {
const self = this;
// 调用父类的onSaving方法
ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
// 确保名称被修剪(去除前后空格)
// Make sure name is trimmed of extra spaces
let name = this.get('name') && this.get('name').trim();
this.set('name', name);
// 如果slug发生变化或者没有slug但有名称时生成新的slug
if (this.hasChanged('slug') || (!this.get('slug') && this.get('name'))) {
// 通过生成器传递新的slug去除非法字符检测重复
return ghostBookshelf.Model.generateSlug(
Label,
this.get('slug') || this.get('name'),
{transacting: options.transacting}
).then(function then(slug) {
self.set({slug: slug});
});
// Pass the new slug through the generator to strip illegal characters, detect duplicates
return ghostBookshelf.Model.generateSlug(Label, this.get('slug') || this.get('name'),
{transacting: options.transacting})
.then(function then(slug) {
self.set({slug: slug});
});
}
},
/**
* 定义与 Member 模型的多对多关联
* 一个标签可以关联多个会员一个会员可以有多个标签
* 通过 members_labels 中间表关联
*/
members: function members() {
return this.belongsToMany(
'Member',
'members_labels',
'label_id',
'member_id'
);
return this.belongsToMany('Member', 'members_labels', 'label_id', 'member_id');
},
/**
* 转换为JSON格式
* @param {Object} unfilteredOptions - 过滤选项
* @returns {Object} JSON数据
*/
toJSON: function toJSON(unfilteredOptions) {
const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, unfilteredOptions);
return attrs;
}
}, {
// 静态方法和属性
/**
* 默认排序选项
*/
orderDefaultOptions: function orderDefaultOptions() {
return {
name: 'ASC', // 按名称升序
created_at: 'DESC' // 按创建时间降序
name: 'ASC',
created_at: 'DESC'
};
},
/**
* 允许的查询选项
* @param {string} methodName - 方法名称
* @returns {Array} 允许的选项数组
*/
permittedOptions: function permittedOptions(methodName) {
let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
// 按方法名称定义允许的选项白名单
// allowlists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
const validOptions = {
findAll: ['columns'], // 查找所有时允许columns选项
findOne: ['columns'], // 查找单个时允许columns选项
destroy: ['destroyAll'] // 删除时允许destroyAll选项
findAll: ['columns'],
findOne: ['columns'],
destroy: ['destroyAll']
};
// 添加特定方法的允许选项
if (validOptions[methodName]) {
options = options.concat(validOptions[methodName]);
}
@ -146,67 +90,47 @@ Label = ghostBookshelf.Model.extend({
return options;
},
/**
* 定义关联计数
* 用于在查询时包含关联对象的计数
*/
countRelations() {
return {
// 计算每个标签关联的会员数量
members(modelOrCollection) {
modelOrCollection.query('columns', 'labels.*', (qb) => {
qb.count('members.id')
.from('members')
.leftOuterJoin('members_labels', 'members.id', 'members_labels.member_id')
.whereRaw('members_labels.label_id = labels.id')
.as('count__members'); // 别名为 count__members
.as('count__members');
});
}
};
},
/**
* 重写销毁方法 - 删除标签及其关联关系
* @param {Object} unfilteredOptions - 未过滤的选项
* @returns {Promise} 删除操作的Promise
*/
destroy: function destroy(unfilteredOptions) {
// 过滤选项,只允许特定的额外属性
const options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
// 包含关联的会员数据
options.withRelated = ['members'];
// 根据ID查找标签
return this.forge({id: options.id})
.fetch(options)
.then(function destroyLabelsAndMember(label) {
// 如果标签不存在抛出404错误
if (!label) {
return Promise.reject(new errors.NotFoundError({
message: tpl(messages.labelNotFound)
}));
}
// 先解除标签与会员的关联关系
return label.related('members')
.detach(null, options)
.then(function destroyLabels() {
// 然后删除标签本身
return label.destroy(options);
});
});
}
});
/**
* Labels 集合 - 标签集合类
*/
Labels = ghostBookshelf.Collection.extend({
model: Label
});
// 导出模型和集合
module.exports = {
Label: ghostBookshelf.model('Label', Label),
Labels: ghostBookshelf.collection('Labels', Labels)
};
};

@ -1,36 +1,25 @@
// 导入基础的Bookshelf模型
const ghostBookshelf = require('./base');
// 定义变量
let Permission;
let Permissions;
/**
* Permission 模型 - 权限数据模型
* 用于处理系统中的权限数据
*/
Permission = ghostBookshelf.Model.extend({
// 数据库表名
tableName: 'permissions',
// 定义模型关联关系
relationships: ['roles'],
relationshipBelongsTo: {
roles: 'roles' // 权限属于角色
roles: 'roles'
},
/**
* 重写 permittedAttributes 方法
* 基础模型只保留模式中定义的列我们需要添加关联关系
* 这样 bookshelf-relations 才能访问到需要更新的嵌套关系
*
* @returns {Array} 包含所有允许属性的数组
* The base model keeps only the columns, which are defined in the schema.
* We have to add the relations on top, otherwise bookshelf-relations
* has no access to the nested relations, which should be updated.
*/
permittedAttributes: function permittedAttributes() {
// 首先调用父类方法获取基础列
let filteredKeys = ghostBookshelf.Model.prototype.permittedAttributes.apply(this, arguments);
// 添加关联关系到允许的属性列表中
this.relationships.forEach((key) => {
filteredKeys.push(key);
});
@ -38,46 +27,20 @@ Permission = ghostBookshelf.Model.extend({
return filteredKeys;
},
/**
* 定义与 Role 模型的多对多关联
* 一个权限可以属于多个角色一个角色可以有多个权限
* 通过 permissions_roles 中间表进行关联
*
* @returns {Bookshelf.Relation} 多对多关联关系
*/
roles: function roles() {
return this.belongsToMany(
'Role', // 关联的模型名称
'permissions_roles', // 中间表名
'permission_id', // 当前模型在中间表中的外键
'role_id' // 关联模型在中间表中的外键
);
return this.belongsToMany('Role', 'permissions_roles', 'permission_id', 'role_id');
},
/**
* 定义与 User 模型的多对多关联
* 一个权限可以直接关联多个用户如果需要的话
*
* @returns {Bookshelf.Relation} 多对多关联关系
*/
users: function users() {
return this.belongsToMany('User');
}
});
/**
* Permissions 集合 - 权限集合类
* 用于处理多个权限对象的集合操作
*/
Permissions = ghostBookshelf.Collection.extend({
// 指定集合中模型的类型
model: Permission
});
// 导出模型和集合
module.exports = {
// 注册 Permission 模型到 Bookshelf
Permission: ghostBookshelf.model('Permission', Permission),
// 注册 Permissions 集合到 Bookshelf
Permissions: ghostBookshelf.collection('Permissions', Permissions)
};
};

@ -530,7 +530,7 @@ Post = ghostBookshelf.Model.extend({
author.emitChange('attached', options);
});
},
//1030 max
onSaving: async function onSaving(model, attrs, options) {
options = options || {};
@ -577,7 +577,7 @@ Post = ghostBookshelf.Model.extend({
}
// CASE: both page and post can get scheduled
if (newStatus === 'scheduled') { //定时发布状态检查
if (newStatus === 'scheduled') {
if (!publishedAt) {
return Promise.reject(new errors.ValidationError({
message: tpl(messages.valueCannotBeBlank, {key: 'published_at'})
@ -604,7 +604,6 @@ Post = ghostBookshelf.Model.extend({
// CASE: Force a change for scheduled posts within 2 minutes of
// publishing. This ensures the scheduler can detect last-minute
// touches to the post
//通过多种判断来防漏发布
const isScheduled = newStatus === 'scheduled';
const isUpdate = options.method === 'update';
const isWithin2Minutes = publishedAt && moment(publishedAt).diff(moment(), 'minutes') <= 2;

@ -41,6 +41,7 @@ Role = ghostBookshelf.Model.extend({
*/
permittedOptions: function permittedOptions(methodName) {
let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
// allowlists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
const validOptions = {
@ -54,7 +55,7 @@ Role = ghostBookshelf.Model.extend({
return options;
},
//检查用户是否有权执行某个操作 权限检查核心方法
permissible: function permissible(roleModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) {
// If we passed in an id instead of a model, get the model
// then check the permissions
@ -66,15 +67,17 @@ Role = ghostBookshelf.Model.extend({
throw new errors.NotFoundError({
message: tpl(messages.roleNotFound)
});
}
}
// Grab the original args without the first one
const origArgs = _.toArray(arguments).slice(1);
return this.permissible(foundRoleModel, ...origArgs);
});
}
const roleModel = roleModelOrId;
// 检查用户是否有赋值权限
}
const roleModel = roleModelOrId;
if (action === 'assign' && loadedPermissions.user) {
const {isOwner, isAdmin, isEitherEditor} = setIsRoles(loadedPermissions);
let checkAgainst;
@ -84,11 +87,12 @@ Role = ghostBookshelf.Model.extend({
checkAgainst = ['Administrator', 'Super Editor', 'Editor', 'Author', 'Contributor'];
} else if (isEitherEditor) {
checkAgainst = ['Author', 'Contributor'];
}
}
// Role in the list of permissible roles
hasUserPermission = roleModelOrId && _.includes(checkAgainst, roleModel.get('name'));
}
// 检查apiKey是否有赋值权限
if (action === 'assign' && loadedPermissions.apiKey) {
// apiKey cannot 'assign' the 'Owner' role
if (roleModel.get('name') === 'Owner') {
@ -99,7 +103,7 @@ Role = ghostBookshelf.Model.extend({
}
if (hasUserPermission && hasApiKeyPermission) {
return Promise.resolve(); // 同时有用户权限和apiKey权限返回成功
return Promise.resolve();
}
return Promise.reject(new errors.NoPermissionError({message: tpl(messages.notEnoughPermission)}));

@ -982,6 +982,7 @@ User = ghostBookshelf.Model.extend({
// @TODO: shorten this function and rename...
check: function check(object) {
const self = this;
return this.getByEmail(object.email)
.then((user) => {
if (!user) {
@ -989,14 +990,17 @@ User = ghostBookshelf.Model.extend({
message: tpl(messages.noUserWithEnteredEmailAddr)
});
}
if (user.isLocked()) {
throw new errors.PasswordResetRequiredError();
}
if (user.isInactive()) {
throw new errors.NoPermissionError({
message: tpl(messages.accountSuspended)
});
}
return self.isPasswordCorrect({plainPassword: object.password, hashedPassword: user.get('password')})
.then(() => {
return user.updateLastSeen();
@ -1012,6 +1016,7 @@ User = ghostBookshelf.Model.extend({
message: tpl(messages.noUserWithEnteredEmailAddr)
});
}
throw err;
});
},

@ -2,7 +2,7 @@ const url = require('url');
const path = require('path');
const debug = require('@tryghost/debug')('web:shared:mw:url-redirects');
const urlUtils = require('../../../../shared/url-utils');
//1030
const _private = {};
_private.redirectUrl = ({redirectTo, query, pathname}) => {

Loading…
Cancel
Save