Compare commits

..

11 Commits

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

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

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

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

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

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

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

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

@ -7,6 +7,20 @@ import {getPostStatusText} from '@tryghost/admin-x-framework/utils/post-utils';
import {useAppContext, useNavigate} from '@tryghost/admin-x-framework'; import {useAppContext, useNavigate} from '@tryghost/admin-x-framework';
import {useGlobalData} from '@src/providers/GlobalDataProvider'; 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 { interface PostlistTooptipProps {
title?: string; title?: string;
metrics?: Array<{ metrics?: Array<{
@ -24,6 +38,11 @@ const PostListTooltip:React.FC<PostlistTooptipProps> = ({
}) => { }) => {
return ( return (
<> <>
{/*
Tooltip
- 使 group-hover/tooltip / hover
-
*/}
<div className={ <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) 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)
}> }>
@ -44,6 +63,7 @@ const PostListTooltip:React.FC<PostlistTooptipProps> = ({
); );
}; };
/* 类型定义Top posts 卡片接收的数据结构 */
interface TopPostsData { interface TopPostsData {
stats?: TopPostViewsStats[]; stats?: TopPostViewsStats[];
} }
@ -53,24 +73,31 @@ interface TopPostsProps {
isLoading: boolean; isLoading: boolean;
} }
/**
* TopPosts
* - topPostsData.stats: TopPostViewsStatsviews, members, sent_count, opened_count
* - isLoading: SkeletonTable
*/
const TopPosts: React.FC<TopPostsProps> = ({ const TopPosts: React.FC<TopPostsProps> = ({
topPostsData, topPostsData,
isLoading isLoading
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const {range} = useGlobalData(); const {range} = useGlobalData(); // 全局时间范围(用于标题 "Top posts (Last 7 days)"
const {appSettings} = useAppContext(); const {appSettings} = useAppContext(); // 全局应用设置,用于决定显示哪些指标
// Show open rate if newsletters are enabled and email tracking is enabled // 根据设置决定是否展示对应指标
const showWebAnalytics = appSettings?.analytics.webAnalytics; const showWebAnalytics = appSettings?.analytics.webAnalytics; // 是否显示 Unique visitors
const showClickTracking = appSettings?.analytics.emailTrackClicks; const showClickTracking = appSettings?.analytics.emailTrackClicks; // 是否显示点击率clicks
const showOpenTracking = appSettings?.analytics.emailTrackOpens; const showOpenTracking = appSettings?.analytics.emailTrackOpens; // 是否显示打开率opens
// 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'; 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 ( return (
<Card className='group/card w-full lg:col-span-2' data-testid='top-posts-card'> <Card className='group/card w-full lg:col-span-2' data-testid='top-posts-card'>
<CardHeader> <CardHeader>
{/* 标题包含时间范围描述getPeriodText 会基于 range 返回如 "this week" */}
<CardTitle className='flex items-baseline justify-between font-medium leading-snug text-muted-foreground'> <CardTitle className='flex items-baseline justify-between font-medium leading-snug text-muted-foreground'>
Top posts {getPeriodText(range)} Top posts {getPeriodText(range)}
</CardTitle> </CardTitle>
@ -78,39 +105,52 @@ const TopPosts: React.FC<TopPostsProps> = ({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? {isLoading ?
/* 加载中展示骨架表格,替代真实条目 */
<SkeletonTable className='mt-6' /> <SkeletonTable className='mt-6' />
: :
<> <>
{ {
/* 列表渲染:遍历 topPostsData.stats每一项渲染为一行 */
topPostsData?.stats?.map((post: TopPostViewsStats) => { topPostsData?.stats?.map((post: TopPostViewsStats) => {
return ( 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'> <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={() => { <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}); navigate(`/posts/analytics/${post.post_id}`, {crossApp: true});
}}> }}>
{post.feature_image ? {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={{ <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})` backgroundImage: `url(${post.feature_image})`
}}></div> }}></div>
: :
/* 否则使用占位组件 */
<FeatureImagePlaceholder className='hidden aspect-[16/10] w-[80px] shrink-0 group-hover:bg-muted-foreground/10 sm:!visible sm:!flex lg:w-[100px]' /> <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'> <div className='flex flex-col'>
{/* 标题(最多两行) */}
<span className='line-clamp-2 text-lg font-semibold leading-[1.35em]'>{post.title}</span> <span className='line-clamp-2 text-lg font-semibold leading-[1.35em]'>{post.title}</span>
{/* 作者与发布时间 */}
<span className='text-sm text-muted-foreground'> <span className='text-sm text-muted-foreground'>
By {post.authors} &ndash; {formatDisplayDate(post.published_at)} By {post.authors} &ndash; {formatDisplayDate(post.published_at)}
</span> </span>
{/* 文章状态(例如 Draft / Published / Scheduled */}
<span className='text-sm text-muted-foreground'> <span className='text-sm text-muted-foreground'>
{getPostStatusText(post)} {getPostStatusText(post)}
</span> </span>
</div> </div>
</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'> <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 && {showWebAnalytics &&
<div className='group/tooltip relative flex w-[66px] lg:w-[92px]' data-testid='statistics-visitors' onClick={(e) => { <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}); navigate(`/posts/analytics/${post.post_id}/web`, {crossApp: true});
}}> }}>
{/* Tooltip展示 Unique visitors 的详细数值 */}
<PostListTooltip <PostListTooltip
metrics={[ metrics={[
{ {
@ -127,27 +167,29 @@ const TopPosts: React.FC<TopPostsProps> = ({
</div> </div>
</div> </div>
} }
{/* Newsletter 列:如果有 sent_count 字段展示邮件相关指标sent / opens / clicks */}
{post.sent_count !== null && {post.sent_count !== null &&
<div className='group/tooltip relative flex w-[66px] lg:w-[92px]' onClick={(e) => { <div className='group/tooltip relative flex w-[66px] lg:w-[92px]' onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/posts/analytics/${post.post_id}/newsletter`, {crossApp: true}); navigate(`/posts/analytics/${post.post_id}/newsletter`, {crossApp: true});
}}> }}>
{/* Tooltip根据 appSettings 显示 sent / opens / clicks */}
<PostListTooltip <PostListTooltip
className={`${!appSettings?.analytics.membersTrackSources ? 'left-auto right-0 translate-x-0' : ''}`} className={`${!appSettings?.analytics.membersTrackSources ? 'left-auto right-0 translate-x-0' : ''}`}
metrics={[ metrics={[
// Always show sent
{ {
icon: <LucideIcon.Send className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />, icon: <LucideIcon.Send className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Sent', label: 'Sent',
metric: formatNumber(post.sent_count || 0) metric: formatNumber(post.sent_count || 0)
}, },
// Only show opens if open tracking is enabled // 仅在启用 open tracking 时展示 Open 数
...(showOpenTracking ? [{ ...(showOpenTracking ? [{
icon: <LucideIcon.MailOpen className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />, icon: <LucideIcon.MailOpen className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Opens', label: 'Opens',
metric: formatNumber(post.opened_count || 0) metric: formatNumber(post.opened_count || 0)
}] : []), }] : []),
// Only show clicks if click tracking is enabled // 仅在启用 click tracking 时展示 Click 数
...(showClickTracking ? [{ ...(showClickTracking ? [{
icon: <LucideIcon.MousePointer className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />, icon: <LucideIcon.MousePointer className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Clicks', label: 'Clicks',
@ -158,8 +200,10 @@ const TopPosts: React.FC<TopPostsProps> = ({
/> />
<div className={metricClass}> <div className={metricClass}>
{(() => { {(() => {
// If clicks and opens are enabled, show open rate % // 在展示区域根据追踪设置选择显示内容:
// If clicks are disabled but opens enabled, show open rate % // - 优先显示 open rate若启用
// - 否则若启用 click tracking 则显示 click rate
// - 否则回退显示已发送数sent
if (showOpenTracking) { if (showOpenTracking) {
return ( return (
<> <>
@ -168,7 +212,6 @@ const TopPosts: React.FC<TopPostsProps> = ({
</> </>
); );
} else if (showClickTracking) { } else if (showClickTracking) {
// If open rate is disabled but clicks enabled, show click rate %
return ( return (
<> <>
<LucideIcon.MousePointer className='text-muted-foreground group-hover:text-foreground' size={16} strokeWidth={1.5} /> <LucideIcon.MousePointer className='text-muted-foreground group-hover:text-foreground' size={16} strokeWidth={1.5} />
@ -176,7 +219,6 @@ const TopPosts: React.FC<TopPostsProps> = ({
</> </>
); );
} else { } else {
// If both are disabled, show sent count
return ( return (
<> <>
<LucideIcon.Send className='text-muted-foreground group-hover:text-foreground' size={16} strokeWidth={1.5} /> <LucideIcon.Send className='text-muted-foreground group-hover:text-foreground' size={16} strokeWidth={1.5} />
@ -188,11 +230,14 @@ const TopPosts: React.FC<TopPostsProps> = ({
</div> </div>
</div> </div>
} }
{/* Members 列:若启用 membersTrackSources则展示新增会员数free / paid */}
{appSettings?.analytics.membersTrackSources && {appSettings?.analytics.membersTrackSources &&
<div className='group/tooltip relative flex w-[66px] lg:w-[92px]' data-testid='statistics-members' onClick={(e) => { <div className='group/tooltip relative flex w-[66px] lg:w-[92px]' data-testid='statistics-members' onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/posts/analytics/${post.post_id}/growth`, {crossApp: true}); navigate(`/posts/analytics/${post.post_id}/growth`, {crossApp: true});
}}> }}>
{/* Tooltip展示 Free / Paid 新增会员Paid 仅在站点启用付费会员时显示) */}
<PostListTooltip <PostListTooltip
className='left-auto right-0 translate-x-0' className='left-auto right-0 translate-x-0'
metrics={[ metrics={[
@ -201,7 +246,6 @@ const TopPosts: React.FC<TopPostsProps> = ({
label: 'Free', label: 'Free',
metric: post.free_members > 0 ? `+${formatNumber(post.free_members)}` : '0' metric: post.free_members > 0 ? `+${formatNumber(post.free_members)}` : '0'
}, },
// Only show paid members if paid members are enabled
...(appSettings?.paidMembersEnabled ? [{ ...(appSettings?.paidMembersEnabled ? [{
icon: <LucideIcon.CreditCard className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />, icon: <LucideIcon.CreditCard className='shrink-0 text-muted-foreground' size={16} strokeWidth={1.5} />,
label: 'Paid', label: 'Paid',
@ -221,6 +265,7 @@ const TopPosts: React.FC<TopPostsProps> = ({
); );
}) })
} }
{/* 无数据状态:若 stats 为空则展示 EmptyIndicator 提示 */}
{(!topPostsData?.stats || topPostsData.stats.length === 0) && ( {(!topPostsData?.stats || topPostsData.stats.length === 0) && (
<EmptyIndicator <EmptyIndicator
className='w-full pb-10' className='w-full pb-10'

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

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

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

@ -1,31 +1,60 @@
import {Page} from '@playwright/test'; import { Page } from '@playwright/test';
import {AdminPage} from '../../AdminPage'; // 导入管理员页面基类,继承管理员页面的通用属性和方法(如页面导航、基础元素等)
import { AdminPage } from '../../AdminPage';
/** /**
* PostAnalyticsWebTrafficPage * POM
* -------------------------- *
* Playwright Web Traffic *
* * - Web Traffic
* * - e2e
* - 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 { export class PostAnalyticsWebTrafficPage extends AdminPage {
/**
*
* @param page PlaywrightPage
*/
constructor(page: Page) { constructor(page: Page) {
// 调用父类AdminPage的构造函数传递page对象以继承基础功能
super(page); super(page);
// TODO: 在这里初始化文章级 Web 流量页面的定位器,例如:
// this.pageUrl = '/ghost/#/editor/analytics/post/...'; // 如有需要可设置具体路由 // TODO当前为占位说明实际使用时需补充以下内容
// 1. 页面URL如有固定路由用于验证是否在目标页面或直接导航
// 示例this.pageUrl = '/ghost/#/analytics/post/123'; // 假设123为文章ID
// //
// 示例(占位): // 2. 元素定位器根据页面实际DOM结构补充
// this.webGraph = page.getByTestId('post-web-graph'); // - 流量图表容器(如折线图/柱状图)
// this.topSourcesCard = page.getByTestId('post-top-sources-card'); // - 总浏览量/唯一访客数等统计数据元素
// - 流量来源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'); // 流量来源列表
} }
}
/**
*
*
* 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,55 +1,68 @@
import {test, expect} from '../../../../helpers/playwright'; // 导入Playwright测试工具test用于定义测试用例结构expect用于结果断言验证
// 从项目自定义helpers中导入playwright工具包可能包含项目特有的配置或辅助函数
import { test, expect } from '../../../../helpers/playwright';
// 导入所需的页面对象类(每个类对应管理后台的一个页面/功能模块)
import { import {
AnalyticsOverviewPage, AnalyticsOverviewPage, // 分析概览页面(展示所有内容的分析入口)
PostAnalyticsPage, PostAnalyticsPage, // 文章分析主页面(包含概览、流量、增长等多个视图切换)
PostAnalyticsGrowthPage, PostAnalyticsGrowthPage, // 文章分析的“增长”子页面(专注于用户增长和流量来源数据)
MembersPage MembersPage // 成员管理页面(展示所有用户成员信息)
} from '../../../../helpers/pages/admin'; } from '../../../../helpers/pages/admin';
// 测试套件Ghost 管理后台 - 文章分析Post Analytics- Growth增长 // 定义测试套件对Ghost管理后台中“文章分析-增长”页面的测试集合
// test.describe用于将相关测试用例分组增强代码可读性和维护性
test.describe('Ghost Admin - Post Analytics - Growth', () => { test.describe('Ghost Admin - Post Analytics - Growth', () => {
// 每个测试开始前的准备工作:导航并打开目标文章的分析页面,再点击 Growth 选项 // 前置钩子函数:每个测试用例执行前自动运行
test.beforeEach(async ({page}) => { // 作用:创建统一的测试前置条件,确保所有用例从相同初始状态开始
test.beforeEach(async ({ page }) => {
// 1. 实例化分析概览页面对象(获取该页面的元素操作能力)
const analyticsOverviewPage = new AnalyticsOverviewPage(page); const analyticsOverviewPage = new AnalyticsOverviewPage(page);
// 2. 导航到分析概览页面(内部封装了页面跳转和加载完成的验证)
await analyticsOverviewPage.goto(); await analyticsOverviewPage.goto();
// 在概览页点击最新文章的 analytics 按钮,进入文章分析面板
// 3. 在概览页面中点击最新文章的“analytics”按钮
// latestPost是AnalyticsOverviewPage中定义的“最新文章”元素对象包含其专属的analyticsButton
await analyticsOverviewPage.latestPost.analyticsButton.click(); await analyticsOverviewPage.latestPost.analyticsButton.click();
// TODO 注释保留:理想情况下不应需等待页面完全加载即可点击 growth 链接 // TODO优化提示——未来可调整为无需等待页面完全加载即可点击growth链接提升测试效率
// 4. 实例化文章分析主页面对象(当前处于文章分析的概览视图)
const postAnalyticsPage = new PostAnalyticsPage(page); const postAnalyticsPage = new PostAnalyticsPage(page);
// 等待文章分析页面加载完成(确保元素可交互 // 5. 等待文章分析页面加载完成(内部通过等待关键元素出现来确认页面就绪,避免操作过早导致失败
await postAnalyticsPage.waitForPageLoad(); await postAnalyticsPage.waitForPageLoad();
// 点击 Growth 按钮,进入增长视图 // 6. 点击“Growth”按钮切换到增长数据视图进入当前测试套件的目标页面
await postAnalyticsPage.growthButton.click(); await postAnalyticsPage.growthButton.click();
}); });
// 测试:空成员卡片应显示 Free members 字样并且数量为 0 // 测试用例1验证空数据时成员卡片的显示内容
test('empty members card', async ({page}) => { test('empty members card', async ({ page }) => {
// 实例化文章分析的增长页面对象(获取当前页面的元素操作能力)
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 断言 members 卡片包含“Free members”标签 // 断言1成员卡片应包含“Free members”文本验证标签显示正确
await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('Free members'); await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('Free members');
// 断言成员数量显示为 0空数据场景 // 断言2成员卡片应包含“0”验证空数据场景下数量显示正确
await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('0'); await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('0');
}); });
// 测试:在空成员场景点击“查看成员”应跳转到 Members 页面并显示无匹配结果 // 测试用例2验证空成员场景下点击“查看成员”的跳转结果
test('empty members card - view member', async ({page}) => { test('empty members card - view member', async ({ page }) => {
// 实例化增长页面对象
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 点击“查看成员”按钮(应导航到 Members 列表 // 点击增长页面上的“查看成员”按钮(模拟用户查看详情的操作
await postAnalyticsPageGrowthPage.viewMemberButton.click(); await postAnalyticsPageGrowthPage.viewMemberButton.click();
// 实例化成员管理页面对象(跳转后的目标页面)
const membersPage = new MembersPage(page); const membersPage = new MembersPage(page);
// 断言 Members 页面显示“无成员匹配”的提示文本 // 断言:成员页面应显示“无成员匹配”的提示文本(验证空数据跳转后的状态正确)
await expect(membersPage.body).toContainText('No members match'); await expect(membersPage.body).toContainText('No members match');
}); });
// 测试Top sources 卡片在无数据时显示“无来源数据”提示 // 测试用例3验证Top sources卡片在无数据时的提示文本
test('empty top sources card', async ({page}) => { test('empty top sources card', async ({ page }) => {
// 实例化增长页面对象
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 断言 top sources 卡片包含“No sources data available” // 断言顶部来源卡片应包含“No sources data available”验证无数据提示正确
await expect(postAnalyticsPageGrowthPage.topSourcesCard).toContainText('No sources data available'); await expect(postAnalyticsPageGrowthPage.topSourcesCard).toContainText('No sources data available');
}); });
}); });

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

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

@ -250,8 +250,7 @@ const controller = {
}, },
async query(frame) { async query(frame) {
let model = await postsService.editPost(frame, { let model = await postsService.editPost(frame, {
// 事件处理器,根据文章状态变更智能处理缓存 eventHandler: (event, dto) => { // 事件处理器,根据文章状态变更智能处理缓存
eventHandler: (event, dto) => {
const cacheInvalidate = getCacheHeaderFromEventString(event, dto); const cacheInvalidate = getCacheHeaderFromEventString(event, dto);
if (cacheInvalidate === true) { if (cacheInvalidate === true) {
frame.setHeader('X-Cache-Invalidate', '/*'); // 失效所有缓存 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() { //获得当前数据库状态 async getState() { //获得当前数据库状态
let state = states.READY; let state = states.READY;
try { try {
await this.knexMigrator.isDatabaseOK(); await this.knexMigrator.isDatabaseOK(); //await 起等待作用,如果有问题,它会率先等异步操作结束在进行
return state; return state;
} catch (error) { //对错误状态进行处理 } catch (error) { //对错误状态进行处理
// CASE: database has not yet been initialized // CASE: database has not yet been initialized

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save