Compare commits

...

38 Commits
master ... main

Author SHA1 Message Date
ws f36afb4bfb 777
3 months ago
ws beb380ace4 2
3 months ago
liucan b99a2be203 Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
liucan d143b0cb10 2
3 months ago
p6fwftuoe 4abad190b3 Update tag.hbs
3 months ago
m3i46ogeb 31e96c08d1 1.1
3 months ago
p7lr8khyn 0e2e55e018 Update overview.js
3 months ago
ws d82b0f0e03 02
3 months ago
wxz 7454a49421 toppost
3 months ago
m3i46ogeb 2d8e020ad7 Merge pull request '0.4' (#5) from main into develop
3 months ago
wxz cb99f349a4 7
3 months ago
wxz 601a93e12a Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
wxz b9f9ef25f9 1
3 months ago
ws ee6166f29d Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
ws ed416720be 0.2
3 months ago
ZYY 14e1a4d689 Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
ZYY 5731d9aa68 1
3 months ago
m3i46ogeb cb86a52649 1.0
3 months ago
m3i46ogeb b8b3e8a4a5 Merge pull request '0.3' (#3) from main into develop
3 months ago
ZYY 3e72e9d244 Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
ZYY f082caf237 1
3 months ago
m3i46ogeb 31764cea57 0.2
3 months ago
m3i46ogeb 911c9d4de1 0.1
3 months ago
wxz 4963526743 Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
ZYY cb079b0acd 1
3 months ago
liucan aa308c51c5 Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
wxz e6b24f373f Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
liucan 561e3dfa5b Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
liucan c983117200 1
3 months ago
ZYY eec5bd999a Merge branch 'main' of https://bdgit.educoder.net/m3i46ogeb/ghost
3 months ago
ZYY 444311e70a 1
3 months ago
wxz 7f13ba4cc9 7
3 months ago
ws 99192e1ee1 db_4
3 months ago
ws d3fbccac3e db_3
3 months ago
ws 2da99f65bb db_2
3 months ago
ws 6a759f8310 db_1
3 months ago
p7lr8khyn e4595f281f Update App.js
3 months ago
ws 35551149e3 1
3 months ago

@ -1,26 +1,39 @@
# 全局配置部分
global: global:
scrape_interval: 15s # By default, scrape targets every 15 seconds. # 默认情况下每15秒抓取一次目标数据
scrape_interval: 15s
# A scrape configuration containing exactly one endpoint to scrape: # 抓取配置部分,包含所有需要抓取的端点配置
# Here it's Prometheus itself. # 这里首先配置了Prometheus自身的监控
scrape_configs: scrape_configs:
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config. # 作业名称会作为标签`job=<job_name>`添加到从该配置抓取的所有时间序列中
- job_name: 'prometheus' - job_name: 'prometheus'
# Override the global default and scrape targets from this job every 5 seconds. # 覆盖全局默认配置从该作业抓取目标的间隔为5秒
scrape_interval: 5s scrape_interval: 5s
# 静态配置的目标列表(不需要服务发现)
static_configs: static_configs:
# 监控Prometheus自身默认运行在localhost:9090
- targets: ['localhost:9090'] - targets: ['localhost:9090']
# 配置Pushgateway的监控抓取
- job_name: 'pushgateway' - job_name: 'pushgateway'
# 抓取间隔设置为1秒更频繁地获取推送的数据
scrape_interval: 1s scrape_interval: 1s
# Pushgateway的静态目标配置
static_configs: static_configs:
# Pushgateway服务地址假设通过容器网络访问服务名为pushgateway端口9091
- targets: ['pushgateway:9091'] - targets: ['pushgateway:9091']
# 保留被推送数据中原有的标签不覆盖job等标签
# 对于Pushgateway尤为重要因为它需要保留推送数据的原始标签信息
honor_labels: true honor_labels: true
# 远程写入配置将Prometheus收集的数据推送到其他服务
remote_write: remote_write:
# 配置推送到Grafana的Prometheus兼容端点
# 假设Grafana服务通过容器网络访问服务名为grafana端口3000
- url: http://grafana:3000/api/prom/push - url: http://grafana:3000/api/prom/push

@ -1,6 +1,8 @@
# 定义一个Bug报告的表单模板
name: 🐛 Bug report name: 🐛 Bug report
description: Report reproducible software issues so we can improve description: Report reproducible software issues so we can improve # 模板用途:报告可复现的软件问题以帮助改进
body: body:
# markdown类型的说明文本用于展示欢迎信息
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
@ -8,69 +10,87 @@ body:
Thank you for taking the time to fill out a bug report 🙂 Thank you for taking the time to fill out a bug report 🙂
We'll respond as quickly as we can. The more information you provide the easier & quicker it is for us to diagnose the problem. We'll respond as quickly as we can. The more information you provide the easier & quicker it is for us to diagnose the problem.
# 文本区域:用于简要描述问题
- type: textarea - type: textarea
id: summary id: summary # 字段唯一标识
attributes: attributes:
label: Issue Summary label: Issue Summary # 字段标签:问题概要
description: Explain roughly what's wrong description: Explain roughly what's wrong # 描述:简要说明哪里出了问题
validations: validations:
required: true required: true # 此字段为必填项
# 文本区域:用于描述复现步骤和预期结果
- type: textarea - type: textarea
id: reproduction id: reproduction # 字段唯一标识
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce # 字段标签:复现步骤
description: Also tell us, what did you expect to happen? description: Also tell us, what did you expect to happen? # 描述:同时说明你预期的结果
placeholder: | placeholder: | # 输入示例
1. This is the first step... 1. This is the first step...
2. This is the second step, etc. 2. This is the second step, etc.
validations: validations:
required: true required: true # 此字段为必填项
# 输入框用于填写Ghost版本号
- type: input - type: input
id: version id: version # 字段唯一标识
attributes: attributes:
label: Ghost Version label: Ghost Version # 字段标签Ghost版本
validations: validations:
required: true required: true # 此字段为必填项
# 输入框用于填写Node.js版本号
- type: input - type: input
id: node id: node # 字段唯一标识
attributes: attributes:
label: Node.js Version label: Node.js Version # 字段标签Node.js版本
validations: validations:
required: true required: true # 此字段为必填项
# 输入框:用于描述安装方式
- type: input - type: input
id: install id: install # 字段唯一标识
attributes: attributes:
label: How did you install Ghost? label: How did you install Ghost? # 字段标签如何安装的Ghost
description: Provide details of your host & operating system description: Provide details of your host & operating system # 描述:提供主机和操作系统的详细信息
validations: validations:
required: true required: true # 此字段为必填项
# 下拉选择框:用于选择数据库类型
- type: dropdown - type: dropdown
id: database id: database # 字段唯一标识
attributes: attributes:
label: Database type label: Database type # 字段标签:数据库类型
options: options: # 可选值列表
- MySQL 5.7 - MySQL 5.7
- MySQL 8 - MySQL 8
- SQLite3 - SQLite3
- Other - Other
validations: validations:
required: true required: true # 此字段为必填项
# 输入框:用于填写浏览器和操作系统版本(前端问题必填)
- type: input - type: input
id: browsers id: browsers # 字段唯一标识
attributes: attributes:
label: Browser & OS version label: Browser & OS version # 字段标签:浏览器和操作系统版本
description: Include this for frontend bugs description: Include this for frontend bugs # 描述:前端问题需要填写此项
# 文本区域:用于展示相关日志或错误输出
- type: textarea - type: textarea
id: logs id: logs # 字段唯一标识
attributes: attributes:
label: Relevant log / error output label: Relevant log / error output # 字段标签:相关日志/错误输出
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. # 描述:请复制粘贴相关日志,会自动格式化为代码块,无需手动添加反引号
render: shell render: shell # 渲染为shell代码块格式
# 复选框:用于确认是否同意行为准则
- type: checkboxes - type: checkboxes
id: terms id: terms # 字段唯一标识
attributes: attributes:
label: Code of Conduct label: Code of Conduct # 字段标签:行为准则
description: By submitting this issue, you agree to follow our [Code of Conduct](https://ghost.org/conduct) description: By submitting this issue, you agree to follow our [Code of Conduct](https://ghost.org/conduct) # 描述:提交此问题即表示同意遵守我们的行为准则
options: options:
- label: I agree to be friendly and polite to people in this repository - label: I agree to be friendly and polite to people in this repository # 同意在仓库中友好礼貌地对待他人
required: true required: true # 此字段为必填项(必须勾选)

@ -1,11 +1,20 @@
# 是否允许创建空白问题(不使用预设模板的问题)
# 设置为true表示用户可以直接提交自定义内容的问题
blank_issues_enabled: true blank_issues_enabled: true
# 联系链接配置:提供额外的支持和资源入口
contact_links: contact_links:
- name: 🚑 Help & Support # 第一个链接:帮助与支持
url: https://forum.ghost.org - name: 🚑 Help & Support # 链接名称,带救护车图标表示紧急支持
about: Please use the community forum for questions url: https://forum.ghost.org # 链接地址Ghost社区论坛
- name: 💡 Features & Ideas about: Please use the community forum for questions # 说明:请在社区论坛提问
url: https://forum.ghost.org/c/Ideas
about: Please vote for & post new ideas in the the forum # 第二个链接:功能与想法建议
- name: 📖 Documentation - name: 💡 Features & Ideas # 链接名称,带灯泡图标表示创意建议
url: https://ghost.org/docs/ url: https://forum.ghost.org/c/Ideas # 链接地址论坛的Ideas分类
about: Tutorials & reference guides for themes, the API and more about: Please vote for & post new ideas in the forum # 说明:请在论坛投票或发布新想法
# 第三个链接:文档资源
- name: 📖 Documentation # 链接名称,带书本图标表示文档
url: https://ghost.org/docs/ # 链接地址(官方文档中心)
about: Tutorials & reference guides for themes, the API and more # 说明包含主题、API等的教程和参考指南

@ -1,38 +1,54 @@
// 引入所需模块文件系统Promise版、子进程execPromise化、路径处理
const fs = require('fs/promises'); const fs = require('fs/promises');
const exec = require('util').promisify(require('child_process').exec); const exec = require('util').promisify(require('child_process').exec);
const path = require('path'); const path = require('path');
// 引入GitHub Actions核心模块用于设置输出和语义化版本处理工具
const core = require('@actions/core'); const core = require('@actions/core');
const semver = require('semver'); const semver = require('semver');
// 异步自执行函数:处理版本号更新逻辑
(async () => { (async () => {
// 1. 定位并读取core模块的package.json文件
const corePackageJsonPath = path.join(__dirname, '../../ghost/core/package.json'); const corePackageJsonPath = path.join(__dirname, '../../ghost/core/package.json');
const corePackageJson = require(corePackageJsonPath); const corePackageJson = require(corePackageJsonPath);
// 2. 获取当前版本号并打印
const current_version = corePackageJson.version; const current_version = corePackageJson.version;
console.log(`Current version: ${current_version}`); console.log(`Current version: ${current_version}`);
// 3. 获取命令行传入的第一个参数(用于判断版本类型)
const firstArg = process.argv[2]; const firstArg = process.argv[2];
console.log('firstArg', firstArg); console.log('firstArg', firstArg);
const buildString = await exec('git rev-parse --short HEAD').then(({stdout}) => stdout.trim()); // 4. 获取当前Git提交的短哈希值用于构建版本号
const buildString = await exec('git rev-parse --short HEAD')
.then(({stdout}) => stdout.trim()); // 去除输出中的换行符
let newVersion; let newVersion; // 声明新版本号变量
// 5. 根据命令行参数生成不同类型的新版本号
if (firstArg === 'canary' || firstArg === 'six') { if (firstArg === 'canary' || firstArg === 'six') {
const bumpedVersion = semver.inc(current_version, 'minor'); // 对于canary或six类型升级minor版本并添加预发布标签包含Git哈希
newVersion = `${bumpedVersion}-pre-g${buildString}`; const bumpedVersion = semver.inc(current_version, 'minor'); // 升级次要版本如1.2.3 → 1.3.0
newVersion = `${bumpedVersion}-pre-g${buildString}`; // 格式1.3.0-pre-gabc123
} else { } else {
const gitVersion = await exec('git describe --long HEAD').then(({stdout}) => stdout.trim().replace(/^v/, '')); // 其他情况使用git describe生成版本号基于最近的标签
// git describe --long会输出类似"v1.2.3-4-gabc123"这里去除前缀v
const gitVersion = await exec('git describe --long HEAD')
.then(({stdout}) => stdout.trim().replace(/^v/, ''));
newVersion = gitVersion; newVersion = gitVersion;
} }
// 6. 统一添加+moya后缀可能用于标识自定义构建
newVersion += '+moya'; newVersion += '+moya';
console.log('newVersion', newVersion); console.log('newVersion', newVersion);
// 7. 更新core模块的package.json版本号并写入文件
corePackageJson.version = newVersion; corePackageJson.version = newVersion;
await fs.writeFile(corePackageJsonPath, JSON.stringify(corePackageJson, null, 2)); await fs.writeFile(corePackageJsonPath, JSON.stringify(corePackageJson, null, 2)); // 保留2空格缩进
// 8. 同步更新admin模块的package.json版本号
const adminPackageJsonPath = path.join(__dirname, '../../ghost/admin/package.json'); const adminPackageJsonPath = path.join(__dirname, '../../ghost/admin/package.json');
const adminPackageJson = require(adminPackageJsonPath); const adminPackageJson = require(adminPackageJsonPath);
adminPackageJson.version = newVersion; adminPackageJson.version = newVersion;
@ -40,6 +56,7 @@ const semver = require('semver');
console.log('Version bumped to', newVersion); console.log('Version bumped to', newVersion);
core.setOutput('BUILD_VERSION', newVersion); // 9. 设置GitHub Actions输出变量供后续步骤使用
core.setOutput('GIT_COMMIT_HASH', buildString) core.setOutput('BUILD_VERSION', newVersion); // 输出构建版本号
})(); core.setOutput('GIT_COMMIT_HASH', buildString) // 输出Git提交哈希
})();

@ -1,30 +1,44 @@
// NOTE: this file can't use any NPM dependencies because it needs to run even if dependencies aren't installed yet or are corrupted // 注意此文件不能使用任何NPM依赖因为它需要在依赖未安装或已损坏的情况下仍能运行
// 引入Node.js内置的子进程同步执行模块
const {execSync} = require('child_process'); const {execSync} = require('child_process');
cleanYarnCache(); // 执行一系列清理操作
resetNxCache(); cleanYarnCache(); // 清理Yarn缓存
deleteNodeModules(); resetNxCache(); // 重置NX构建缓存
deleteBuildArtifacts(); deleteNodeModules(); // 删除所有node_modules目录
console.log('Cleanup complete!'); deleteBuildArtifacts(); // 删除构建产物
console.log('Cleanup complete!'); // 清理完成提示
/**
* 删除项目中的构建产物
* 包括ghost目录下所有名为build的文件夹和tsconfig.tsbuildinfo文件
*/
function deleteBuildArtifacts() { function deleteBuildArtifacts() {
console.log('Deleting all build artifacts...'); console.log('Deleting all build artifacts...');
try { try {
// 查找ghost目录下所有名为build的目录并递归删除
execSync('find ./ghost -type d -name "build" -exec rm -rf \'{}\' +', { execSync('find ./ghost -type d -name "build" -exec rm -rf \'{}\' +', {
stdio: 'inherit' stdio: 'inherit' // 子进程的输入输出继承自当前进程(显示执行过程)
}); });
// 查找ghost目录下所有tsconfig.tsbuildinfo文件并删除
execSync('find ./ghost -type f -name "tsconfig.tsbuildinfo" -delete', { execSync('find ./ghost -type f -name "tsconfig.tsbuildinfo" -delete', {
stdio: 'inherit' stdio: 'inherit'
}); });
} catch (error) { } catch (error) {
console.error('Failed to delete build artifacts:', error); console.error('Failed to delete build artifacts:', error);
process.exit(1); process.exit(1); // 执行失败时退出进程状态码1表示错误
} }
} }
/**
* 删除项目中所有node_modules目录
* 用于彻底清理已安装的依赖包
*/
function deleteNodeModules() { function deleteNodeModules() {
console.log('Deleting all node_modules directories...'); console.log('Deleting all node_modules directories...');
try { try {
// 从当前目录开始查找所有node_modules目录并递归删除
// -prune确保不会进入已找到的node_modules目录内部继续查找
execSync('find . -name "node_modules" -type d -prune -exec rm -rf \'{}\' +', { execSync('find . -name "node_modules" -type d -prune -exec rm -rf \'{}\' +', {
stdio: 'inherit' stdio: 'inherit'
}); });
@ -34,9 +48,14 @@ function deleteNodeModules() {
} }
} }
/**
* 重置NX构建工具的缓存
* NX是用于 monorepo 项目的构建系统缓存可能影响构建结果
*/
function resetNxCache() { function resetNxCache() {
console.log('Resetting NX cache...'); console.log('Resetting NX cache...');
try { try {
// 删除NX的缓存目录
execSync('rm -rf .nxcache .nx'); execSync('rm -rf .nxcache .nx');
} catch (error) { } catch (error) {
console.error('Failed to reset NX cache:', error); console.error('Failed to reset NX cache:', error);
@ -44,12 +63,17 @@ function resetNxCache() {
} }
} }
/**
* 清理Yarn包管理器的缓存
* 移除缓存的依赖包副本避免旧版本缓存影响安装
*/
function cleanYarnCache() { function cleanYarnCache() {
console.log('Cleaning yarn cache...'); console.log('Cleaning yarn cache...');
try { try {
// 删除Yarn缓存目录下的所有内容
execSync('rm -rf .yarncache/* .yarncachecopy/*'); execSync('rm -rf .yarncache/* .yarncachecopy/*');
} catch (error) { } catch (error) {
console.error('Failed to clean yarn cache:', error); console.error('Failed to clean yarn cache:', error);
process.exit(1); process.exit(1);
} }
} }

@ -3,9 +3,9 @@ const fs = require('fs/promises');
const exec = require('util').promisify(require('child_process').exec); const exec = require('util').promisify(require('child_process').exec);
const readline = require('readline/promises'); const readline = require('readline/promises');
const semver = require('semver'); const semver = require('semver'); // 用于语义化版本号处理
// Maps a package name to the config key in defaults.json // 映射包名到defaults.json中的配置键名用于版本同步
const CONFIG_KEYS = { const CONFIG_KEYS = {
'@tryghost/portal': 'portal', '@tryghost/portal': 'portal',
'@tryghost/sodo-search': 'sodoSearch', '@tryghost/sodo-search': 'sodoSearch',
@ -14,14 +14,20 @@ const CONFIG_KEYS = {
'@tryghost/signup-form': 'signupForm' '@tryghost/signup-form': 'signupForm'
}; };
const CURRENT_DIR = process.cwd(); const CURRENT_DIR = process.cwd(); // 当前工作目录
// 读取当前项目的package.json路径和内容
const packageJsonPath = path.join(CURRENT_DIR, 'package.json'); const packageJsonPath = path.join(CURRENT_DIR, 'package.json');
const packageJson = require(packageJsonPath); const packageJson = require(packageJsonPath);
// 从package.json中提取应用名称和当前版本
const APP_NAME = packageJson.name; const APP_NAME = packageJson.name;
const APP_VERSION = packageJson.version; const APP_VERSION = packageJson.version;
/**
* 安全执行命令的工具函数
* 捕获错误并返回包含stdout和stderr的对象避免进程直接崩溃
*/
async function safeExec(command) { async function safeExec(command) {
try { try {
return await exec(command); return await exec(command);
@ -33,106 +39,143 @@ async function safeExec(command) {
} }
} }
/**
* 确保当前应用在允许发布的列表中
* 检查APP_NAME是否存在于CONFIG_KEYS的键名中
*/
async function ensureEnabledApp() { async function ensureEnabledApp() {
const ENABLED_APPS = Object.keys(CONFIG_KEYS); const ENABLED_APPS = Object.keys(CONFIG_KEYS);
if (!ENABLED_APPS.includes(APP_NAME)) { if (!ENABLED_APPS.includes(APP_NAME)) {
console.error(`${APP_NAME} is not enabled, please modify ${__filename}`); console.error(`${APP_NAME} is not enabled, please modify ${__filename}`);
process.exit(1); process.exit(1); // 非允许的应用,退出进程
} }
} }
/**
* 确保不在main分支上执行发布
* 防止直接在主分支上修改代码
*/
async function ensureNotOnMain() { async function ensureNotOnMain() {
const currentGitBranch = await safeExec(`git branch --show-current`); const currentGitBranch = await safeExec(`git branch --show-current`);
if (currentGitBranch.stderr) { if (currentGitBranch.stderr) {
console.error(`There was an error checking the current git branch`) console.error(`There was an error checking the current git branch`)
console.error(`${currentGitBranch.stderr}`); console.error(`${currentGitBranch.stderr}`);
process.exit(1); process.exit(1); // 分支检查失败,退出进程
} }
if (currentGitBranch.stdout.trim() === 'main') { if (currentGitBranch.stdout.trim() === 'main') {
console.error(`The release can not be done on the "main" branch`) console.error(`The release can not be done on the "main" branch`)
process.exit(1); process.exit(1); // 在main分支上退出进程
} }
} }
/**
* 确保当前git工作区干净无未提交的更改
* 避免发布时包含未跟踪的修改
*/
async function ensureCleanGit() { async function ensureCleanGit() {
const localGitChanges = await safeExec(`git status --porcelain`); const localGitChanges = await safeExec(`git status --porcelain`);
if (localGitChanges.stderr) { if (localGitChanges.stderr) {
console.error(`There was an error checking the local git status`) console.error(`There was an error checking the local git status`)
console.error(`${localGitChanges.stderr}`); console.error(`${localGitChanges.stderr}`);
process.exit(1); process.exit(1); // 状态检查失败,退出进程
} }
if (localGitChanges.stdout) { if (localGitChanges.stdout) {
console.error(`You have local git changes - are you sure you're ready to release?`) console.error(`You have local git changes - are you sure you're ready to release?`)
console.error(`${localGitChanges.stdout}`); console.error(`${localGitChanges.stdout}`);
process.exit(1); process.exit(1); // 有未提交的更改,退出进程
} }
} }
/**
* 获取用户输入的版本更新类型patch/minor/major并计算新版本号
* 默认使用patch更新
*/
async function getNewVersion() { async function getNewVersion() {
const rl = readline.createInterface({input: process.stdin, output: process.stdout}); const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const bumpTypeInput = await rl.question('Is this a patch, minor or major (patch)? '); const bumpTypeInput = await rl.question('Is this a patch, minor or major (patch)? ');
rl.close(); rl.close();
const bumpType = bumpTypeInput.trim().toLowerCase() || 'patch'; const bumpType = bumpTypeInput.trim().toLowerCase() || 'patch';
if (!['patch', 'minor', 'major'].includes(bumpType)) { if (!['patch', 'minor', 'major'].includes(bumpType)) {
console.error(`Unknown bump type ${bumpTypeInput} - expected one of "patch", "minor, "major"`) console.error(`Unknown bump type ${bumpTypeInput} - expected one of "patch", "minor, "major"`)
process.exit(1); process.exit(1); // 无效的更新类型,退出进程
} }
return semver.inc(APP_VERSION, bumpType);
return semver.inc(APP_VERSION, bumpType); // 使用semver计算新版本号
} }
/**
* 更新全局配置文件中的应用版本
* 只保留主版本号和次版本号如1.2.3 1.2
*/
async function updateConfig(newVersion) { async function updateConfig(newVersion) {
// 定位全局配置文件defaults.json
const defaultConfigPath = path.resolve(__dirname, '../../ghost/core/core/shared/config/defaults.json'); const defaultConfigPath = path.resolve(__dirname, '../../ghost/core/core/shared/config/defaults.json');
const defaultConfig = require(defaultConfigPath); const defaultConfig = require(defaultConfigPath);
// 获取当前应用在配置文件中的键名
const configKey = CONFIG_KEYS[APP_NAME]; const configKey = CONFIG_KEYS[APP_NAME];
// 更新配置中的版本只保留major和minor
defaultConfig[configKey].version = `${semver.major(newVersion)}.${semver.minor(newVersion)}`; defaultConfig[configKey].version = `${semver.major(newVersion)}.${semver.minor(newVersion)}`;
// 写回配置文件保留4空格缩进
await fs.writeFile(defaultConfigPath, JSON.stringify(defaultConfig, null, 4) + '\n'); await fs.writeFile(defaultConfigPath, JSON.stringify(defaultConfig, null, 4) + '\n');
} }
/**
* 更新当前应用的package.json版本号
*/
async function updatePackageJson(newVersion) { async function updatePackageJson(newVersion) {
const newPackageJson = Object.assign({}, packageJson, { const newPackageJson = Object.assign({}, packageJson, {
version: newVersion version: newVersion
}); });
// 写回package.json保留2空格缩进
await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2) + '\n'); await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2) + '\n');
} }
/**
* 生成更新日志Changelog
* 包含i18n更新信息和相关的提交记录
*/
async function getChangelog(newVersion) { async function getChangelog(newVersion) {
const rl = readline.createInterface({input: process.stdin, output: process.stdout}); const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const i18nChangesInput = await rl.question('Does this release contain i18n updates (Y/n)? '); const i18nChangesInput = await rl.question('Does this release contain i18n updates (Y/n)? ');
rl.close(); rl.close();
// 判断是否包含国际化更新
const i18nChanges = i18nChangesInput.trim().toLowerCase() !== 'n'; const i18nChanges = i18nChangesInput.trim().toLowerCase() !== 'n';
let changelogItems = []; let changelogItems = [];
// 如果有i18n更新添加到更新日志
if (i18nChanges) { if (i18nChanges) {
changelogItems.push('Updated i18n translations'); changelogItems.push('Updated i18n translations');
} }
// Restrict git log to only the current directory (the specific app) // 获取当前应用目录下最近50条提交记录限制在当前目录
const lastFiftyCommits = await safeExec(`git log -n 50 --oneline -- .`); const lastFiftyCommits = await safeExec(`git log -n 50 --oneline -- .`);
if (lastFiftyCommits.stderr) { if (lastFiftyCommits.stderr) {
console.error(`There was an error getting the last 50 commits`); console.error(`There was an error getting the last 50 commits`);
process.exit(1); process.exit(1); // 获取提交记录失败,退出进程
} }
// 解析提交记录,查找上一次发布的提交
const lastFiftyCommitsList = lastFiftyCommits.stdout.split('\n'); const lastFiftyCommitsList = lastFiftyCommits.stdout.split('\n');
const releaseRegex = new RegExp(`Released ${APP_NAME} v${APP_VERSION}`); const releaseRegex = new RegExp(`Released ${APP_NAME} v${APP_VERSION}`);
const indexOfLastRelease = lastFiftyCommitsList.findIndex((commitLine) => { const indexOfLastRelease = lastFiftyCommitsList.findIndex((commitLine) => {
const commitMessage = commitLine.slice(11); // Take the hash off the front const commitMessage = commitLine.slice(11); // 移除哈希前缀
return releaseRegex.test(commitMessage); return releaseRegex.test(commitMessage);
}); });
if (indexOfLastRelease === -1) { if (indexOfLastRelease === -1) {
// 未找到上一次发布记录使用最近20条相关提交作为 fallback
console.warn(`Could not find commit for previous release. Will include recent commits affecting this app.`); console.warn(`Could not find commit for previous release. Will include recent commits affecting this app.`);
// Fallback: get recent commits for this app (last 20)
const recentCommits = await safeExec(`git log -n 20 --pretty=format:"%h%n%B__SPLIT__" -- .`); const recentCommits = await safeExec(`git log -n 20 --pretty=format:"%h%n%B__SPLIT__" -- .`);
if (recentCommits.stderr) { if (recentCommits.stderr) {
console.error(`There was an error getting recent commits`); console.error(`There was an error getting recent commits`);
@ -141,25 +184,28 @@ async function getChangelog(newVersion) {
const recentCommitsList = recentCommits.stdout.split('__SPLIT__'); const recentCommitsList = recentCommits.stdout.split('__SPLIT__');
// 筛选包含Linear链接的提交通常是已处理的任务
const recentCommitsWhichMentionLinear = recentCommitsList.filter((commitBlock) => { const recentCommitsWhichMentionLinear = recentCommitsList.filter((commitBlock) => {
return commitBlock.includes('https://linear.app/ghost'); return commitBlock.includes('https://linear.app/ghost');
}); });
// 生成提交记录的GitHub链接
const commitChangelogItems = recentCommitsWhichMentionLinear.map((commitBlock) => { const commitChangelogItems = recentCommitsWhichMentionLinear.map((commitBlock) => {
const lines = commitBlock.split('\n'); const lines = commitBlock.split('\n');
if (!lines.length || !lines[0].trim()) { if (!lines.length || !lines[0].trim()) {
return null; // Skip entries with no hash return null; // 跳过无效条目
} }
const hash = lines[0].trim(); const hash = lines[0].trim();
return `https://github.com/TryGhost/Ghost/commit/${hash}`; return `https://github.com/TryGhost/Ghost/commit/${hash}`;
}).filter(Boolean); // Filter out any null entries }).filter(Boolean); // 过滤空值
changelogItems.push(...commitChangelogItems); changelogItems.push(...commitChangelogItems);
} else { } else {
// 找到上一次发布记录,获取两次发布之间的提交
const lastReleaseCommit = lastFiftyCommitsList[indexOfLastRelease]; const lastReleaseCommit = lastFiftyCommitsList[indexOfLastRelease];
const lastReleaseCommitHash = lastReleaseCommit.slice(0, 10); const lastReleaseCommitHash = lastReleaseCommit.slice(0, 10); // 提取提交哈希
// Also restrict this git log to only the current directory (the specific app) // 获取从上次发布到现在的提交(限制在当前目录)
const commitsSinceLastRelease = await safeExec(`git log ${lastReleaseCommitHash}..HEAD --pretty=format:"%h%n%B__SPLIT__" -- .`); const commitsSinceLastRelease = await safeExec(`git log ${lastReleaseCommitHash}..HEAD --pretty=format:"%h%n%B__SPLIT__" -- .`);
if (commitsSinceLastRelease.stderr) { if (commitsSinceLastRelease.stderr) {
console.error(`There was an error getting commits since the last release`); console.error(`There was an error getting commits since the last release`);
@ -167,49 +213,63 @@ async function getChangelog(newVersion) {
} }
const commitsSinceLastReleaseList = commitsSinceLastRelease.stdout.split('__SPLIT__'); const commitsSinceLastReleaseList = commitsSinceLastRelease.stdout.split('__SPLIT__');
// 筛选包含Linear链接的提交
const commitsSinceLastReleaseWhichMentionLinear = commitsSinceLastReleaseList.filter((commitBlock) => { const commitsSinceLastReleaseWhichMentionLinear = commitsSinceLastReleaseList.filter((commitBlock) => {
return commitBlock.includes('https://linear.app/ghost'); return commitBlock.includes('https://linear.app/ghost');
}); });
// 生成提交记录的GitHub链接
const commitChangelogItems = commitsSinceLastReleaseWhichMentionLinear.map((commitBlock) => { const commitChangelogItems = commitsSinceLastReleaseWhichMentionLinear.map((commitBlock) => {
const lines = commitBlock.split('\n'); const lines = commitBlock.split('\n');
if (!lines.length || !lines[0].trim()) { if (!lines.length || !lines[0].trim()) {
return null; // Skip entries with no hash return null; // 跳过无效条目
} }
const hash = lines[0].trim(); const hash = lines[0].trim();
return `https://github.com/TryGhost/Ghost/commit/${hash}`; return `https://github.com/TryGhost/Ghost/commit/${hash}`;
}).filter(Boolean); // Filter out any null entries }).filter(Boolean); // 过滤空值
changelogItems.push(...commitChangelogItems); changelogItems.push(...commitChangelogItems);
} }
// 格式化更新日志列表
const changelogList = changelogItems.map(item => ` - ${item}`).join('\n'); const changelogList = changelogItems.map(item => ` - ${item}`).join('\n');
return `Changelog for v${APP_VERSION} -> ${newVersion}: \n${changelogList}`; return `Changelog for v${APP_VERSION} -> ${newVersion}: \n${changelogList}`;
} }
/**
* 主函数执行发布流程
*/
async function main() { async function main() {
await ensureEnabledApp(); // 前置检查
await ensureNotOnMain(); await ensureEnabledApp(); // 检查应用是否允许发布
await ensureCleanGit(); await ensureNotOnMain(); // 检查是否不在main分支
await ensureCleanGit(); // 检查工作区是否干净
console.log(`Running release for ${APP_NAME}`); console.log(`Running release for ${APP_NAME}`);
console.log(`Current version is ${APP_VERSION}`); console.log(`Current version is ${APP_VERSION}`);
// 获取新版本号
const newVersion = await getNewVersion(); const newVersion = await getNewVersion();
console.log(`Bumping to version ${newVersion}`); console.log(`Bumping to version ${newVersion}`);
// 生成更新日志
const changelog = await getChangelog(newVersion); const changelog = await getChangelog(newVersion);
// 更新package.json并提交
await updatePackageJson(newVersion); await updatePackageJson(newVersion);
await exec(`git add package.json`); await exec(`git add package.json`);
// 更新全局配置并提交
await updateConfig(newVersion); await updateConfig(newVersion);
await exec(`git add ../../ghost/core/core/shared/config/defaults.json`); await exec(`git add ../../ghost/core/core/shared/config/defaults.json`);
// 创建发布提交
await exec(`git commit -m 'Released ${APP_NAME} v${newVersion}\n\n${changelog}'`); await exec(`git commit -m 'Released ${APP_NAME} v${newVersion}\n\n${changelog}'`);
// 提示用户检查并提交PR
console.log(`Release commit created - please double check it and use "git commit --amend" to make any changes before opening a PR to merge into main`) console.log(`Release commit created - please double check it and use "git commit --amend" to make any changes before opening a PR to merge into main`)
} }
main(); // 执行主函数
main();

@ -1,21 +1,25 @@
# 工作流名称:为 Issues 添加标签
name: 'Label Issues' name: 'Label Issues'
# 触发条件:定义工作流在哪些事件发生时运行
on: on:
workflow_dispatch: workflow_dispatch: # 允许手动触发工作流
issues: issues:
types: [opened, closed, labeled] types: [opened, closed, labeled] # 当 issues 被打开、关闭或添加标签时触发
pull_request_target: pull_request_target:
types: [closed, labeled] types: [closed, labeled] # 当 PR 被关闭或添加标签时触发(使用 target 分支上下文)
schedule: schedule:
- cron: '0 * * * *' - cron: '0 * * * *' # 定时触发,每小时执行一次(分钟 小时 日 月 周)
# 权限设置:工作流运行时拥有的权限
permissions: permissions:
issues: write issues: write # 允许对 issues 执行写操作(如添加标签)
pull-requests: write pull-requests: write # 允许对 PR 执行写操作(如添加标签)
jobs: jobs:
action: action: # 定义一个名为 action 的任务
runs-on: ubuntu-latest runs-on: ubuntu-latest # 任务运行在最新版 Ubuntu 系统上
if: github.repository_owner == 'TryGhost' if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为 TryGhost 时才执行
steps: steps:
- uses: tryghost/actions/actions/label-actions@main # 步骤:使用自定义动作处理标签操作
- uses: tryghost/actions/actions/label-actions@main # 引用 TryGhost 组织下的 label-actions 动作main 分支)

@ -1,30 +1,38 @@
# 工作流名称:迁移审查
name: Migration Review name: Migration Review
# 触发条件当目标分支的PR被打开且修改了特定迁移相关路径时触发
on: on:
pull_request_target: pull_request_target:
types: [opened] types: [opened] # 仅在PR被打开时触发
paths: paths:
- 'ghost/core/core/server/data/schema/**' - 'ghost/core/core/server/data/schema/**' # 监控schema目录下的所有文件变更
- 'ghost/core/core/server/data/migrations/versions/**' - 'ghost/core/core/server/data/migrations/versions/**' # 监控迁移版本目录下的所有文件变更
jobs: jobs:
createComment: createComment: # 定义一个名为createComment的任务
runs-on: ubuntu-latest runs-on: ubuntu-latest # 任务运行在最新版Ubuntu系统上
if: github.repository_owner == 'TryGhost' if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为TryGhost时执行
name: Add migration review requirements name: Add migration review requirements # 任务名称:添加迁移审查要求
steps: steps:
- uses: actions/github-script@v7 # 步骤1为PR添加"migration"标签
- uses: actions/github-script@v7 # 使用GitHub官方脚本动作
with: with:
script: | script: |
# 调用GitHub API为当前PR添加标签
github.rest.issues.addLabels({ github.rest.issues.addLabels({
issue_number: context.issue.number, issue_number: context.issue.number, # 获取当前PR的编号
owner: context.repo.owner, owner: context.repo.owner, # 仓库所有者(从上下文获取)
repo: context.repo.repo, repo: context.repo.repo, # 仓库名称(从上下文获取)
labels: ["migration"] labels: ["migration"] # 要添加的标签
}) })
# 步骤2在PR中添加迁移审查清单评论
- uses: peter-evans/create-or-update-comment@ac8e6509d7545ebc2e5e7c35eaa12195c2f77adc - uses: peter-evans/create-or-update-comment@ac8e6509d7545ebc2e5e7c35eaa12195c2f77adc
with: with:
issue-number: ${{ github.event.pull_request.number }} issue-number: ${{ github.event.pull_request.number }} # 指定要评论的PR编号
body: | body: | # 评论内容(迁移审查清单)
It looks like this PR contains a migration 👀 It looks like this PR contains a migration 👀
Here's the checklist for reviewing migrations: Here's the checklist for reviewing migrations:
@ -54,4 +62,4 @@ jobs:
- [ ] Mass updates/inserts are batched appropriately - [ ] Mass updates/inserts are batched appropriately
- [ ] Does not loop over large tables/datasets - [ ] Does not loop over large tables/datasets
- [ ] Defends against missing or invalid data - [ ] Defends against missing or invalid data
- [ ] For settings updates: follows the appropriate guidelines - [ ] For settings updates: follows the appropriate guidelines

@ -1,15 +1,21 @@
# 工作流名称关闭过时的国际化i18n相关PR
name: 'Close stale i18n PRs' name: 'Close stale i18n PRs'
# 触发条件
on: on:
workflow_dispatch: workflow_dispatch: # 允许手动触发工作流
schedule: schedule:
- cron: '0 6 * * *' - cron: '0 6 * * *' # 定时触发每天早上6点执行UTC时间
jobs: jobs:
stale: stale: # 定义名为stale的任务处理过时PR
if: github.repository_owner == 'TryGhost' if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为TryGhost时执行
runs-on: ubuntu-latest runs-on: ubuntu-latest # 任务运行在最新版Ubuntu系统上
steps: steps:
# 使用官方的stale动作处理过时PR
- uses: actions/stale@v9 - uses: actions/stale@v9
with: with:
# 标记PR为过时的提示消息
stale-pr-message: | stale-pr-message: |
Thanks for contributing to Ghost's i18n :) Thanks for contributing to Ghost's i18n :)
@ -17,10 +23,12 @@ jobs:
I18n PRs tend to get out of date quickly, so we're closing them to keep the PR list clean. I18n PRs tend to get out of date quickly, so we're closing them to keep the PR list clean.
If you're still interested in working on this PR, please let us know. Otherwise this PR will be closed shortly, but can always be reopened later. Thank you for understanding 🙂 If you're still interested in working on this PR, please let us know. Otherwise this PR will be closed shortly, but can always be reopened later. Thank you for understanding 🙂
only-labels: 'affects:i18n'
days-before-pr-stale: 21 only-labels: 'affects:i18n' # 仅对带有affects:i18n标签的PR生效
days-before-pr-close: 7 days-before-pr-stale: 21 # PR闲置21天3周后标记为过时
exempt-pr-labels: 'feature,pinned,needs:triage' days-before-pr-close: 7 # 标记为过时后7天自动关闭
stale-pr-label: 'stale' exempt-pr-labels: 'feature,pinned,needs:triage' # 这些标签的PR不会被标记为过时
stale-pr-label: 'stale' # 标记过时PR时添加的标签
# 关闭PR时的提示消息
close-pr-message: | close-pr-message: |
This PR has been automatically closed due to inactivity. If you'd like to continue working on it, feel free to open a new PR. This PR has been automatically closed due to inactivity. If you'd like to continue working on it, feel free to open a new PR.

@ -1,29 +1,43 @@
# 工作流名称关闭过时的issues和PRs
name: 'Close stale issues and PRs' name: 'Close stale issues and PRs'
# 触发条件
on: on:
workflow_dispatch: workflow_dispatch: # 允许手动触发工作流
schedule: schedule:
- cron: '0 6 * * *' - cron: '0 6 * * *' # 定时触发每天早上6点执行UTC时间
jobs: jobs:
stale: stale: # 定义名为stale的任务处理过时的issues和PRs
if: github.repository_owner == 'TryGhost' if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为TryGhost时执行
runs-on: ubuntu-latest runs-on: ubuntu-latest # 任务运行在最新版Ubuntu系统上
steps: steps:
# 使用官方的stale动作处理过时内容
- uses: actions/stale@v9 - uses: actions/stale@v9
with: with:
# 标记issue为过时的提示消息
stale-issue-message: | stale-issue-message: |
Our bot has automatically marked this issue as stale because there has not been any activity here in some time. Our bot has automatically marked this issue as stale because there has not been any activity here in some time.
The issue will be closed soon if there are no further updates, however we ask that you do not post comments to keep the issue open if you are not actively working on a PR. The issue will be closed soon if there are no further updates, however we ask that you do not post comments to keep the issue open if you are not actively working on a PR.
We keep the issue list minimal so we can keep focus on the most pressing issues. Closed issues can always be reopened if a new contributor is found. Thank you for understanding 🙂 We keep the issue list minimal so we can keep focus on the most pressing issues. Closed issues can always be reopened if a new contributor is found. Thank you for understanding 🙂
# 标记PR为过时的提示消息
stale-pr-message: | stale-pr-message: |
Our bot has automatically marked this PR as stale because there has not been any activity here in some time. Our bot has automatically marked this PR as stale because there has not been any activity here in some time.
If weve missed reviewing your PR & youre still interested in working on it, please let us know. Otherwise this PR will be closed shortly, but can always be reopened later. Thank you for understanding 🙂 If weve missed reviewing your PR & youre still interested in working on it, please let us know. Otherwise this PR will be closed shortly, but can always be reopened later. Thank you for understanding 🙂
# 免于标记为过时的issue标签这些标签的issue不会被处理
exempt-issue-labels: 'feature,pinned,needs:triage' exempt-issue-labels: 'feature,pinned,needs:triage'
# 免于标记为过时的PR标签这些标签的PR不会被处理
exempt-pr-labels: 'feature,pinned,needs:triage' exempt-pr-labels: 'feature,pinned,needs:triage'
days-before-stale: 113
days-before-pr-stale: 358 days-before-stale: 113 # issue闲置113天后标记为过时
stale-issue-label: 'stale' days-before-pr-stale: 358 # PR闲置358天后标记为过时
stale-pr-label: 'stale'
close-issue-reason: 'not_planned' stale-issue-label: 'stale' # 标记过时issue时添加的标签
stale-pr-label: 'stale' # 标记过时PR时添加的标签
close-issue-reason: 'not_planned' # 关闭issue时的原因GitHub内置选项

@ -1,26 +1,37 @@
{ {
//
"editor.quickSuggestions": { "editor.quickSuggestions": {
"strings": true "strings": true // HTML/CSS
}, },
// ESLint
"eslint.workingDirectories": [ "eslint.workingDirectories": [
{ {
"pattern": "./apps/*/" "pattern": "./apps/*/" // appsESLint
}, },
{ {
"pattern": "./ghost/*/" "pattern": "./ghost/*/" // ghostESLint
} }
], ],
//
"search.exclude": { "search.exclude": {
"**/.git": true, "**/.git": true, // git
"**/build/*": true, "**/build/*": true, //
"**/coverage/**": true, "**/coverage/**": true, //
"**/dist/**": true, "**/dist/**": true, //
"**/ghost.map": true, "**/ghost.map": true, // ghost.map
"**/node_modules": true, "**/node_modules": true, //
"ghost/core/core/built/**": true "ghost/core/core/built/**": true //
}, },
// Tailwind CSS
"tailwindCSS.experimental.classRegex": [ "tailwindCSS.experimental.classRegex": [
// clsxTailwind
// clsx('text-red-500', 'bg-white')
["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
], ],
"git.detectSubmodules": false
} // Git
"git.detectSubmodules": false // Git
}

@ -99,6 +99,6 @@ To stay up to date with all the latest news and product updates, make sure you [
&nbsp; &nbsp;
# Copyright & license # Copyright & license
+ MIT
Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE).
Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.

File diff suppressed because it is too large Load Diff

@ -1,82 +1,102 @@
import clsx from 'clsx'; import clsx from 'clsx'; // 用于条件性组合CSS类名的库
import {Button} from '@tryghost/shade'; import { Button } from '@tryghost/shade'; // 引入UI组件库中的Button组件
import {useEffect, useState} from 'react'; import { useEffect, useState } from 'react'; // 引入React的钩子函数
import {useFollowMutationForUser, useUnfollowMutationForUser} from '@hooks/use-activity-pub-queries'; // 引入用于关注和取消关注的自定义钩子
import { useFollowMutationForUser, useUnfollowMutationForUser } from '@hooks/use-activity-pub-queries';
// 定义FollowButton组件的属性接口
interface FollowButtonProps { interface FollowButtonProps {
className?: string; className?: string; // 可选的CSS类名
following: boolean; following: boolean; // 表示当前是否已关注的状态
handle: string; handle: string; // 被关注用户的唯一标识(如用户名)
type?: 'primary' | 'secondary'; type?: 'primary' | 'secondary'; // 按钮类型(未在组件中使用,预留扩展)
onFollow?: () => void; onFollow?: () => void; // 关注操作完成后的回调函数
onUnfollow?: () => void; onUnfollow?: () => void; // 取消关注操作完成后的回调函数
'data-testid'?: string; 'data-testid'?: string; // 用于测试的标识属性
} }
// 空函数,作为回调函数的默认值
const noop = () => {}; const noop = () => {};
// 关注/取消关注按钮组件
const FollowButton: React.FC<FollowButtonProps> = ({ const FollowButton: React.FC<FollowButtonProps> = ({
className, className,
following, following,
handle, handle,
onFollow = noop, onFollow = noop, // 默认使用空函数
onUnfollow = noop, onUnfollow = noop, // 默认使用空函数
'data-testid': testId 'data-testid': testId
}) => { }) => {
// 本地状态管理当前是否关注初始值由props传入
const [isFollowing, setIsFollowing] = useState(following); const [isFollowing, setIsFollowing] = useState(following);
const unfollowMutation = useUnfollowMutationForUser('index', // 初始化取消关注的mutation hook
const unfollowMutation = useUnfollowMutationForUser(
'index', // 缓存键名
() => { () => {
// Success handled by cache updates // 成功回调 - 由缓存更新处理,这里不需要额外操作
}, },
() => { () => {
// 失败回调 - 恢复关注状态
setIsFollowing(true); setIsFollowing(true);
} }
); );
const followMutation = useFollowMutationForUser('index', // 初始关注的mutation hook
const followMutation = useFollowMutationForUser(
'index', // 缓存键名
() => { () => {
// Success handled by cache updates // 成功回调 - 由缓存更新处理,这里不需要额外操作
}, },
() => { () => {
// 失败回调 - 恢复未关注状态
setIsFollowing(false); setIsFollowing(false);
} }
); );
// 处理按钮点击事件
const handleClick = async () => { const handleClick = async () => {
if (isFollowing) { if (isFollowing) {
setIsFollowing(false); // 当前已关注,执行取消关注流程
onUnfollow(); setIsFollowing(false); // 先更新UI状态
unfollowMutation.mutate(handle); onUnfollow(); // 触发取消关注回调
unfollowMutation.mutate(handle); // 调用取消关注API
} else { } else {
setIsFollowing(true); // 当前未关注,执行关注流程
onFollow(); setIsFollowing(true); // 先更新UI状态
followMutation.mutate(handle); onFollow(); // 触发关注回调
followMutation.mutate(handle); // 调用关注API
} }
}; };
// 当props中的following变化时同步更新本地状态
useEffect(() => { useEffect(() => {
setIsFollowing(following); setIsFollowing(following);
}, [following]); }, [following]);
return ( return (
<Button <Button
// 组合CSS类名确保最小宽度为90px
className={clsx( className={clsx(
'min-w-[90px]', 'min-w-[90px]',
className className
)} )}
data-testid={testId} data-testid={testId} // 测试标识
// 已关注状态时显示提示文本
title={isFollowing ? 'Click to unfollow' : ''} title={isFollowing ? 'Click to unfollow' : ''}
// 根据关注状态切换按钮样式(默认/轮廓)
variant={!isFollowing ? 'default' : 'outline'} variant={!isFollowing ? 'default' : 'outline'}
// 点击事件处理,阻止默认行为和事件冒泡
onClick={(event) => { onClick={(event) => {
event?.preventDefault(); event?.preventDefault();
event?.stopPropagation(); event?.stopPropagation();
handleClick(); handleClick();
}} }}
> >
{/* 根据关注状态显示不同文本 */}
{isFollowing ? 'Following' : 'Follow'} {isFollowing ? 'Following' : 'Follow'}
</Button> </Button>
); );
}; };
export default FollowButton; export default FollowButton;

@ -1,52 +1,72 @@
import React, {useCallback, useEffect, useState} from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import {Button, Dialog, DialogClose, DialogContent, LucideIcon} from '@tryghost/shade'; // 导入UI组件按钮、对话框及内容容器、Lucide图标库
import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import { Button, Dialog, DialogClose, DialogContent, LucideIcon } from '@tryghost/shade';
import {getAttachment} from '@components/feed/FeedItem'; // 导入活动发布对象的类型定义
import { ObjectProperties } from '@tryghost/admin-x-framework/api/activitypub';
// 导入获取附件的工具函数
import { getAttachment } from '@components/feed/FeedItem';
// 定义灯箱中图片的类型接口
export interface LightboxImage { export interface LightboxImage {
url: string; url: string; // 图片URL
alt: string; alt: string; // 图片替代文本
} }
// 定义灯箱状态的类型接口
export interface LightboxState { export interface LightboxState {
images: LightboxImage[]; images: LightboxImage[]; // 所有图片列表
currentIndex: number; currentIndex: number; // 当前显示图片的索引
isOpen: boolean; isOpen: boolean; // 灯箱是否打开
} }
/**
* Hook
* @param object -
* @returns
*/
export function useLightboxImages(object: ObjectProperties | null) { export function useLightboxImages(object: ObjectProperties | null) {
// 初始化灯箱状态
const [lightboxState, setLightboxState] = useState<LightboxState>({ const [lightboxState, setLightboxState] = useState<LightboxState>({
images: [], images: [],
currentIndex: 0, currentIndex: 0,
isOpen: false isOpen: false
}); });
/**
*
* @param obj -
* @returns
*/
const getAllImagesFromAttachment = (obj: ObjectProperties): LightboxImage[] => { const getAllImagesFromAttachment = (obj: ObjectProperties): LightboxImage[] => {
// 获取对象的附件
const attachment = getAttachment(obj); const attachment = getAttachment(obj);
if (!attachment) { if (!attachment) {
return []; return [];
} }
// 处理附件为数组的情况
if (Array.isArray(attachment)) { if (Array.isArray(attachment)) {
return attachment.map((item, index) => ({ return attachment.map((item, index) => ({
url: item.url, url: item.url,
alt: item.name || `Image-${index}` alt: item.name || `Image-${index}` // 用索引作为默认alt文本
})); }));
} }
// 处理单个图片附件
if (attachment.mediaType?.startsWith('image/') || attachment.type === 'Image') { if (attachment.mediaType?.startsWith('image/') || attachment.type === 'Image') {
return [{ return [{
url: attachment.url, url: attachment.url,
alt: attachment.name || 'Image' alt: attachment.name || 'Image' // 用默认文本作为fallback
}]; }];
} }
// 处理对象中直接包含image字段的情况
if (obj.image) { if (obj.image) {
let imageUrl; let imageUrl;
if (typeof obj.image === 'string') { if (typeof obj.image === 'string') {
imageUrl = obj.image; imageUrl = obj.image; // 图片URL直接是字符串
} else { } else {
imageUrl = obj.image?.url; imageUrl = obj.image?.url; // 图片是对象取其url属性
} }
if (imageUrl) { if (imageUrl) {
@ -57,17 +77,23 @@ export function useLightboxImages(object: ObjectProperties | null) {
} }
} }
return []; return []; // 没有找到图片时返回空数组
}; };
/**
*
* @param clickedUrl - URL
*/
const openLightbox = (clickedUrl: string) => { const openLightbox = (clickedUrl: string) => {
if (!object) { if (!object) {
return; return; // 对象为空时不执行操作
} }
// 获取所有图片并找到被点击图片的索引
const images = getAllImagesFromAttachment(object); const images = getAllImagesFromAttachment(object);
const clickedIndex = images.findIndex(img => img.url === clickedUrl); const clickedIndex = images.findIndex(img => img.url === clickedUrl);
// 找到对应图片时更新灯箱状态
if (clickedIndex !== -1) { if (clickedIndex !== -1) {
setLightboxState({ setLightboxState({
images, images,
@ -77,6 +103,9 @@ export function useLightboxImages(object: ObjectProperties | null) {
} }
}; };
/**
*
*/
const closeLightbox = () => { const closeLightbox = () => {
setLightboxState(prev => ({ setLightboxState(prev => ({
...prev, ...prev,
@ -84,6 +113,10 @@ export function useLightboxImages(object: ObjectProperties | null) {
})); }));
}; };
/**
*
* @param newIndex -
*/
const navigateToIndex = (newIndex: number) => { const navigateToIndex = (newIndex: number) => {
setLightboxState(prev => ({ setLightboxState(prev => ({
...prev, ...prev,
@ -99,14 +132,18 @@ export function useLightboxImages(object: ObjectProperties | null) {
}; };
} }
// 图片灯箱组件的属性接口
interface ImageLightboxProps { interface ImageLightboxProps {
images: LightboxImage[]; images: LightboxImage[]; // 图片列表
currentIndex: number; currentIndex: number; // 当前显示图片索引
isOpen: boolean; isOpen: boolean; // 是否打开
onClose: () => void; onClose: () => void; // 关闭回调
onNavigate: (newIndex: number) => void; onNavigate: (newIndex: number) => void; // 导航回调
} }
/**
*
*/
const ImageLightbox: React.FC<ImageLightboxProps> = ({ const ImageLightbox: React.FC<ImageLightboxProps> = ({
images, images,
currentIndex, currentIndex,
@ -114,44 +151,65 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({
onClose, onClose,
onNavigate onNavigate
}) => { }) => {
// 判断是否是第一张/最后一张图片
const isFirstImage = currentIndex === 0; const isFirstImage = currentIndex === 0;
const isLastImage = currentIndex === images.length - 1; const isLastImage = currentIndex === images.length - 1;
/**
*
* 使useCallback
*/
const goToNext = useCallback(() => { const goToNext = useCallback(() => {
// 只有一张图片或已经是最后一张时不执行
if (images.length <= 1 || isLastImage) { if (images.length <= 1 || isLastImage) {
return; return;
} }
// 计算下一张索引(循环导航)
const nextIndex = (currentIndex + 1) % images.length; const nextIndex = (currentIndex + 1) % images.length;
onNavigate(nextIndex); onNavigate(nextIndex);
}, [images.length, isLastImage, currentIndex, onNavigate]); }, [images.length, isLastImage, currentIndex, onNavigate]);
/**
*
* 使useCallback
*/
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
// 只有一张图片或已经是第一张时不执行
if (images.length <= 1 || isFirstImage) { if (images.length <= 1 || isFirstImage) {
return; return;
} }
// 计算上一张索引(循环导航)
const prevIndex = (currentIndex - 1 + images.length) % images.length; const prevIndex = (currentIndex - 1 + images.length) % images.length;
onNavigate(prevIndex); onNavigate(prevIndex);
}, [images.length, isFirstImage, currentIndex, onNavigate]); }, [images.length, isFirstImage, currentIndex, onNavigate]);
/**
*
*/
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) { if (!isOpen) {
return; return; // 灯箱关闭时不处理
} }
// 右箭头导航到下一张(非最后一张时)
if (e.key === 'ArrowRight' && !isLastImage) { if (e.key === 'ArrowRight' && !isLastImage) {
goToNext(); goToNext();
} else if (e.key === 'ArrowLeft' && !isFirstImage) { }
// 左箭头导航到上一张(非第一张时)
else if (e.key === 'ArrowLeft' && !isFirstImage) {
goToPrev(); goToPrev();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
// 组件卸载时移除事件监听
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, [isOpen, currentIndex, images.length, goToNext, goToPrev, isLastImage, isFirstImage]); }, [isOpen, currentIndex, images.length, goToNext, goToPrev, isLastImage, isFirstImage]);
// 灯箱未打开或没有图片时不渲染
if (!isOpen || images.length === 0) { if (!isOpen || images.length === 0) {
return null; return null;
} }
@ -159,37 +217,46 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({
return ( return (
<Dialog <Dialog
open={isOpen} open={isOpen}
// 监听对话框状态变化,关闭时触发回调
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
onClose(); onClose();
} }
}} }}
> >
<DialogContent className="top-[50%] h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] translate-y-[-50%] items-center border-none bg-transparent p-0 shadow-none data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%]" onClick={() => onClose()}> {/* 灯箱内容容器,全屏显示且居中 */}
<DialogContent
className="top-[50%] h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] translate-y-[-50%] items-center border-none bg-transparent p-0 shadow-none data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%]"
onClick={() => onClose()} // 点击空白区域关闭
>
{/* 当前显示的图片 */}
<img <img
alt={images[currentIndex].alt} alt={images[currentIndex].alt}
className="mx-auto max-h-[90vh] max-w-[90vw] object-contain" className="mx-auto max-h-[90vh] max-w-[90vw] object-contain" // 保持比例,限制最大尺寸
src={images[currentIndex].url} src={images[currentIndex].url}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()} // 点击图片本身不关闭灯箱
/> />
{/* 多张图片时显示导航按钮 */}
{images.length > 1 && ( {images.length > 1 && (
<> <>
{/* 上一张按钮 */}
<Button <Button
className="absolute left-5 top-1/2 size-11 -translate-y-1/2 rounded-full bg-black/50 p-0 pr-0.5 hover:bg-black/70" className="absolute left-5 top-1/2 size-11 -translate-y-1/2 rounded-full bg-black/50 p-0 pr-0.5 hover:bg-black/70"
disabled={isFirstImage} disabled={isFirstImage} // 第一张时禁用
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation(); // 阻止事件冒泡(避免关闭灯箱)
goToPrev(); goToPrev();
}} }}
> >
<LucideIcon.ChevronLeft className="!size-6" /> <LucideIcon.ChevronLeft className="!size-6" />
<span className="sr-only">Previous image</span> <span className="sr-only">Previous image</span> // 屏幕阅读器文本
</Button> </Button>
{/* 下一张按钮 */}
<Button <Button
className="absolute right-5 top-1/2 size-11 -translate-y-1/2 rounded-full bg-black/50 p-0 pl-0.5 hover:bg-black/70" className="absolute right-5 top-1/2 size-11 -translate-y-1/2 rounded-full bg-black/50 p-0 pl-0.5 hover:bg-black/70"
disabled={isLastImage} disabled={isLastImage} // 最后一张时禁用
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
goToNext(); goToNext();
@ -200,6 +267,8 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({
</Button> </Button>
</> </>
)} )}
{/* 关闭按钮 */}
<DialogClose asChild> <DialogClose asChild>
<Button className="absolute right-5 top-5 size-11 rounded-full bg-black/50 p-0 hover:bg-black/70"> <Button className="absolute right-5 top-5 size-11 rounded-full bg-black/50 p-0 hover:bg-black/70">
<LucideIcon.X className="!size-5" /> <LucideIcon.X className="!size-5" />
@ -211,4 +280,4 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({
); );
}; };
export default ImageLightbox; export default ImageLightbox;

@ -1,39 +1,65 @@
import React, {useRef} from 'react'; import React, { useRef } from 'react';
import {Button, LoadingIndicator} from '@tryghost/shade'; // 导入UI组件按钮组件和加载指示器组件
import { Button, LoadingIndicator } from '@tryghost/shade';
// 定义显示回复按钮的属性接口
interface ShowRepliesButtonProps { interface ShowRepliesButtonProps {
count?: number; count?: number; // 可选,回复的数量
onClick: () => void; onClick: () => void; // 点击按钮时触发的回调函数
variant?: 'default' | 'expand' | 'loadMore'; variant?: 'default' | 'expand' | 'loadMore'; // 可选,按钮文本变体
preserveScroll?: boolean; preserveScroll?: boolean; // 可选是否在点击后保持滚动位置默认true
loading?: boolean; loading?: boolean; // 可选是否处于加载状态默认false
} }
const ShowRepliesButton: React.FC<ShowRepliesButtonProps> = ({count, onClick, variant = 'default', preserveScroll = true, loading = false}) => { /**
*
*
*/
const ShowRepliesButton: React.FC<ShowRepliesButtonProps> = ({
count,
onClick,
variant = 'default', // 默认使用'default'文本变体
preserveScroll = true, // 默认保持滚动位置
loading = false // 默认不显示加载状态
}) => {
// 用于获取按钮容器DOM元素的引用
const buttonRef = useRef<HTMLDivElement>(null); const buttonRef = useRef<HTMLDivElement>(null);
/**
*
* @returns
*/
const getButtonText = () => { const getButtonText = () => {
// 当有明确的回复数量且数量大于0时显示包含数量的文本
if (count && count > 0) { if (count && count > 0) {
return `Show ${count} more ${count === 1 ? 'reply' : 'replies'}`; return `Show ${count} more ${count === 1 ? 'reply' : 'replies'}`;
} }
// 根据不同的按钮变体返回对应文本
switch (variant) { switch (variant) {
case 'expand': case 'expand':
return 'Show replies'; return 'Show replies';
case 'loadMore': case 'loadMore':
return 'Show more replies'; return 'Show more replies';
default: default:
return 'Show replies'; return 'Show replies';
} }
}; };
/**
*
*
*/
const handleClick = () => { const handleClick = () => {
if (preserveScroll) { if (preserveScroll) {
// 查找自定义滚动容器优先或使用window作为滚动容器
const container = document.querySelector('[data-scrollable-container]') as HTMLElement; const container = document.querySelector('[data-scrollable-container]') as HTMLElement;
const scrollTop = container ? container.scrollTop : window.scrollY; const scrollTop = container ? container.scrollTop : window.scrollY;
// 执行点击回调(如加载更多回复)
onClick(); onClick();
// 在下一次DOM更新周期中恢复滚动位置
setTimeout(() => { setTimeout(() => {
if (container) { if (container) {
container.scrollTop = scrollTop; container.scrollTop = scrollTop;
@ -42,30 +68,36 @@ const ShowRepliesButton: React.FC<ShowRepliesButtonProps> = ({count, onClick, va
} }
}, 0); }, 0);
} else { } else {
// 不需要保持滚动位置时,直接执行回调
onClick(); onClick();
} }
}; };
return ( return (
// 按钮容器,包含装饰线和按钮本身
<div ref={buttonRef} className='mt-[-7px] flex items-center pb-3'> <div ref={buttonRef} className='mt-[-7px] flex items-center pb-3'>
{/* 左侧装饰元素:三条垂直短横线 */}
<div className='flex w-10 flex-col items-center justify-center gap-1'> <div className='flex w-10 flex-col items-center justify-center gap-1'>
<div className='size-0.5 rounded-sm bg-gray-300'></div> <div className='size-0.5 rounded-sm bg-gray-300'></div>
<div className='size-0.5 rounded-sm bg-gray-300'></div> <div className='size-0.5 rounded-sm bg-gray-300'></div>
<div className='size-0.5 rounded-sm bg-gray-300'></div> <div className='size-0.5 rounded-sm bg-gray-300'></div>
</div> </div>
{/* 主按钮组件 */}
<Button <Button
className='hover:text-blue-800 text-sm font-medium text-blue-600' className='hover:text-blue-800 text-sm font-medium text-blue-600'
variant="ghost" variant="ghost" // 幽灵按钮样式(无背景色,仅文字交互)
onClick={(e: React.MouseEvent<HTMLElement>) => { onClick={(e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault(); // 阻止默认事件
e.stopPropagation(); e.stopPropagation(); // 阻止事件冒泡
(e.target as HTMLElement).blur(); (e.target as HTMLElement).blur(); // 移除按钮焦点状态
handleClick(); handleClick(); // 执行点击处理逻辑
}} }}
> >
{/* 加载状态显示加载指示器和文本,否则显示按钮文本 */}
{loading ? ( {loading ? (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<LoadingIndicator size='sm' /> <LoadingIndicator size='sm' /> {/* 小型加载指示器 */}
<span>Loading...</span> <span>Loading...</span>
</div> </div>
) : ( ) : (
@ -76,4 +108,4 @@ const ShowRepliesButton: React.FC<ShowRepliesButtonProps> = ({count, onClick, va
); );
}; };
export default ShowRepliesButton; export default ShowRepliesButton;

@ -1,88 +1,117 @@
import APAvatar from './APAvatar'; import APAvatar from './APAvatar'; // 头像组件,用于显示用户头像及相关信息
import ActivityItem from '../activities/ActivityItem'; import ActivityItem from '../activities/ActivityItem'; // 活动项容器组件
import FollowButton from './FollowButton'; import FollowButton from './FollowButton'; // 关注/取消关注按钮组件
import React from 'react'; import React from 'react';
import {type Account} from '../../api/activitypub'; import { type Account } from '../../api/activitypub'; // 导入Account类型定义描述用户账号信息
import {Skeleton} from '@tryghost/shade'; import { Skeleton } from '@tryghost/shade'; // 骨架屏组件,用于加载状态显示
import {useNavigate} from '@tryghost/admin-x-framework'; import { useNavigate } from '@tryghost/admin-x-framework'; // 路由导航钩子
import {useSuggestedProfilesForUser} from '@hooks/use-activity-pub-queries'; import { useSuggestedProfilesForUser } from '@hooks/use-activity-pub-queries'; // 获取推荐用户列表的自定义钩子
// 推荐用户项组件的属性接口
interface SuggestedProfileProps { interface SuggestedProfileProps {
profile: Account; profile: Account; // 用户账号信息
update: (id: string, updated: Partial<Account>) => void; update: (id: string, updated: Partial<Account>) => void; // 更新用户信息的回调函数
isLoading: boolean; isLoading: boolean; // 是否处于加载状态
} }
/**
*
*
*/
export const SuggestedProfile: React.FC<SuggestedProfileProps & { export const SuggestedProfile: React.FC<SuggestedProfileProps & {
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void; // 控制父组件弹窗/面板显示状态的回调
}> = ({profile, update, isLoading, onOpenChange}) => { }> = ({ profile, update, isLoading, onOpenChange }) => {
// 处理关注操作的回调
const onFollow = () => { const onFollow = () => {
update(profile.id, { update(profile.id, {
followedByMe: true, followedByMe: true, // 更新为已关注状态
followerCount: profile.followerCount + 1 followerCount: profile.followerCount + 1 // 粉丝数+1
}); });
}; };
// 处理取消关注操作的回调
const onUnfollow = () => { const onUnfollow = () => {
update(profile.id, { update(profile.id, {
followedByMe: false, followedByMe: false, // 更新为未关注状态
followerCount: profile.followerCount - 1 followerCount: profile.followerCount - 1 // 粉丝数-1
}); });
}; };
const navigate = useNavigate(); const navigate = useNavigate(); // 路由导航实例
return ( return (
<ActivityItem <ActivityItem
key={profile.id} key={profile.id}
// 点击用户项导航到用户主页,并关闭可能的父级弹窗
onClick={() => { onClick={() => {
onOpenChange?.(false); onOpenChange?.(false);
navigate(`/profile/${profile.handle}`); navigate(`/profile/${profile.handle}`);
}} }}
> >
<APAvatar author={ {/* 用户头像组件 */}
{ <APAvatar
icon: { author={{
url: profile.avatarUrl icon: { url: profile.avatarUrl },
},
name: profile.name, name: profile.name,
handle: profile.handle handle: profile.handle
} }}
} onClick={() => onOpenChange?.(false)} /> onClick={() => onOpenChange?.(false)} // 点击头像关闭父级弹窗
/>
{/* 用户信息区域 */}
<div className='flex grow flex-col break-anywhere'> <div className='flex grow flex-col break-anywhere'>
<span className='line-clamp-1 font-semibold text-black dark:text-white'>{!isLoading ? profile.name : <Skeleton className='w-full max-w-64' />}</span> {/* 用户名 - 加载状态显示骨架屏 */}
<span className='line-clamp-1 text-sm text-gray-700 dark:text-gray-600'>{!isLoading ? profile.handle : <Skeleton className='w-24' />}</span> <span className='line-clamp-1 font-semibold text-black dark:text-white'>
{!isLoading ? profile.name : <Skeleton className='w-full max-w-64' />}
</span>
{/* 用户账号handle - 加载状态显示骨架屏 */}
<span className='line-clamp-1 text-sm text-gray-700 dark:text-gray-600'>
{!isLoading ? profile.handle : <Skeleton className='w-24' />}
</span>
</div> </div>
{!isLoading ?
{/* 关注按钮 - 加载状态显示骨架屏 */}
{!isLoading ? (
<FollowButton <FollowButton
className='ml-auto' className='ml-auto'
following={profile.followedByMe} following={profile.followedByMe} // 当前关注状态
handle={profile.handle} handle={profile.handle} // 用户账号标识
type='secondary' type='secondary'
onFollow={onFollow} onFollow={onFollow} // 关注回调
onUnfollow={onUnfollow} onUnfollow={onUnfollow} // 取消关注回调
/> : />
) : (
<div className='inline-flex items-center'> <div className='inline-flex items-center'>
<Skeleton className='w-12' /> <Skeleton className='w-12' />
</div> </div>
} )}
</ActivityItem> </ActivityItem>
); );
}; };
/**
*
*
*/
export const SuggestedProfiles: React.FC<{ export const SuggestedProfiles: React.FC<{
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void; // 控制父组件弹窗/面板显示状态的回调
}> = ({onOpenChange}) => { }> = ({ onOpenChange }) => {
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfilesForUser('index', 5); // 获取推荐用户列表及更新方法最多5个用户
const {data: suggestedProfilesData = [], isLoading: isLoadingSuggestedProfiles} = suggestedProfilesQuery; const { suggestedProfilesQuery, updateSuggestedProfile } = useSuggestedProfilesForUser('index', 5);
const {
data: suggestedProfilesData = [], // 推荐用户数据,默认空数组
isLoading: isLoadingSuggestedProfiles // 是否正在加载
} = suggestedProfilesQuery;
return ( return (
<div className='mb-[-15px] flex flex-col gap-3 pt-2'> <div className='mb-[-15px] flex flex-col gap-3 pt-2'>
<div className='flex flex-col'> <div className='flex flex-col'>
{/* 加载状态时显示5个骨架项否则显示实际推荐用户 */}
{(isLoadingSuggestedProfiles ? Array(5).fill(null) : (suggestedProfilesData || [])).map((profile, index) => ( {(isLoadingSuggestedProfiles ? Array(5).fill(null) : (suggestedProfilesData || [])).map((profile, index) => (
<React.Fragment key={profile?.id || `loading-${index}`}> <React.Fragment key={profile?.id || `loading-${index}`}>
<SuggestedProfile <SuggestedProfile
isLoading={isLoadingSuggestedProfiles} isLoading={isLoadingSuggestedProfiles}
// 加载状态时传入默认空对象,避免报错
profile={profile || { profile={profile || {
id: '', id: '',
name: '', name: '',
@ -93,7 +122,7 @@ export const SuggestedProfiles: React.FC<{
followingCount: 0, followingCount: 0,
followedByMe: false followedByMe: false
}} }}
update={updateSuggestedProfile} update={updateSuggestedProfile} // 传递更新方法
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
/> />
</React.Fragment> </React.Fragment>
@ -101,4 +130,4 @@ export const SuggestedProfiles: React.FC<{
</div> </div>
</div> </div>
); );
}; };

@ -1,37 +1,66 @@
import * as React from 'react'; import * as React from 'react';
// 导入反馈框组件
import FeedbackBox from './FeedbackBox'; import FeedbackBox from './FeedbackBox';
// 导入新建笔记模态框组件
import NewNoteModal from '@src/components/modals/NewNoteModal'; import NewNoteModal from '@src/components/modals/NewNoteModal';
// 导入推荐内容组件
import Recommendations from './Recommendations'; import Recommendations from './Recommendations';
// 导入搜索相关组件
import Search from '@src/components/modals/Search'; import Search from '@src/components/modals/Search';
import SearchInput from '../Header/SearchInput'; import SearchInput from '../Header/SearchInput';
// 导入侧边栏菜单链接组件
import SidebarMenuLink from './SidebarMenuLink'; import SidebarMenuLink from './SidebarMenuLink';
import {Button, Dialog, DialogContent, DialogTrigger, LucideIcon} from '@tryghost/shade'; // 导入UI组件按钮、对话框及相关组件、Lucide图标库
import {useCurrentUser} from '@tryghost/admin-x-framework/api/currentUser'; import { Button, Dialog, DialogContent, DialogTrigger, LucideIcon } from '@tryghost/shade';
import {useFeatureFlags} from '@src/lib/feature-flags'; // 导入获取当前用户信息的钩子
import {useLocation} from '@tryghost/admin-x-framework'; import { useCurrentUser } from '@tryghost/admin-x-framework/api/currentUser';
import {useNotificationsCountForUser, useResetNotificationsCountForUser} from '@src/hooks/use-activity-pub-queries'; // 导入特性标志相关钩子
import { useFeatureFlags } from '@src/lib/feature-flags';
// 导入路由位置钩子
import { useLocation } from '@tryghost/admin-x-framework';
// 导入通知计数相关钩子
import { useNotificationsCountForUser, useResetNotificationsCountForUser } from '@src/hooks/use-activity-pub-queries';
// 侧边栏组件属性接口
interface SidebarProps { interface SidebarProps {
isMobileSidebarOpen: boolean; isMobileSidebarOpen: boolean; // 移动端侧边栏是否打开
onCloseMobileSidebar: () => void; onCloseMobileSidebar: () => void; // 关闭移动端侧边栏的回调(注:组件内未使用,可能为预留)
} }
const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => { /**
const {allFlags, flags} = useFeatureFlags(); *
const [isSearchOpen, setIsSearchOpen] = React.useState(false); *
const [searchQuery, setSearchQuery] = React.useState(''); */
const {data: currentUser} = useCurrentUser(); const Sidebar: React.FC<SidebarProps> = ({ isMobileSidebarOpen }) => {
// 获取特性标志(用于功能开关控制)
const { allFlags, flags } = useFeatureFlags();
// 搜索相关状态
const [isSearchOpen, setIsSearchOpen] = React.useState(false); // 搜索对话框是否打开
const [searchQuery, setSearchQuery] = React.useState(''); // 搜索关键词
// 获取当前用户信息
const { data: currentUser } = useCurrentUser();
// 获取当前路由位置
const location = useLocation(); const location = useLocation();
const {data: notificationsCount} = useNotificationsCountForUser(currentUser?.slug || ''); // 获取通知计数
const { data: notificationsCount } = useNotificationsCountForUser(currentUser?.slug || '');
// 重置通知计数的方法
const resetNotificationsCount = useResetNotificationsCountForUser(currentUser?.slug || ''); const resetNotificationsCount = useResetNotificationsCountForUser(currentUser?.slug || '');
// Reset count when on notifications page /**
*
*
*/
React.useEffect(() => { React.useEffect(() => {
if (location.pathname === '/notifications' && notificationsCount && notificationsCount > 0) { if (location.pathname === '/notifications' && notificationsCount && notificationsCount > 0) {
resetNotificationsCount.mutate(); resetNotificationsCount.mutate();
} }
}, [location.pathname, notificationsCount, resetNotificationsCount]); }, [location.pathname, notificationsCount, resetNotificationsCount]);
/**
*
*
* 使useCallback
*/
const handleNotificationsClick = React.useCallback(() => { const handleNotificationsClick = React.useCallback(() => {
if (notificationsCount && notificationsCount > 0) { if (notificationsCount && notificationsCount > 0) {
resetNotificationsCount.mutate(); resetNotificationsCount.mutate();
@ -39,21 +68,29 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
}, [notificationsCount, resetNotificationsCount]); }, [notificationsCount, resetNotificationsCount]);
return ( return (
<div className={`sticky top-0 flex min-h-screen w-[320px] flex-col border-l border-gray-200 pr-8 transition-transform duration-300 ease-in-out max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-50 max-lg:border-0 max-lg:bg-white max-lg:shadow-xl max-md:bottom-[72px] max-md:min-h-[auto] max-md:overflow-y-scroll dark:border-gray-950 max-lg:dark:bg-black ${ // 侧边栏容器,根据设备尺寸适配样式
isMobileSidebarOpen ? 'max-lg:translate-x-0' : 'max-lg:translate-x-full' <div className={`sticky top-0 flex min-h-screen w-[320px] flex-col border-l border-gray-200 pr-8 transition-transform duration-300 ease-in-out
}`}> max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-50 max-lg:border-0 max-lg:bg-white max-lg:shadow-xl
max-md:bottom-[72px] max-md:min-h-[auto] max-md:overflow-y-scroll
dark:border-gray-950 max-lg:dark:bg-black
${isMobileSidebarOpen ? 'max-lg:translate-x-0' : 'max-lg:translate-x-full'}`}>
<div className='flex grow flex-col justify-between'> <div className='flex grow flex-col justify-between'>
{/* 侧边栏主要内容区域 */}
<div className='isolate flex w-full flex-col items-start gap-6 pl-6 pt-6'> <div className='isolate flex w-full flex-col items-start gap-6 pl-6 pt-6'>
{/* 搜索框区域 */}
<div className='flex h-[52px] w-full items-center'> <div className='flex h-[52px] w-full items-center'>
<Dialog open={isSearchOpen} onOpenChange={setIsSearchOpen}> <Dialog open={isSearchOpen} onOpenChange={setIsSearchOpen}>
<DialogTrigger className='w-full'> <DialogTrigger className='w-full'>
<SearchInput /> <SearchInput /> {/* 搜索输入框 */}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
{/* 搜索结果展示组件 */}
<Search query={searchQuery} setQuery={setSearchQuery} onOpenChange={setIsSearchOpen} /> <Search query={searchQuery} setQuery={setSearchQuery} onOpenChange={setIsSearchOpen} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
{/* 导航菜单链接列表 */}
<div className='flex w-full flex-col gap-px'> <div className='flex w-full flex-col gap-px'>
<SidebarMenuLink to='/reader'> <SidebarMenuLink to='/reader'>
<LucideIcon.BookOpen size={18} strokeWidth={1.5} /> <LucideIcon.BookOpen size={18} strokeWidth={1.5} />
@ -64,7 +101,7 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
Notes Notes
</SidebarMenuLink> </SidebarMenuLink>
<SidebarMenuLink <SidebarMenuLink
count={location.pathname !== '/notifications' ? notificationsCount : undefined} count={location.pathname !== '/notifications' ? notificationsCount : undefined} // 非通知页显示未读计数
to='/notifications' to='/notifications'
onClick={handleNotificationsClick} onClick={handleNotificationsClick}
> >
@ -84,6 +121,8 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
Preferences Preferences
</SidebarMenuLink> </SidebarMenuLink>
</div> </div>
{/* 新建笔记按钮(包裹在模态框组件中) */}
<NewNoteModal> <NewNoteModal>
<Button className='h-9 rounded-full bg-purple-500 px-3 text-md text-white hover:bg-purple-600 dark:hover:bg-purple-600'> <Button className='h-9 rounded-full bg-purple-500 px-3 text-md text-white hover:bg-purple-600 dark:hover:bg-purple-600'>
<LucideIcon.FilePen /> <LucideIcon.FilePen />
@ -91,8 +130,10 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
</Button> </Button>
</NewNoteModal> </NewNoteModal>
{/* 推荐内容区域 */}
<Recommendations /> <Recommendations />
{/* 特性标志展示(仅显示开启的标志,用于开发/调试) */}
{allFlags.map((flag) => { {allFlags.map((flag) => {
if (flags[flag]) { if (flags[flag]) {
return ( return (
@ -104,9 +145,11 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
</div> </div>
); );
} }
return (<></>); return null;
})} })}
</div> </div>
{/* 底部反馈框 */}
<div className='sticky bottom-0 flex items-center gap-2 bg-white pb-4 pl-4 dark:bg-black'> <div className='sticky bottom-0 flex items-center gap-2 bg-white pb-4 pl-4 dark:bg-black'>
<FeedbackBox /> <FeedbackBox />
</div> </div>
@ -115,6 +158,7 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
); );
}; };
// 设置组件显示名称(便于调试)
Sidebar.displayName = 'Sidebar'; Sidebar.displayName = 'Sidebar';
export default Sidebar; export default Sidebar;

@ -1,52 +1,68 @@
import * as React from 'react'; import * as React from 'react';
import {Button, ButtonProps, cn, formatNumber} from '@tryghost/shade'; // 导入UI组件及工具函数按钮组件、按钮属性类型、类名组合函数、数字格式化函数
import {Link, resetScrollPosition, useLocation, useNavigationStack} from '@tryghost/admin-x-framework'; import { Button, ButtonProps, cn, formatNumber } from '@tryghost/shade';
// 导入路由相关工具:链接组件、滚动位置重置函数、路由位置钩子、导航栈钩子
import { Link, resetScrollPosition, useLocation, useNavigationStack } from '@tryghost/admin-x-framework';
// 侧边栏菜单链接组件的属性接口,继承按钮属性并扩展路由和计数相关属性
interface SidebarButtonProps extends ButtonProps { interface SidebarButtonProps extends ButtonProps {
to?: string; to?: string; // 路由路径(可选,用于导航链接)
children: React.ReactNode; children: React.ReactNode; // 子元素(图标和文本)
count?: number; count?: number; // 数量标记(可选,如未读通知数)
} }
/**
*
*
*/
const SidebarMenuLink = React.forwardRef<HTMLButtonElement, SidebarButtonProps>( const SidebarMenuLink = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
({to, children, count, ...props}, ref) => { ({ to, children, count, ...props }, ref) => {
const location = useLocation(); const location = useLocation(); // 获取当前路由位置
const {resetStack} = useNavigationStack(); const { resetStack } = useNavigationStack(); // 获取重置导航栈的方法
// 组合链接的CSS类名
const linkClass = cn( const linkClass = cn(
// 基础样式:左对齐、中等文本大小、中等字重、默认文本色、悬停背景(深色模式)
'justify-start text-md font-medium text-gray-800 dark:hover:bg-gray-925/70 dark:text-gray-500 h-9 [&_svg]:size-[18px]', 'justify-start text-md font-medium text-gray-800 dark:hover:bg-gray-925/70 dark:text-gray-500 h-9 [&_svg]:size-[18px]',
// 当前页面高亮样式:背景色、文本色加深(深色模式适配)
(to && location.pathname === to) && 'bg-gray-100 dark:bg-gray-925/70 dark:text-white text-black font-semibold' (to && location.pathname === to) && 'bg-gray-100 dark:bg-gray-925/70 dark:text-white text-black font-semibold'
); );
// 数量标记徽章当有数量且大于0时显示
const badge = count && count > 0 ? ( const badge = count && count > 0 ? (
<span className={cn( <span className={cn(
'ml-auto bg-purple-500 text-white text-xs font-semibold py-1 px-1.5 rounded-full min-w-[20px] h-5 flex items-center justify-center' 'ml-auto bg-purple-500 text-white text-xs font-semibold py-1 px-1.5 rounded-full min-w-[20px] h-5 flex items-center justify-center'
)}> )}>
{formatNumber(count)} {formatNumber(count)} {/* 格式化数字显示如1000→1k */}
</span> </span>
) : null; ) : null;
// 当提供路由路径时,渲染为链接组件
if (to) { if (to) {
return ( return (
<Button className={linkClass} variant='ghost' asChild> <Button className={linkClass} variant='ghost' asChild>
<Link to={to} onClick={() => { <Link
resetStack(); to={to}
resetScrollPosition(to); onClick={() => {
}}> resetStack(); // 重置导航栈(清除历史记录)
{children} resetScrollPosition(to); // 跳转到目标路由时重置滚动位置
{badge} }}
>
{children} {/* 显示图标和文本 */}
{badge} {/* 显示数量徽章(如有) */}
</Link> </Link>
</Button> </Button>
); );
} }
// 未提供路由路径时,渲染为普通按钮
return ( return (
<Button <Button
ref={ref} ref={ref} // 转发ref到按钮元素
className={linkClass} className={linkClass}
variant='ghost' variant='ghost'
onClick={props.onClick} onClick={props.onClick} // 传递点击事件
{...props} {...props} // 传递其他按钮属性
> >
{children} {children}
{badge} {badge}
@ -55,6 +71,7 @@ const SidebarMenuLink = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
} }
); );
// 设置组件显示名称便于React DevTools识别
SidebarMenuLink.displayName = 'SidebarMenuLink'; SidebarMenuLink.displayName = 'SidebarMenuLink';
export default SidebarMenuLink; export default SidebarMenuLink;

@ -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}

@ -1,39 +1,64 @@
/**
* Admin Tags Page
*
* Responsibilities:
* - Fetch tags using useBrowseTags and the current visibility filter (public/internal)
* - Render loading, error, empty and list states
* - Wire list pagination (infinite) into TagsList
*
*
* `useBrowseTags` tags API
* URL `type` public/internal
* /// `TagsList`
*/
import React from 'react'; import React from 'react';
import TagsContent from './components/TagsContent'; import TagsContent from './components/TagsContent';
import TagsHeader from './components/TagsHeader'; import TagsHeader from './components/TagsHeader';
import TagsLayout from './components/TagsLayout'; import TagsLayout from './components/TagsLayout';
import TagsList from './components/TagsList'; import TagsList from './components/TagsList';
import {Button, LoadingIndicator, LucideIcon} from '@tryghost/shade'; import { Button, LoadingIndicator, LucideIcon } from '@tryghost/shade';
import {useBrowseTags} from '@tryghost/admin-x-framework/api/tags'; import { useBrowseTags } from '@tryghost/admin-x-framework/api/tags';
import {useLocation} from '@tryghost/admin-x-framework'; import { useLocation } from '@tryghost/admin-x-framework';
/**
*
*
*/
const Tags: React.FC = () => { const Tags: React.FC = () => {
const {search} = useLocation(); // 获取当前URL的查询参数用于确定标签筛选类型
const { search } = useLocation();
const qs = new URLSearchParams(search); const qs = new URLSearchParams(search);
// 从查询参数中获取标签类型(默认为'public'公开标签)
const type = qs.get('type') ?? 'public'; const type = qs.get('type') ?? 'public';
// 调用标签浏览API根据可见性类型筛选标签
const { const {
data, data, // 接口返回的标签数据
isError, isError, // 接口请求是否出错
isLoading, isLoading, // 接口是否正在加载(首次)
isFetchingNextPage, isFetchingNextPage, // 是否正在加载下一页数据
fetchNextPage, fetchNextPage, // 加载下一页数据的函数
hasNextPage hasNextPage // 是否有下一页数据
} = useBrowseTags({ } = useBrowseTags({
filter: { filter: {
visibility: type visibility: type // 筛选条件:按可见性类型
} }
}); });
return ( return (
<TagsLayout> <TagsLayout>
{/* 标签页面头部,显示当前选中的标签类型标签 */}
<TagsHeader currentTab={type} /> <TagsHeader currentTab={type} />
{/* 标签内容区域 */}
<TagsContent> <TagsContent>
{/* 加载状态:显示加载指示器 */}
{isLoading ? ( {isLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<LoadingIndicator size="lg" /> <LoadingIndicator size="lg" />
</div> </div>
) : isError ? ( ) : isError ? (
{/* 错误状态:显示错误信息和重试按钮 */}
<div className="mb-16 flex h-full flex-col items-center justify-center"> <div className="mb-16 flex h-full flex-col items-center justify-center">
<h2 className="mb-2 text-xl font-medium"> <h2 className="mb-2 text-xl font-medium">
Error loading tags Error loading tags
@ -46,6 +71,7 @@ const Tags: React.FC = () => {
</Button> </Button>
</div> </div>
) : !data?.tags.length ? ( ) : !data?.tags.length ? (
{/* 空状态:当没有标签时显示引导创建标签 */}
<div className="mb-16 flex h-full flex-col items-center justify-center gap-8"> <div className="mb-16 flex h-full flex-col items-center justify-center gap-8">
<LucideIcon.Tags className="-mb-4 size-16 text-muted-foreground" strokeWidth={1} /> <LucideIcon.Tags className="-mb-4 size-16 text-muted-foreground" strokeWidth={1} />
<h2 className="text-xl font-medium"> <h2 className="text-xl font-medium">
@ -56,12 +82,13 @@ const Tags: React.FC = () => {
</Button> </Button>
</div> </div>
) : ( ) : (
{/* 列表状态:显示标签列表,支持分页加载 */}
<TagsList <TagsList
fetchNextPage={fetchNextPage} fetchNextPage={fetchNextPage} // 加载下一页的回调
hasNextPage={hasNextPage} hasNextPage={hasNextPage} // 是否有更多数据
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage} // 是否正在加载下一页
items={data?.tags ?? []} items={data?.tags ?? []} // 标签数据列表
totalItems={data?.meta?.pagination?.total ?? 0} totalItems={data?.meta?.pagination?.total ?? 0} // 总标签数量
/> />
)} )}
</TagsContent> </TagsContent>
@ -69,4 +96,4 @@ const Tags: React.FC = () => {
); );
}; };
export default Tags; export default Tags;

@ -1,3 +1,5 @@
// TagsHeader: top navigation for the tags admin page
// - shows public/internal tab selection and New tag action
import React from 'react'; import React from 'react';
import {Button, Header, PageMenu, PageMenuItem} from '@tryghost/shade'; import {Button, Header, PageMenu, PageMenuItem} from '@tryghost/shade';
import {Link} from '@tryghost/admin-x-framework'; import {Link} from '@tryghost/admin-x-framework';

@ -1,3 +1,11 @@
/**
* TagsList component
*
* Responsibilities:
* - Render a virtualized/infinite-scrolling table of tags
+ * - Show placeholders while items are being fetched
+ * - Render each tag row with name, slug, post count and edit action
+ */
import { import {
Button, Button,
LucideIcon, LucideIcon,
@ -9,16 +17,26 @@ import {
TableRow, TableRow,
formatNumber formatNumber
} from '@tryghost/shade'; } from '@tryghost/shade';
import {Tag} from '@tryghost/admin-x-framework/api/tags'; import { Tag } from '@tryghost/admin-x-framework/api/tags';
import {forwardRef, useRef} from 'react'; import { forwardRef, useRef } from 'react';
import {useInfiniteVirtualScroll} from './VirtualTable/useInfiniteVirtualScroll'; import { useInfiniteVirtualScroll } from './VirtualTable/useInfiniteVirtualScroll';
const SpacerRow = ({height}: { height: number }) => ( /**
*
* @param {Object} props -
* @param {number} props.height -
* @returns {JSX.Element}
*/
const SpacerRow = ({ height }: { height: number }) => (
<tr className="flex lg:table-row"> <tr className="flex lg:table-row">
<td className="flex lg:table-cell" style={{height}} /> <td className="flex lg:table-cell" style={{ height }} />
</tr> </tr>
); );
/**
*
* TODO: React 19forwardRef
*/
// TODO: Remove forwardRef once we have upgraded to React 19 // TODO: Remove forwardRef once we have upgraded to React 19
const PlaceholderRow = forwardRef<HTMLTableRowElement>(function PlaceholderRow( const PlaceholderRow = forwardRef<HTMLTableRowElement>(function PlaceholderRow(
props, props,
@ -37,6 +55,16 @@ const PlaceholderRow = forwardRef<HTMLTableRowElement>(function PlaceholderRow(
); );
}); });
/**
*
* @param {Object} props -
* @param {Tag[]} props.items -
* @param {number} props.totalItems -
* @param {boolean} [props.hasNextPage] -
* @param {boolean} [props.isFetchingNextPage] -
* @param {() => void} props.fetchNextPage -
* @returns {JSX.Element}
*/
function TagsList({ function TagsList({
items, items,
totalItems, totalItems,
@ -50,8 +78,11 @@ function TagsList({
isFetchingNextPage?: boolean; isFetchingNextPage?: boolean;
fetchNextPage: () => void; fetchNextPage: () => void;
}) { }) {
// 父容器引用,用于计算虚拟滚动区域
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const {visibleItems, spaceBefore, spaceAfter} = useInfiniteVirtualScroll({
// 调用虚拟滚动钩子,获取可见项和间隔高度
const { visibleItems, spaceBefore, spaceAfter } = useInfiniteVirtualScroll({
items, items,
totalItems, totalItems,
hasNextPage, hasNextPage,
@ -63,6 +94,7 @@ function TagsList({
return ( return (
<div ref={parentRef}> <div ref={parentRef}>
<Table className="flex table-fixed flex-col lg:table"> <Table className="flex table-fixed flex-col lg:table">
{/* 桌面端表头 */}
<TableHeader className="hidden lg:!visible lg:!table-header-group"> <TableHeader className="hidden lg:!visible lg:!table-header-group">
<TableRow> <TableRow>
<TableHead className="w-auto px-4"> <TableHead className="w-auto px-4">
@ -75,9 +107,15 @@ function TagsList({
<TableHead className="w-20 px-4"></TableHead> <TableHead className="w-20 px-4"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
{/* 表格内容区域 */}
<TableBody className="flex flex-col lg:table-row-group"> <TableBody className="flex flex-col lg:table-row-group">
{/* 顶部空白间隔(用于虚拟滚动定位) */}
<SpacerRow height={spaceBefore} /> <SpacerRow height={spaceBefore} />
{visibleItems.map(({key, virtualItem, item, props}) => {
{/* 渲染可见的标签行 */}
{visibleItems.map(({ key, virtualItem, item, props }) => {
// 判断是否需要渲染占位行(数据尚未加载时)
const shouldRenderPlaceholder = const shouldRenderPlaceholder =
virtualItem.index > items.length - 1; virtualItem.index > items.length - 1;
@ -85,12 +123,14 @@ function TagsList({
return <PlaceholderRow key={key} {...props} />; return <PlaceholderRow key={key} {...props} />;
} }
// 渲染实际标签行(响应式布局)
return ( return (
<TableRow <TableRow
key={key} key={key}
{...props} {...props}
className="relative grid w-full grid-cols-[1fr_5rem] items-center gap-x-4 p-2 md:grid-cols-[1fr_auto_5rem] lg:table-row lg:p-0" className="relative grid w-full grid-cols-[1fr_5rem] items-center gap-x-4 p-2 md:grid-cols-[1fr_auto_5rem] lg:table-row lg:p-0"
> >
{/* 标签名称和描述(移动端占满一行,桌面端占多列) */}
<TableCell className="static col-start-1 col-end-1 row-start-1 row-end-1 flex min-w-0 flex-col p-0 lg:table-cell lg:w-1/2 lg:p-4 xl:w-3/5"> <TableCell className="static col-start-1 col-end-1 row-start-1 row-end-1 flex min-w-0 flex-col p-0 lg:table-cell lg:w-1/2 lg:p-4 xl:w-3/5">
<a <a
className="block truncate pb-1 text-lg font-medium before:absolute before:inset-0 before:z-10" className="block truncate pb-1 text-lg font-medium before:absolute before:inset-0 before:z-10"
@ -102,11 +142,15 @@ function TagsList({
{item.description} {item.description}
</span> </span>
</TableCell> </TableCell>
{/* 标签Slug移动端第二行 */}
<TableCell className="col-start-1 col-end-1 row-start-2 row-end-2 flex p-0 lg:table-cell lg:p-4"> <TableCell className="col-start-1 col-end-1 row-start-2 row-end-2 flex p-0 lg:table-cell lg:p-4">
<span className="block truncate"> <span className="block truncate">
{item.slug} {item.slug}
</span> </span>
</TableCell> </TableCell>
{/* 关联文章数量(移动端第三行,桌面端单独列) */}
<TableCell className="col-start-1 col-end-1 row-start-3 row-end-3 flex p-0 md:col-start-2 md:col-end-2 md:row-start-1 md:row-end-3 lg:table-cell lg:p-4"> <TableCell className="col-start-1 col-end-1 row-start-3 row-end-3 flex p-0 md:col-start-2 md:col-end-2 md:row-start-1 md:row-end-3 lg:table-cell lg:p-4">
{item.count?.posts ? ( {item.count?.posts ? (
<a <a
@ -121,6 +165,8 @@ function TagsList({
</span> </span>
)} )}
</TableCell> </TableCell>
{/* 编辑按钮(移动端右上角,桌面端最后一列) */}
<TableCell className="col-start-2 col-end-2 row-start-1 row-end-3 p-0 md:col-start-3 md:col-end-3 lg:table-cell lg:p-4"> <TableCell className="col-start-2 col-end-2 row-start-1 row-end-3 p-0 md:col-start-3 md:col-end-3 lg:table-cell lg:p-4">
<Button <Button
aria-hidden="true" aria-hidden="true"
@ -135,6 +181,8 @@ function TagsList({
</TableRow> </TableRow>
); );
})} })}
{/* 底部空白间隔(用于虚拟滚动定位) */}
<SpacerRow height={spaceAfter} /> <SpacerRow height={spaceAfter} />
</TableBody> </TableBody>
</Table> </Table>
@ -142,4 +190,4 @@ function TagsList({
); );
} }
export default TagsList; export default TagsList;

@ -5,54 +5,74 @@ import PopupModal from './components/PopupModal';
import SearchIndex from './search-index.js'; import SearchIndex from './search-index.js';
import i18nLib from '@tryghost/i18n'; import i18nLib from '@tryghost/i18n';
/**
* 搜索应用的主组件 - 负责管理整个搜索功能的布局和状态
*/
export default class App extends React.Component { export default class App extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
// ==================== 国际化设置 ====================
// 设置语言环境,默认为英语
const i18nLanguage = this.props.locale || 'en'; const i18nLanguage = this.props.locale || 'en';
const i18n = i18nLib(i18nLanguage, 'search'); const i18n = i18nLib(i18nLanguage, 'search');
const dir = i18n.dir() || 'ltr'; const dir = i18n.dir() || 'ltr'; // 文字方向(左到右或右到左)
// ==================== 搜索索引初始化 ====================
// 创建搜索索引实例,用于处理搜索逻辑
const searchIndex = new SearchIndex({ const searchIndex = new SearchIndex({
adminUrl: props.adminUrl, adminUrl: props.adminUrl, // 管理后台URL
apiKey: props.apiKey, apiKey: props.apiKey, // API密钥
dir: dir dir: dir // 文字方向
}); });
// ==================== 组件状态初始化 ====================
this.state = { this.state = {
searchIndex, searchIndex, // 搜索索引实例
showPopup: false, showPopup: false, // 是否显示搜索弹窗
indexStarted: false, indexStarted: false, // 是否开始构建索引
indexComplete: false, indexComplete: false, // 索引构建是否完成
t: i18n.t, t: i18n.t, // 国际化翻译函数
dir: dir, dir: dir, // 文字方向
scrollbarWidth: 0 scrollbarWidth: 0 // 滚动条宽度(用于防止布局偏移)
}; };
// 创建输入框的引用用于直接操作DOM元素
this.inputRef = React.createRef(); this.inputRef = React.createRef();
} }
/**
* 组件挂载后立即执行
*/
componentDidMount() { componentDidMount() {
// 计算并保存滚动条宽度
const scrollbarWidth = this.getScrollbarWidth(); const scrollbarWidth = this.getScrollbarWidth();
this.setState({scrollbarWidth}); this.setState({scrollbarWidth});
// 初始化搜索设置
this.initSetup(); this.initSetup();
} }
/**
* 组件更新时执行主要用于处理弹窗显示/隐藏时的滚动条逻辑
*/
componentDidUpdate(_prevProps, prevState) { componentDidUpdate(_prevProps, prevState) {
// ==================== 弹窗显示状态变化处理 ====================
if (prevState.showPopup !== this.state.showPopup) { if (prevState.showPopup !== this.state.showPopup) {
/** Remove background scroll when popup is opened */ /** 当弹窗打开时移除背景滚动,防止页面抖动 */
try { try {
if (this.state.showPopup) { if (this.state.showPopup) {
/** When modal is opened, store current overflow and set as hidden */ /** 当弹窗打开时保存当前的overflow状态并设置为hidden */
this.bodyScroll = window.document?.body?.style?.overflow; this.bodyScroll = window.document?.body?.style?.overflow;
this.bodyMargin = window.getComputedStyle(document.body).getPropertyValue('margin-right'); this.bodyMargin = window.getComputedStyle(document.body).getPropertyValue('margin-right');
window.document.body.style.overflow = 'hidden'; window.document.body.style.overflow = 'hidden';
// 如果有滚动条且内容高度超过视口,调整右边距防止布局偏移
if (this.state.scrollbarWidth && document.body.scrollHeight > window.innerHeight) { if (this.state.scrollbarWidth && document.body.scrollHeight > window.innerHeight) {
window.document.body.style.marginRight = `calc(${this.bodyMargin} + ${this.state.scrollbarWidth}px)`; window.document.body.style.marginRight = `calc(${this.bodyMargin} + ${this.state.scrollbarWidth}px)`;
} }
} else { } else {
/** When the modal is hidden, reset overflow property for body */ /** 当弹窗隐藏时恢复body的overflow属性 */
window.document.body.style.overflow = this.bodyScroll || ''; window.document.body.style.overflow = this.bodyScroll || '';
if (!this.bodyMargin || this.bodyMargin === '0px') { if (!this.bodyMargin || this.bodyMargin === '0px') {
window.document.body.style.marginRight = ''; window.document.body.style.marginRight = '';
@ -61,74 +81,93 @@ export default class App extends React.Component {
} }
} }
} catch (e) { } catch (e) {
/** Ignore any errors for scroll handling */ /** 忽略滚动处理中的任何错误 */
} }
} }
// ==================== 弹窗关闭时清空搜索值 ====================
if (this.state.showPopup !== prevState?.showPopup && !this.state.showPopup) { if (this.state.showPopup !== prevState?.showPopup && !this.state.showPopup) {
this.setState({ this.setState({
searchValue: '' searchValue: ''
}); });
} }
// ==================== 弹窗打开时初始化搜索索引 ====================
if (this.state.showPopup && !this.state.indexStarted) { if (this.state.showPopup && !this.state.indexStarted) {
this.setupSearchIndex(); this.setupSearchIndex();
} }
} }
/**
* 设置搜索索引 - 异步初始化搜索功能
*/
async setupSearchIndex() { async setupSearchIndex() {
this.setState({ this.setState({
indexStarted: true indexStarted: true // 标记索引构建开始
}); });
await this.state.searchIndex.init(); await this.state.searchIndex.init(); // 等待索引初始化完成
this.setState({ this.setState({
indexComplete: true indexComplete: true // 标记索引构建完成
}); });
} }
/**
* 组件卸载前的清理工作
*/
componentWillUnmount() { componentWillUnmount() {
/**Clear timeouts and event listeners on unmount */ /** 清除超时和事件监听器 */
window.removeEventListener('hashchange', this.hashHandler, false); window.removeEventListener('hashchange', this.hashHandler, false);
window.removeEventListener('keydown', this.handleKeyDown, false); window.removeEventListener('keydown', this.handleKeyDown, false);
} }
/**
* 初始化搜索设置
*/
initSetup() { initSetup() {
// Listen to preview mode changes // 监听预览模式变化
this.handleSearchUrl(); this.handleSearchUrl();
this.addKeyboardShortcuts(); this.addKeyboardShortcuts();
this.setupCustomTriggerButton(); this.setupCustomTriggerButton();
// 哈希变化处理器用于URL路由
this.hashHandler = () => { this.hashHandler = () => {
this.handleSearchUrl(); this.handleSearchUrl();
}; };
window.addEventListener('hashchange', this.hashHandler, false); window.addEventListener('hashchange', this.hashHandler, false);
} }
// User for adding trailing margin to prevent layout shift when popup appears /**
* 计算滚动条宽度 - 用于防止弹窗出现时的布局偏移
*/
getScrollbarWidth() { getScrollbarWidth() {
// Create a temporary div // 创建临时div元素
const div = document.createElement('div'); const div = document.createElement('div');
div.style.visibility = 'hidden'; div.style.visibility = 'hidden';
div.style.overflow = 'scroll'; // forcing scrollbar to appear div.style.overflow = 'scroll'; // 强制显示滚动条
document.body.appendChild(div); document.body.appendChild(div);
// Calculate the width difference // 计算宽度差异(这就是滚动条的宽度)
const scrollbarWidth = div.offsetWidth - div.clientWidth; const scrollbarWidth = div.offsetWidth - div.clientWidth;
// Clean up // 清理临时元素
document.body.removeChild(div); document.body.removeChild(div);
return scrollbarWidth; return scrollbarWidth;
} }
/** Setup custom trigger buttons handling on page */ /**
* 设置页面上的自定义触发按钮处理
*/
setupCustomTriggerButton() { setupCustomTriggerButton() {
// Handler for custom buttons // 自定义按钮的点击处理器
this.clickHandler = (event) => { this.clickHandler = (event) => {
event.preventDefault(); event.preventDefault();
this.setState({ this.setState({
showPopup: true showPopup: true // 显示搜索弹窗
}); });
// ==================== 焦点管理技巧 ====================
// 创建临时输入元素来解决焦点问题
const tmpElement = document.createElement('input'); const tmpElement = document.createElement('input');
tmpElement.style.opacity = '0'; tmpElement.style.opacity = '0';
tmpElement.style.position = 'fixed'; tmpElement.style.position = 'fixed';
@ -136,12 +175,14 @@ export default class App extends React.Component {
document.body.appendChild(tmpElement); document.body.appendChild(tmpElement);
tmpElement.focus(); tmpElement.focus();
// 延迟后将焦点转移到真正的搜索输入框
setTimeout(() => { setTimeout(() => {
this.inputRef.current.focus(); this.inputRef.current.focus();
document.body.removeChild(tmpElement); document.body.removeChild(tmpElement);
}, 150); }, 150);
}; };
// 获取所有自定义触发按钮并添加点击事件
this.customTriggerButtons = this.getCustomTriggerButtons(); this.customTriggerButtons = this.getCustomTriggerButtons();
this.customTriggerButtons.forEach((customTriggerButton) => { this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.removeEventListener('click', this.clickHandler); customTriggerButton.removeEventListener('click', this.clickHandler);
@ -149,27 +190,39 @@ export default class App extends React.Component {
}); });
} }
/**
* 获取页面上的自定义触发按钮
*/
getCustomTriggerButtons() { getCustomTriggerButtons() {
const customTriggerSelector = '[data-ghost-search]'; const customTriggerSelector = '[data-ghost-search]'; // 通过data属性选择按钮
return document.querySelectorAll(customTriggerSelector) || []; return document.querySelectorAll(customTriggerSelector) || [];
} }
/**
* 处理搜索URL - 当URL哈希为/#search时自动打开搜索弹窗
*/
handleSearchUrl() { handleSearchUrl() {
const [path] = window.location.hash.substr(1).split('?'); const [path] = window.location.hash.substr(1).split('?');
if (path === '/search' || path === '/search/') { if (path === '/search' || path === '/search/') {
this.setState({ this.setState({
showPopup: true showPopup: true
}); });
// 替换历史状态,移除哈希部分
window.history.replaceState('', document.title, window.location.pathname); window.history.replaceState('', document.title, window.location.pathname);
} }
} }
/**
* 添加快捷键支持 - Cmd+K 打开搜索
*/
addKeyboardShortcuts() { addKeyboardShortcuts() {
const customTriggerButtons = this.getCustomTriggerButtons(); const customTriggerButtons = this.getCustomTriggerButtons();
if (!customTriggerButtons?.length) { if (!customTriggerButtons?.length) {
return; return;
} }
this.handleKeyDown = (e) => { this.handleKeyDown = (e) => {
// 检测 Cmd+K (Mac) 或 Ctrl+K (Windows)
if (e.key === 'k' && e.metaKey) { if (e.key === 'k' && e.metaKey) {
this.setState({ this.setState({
showPopup: true showPopup: true
@ -182,19 +235,26 @@ export default class App extends React.Component {
document.addEventListener('keydown', this.handleKeyDown); document.addEventListener('keydown', this.handleKeyDown);
} }
/**
* 渲染组件 - 这里是整个搜索应用的布局入口
*/
render() { render() {
return ( return (
/**
* 使用Context API提供全局状态给所有子组件
* 这是React中管理全局状态的常用模式
*/
<AppContext.Provider value={{ <AppContext.Provider value={{
page: 'search', page: 'search', // 当前页面标识
showPopup: this.state.showPopup, showPopup: this.state.showPopup, // 弹窗显示状态
adminUrl: this.props.adminUrl, adminUrl: this.props.adminUrl, // 管理后台URL
stylesUrl: this.props.stylesUrl, stylesUrl: this.props.stylesUrl, // 样式文件URL
searchIndex: this.state.searchIndex, searchIndex: this.state.searchIndex, // 搜索索引实例
indexComplete: this.state.indexComplete, indexComplete: this.state.indexComplete, // 索引完成状态
searchValue: this.state.searchValue, searchValue: this.state.searchValue, // 搜索关键词
inputRef: this.inputRef, inputRef: this.inputRef, // 输入框引用
onAction: () => {}, onAction: () => {}, // 动作处理器
dispatch: (action, data) => { dispatch: (action, data) => { // 状态更新函数
if (action === 'update') { if (action === 'update') {
this.setState({ this.setState({
...this.state, ...this.state,
@ -202,11 +262,12 @@ export default class App extends React.Component {
}); });
} }
}, },
t: this.state.t, t: this.state.t, // 翻译函数
dir: this.state.dir dir: this.state.dir // 文字方向
}}> }}>
{/* 渲染搜索弹窗组件 - 这是主要的UI组件 */}
<PopupModal /> <PopupModal />
</AppContext.Provider> </AppContext.Provider>
); );
} }
} }

@ -1,3 +1,11 @@
/*
* 全局应用上下文AppContext
*
* 中文说明
* - sodo-search 提供共享状态包括已加载的 posts/authors/tags 索引搜索关键字
* 以及 dispatch 方法用于触发 UI 状态改变比如打开/关闭弹窗
* - tags 列表会在 SearchIndex 初始化时被填充并用于弹窗内的 tag 搜索结果展示
*/
// Ref: https://reactjs.org/docs/context.html // Ref: https://reactjs.org/docs/context.html
import React from 'react'; import React from 'react';

@ -1,3 +1,12 @@
/*
* 搜索弹窗PopupModal组件
*
* 中文说明
* - 该文件实现站内搜索弹窗 UI用于搜索 poststagsauthors 等内容并展示结果
* - 当用户输入关键字时会使用内部的 SearchIndex `apps/sodo-search/src/search-index.js` 提供
* 来检索本地索引在初始化时会向 Ghost 内容 API 拉取 posts/authors/tags 并建立索引
* - 在结果中tags 的条目会被渲染为可点击项`TagListItem`点击会跳转到 tag.url
*/
import Frame from './Frame'; import Frame from './Frame';
import AppContext from '../AppContext'; import AppContext from '../AppContext';
import {ReactComponent as SearchIcon} from '../icons/search.svg'; import {ReactComponent as SearchIcon} from '../icons/search.svg';

@ -1,3 +1,12 @@
/*
* 搜索索引SearchIndex
*
* 中文说明
* - 使用 FlexSearch 在浏览器端建立 postsauthorstags 的索引以便快速检索
* - 在初始化时会向 Ghost 内容 API 拉取 posts/authors/tags使用 admin/content search-index endpoints
* 并把返回的数据加入对应的索引文档postsIndex/authorsIndex/tagsIndex
* - tags 的索引托管在 `tagsIndex`其文档字段只索引 `name`并且使用自定义编码器以支持 CJK 分词
*/
import Flexsearch, {Charset} from 'flexsearch'; import Flexsearch, {Charset} from 'flexsearch';
const cjkEncoderPresetCodepoint = { const cjkEncoderPresetCodepoint = {

@ -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,

@ -77,8 +77,14 @@ const Overview: React.FC = () => {
} }
}); });
/* Get visitors /* 访visitors
/* ---------------------------------------------------------------------- */ ----------------------------------------------------------------------
- `useTinybirdQuery` Tinybird stats 访
- 使 `sanitizeChartData` // range //
- `GhAreaChart` date, value, formattedValue, label
- visitorsChartData 访线
*/
const visitorsParams = { const visitorsParams = {
site_uuid: statsConfig?.id || '', site_uuid: statsConfig?.id || '',
date_from: formatQueryDate(startDate), date_from: formatQueryDate(startDate),
@ -94,7 +100,11 @@ const Overview: React.FC = () => {
}); });
const visitorsChartData = useMemo(() => { const visitorsChartData = useMemo(() => {
return sanitizeChartData<WebKpiDataItem>(visitorsData as WebKpiDataItem[] || [], range, 'visits' as keyof WebKpiDataItem, 'sum')?.map((item: WebKpiDataItem) => { // 先对原始数据进行清洗和聚合(按 range: day/week/month
const sanitized = sanitizeChartData<WebKpiDataItem>(visitorsData as WebKpiDataItem[] || [], range, 'visits' as keyof WebKpiDataItem, 'sum') || [];
// 将每项转换为 GhAreaChart 所需的字段格式
return sanitized.map((item: WebKpiDataItem) => {
const value = Number(item.visits); const value = Number(item.visits);
const safeValue = isNaN(value) ? 0 : value; const safeValue = isNaN(value) ? 0 : value;
return { return {

@ -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,51 +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;
// 页面中流量图表(折线图/柱状图)的容器定位器
// 私有属性,仅在类内部使用(通过方法暴露交互能力)
private readonly webGraph: Locator; private readonly webGraph: Locator;
// "热门内容"统计卡片的定位器(包含内容访问量排名)
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;
// "热门来源"统计卡片的定位器(显示流量来源渠道排名,如搜索引擎、外部链接等)
public readonly topSourcesCard: Locator; public readonly topSourcesCard: Locator;
/**
*
* @param page PlaywrightPage
*/
constructor(page: Page) { constructor(page: Page) {
// 调用父类构造函数传递page对象
super(page); super(page);
// 当前页面的URL路径用于页面导航或验证是否在目标页面
this.pageUrl = '/ghost/#/analytics/web'; this.pageUrl = '/ghost/#/analytics/web';
this.totalViewsTab = page.getByRole('tab', {name: 'Total views'}); // 初始化标签页定位器使用角色定位role="tab"),更符合页面语义和可访问性
this.totalUniqueVisitorsTab = page.getByRole('tab', {name: 'Unique visitors'}); this.totalViewsTab = page.getByRole('tab', { name: 'Total views' });
this.totalUniqueVisitorsTab = page.getByRole('tab', { name: 'Unique visitors' });
// 初始化图表定位器使用data-testid属性开发者为测试预留的标识定位更稳定
this.webGraph = page.getByTestId('web-graph'); this.webGraph = page.getByTestId('web-graph');
// 初始化热门内容卡片及内部标签:通过卡片容器定位子元素,缩小查找范围提高效率
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');
} }
/**
*
*
* @returns null
*/
async totalViewsContent() { async totalViewsContent() {
return await this.webGraph.textContent(); return await this.webGraph.textContent();
} }
/**
* "唯一访客"
*
* @returns null
*/
async totalUniqueVisitorsContent() { async totalUniqueVisitorsContent() {
return await this.totalUniqueVisitorsTab.textContent(); return await this.totalUniqueVisitorsTab.textContent();
} }
/**
* "总浏览量"
*
*/
async viewTotalViews() { async viewTotalViews() {
await this.totalViewsTab.click(); await this.totalViewsTab.click();
} }
/**
* "唯一访客"访
* 访
*/
async viewTotalUniqueVisitors() { async viewTotalUniqueVisitors() {
await this.totalUniqueVisitorsTab.click(); await this.totalUniqueVisitorsTab.click();
} }0 vcf
/**
* totalViewsContent
* 使return
*/
async viewWebGraphContent() { async viewWebGraphContent() {
await this.webGraph.textContent(); await this.webGraph.textContent();
} }

@ -1,17 +1,42 @@
import {Locator, Page} from '@playwright/test'; import { Locator, Page } from '@playwright/test';
import {AdminPage} from '../../AdminPage'; // 导入管理员页面基类,继承通用的管理员页面属性和方法(如页面导航、基础元素操作)
import { AdminPage } from '../../AdminPage';
/**
* PostAnalyticsGrowthPage
* Growth
* e2e
*/
export class PostAnalyticsGrowthPage extends AdminPage { export class PostAnalyticsGrowthPage extends AdminPage {
// 成员统计卡片的容器定位器
// 说明通过data-testid定位这是开发者为测试预留的标识定位稳定性高不易受UI变动影响
// 用途:用于获取成员相关统计信息(如免费成员数、付费成员数等)的容器元素
readonly membersCard: Locator; readonly membersCard: Locator;
// 成员卡片内的“View member”按钮定位器
// 说明在membersCard容器内通过角色button和名称View member定位确保按钮唯一
// 用途:点击后跳转到成员管理页面,查看该文章相关的成员详情
readonly viewMemberButton: Locator; readonly viewMemberButton: Locator;
// 流量来源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-card"的元素
this.membersCard = this.page.getByTestId('members-card'); this.membersCard = this.page.getByTestId('members-card');
this.viewMemberButton = this.membersCard.getByRole('button', {name: 'View member'});
// 初始化“View member”按钮定位器在成员卡片内查找角色为button、名称为View member的元素
this.viewMemberButton = this.membersCard.getByRole('button', { name: 'View member' });
// 初始化流量来源卡片定位器匹配data-testid="top-sources-card"的元素
this.topSourcesCard = this.page.getByTestId('top-sources-card'); this.topSourcesCard = this.page.getByTestId('top-sources-card');
} }
} }

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

@ -1,8 +1,60 @@
import {Page} from '@playwright/test'; import { Page } from '@playwright/test';
import {AdminPage} from '../../AdminPage'; // 导入管理员页面基类,继承管理员页面的通用属性和方法(如页面导航、基础元素等)
import { AdminPage } from '../../AdminPage';
/**
* POM
*
*
* - Web Traffic
* - 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当前为占位说明实际使用时需补充以下内容
// 1. 页面URL如有固定路由用于验证是否在目标页面或直接导航
// 示例this.pageUrl = '/ghost/#/analytics/post/123'; // 假设123为文章ID
//
// 2. 元素定位器根据页面实际DOM结构补充
// - 流量图表容器(如折线图/柱状图)
// - 总浏览量/唯一访客数等统计数据元素
// - 流量来源Top sources卡片及内部元素
// - 热门内容Top content相关元素
// - 时间范围切换控件如7天/30天/90天
//
// 示例定位器仅为参考需根据实际测试ID或属性调整
// this.trafficGraph = page.getByTestId('post-traffic-graph'); // 文章流量图表
// this.totalViewsStat = page.getByTestId('post-total-views'); // 总浏览量统计
// this.timeRangeSelector = page.getByRole('combobox', { name: 'Time range' }); // 时间范围选择器
// this.topSourcesList = page.getByTestId('post-top-sources-list'); // 流量来源列表
} }
}
/**
*
*
* 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,42 +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管理后台中“文章分析-增长”页面的测试集合
// test.describe用于将相关测试用例分组增强代码可读性和维护性
test.describe('Ghost Admin - Post Analytics - Growth', () => { test.describe('Ghost Admin - Post Analytics - 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();
// 3. 在概览页面中点击最新文章的“analytics”按钮
// latestPost是AnalyticsOverviewPage中定义的“最新文章”元素对象包含其专属的analyticsButton
await analyticsOverviewPage.latestPost.analyticsButton.click(); await analyticsOverviewPage.latestPost.analyticsButton.click();
// TODO: check post analytics component, we shouldn't need to wait on page load to be able to click growth link // TODO优化提示——未来可调整为无需等待页面完全加载即可点击growth链接提升测试效率
// 4. 实例化文章分析主页面对象(当前处于文章分析的概览视图)
const postAnalyticsPage = new PostAnalyticsPage(page); const postAnalyticsPage = new PostAnalyticsPage(page);
// 5. 等待文章分析页面加载完成(内部通过等待关键元素出现来确认页面就绪,避免操作过早导致失败)
await postAnalyticsPage.waitForPageLoad(); await postAnalyticsPage.waitForPageLoad();
// 6. 点击“Growth”按钮切换到增长数据视图进入当前测试套件的目标页面
await postAnalyticsPage.growthButton.click(); await postAnalyticsPage.growthButton.click();
}); });
test('empty members card', async ({page}) => { // 测试用例1验证空数据时成员卡片的显示内容
test('empty members card', async ({ page }) => {
// 实例化文章分析的增长页面对象(获取当前页面的元素操作能力)
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 断言1成员卡片应包含“Free members”文本验证标签显示正确
await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('Free members'); await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('Free members');
// 断言2成员卡片应包含“0”验证空数据场景下数量显示正确
await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('0'); await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('0');
}); });
test('empty members card - view member', async ({page}) => { // 测试用例2验证空成员场景下点击“查看成员”的跳转结果
test('empty members card - view member', async ({ page }) => {
// 实例化增长页面对象
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 点击增长页面上的“查看成员”按钮(模拟用户查看详情的操作)
await postAnalyticsPageGrowthPage.viewMemberButton.click(); await postAnalyticsPageGrowthPage.viewMemberButton.click();
// 实例化成员管理页面对象(跳转后的目标页面)
const membersPage = new MembersPage(page); const membersPage = new MembersPage(page);
// 断言:成员页面应显示“无成员匹配”的提示文本(验证空数据跳转后的状态正确)
await expect(membersPage.body).toContainText('No members match'); await expect(membersPage.body).toContainText('No members match');
}); });
test('empty top sources card', async ({page}) => { // 测试用例3验证Top sources卡片在无数据时的提示文本
test('empty top sources card', async ({ page }) => {
// 实例化增长页面对象
const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page);
// 断言顶部来源卡片应包含“No sources data available”验证无数据提示正确
await expect(postAnalyticsPageGrowthPage.topSourcesCard).toContainText('No sources data available'); await expect(postAnalyticsPageGrowthPage.topSourcesCard).toContainText('No sources data available');
}); });
}); });

@ -1,48 +1,80 @@
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';
/**
*
* Analytics Overview e2e
* /
*/
// 定义测试套件Ghost管理后台 - 文章分析 - Overview概览视图的测试
// 所有相关测试用例都归类在此套件下,便于管理和理解测试范围
test.describe('Ghost Admin - Post Analytics - Overview', () => { test.describe('Ghost Admin - Post Analytics - Overview', () => {
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();
// 3. 在概览页面中点击“最新文章”的analytics按钮进入该文章的分析面板
// (模拟用户从概览页查看单篇文章详情分析的操作流程)
await analyticsOverviewPage.latestPost.analyticsButton.click(); await analyticsOverviewPage.latestPost.analyticsButton.click();
}); });
test('empty page with all tabs', async ({page}) => { // 测试用例1验证概览页面存在三个主要选项卡确保页面结构完整性
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();
}); });
test('empty page - overview - web performance - view more', async ({page}) => { // 测试用例2验证从Overview的Web performance区块点击"View more"的跳转和空数据提示
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 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页面应显示指定提示文本验证空状态展示正确
await expect(postAnalyticsWebTrafficPage.body).toContainText('No visitors in the last 30 days'); await expect(postAnalyticsWebTrafficPage.body).toContainText('No visitors in the last 30 days');
}); });
test('empty page - overview - growth', async ({page}) => { // 测试用例3验证Overview视图中Growth区块的空数据展示成员数相关
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');
}); });
test('empty page - overview - growth - view more', async ({page}) => { // 测试用例4验证从Overview的Growth区块点击"View more"的跳转和空数据提示
test('empty page - overview - growth - view more', async ({ page }) => {
// 实例化文章分析主页面对象
const postAnalyticsPage = new PostAnalyticsPage(page); const postAnalyticsPage = new PostAnalyticsPage(page);
// 点击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卡片应显示指定提示文本验证空状态展示正确
await expect(postAnalyticsGrowthPage.topSourcesCard).toContainText('No sources data available'); await expect(postAnalyticsGrowthPage.topSourcesCard).toContainText('No sources data available');
}); });
}); });

@ -1,10 +1,27 @@
import ApplicationAdapter from 'ghost-admin/adapters/application'; import ApplicationAdapter from 'ghost-admin/adapters/application';
import SlugUrl from 'ghost-admin/utils/slug-url'; import SlugUrl from 'ghost-admin/utils/slug-url';
/**
* Label 适配器
* 继承自应用程序基础适配器用于处理标签(Label)模型与后端API的交互
* 主要扩展了URL构建逻辑支持基于slug参数生成URL
*/
export default class Label extends ApplicationAdapter { export default class Label extends ApplicationAdapter {
/**
* 重写基础适配器的URL构建方法
* 用于生成与标签相关的API请求URL并支持通过query参数中的slug生成友好URL
* @param {string} _modelName - 模型名称此处未使用保留参数位置
* @param {string|number} _id - 模型ID此处未使用保留参数位置
* @param {Object} _snapshot - 模型快照此处未使用保留参数位置
* @param {string} _requestType - 请求类型如findcreate等此处未使用
* @param {Object} query - 查询参数对象可能包含slug等信息
* @returns {string} 构建后的API请求URL
*/
buildURL(_modelName, _id, _snapshot, _requestType, query) { buildURL(_modelName, _id, _snapshot, _requestType, query) {
// 调用父类的buildURL方法生成基础URL
let url = super.buildURL(...arguments); let url = super.buildURL(...arguments);
// 使用SlugUrl工具处理URL结合query参数如slug生成最终URL
return SlugUrl(url, query); return SlugUrl(url, query);
} }
} }

@ -1,20 +1,36 @@
import ApplicationAdapter from 'ghost-admin/adapters/application'; import ApplicationAdapter from 'ghost-admin/adapters/application';
/**
* 代码片段Snippet适配器
* 继承自应用程序基础适配器用于处理代码片段模型与后端API的交互
* 核心功能是自动为API请求添加数据格式参数确保后端返回指定格式的内容
*/
export default class Snippet extends ApplicationAdapter { export default class Snippet extends ApplicationAdapter {
/**
* 重写URL构建方法自动添加格式参数
* 确保所有代码片段相关请求都包含formats=mobiledoc,lexical
* @param {...any} args - 传递给父类的参数模型名ID等
* @returns {string} 处理后的API请求URL
*/
buildURL() { buildURL() {
// 调用父类方法生成基础URL包含基础路径、模型路由等
const url = super.buildURL(...arguments); const url = super.buildURL(...arguments);
try { try {
// 解析URL以操作查询参数
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
// 检查是否已包含formats参数若没有则添加
if (!parsedUrl.searchParams.get('formats')) { if (!parsedUrl.searchParams.get('formats')) {
parsedUrl.searchParams.set('formats', 'mobiledoc,lexical'); parsedUrl.searchParams.set('formats', 'mobiledoc,lexical');
return parsedUrl.href; return parsedUrl.href; // 返回更新后的URL
} }
} catch (e) { } catch (e) {
// noop, just use the original url // URL解析失败时使用原始URL并记录错误
console.error('Couldn\'t parse URL', e); // eslint-disable-line console.error('Couldn\'t parse URL', e); // eslint-disable-line
} }
// 若已包含formats参数或解析失败返回原始URL
return url; return url;
} }
} }

@ -1,10 +1,27 @@
import ApplicationAdapter from 'ghost-admin/adapters/application'; import ApplicationAdapter from 'ghost-admin/adapters/application';
import SlugUrl from 'ghost-admin/utils/slug-url'; import SlugUrl from 'ghost-admin/utils/slug-url';
/**
* 标签Tag适配器
* 继承自应用程序基础适配器用于处理标签模型与后端API的交互
* 主要扩展了URL构建逻辑支持基于slug参数生成友好URL
*/
export default class Tag extends ApplicationAdapter { export default class Tag extends ApplicationAdapter {
/**
* 重写基础适配器的URL构建方法
* 用于生成与标签相关的API请求URL并支持通过query参数中的slug生成语义化URL
* @param {string} _modelName - 模型名称此处未使用保留参数位置
* @param {string|number} _id - 模型ID此处未使用保留参数位置
* @param {Object} _snapshot - 模型快照此处未使用保留参数位置
* @param {string} _requestType - 请求类型如findcreate等此处未使用
* @param {Object} query - 查询参数对象可能包含slug等信息
* @returns {string} 构建后的API请求URL
*/
buildURL(_modelName, _id, _snapshot, _requestType, query) { buildURL(_modelName, _id, _snapshot, _requestType, query) {
// 调用父类的buildURL方法生成基础URL
let url = super.buildURL(...arguments); let url = super.buildURL(...arguments);
// 使用SlugUrl工具处理URL结合query参数如slug生成最终的语义化URL
return SlugUrl(url, query); return SlugUrl(url, query);
} }
} }

@ -1,13 +1,20 @@
import AdminXComponent from './admin-x-component'; import AdminXComponent from './admin-x-component';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
// AdminXActivityPub 继承自 AdminXComponent提供与 ActivityPub社交网络功能相关的绑定和属性。
export default class AdminXActivityPub extends AdminXComponent { export default class AdminXActivityPub extends AdminXComponent {
// 注入 upgradeStatus 服务,用于查询或响应系统升级状态(保留以备使用或观察)。
@service upgradeStatus; @service upgradeStatus;
// 注入 settings 服务,包含站点设置(例如 socialWebEnabled 标志)。
@service settings; @service settings;
// 指定该组件所属的 NPM 包名,供框架或调试时识别来源。
static packageName = '@tryghost/admin-x-activitypub'; static packageName = '@tryghost/admin-x-activitypub';
// additionalProps 返回一个对象,这些属性会传递给子组件或渲染模板。
// 这里我们暴露了 activityPubEnabled基于 settings 服务中的 socialWebEnabled 标志。
additionalProps = () => ({ additionalProps = () => ({
// activityPubEnabled: 布尔值指示是否启用了社交网络ActivityPub功能。
activityPubEnabled: this.settings.socialWebEnabled activityPubEnabled: this.settings.socialWebEnabled
}); });
} }

@ -1,43 +1,60 @@
import Component from '@glimmer/component'; // 导入必要的模块和依赖
import {action} from '@ember/object'; import Component from '@glimmer/component'; // Glimmer组件基类用于创建Ember组件
import {formatNumber} from '../../../helpers/format-number'; import {action} from '@ember/object'; // 动作装饰器,用于标记组件中的动作方法
import {inject as service} from '@ember/service'; import {formatNumber} from '../../../helpers/format-number'; // 数字格式化工具函数
import {inject as service} from '@ember/service'; // 服务注入装饰器,用于依赖注入
// 定义仪表板总览组件类
export default class Overview extends Component { export default class Overview extends Component {
// 注入仪表板统计服务,提供会员数据和趋势数据
@service dashboardStats; @service dashboardStats;
// 组件动作:加载图表数据
// 当组件插入DOM时自动调用此方法
@action @action
loadCharts() { loadCharts() {
// 调用统计服务的加载会员统计方法
this.dashboardStats.loadMemberCountStats(); this.dashboardStats.loadMemberCountStats();
} }
// 计算属性:检查数据是否正在加载中
// 返回true表示数据尚未加载完成
get loading() { get loading() {
return this.dashboardStats.memberCountStats === null; return this.dashboardStats.memberCountStats === null;
} }
// 计算属性:获取总会员数量
// 使用可选链操作符(?.)和空值合并操作符(??)确保安全访问
get totalMembers() { get totalMembers() {
return this.dashboardStats.memberCounts?.total ?? 0; return this.dashboardStats.memberCounts?.total ?? 0;
} }
// 计算属性:检查总会员数是否大于零
// 用于控制总览区域的显示/隐藏
get isTotalMembersMoreThanZero() { get isTotalMembersMoreThanZero() {
return this.dashboardStats.memberCounts && this.totalMembers > 0; return this.dashboardStats.memberCounts && this.totalMembers > 0;
} }
// 计算属性:获取付费会员数量
get paidMembers() { get paidMembers() {
return this.dashboardStats.memberCounts?.paid ?? 0; return this.dashboardStats.memberCounts?.paid ?? 0;
} }
// 计算属性:获取免费会员数量
get freeMembers() { get freeMembers() {
return this.dashboardStats.memberCounts?.free ?? 0; return this.dashboardStats.memberCounts?.free ?? 0;
} }
// 计算属性:获取格式化后的总会员数显示文本
// 数据未加载时返回"-"作为占位符
get totalMembersFormatted() { get totalMembersFormatted() {
if (this.dashboardStats.memberCounts === null) { if (this.dashboardStats.memberCounts === null) {
return '-'; return '-'; // 数据加载中的占位符
} }
return formatNumber(this.totalMembers); return formatNumber(this.totalMembers); // 调用格式化函数1000 → "1,000"
} }
// 计算属性:获取格式化后的付费会员数显示文本
get paidMembersFormatted() { get paidMembersFormatted() {
if (this.dashboardStats.memberCounts === null) { if (this.dashboardStats.memberCounts === null) {
return '-'; return '-';
@ -45,6 +62,7 @@ export default class Overview extends Component {
return formatNumber(this.paidMembers); return formatNumber(this.paidMembers);
} }
// 计算属性:获取格式化后的免费会员数显示文本
get freeMembersFormatted() { get freeMembersFormatted() {
if (this.dashboardStats.memberCounts === null) { if (this.dashboardStats.memberCounts === null) {
return '-'; return '-';
@ -52,6 +70,8 @@ export default class Overview extends Component {
return formatNumber(this.freeMembers); return formatNumber(this.freeMembers);
} }
// 计算属性:检查是否有可用的趋势数据
// 需要确保所有必需的数据都已加载完成
get hasTrends() { get hasTrends() {
return this.dashboardStats.memberCounts !== null return this.dashboardStats.memberCounts !== null
&& this.dashboardStats.memberCountsTrend !== null && this.dashboardStats.memberCountsTrend !== null
@ -59,30 +79,43 @@ export default class Overview extends Component {
&& this.dashboardStats.currentMRRTrend !== null; && this.dashboardStats.currentMRRTrend !== null;
} }
// 计算属性:计算总会员数的趋势百分比
// 使用历史数据和当前数据进行对比计算
get totalMembersTrend() { get totalMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.total, this.dashboardStats.memberCounts.total); return this.calculatePercentage(this.dashboardStats.memberCountsTrend.total, this.dashboardStats.memberCounts.total);
} }
// 计算属性:计算付费会员数的趋势百分比
get paidMembersTrend() { get paidMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.paid, this.dashboardStats.memberCounts.paid); return this.calculatePercentage(this.dashboardStats.memberCountsTrend.paid, this.dashboardStats.memberCounts.paid);
} }
// 计算属性:计算免费会员数的趋势百分比
get freeMembersTrend() { get freeMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.free, this.dashboardStats.memberCounts.free); return this.calculatePercentage(this.dashboardStats.memberCountsTrend.free, this.dashboardStats.memberCounts.free);
} }
// 计算属性:检查网站是否设置了付费层级
// 用于控制付费相关功能的显示
get hasPaidTiers() { get hasPaidTiers() {
return this.dashboardStats.siteStatus?.hasPaidTiers; return this.dashboardStats.siteStatus?.hasPaidTiers;
} }
// 私有方法:计算两个数值之间的百分比变化
// @param {number} from - 起始值(历史数据)
// @param {number} to - 结束值(当前数据)
// @returns {number} 百分比变化值,四舍五入到整数
calculatePercentage(from, to) { calculatePercentage(from, to) {
// 特殊处理如果起始值为0
if (from === 0) { if (from === 0) {
if (to > 0) { if (to > 0) {
return 100; return 100; // 从0增长到任何正数都视为100%增长
} }
return 0; return 0; // 保持为0表示无变化
} }
// 标准百分比计算公式:(新值-旧值)/旧值 × 100
// 使用Math.round进行四舍五入
return Math.round((to - from) / from * 100); return Math.round((to - from) / from * 100);
} }
} }

@ -1,28 +1,55 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
// 导入删除标签模态框组件
import DeleteTagModal from '../components/tags/delete-tag-modal'; import DeleteTagModal from '../components/tags/delete-tag-modal';
import {action} from '@ember/object'; // 导入Ember动作装饰器
import {inject} from 'ghost-admin/decorators/inject'; import { action } from '@ember/object';
import {inject as service} from '@ember/service'; // 导入注入装饰器(用于注入配置)
import {task} from 'ember-concurrency'; import { inject } from 'ghost-admin/decorators/inject';
// 导入服务注入工具
import { inject as service } from '@ember/service';
// 导入Ember并发任务工具用于处理异步操作
import { task } from 'ember-concurrency';
/**
* 标签控制器TagController
* 处理标签编辑/新建页面的交互逻辑包括保存删除等操作
*/
export default class TagController extends Controller { export default class TagController extends Controller {
// 注入模态框服务(用于打开删除确认弹窗)
@service modals; @service modals;
// 注入通知服务(用于显示操作结果提示)
@service notifications; @service notifications;
// 注入路由服务(用于路由跳转)
@service router; @service router;
// 注入标签管理服务(用于管理标签列表数据)
@service tagsManager; @service tagsManager;
// 注入应用配置用于获取博客基础URL等信息
@inject config; @inject config;
/**
* 获取当前标签模型
* 简化模板中对模型的访问this.tag 等价于 this.model
* @returns {Model} 标签模型实例
*/
get tag() { get tag() {
return this.model; return this.model;
} }
/**
* 计算标签的访问URL
* 优先使用标签的canonicalUrl否则通过博客URL和标签slug拼接
* 确保URL以斜杠结尾
* @returns {string} 标签的完整访问URL
*/
get tagURL() { get tagURL() {
const blogUrl = this.config.blogUrl; const blogUrl = this.config.blogUrl; // 从配置中获取博客基础URL
const tagSlug = this.tag?.slug || ''; const tagSlug = this.tag?.slug || ''; // 获取标签的slugURL别名
// 构建标签URL优先使用规范URL否则拼接基础URL和slug
let tagURL = this.tag?.canonicalUrl || `${blogUrl}/tag/${tagSlug}`; let tagURL = this.tag?.canonicalUrl || `${blogUrl}/tag/${tagSlug}`;
// 确保URL以斜杠结尾标准化URL格式
if (!tagURL.endsWith('/')) { if (!tagURL.endsWith('/')) {
tagURL += '/'; tagURL += '/';
} }
@ -30,35 +57,51 @@ export default class TagController extends Controller {
return tagURL; return tagURL;
} }
/**
* 打开删除标签确认弹窗的动作
* 点击"删除标签"按钮时触发显示确认弹窗并传入当前标签数据
*/
@action @action
confirmDeleteTag() { confirmDeleteTag() {
return this.modals.open(DeleteTagModal, { return this.modals.open(DeleteTagModal, {
tag: this.model tag: this.model // 向弹窗组件传递当前标签模型
}); });
} }
@task({drop: true}) /**
* 保存标签的并发任务
* 处理标签的新建或编辑保存逻辑包含错误处理和成功后的路由跳转
* 使用drop策略新任务触发时若当前任务正在运行则忽略新任务
*/
@task({ drop: true })
*saveTask() { *saveTask() {
let {tag} = this; let { tag } = this;
try { try {
// 若标签模型存在验证错误,不执行保存操作
if (tag.get('errors').length !== 0) { if (tag.get('errors').length !== 0) {
return; return;
} }
// 记录标签是否为新建状态(用于后续逻辑区分)
const wasNew = tag.isNew; const wasNew = tag.isNew;
// 执行保存操作Ember Data的save方法返回Promise
yield tag.save(); yield tag.save();
// 若为新建标签,将其添加到标签列表数据中(更新缓存)
if (wasNew) { if (wasNew) {
this.tagsManager.tagsScreenInfinityModel?.pushObjects([tag]); this.tagsManager.tagsScreenInfinityModel?.pushObjects([tag]);
} }
// replace 'new' route with 'tag' route
// 保存成功后,将"新建标签"路由替换为"标签详情"路由(避免回退到新建页)
this.replaceRoute('tag', tag); this.replaceRoute('tag', tag);
return tag; return tag; // 返回保存后的标签模型
} catch (error) { } catch (error) {
// 保存失败时显示API错误通知指定key避免重复显示相同错误
if (error) { if (error) {
this.notifications.showAPIError(error, {key: 'tag.save'}); this.notifications.showAPIError(error, { key: 'tag.save' });
} }
} }
} }
} }

@ -1,51 +1,93 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import {action} from '@ember/object'; import { action } from '@ember/object';
import {inject as service} from '@ember/service'; import { inject as service } from '@ember/service';
import {tracked} from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
/**
* 标签列表控制器TagsController
* 处理标签列表页面的逻辑包括筛选排序加载更多新建标签等交互
*/
export default class TagsController extends Controller { export default class TagsController extends Controller {
// 注入无限滚动服务(用于处理列表分页加载)
@service infinity; @service infinity;
// 注入路由服务(用于页面跳转)
@service router; @service router;
// 注入标签管理服务(用于标签排序等共享逻辑)
@service tagsManager; @service tagsManager;
// 路由参数配置:将"type"作为查询参数(控制标签筛选类型)
queryParams = ['type']; queryParams = ['type'];
// 跟踪标签筛选类型(默认筛选"公开标签"
@tracked type = 'public'; @tracked type = 'public';
/**
* 获取标签列表数据源
* 简化模板中对模型的访问this.tags 等价于 this.model
* @returns {Array} 标签模型数组
*/
get tags() { get tags() {
return this.model; return this.model;
} }
/**
* 获取筛选后的标签列表
* 1. 去重避免新标签在分页请求后重复显示
* 2. 过滤仅保留非新建非删除状态且符合当前筛选类型的标签
* @returns {Array} 去重并筛选后的标签数组
*/
get filteredTags() { get filteredTags() {
// new tags are preemptively added to the client-side tagsScreenInfinityModel, // 用Map去重以标签ID为键确保每个标签只出现一次
// but if the new tag is included in a later pagination request it will end up duplicated
// this makes sure each tag only shows up once
const tagMap = new Map(); const tagMap = new Map();
this.tags.forEach((tag) => { this.tags.forEach((tag) => {
// 过滤条件:
// - 非新建标签(!tag.isNew
// - 非销毁中/已销毁状态(!tag.isDestroyed && !tag.isDestroying
// - 非已删除状态(!tag.isDeleted
// - 符合当前筛选类型无筛选类型时显示全部否则匹配visibility
if (!tag.isNew && !tag.isDestroyed && !tag.isDestroying && !tag.isDeleted && (!this.type || tag.visibility === this.type)) { if (!tag.isNew && !tag.isDestroyed && !tag.isDestroying && !tag.isDeleted && (!this.type || tag.visibility === this.type)) {
tagMap.set(tag.id, tag); tagMap.set(tag.id, tag);
} }
}); });
// 将Map的值转为数组返回去重后的结果
return [...tagMap.values()]; return [...tagMap.values()];
} }
/**
* 获取排序后的标签列表
* 调用标签管理服务的排序方法对筛选后的标签进行排序
* @returns {Array} 排序后的标签数组
*/
get sortedTags() { get sortedTags() {
return this.tagsManager.sortTags(this.filteredTags); return this.tagsManager.sortTags(this.filteredTags);
} }
/**
* 切换标签筛选类型的动作
* 点击"公开标签/内部标签"按钮时触发更新筛选类型
* @param {string} type - 筛选类型"public" "internal"
*/
@action @action
changeType(type) { changeType(type) {
this.type = type; this.type = type;
} }
/**
* 跳转到新建标签页面的动作
* 点击"New tag"按钮或按快捷键"c"时触发
*/
@action @action
newTag() { newTag() {
this.router.transitionTo('tag.new'); this.router.transitionTo('tag.new');
} }
/**
* 加载更多标签的动作
* 滚动到列表底部时触发通过无限滚动服务加载下一页标签
*/
@action @action
loadMoreTags() { loadMoreTags() {
this.infinity.infinityLoad(this.model); this.infinity.infinityLoad(this.model);
} }
} }

@ -1,62 +1,108 @@
import Model, {attr} from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
// 导入验证混入,用于为模型添加验证能力
import ValidationEngine from 'ghost-admin/mixins/validation-engine'; import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {equal} from '@ember/object/computed'; // 导入Ember计算属性工具用于定义等值判断计算属性
import {inject as service} from '@ember/service'; import { equal } from '@ember/object/computed';
// 导入Ember服务注入工具用于注入依赖服务
import { inject as service } from '@ember/service';
/**
* 标签Tag模型
* 继承自Ember Data的基础Model集成验证能力用于描述标签的属性和行为
* 对应后端标签数据支持属性管理可见性控制搜索缓存更新等功能
*/
export default Model.extend(ValidationEngine, { export default Model.extend(ValidationEngine, {
// 注入搜索服务,用于操作搜索缓存
search: service(), search: service(),
// 注入特性服务,用于特性开关控制(当前未在方法中使用,预留扩展)
feature: service(),
// 指定验证规则类型,对应后端/前端的"tag"验证配置
validationType: 'tag', validationType: 'tag',
name: attr('string'), // ------------------------------
slug: attr('string'), // 标签核心属性(与后端字段对应)
url: attr('string'), // ------------------------------
description: attr('string'), name: attr('string'), // 标签名称(必填,如"技术"
metaTitle: attr('string'), slug: attr('string'), // 标签URL别名如"tech"用于生成友好URL
metaDescription: attr('string'), url: attr('string'), // 标签完整URL如"https://xxx.com/tag/tech"
twitterImage: attr('string'), description: attr('string'), // 标签描述(可选)
twitterTitle: attr('string'), metaTitle: attr('string'), // 标签SEO标题可选用于页面<head>的title标签
twitterDescription: attr('string'), metaDescription: attr('string'), // 标签SEO描述可选用于页面<head>的meta description
ogImage: attr('string'), // 社交媒体Twitter相关属性
ogTitle: attr('string'), twitterImage: attr('string'), // Twitter分享时的封面图URL
ogDescription: attr('string'), twitterTitle: attr('string'), // Twitter分享标题
codeinjectionHead: attr('string'), twitterDescription: attr('string'), // Twitter分享描述
codeinjectionFoot: attr('string'), // 社交媒体Open Graph相关属性
canonicalUrl: attr('string'), ogImage: attr('string'), // OG分享时的封面图URL
accentColor: attr('string'), ogTitle: attr('string'), // OG分享标题
featureImage: attr('string'), ogDescription: attr('string'), // OG分享描述
visibility: attr('string', {defaultValue: 'public'}), // 代码注入相关属性(用于在标签页注入自定义脚本/样式)
createdAtUTC: attr('moment-utc'), codeinjectionHead: attr('string'), // 注入到<head>的代码
updatedAtUTC: attr('moment-utc'), codeinjectionFoot: attr('string'), // 注入到</body>前的代码
count: attr('raw'), canonicalUrl: attr('string'), // 标签页规范URL用于SEO避免重复内容
accentColor: attr('string'), // 标签强调色(用于前端样式渲染)
featureImage: attr('string'), // 标签封面图URL
// 标签可见性(默认"public"公开,可选"internal"内部)
visibility: attr('string', { defaultValue: 'public' }),
createdAtUTC: attr('moment-utc'), // 标签创建时间UTC时区moment对象
updatedAtUTC: attr('moment-utc'), // 标签更新时间UTC时区moment对象
count: attr('raw'), // 标签关联内容数量(原始数据,如{posts: 10}表示关联10篇文章
// ------------------------------
// 计算属性(基于基础属性派生)
// ------------------------------
// 判断标签是否为"内部可见"visibility === 'internal'
isInternal: equal('visibility', 'internal'), isInternal: equal('visibility', 'internal'),
// 判断标签是否为"公开可见"visibility === 'public'
isPublic: equal('visibility', 'public'), isPublic: equal('visibility', 'public'),
feature: service(), // ------------------------------
// 标签行为方法
// ------------------------------
/**
* 更新标签可见性
* 根据标签名称是否以"#"开头自动设置可见性
* - "#"开头 "internal"内部标签仅管理员可见
* - 其他情况 "public"公开标签前端可展示
*/
updateVisibility() { updateVisibility() {
// 正则表达式:匹配以"#"开头的名称(支持"#"后紧跟其他字符,如"#内部标签"
let internalRegex = /^#.?/; let internalRegex = /^#.?/;
this.set('visibility', internalRegex.test(this.name) ? 'internal' : 'public'); this.set('visibility', internalRegex.test(this.name) ? 'internal' : 'public');
}, },
/**
* 重写模型默认save方法
* 扩展功能
* 1. 名称变更时自动更新可见性
* 2. 名称/URL变更或标签删除时清理搜索缓存
* @returns {Promise<Model>} 保存成功后的模型实例
*/
save() { save() {
// 判断标签名称是否有变更changedAttributes()返回变更的属性键值对)
const nameChanged = !!this.changedAttributes().name; const nameChanged = !!this.changedAttributes().name;
// 若名称变更且标签未被删除,更新可见性
if (nameChanged && !this.isDeleted) { if (nameChanged && !this.isDeleted) {
this.updateVisibility(); this.updateVisibility();
} }
const {url} = this; // 保存当前URL用于后续判断URL是否变更
const { url } = this;
// 调用父类save方法执行实际保存返回Promise
return this._super(...arguments).then((savedModel) => { return this._super(...arguments).then((savedModel) => {
// 判断保存后URL是否变更与保存前对比
const urlChanged = url !== savedModel.url; const urlChanged = url !== savedModel.url;
// 若名称变更、URL变更或标签被删除清理搜索服务的内容缓存
if (nameChanged || urlChanged || this.isDeleted) { if (nameChanged || urlChanged || this.isDeleted) {
this.search.expireContent(); this.search.expireContent();
} }
// 返回保存后的模型实例
return savedModel; return savedModel;
}); });
} }
}); });

@ -1,18 +1,30 @@
import * as Sentry from '@sentry/ember'; import * as Sentry from '@sentry/ember'; // Sentry错误跟踪工具
import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; // 已认证路由基类(需要登录)
import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; // 未保存更改确认弹窗
import {action} from '@ember/object'; import { action } from '@ember/object'; // Ember动作装饰器
import {inject as service} from '@ember/service'; import { inject as service } from '@ember/service'; // 服务注入工具
/**
* 标签路由TagRoute
* 处理标签的查看编辑和新建功能继承自需要登录的路由基类
* 包含权限控制数据加载未保存更改确认等逻辑
*/
export default class TagRoute extends AuthenticatedRoute { export default class TagRoute extends AuthenticatedRoute {
// 注入模态框服务(用于打开确认弹窗)
@service modals; @service modals;
// 注入路由服务(用于路由跳转)
@service router; @service router;
// 注入会话服务(用于获取当前用户信息)
@service session; @service session;
// ensures if a tag model is passed in directly we show it immediately // 标记是否需要在后台刷新数据(用于直接传入标签模型时的场景)
// and refresh in the background
_requiresBackgroundRefresh = true; _requiresBackgroundRefresh = true;
/**
* 路由进入前的钩子
* 先执行父类的beforeModel逻辑验证登录状态等
* 然后检查用户权限如果是作者或贡献者不允许访问标签管理跳转到首页
*/
beforeModel() { beforeModel() {
super.beforeModel(...arguments); super.beforeModel(...arguments);
@ -21,73 +33,114 @@ export default class TagRoute extends AuthenticatedRoute {
} }
} }
/**
* 加载路由模型数据
* @param {Object} params - 路由参数
* @returns {Promise} 标签模型实例
*/
model(params) { model(params) {
this._requiresBackgroundRefresh = false; this._requiresBackgroundRefresh = false; // 重置后台刷新标记
if (params.tag_slug) { if (params.tag_slug) {
return this.store.queryRecord('tag', {slug: params.tag_slug}); // 如果有标签slug参数查询对应的标签记录
return this.store.queryRecord('tag', { slug: params.tag_slug });
} else { } else {
// 没有slug参数创建一个新的标签记录用于新建标签
return this.store.createRecord('tag'); return this.store.createRecord('tag');
} }
} }
/**
* 序列化模型数据为路由参数
* 用于生成带标签slug的URL
* @param {Model} tag - 标签模型实例
* @returns {Object} 路由参数对象
*/
serialize(tag) { serialize(tag) {
return {tag_slug: tag.get('slug')}; return { tag_slug: tag.get('slug') };
} }
/**
* 设置控制器数据
* 在控制器中准备好模型数据供模板使用
* @param {Controller} controller - 对应的控制器实例
* @param {Model} tag - 标签模型实例
*/
setupController(controller, tag) { setupController(controller, tag) {
super.setupController(...arguments); super.setupController(...arguments);
// 如果需要后台刷新,重新加载标签数据(用于直接传入模型时的场景)
if (this._requiresBackgroundRefresh) { if (this._requiresBackgroundRefresh) {
tag.reload(); tag.reload();
} }
} }
/**
* 路由离开时的钩子
* 重置相关状态清理确认弹窗引用
*/
deactivate() { deactivate() {
this._requiresBackgroundRefresh = true; this._requiresBackgroundRefresh = true; // 恢复后台刷新标记
this.confirmModal = null; this.confirmModal = null; // 清空弹窗引用
this.hasConfirmed = false; this.hasConfirmed = false; // 重置确认状态
} }
/**
* 路由切换前的动作Ember动作
* 处理未保存更改的确认逻辑防止用户意外丢失数据
* @param {Transition} transition - 路由切换对象
*/
@action @action
async willTransition(transition) { async willTransition(transition) {
// 如果已经确认离开,直接允许切换
if (this.hasConfirmed) { if (this.hasConfirmed) {
return true; return true;
} }
// 先中断当前切换
transition.abort(); transition.abort();
// wait for any existing confirm modal to be closed before allowing transition // 如果已有确认弹窗打开,等待其关闭后再处理
if (this.confirmModal) { if (this.confirmModal) {
return; return;
} }
// 如果正在保存标签,等待保存完成
if (this.controller.saveTask?.isRunning) { if (this.controller.saveTask?.isRunning) {
await this.controller.saveTask.last; await this.controller.saveTask.last;
} }
// 确认是否允许离开(检查是否有未保存的更改)
const shouldLeave = await this.confirmUnsavedChanges(); const shouldLeave = await this.confirmUnsavedChanges();
if (shouldLeave) { if (shouldLeave) {
// 放弃未保存的更改,标记已确认,重试路由切换
this.controller.model.rollbackAttributes(); this.controller.model.rollbackAttributes();
this.hasConfirmed = true; this.hasConfirmed = true;
return transition.retry(); return transition.retry();
} }
} }
/**
* 确认未保存的更改
* 如果模型有未保存的属性显示确认弹窗否则直接允许离开
* @returns {Promise<boolean>} 是否允许离开的Promise
*/
async confirmUnsavedChanges() { async confirmUnsavedChanges() {
if (this.controller.model?.hasDirtyAttributes) { if (this.controller.model?.hasDirtyAttributes) {
// 有未保存的更改记录Sentry日志并打开确认弹窗
Sentry.captureMessage('showing unsaved changes modal for tags route'); Sentry.captureMessage('showing unsaved changes modal for tags route');
this.confirmModal = this.modals this.confirmModal = this.modals
.open(ConfirmUnsavedChangesModal) .open(ConfirmUnsavedChangesModal)
.finally(() => { .finally(() => {
this.confirmModal = null; this.confirmModal = null; // 弹窗关闭后清空引用
}); });
return this.confirmModal; return this.confirmModal; // 返回弹窗的结果(用户是否确认离开)
} }
// 没有未保存的更改,直接允许离开
return true; return true;
} }
} }

@ -1,6 +1,12 @@
import TagRoute from '../tag'; import TagRoute from '../tag';
/**
* 新建标签路由NewRoute
* 继承自标签路由TagRoute用于处理新建标签的页面逻辑
* 复用标签路由的控制器和模板简化新建标签功能的实现
*/
export default class NewRoute extends TagRoute { export default class NewRoute extends TagRoute {
// 指定使用的控制器名称为'tag',复用标签控制器的逻辑
controllerName = 'tag'; controllerName = 'tag';
templateName = 'tag'; // 指定使用的模板名称为'tag',复用标签页面的模板
} }

@ -1,17 +1,35 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import {inject as service} from '@ember/service'; import { inject as service } from '@ember/service';
const CACHE_TIME = 1000 * 60 * 5; // 5 minutes // 数据缓存时间5分钟用于控制无限滚动数据的缓存有效期
const CACHE_TIME = 1000 * 60 * 5;
/**
* 标签列表路由TagsRoute
* 继承自已认证路由基类用于处理标签列表页面的逻辑
* 支持权限控制数据分页加载特性开关适配等功能
*/
export default class TagsRoute extends AuthenticatedRoute { export default class TagsRoute extends AuthenticatedRoute {
// 注入无限滚动服务(用于实现标签列表的分页加载)
@service infinity; @service infinity;
// 注入标签管理服务(用于存储和共享标签列表数据)
@service tagsManager; @service tagsManager;
// 注入特性开关服务(用于控制新旧标签页面的切换)
@service feature; @service feature;
/**
* 动态指定模板名称
* 根据特性开关"tagsX"决定使用新模板tags-x还是旧模板tags
* @returns {string} 模板名称
*/
get templateName() { get templateName() {
return this.feature.tagsX ? 'tags-x' : 'tags'; return this.feature.tagsX ? 'tags-x' : 'tags';
} }
/**
* 路由参数配置
* "type"参数变化时刷新模型数据并替换历史记录避免回退时重复显示
*/
queryParams = { queryParams = {
type: { type: {
refreshModel: true, refreshModel: true,
@ -19,7 +37,11 @@ export default class TagsRoute extends AuthenticatedRoute {
} }
}; };
// authors aren't allowed to manage tags /**
* 路由进入前的钩子
* 先执行父类的权限验证确保用户已登录
* 然后检查用户权限作者或贡献者不允许访问标签管理自动跳转到首页
*/
beforeModel() { beforeModel() {
super.beforeModel(...arguments); super.beforeModel(...arguments);
@ -28,41 +50,62 @@ export default class TagsRoute extends AuthenticatedRoute {
} }
} }
/**
* 加载路由模型数据
* @param {Object} params - 路由参数包含type筛选条件
* @returns {Promise} 标签列表数据无限滚动模型
*/
model(params) { model(params) {
// 如果启用新标签页面特性暂时返回null由新页面自行处理数据加载
if (this.feature.tagsX) { if (this.feature.tagsX) {
return null; return null;
} }
// 构建筛选参数根据type参数筛选标签可见性
const filterParams = { const filterParams = {
visibility: params.type visibility: params.type
}; };
// 构建分页参数
const paginationParams = { const paginationParams = {
perPage: 100, perPage: 100, // 每页加载100条标签
perPageParam: 'limit', perPageParam: 'limit', // 后端接收的每页数量参数名
totalPagesParam: 'meta.pagination.pages', totalPagesParam: 'meta.pagination.pages', // 后端返回的总页数字段路径
order: 'name asc', order: 'name asc', // 按名称升序排序
include: 'count.posts' include: 'count.posts' // 关联加载标签下的文章数量
}; };
// 使用无限滚动服务加载标签数据,并缓存到标签管理服务中
this.tagsManager.tagsScreenInfinityModel = this.infinity.model('tag', { this.tagsManager.tagsScreenInfinityModel = this.infinity.model('tag', {
...paginationParams, ...paginationParams,
filter: this._filterString({...filterParams}), filter: this._filterString({...filterParams}), // 转换筛选参数为后端需要的格式
infinityCache: CACHE_TIME infinityCache: CACHE_TIME // 设置数据缓存时间
}); });
// 返回加载的标签列表数据
return this.tagsManager.tagsScreenInfinityModel; return this.tagsManager.tagsScreenInfinityModel;
} }
/**
* 构建路由信息元数据
* 用于设置页面标题等信息
* @returns {Object} 包含标题令牌的元数据
*/
buildRouteInfoMetadata() { buildRouteInfoMetadata() {
return { return {
titleToken: 'Tags' titleToken: 'Tags' // 页面标题为"Tags"
}; };
} }
/**
* 将筛选参数转换为后端API需要的字符串格式
* 例如{visibility: 'public'} "visibility:public"
* @param {Object} filter - 筛选参数对象
* @returns {string} 格式化后的筛选字符串
*/
_filterString(filter) { _filterString(filter) {
return Object.entries(filter).map(([key, value]) => { return Object.entries(filter)
return `${key}:${value}`; .map(([key, value]) => `${key}:${value}`) // 键值对转换为"key:value"格式
}).join(','); .join(','); // 多个筛选条件用逗号分隔
} }
} }

@ -1,34 +1,64 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import ApplicationSerializer from './application'; import ApplicationSerializer from './application'; // 导入应用程序基础序列化器
import {pluralize} from 'ember-inflector'; import { pluralize } from 'ember-inflector'; // 导入单复数转换工具函数
/**
* 标签Label序列化器
* 继承自应用程序基础序列化器用于处理Label模型与后端API数据的序列化/反序列化
* 负责在前端模型和后端数据格式之间进行转换确保数据交互的一致性
*/
export default class LabelSerializer extends ApplicationSerializer { export default class LabelSerializer extends ApplicationSerializer {
/**
* 属性映射配置
* 定义前端模型属性与后端API字段的对应关系
*/
attrs = { attrs = {
createdAtUTC: {key: 'created_at'}, createdAtUTC: { key: 'created_at' }, // 前端createdAtUTC属性对应后端created_at字段
updatedAtUTC: {key: 'updated_at'} updatedAtUTC: { key: 'updated_at' } // 前端updatedAtUTC属性对应后端updated_at字段
}; };
/**
* 序列化方法
* 将前端Label模型实例转换为后端API可接受的JSON格式
* @param {Snapshot} snapshot - 模型快照包含模型当前属性数据
* @param {Object} options - 序列化选项可选
* @returns {Object} 处理后的JSON数据
*/
serialize(/*snapshot, options*/) { serialize(/*snapshot, options*/) {
// 调用父类的序列化方法获取基础JSON数据
let json = super.serialize(...arguments); let json = super.serialize(...arguments);
// Properties that exist on the model but we don't want sent in the payload // 移除不需要发送到后端的属性:
// count属性由后端计算前端无需提交
delete json.count; delete json.count;
return json; return json;
} }
// if we use `queryRecord` ensure we grab the first record to avoid /**
// DS.SERIALIZER.REST.QUERYRECORD-ARRAY-RESPONSE deprecations * 反序列化响应数据
* 将后端API返回的原始数据转换为前端模型可识别的格式
* @param {Store} store - 数据存储实例
* @param {Model} primaryModelClass - 主模型类此处为Label
* @param {Object} payload - 后端返回的原始数据
* @param {string} id - 模型ID
* @param {string} requestType - 请求类型如findqueryRecord等
* @returns {Object} 标准化后的数据供前端模型使用
*/
normalizeResponse(store, primaryModelClass, payload, id, requestType) { normalizeResponse(store, primaryModelClass, payload, id, requestType) {
// 处理queryRecord请求查询单条记录的响应格式
if (requestType === 'queryRecord') { if (requestType === 'queryRecord') {
let singular = primaryModelClass.modelName; const singular = primaryModelClass.modelName; // 模型单数名称(如"label"
let plural = pluralize(singular); const plural = pluralize(singular); // 模型复数名称(如"labels"
// 后端可能返回复数形式的数组(如{labels: [{...}]}),需转换为单数形式
if (payload[plural]) { if (payload[plural]) {
payload[singular] = payload[plural][0]; payload[singular] = payload[plural][0]; // 取数组第一个元素作为单条记录
delete payload[plural]; delete payload[plural]; // 移除复数字段,避免序列化警告
} }
} }
// 调用父类方法完成最终的标准化处理
return super.normalizeResponse(...arguments); return super.normalizeResponse(...arguments);
} }
} }

@ -1,35 +1,66 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import ApplicationSerializer from 'ghost-admin/serializers/application'; import ApplicationSerializer from 'ghost-admin/serializers/application';
import {pluralize} from 'ember-inflector'; import { pluralize } from 'ember-inflector'; // 用于模型名称单复数转换的工具
/**
* 标签序列化器TagSerializer
* 继承自应用程序基础序列化器用于处理标签(Tag)模型与API数据的序列化/反序列化
* 负责在前端模型与后端API数据格式之间进行转换
*/
export default class TagSerializer extends ApplicationSerializer { export default class TagSerializer extends ApplicationSerializer {
/**
* 属性映射配置
* 定义前端模型属性与后端API字段的对应关系
*/
attrs = { attrs = {
createdAtUTC: {key: 'created_at'}, createdAtUTC: { key: 'created_at' }, // 前端createdAtUTC属性对应后端created_at字段
updatedAtUTC: {key: 'updated_at'} updatedAtUTC: { key: 'updated_at' } // 前端updatedAtUTC属性对应后端updated_at字段
}; };
/**
* 序列化方法
* 将前端标签模型转换为后端API所需的JSON格式
* @param {Snapshot} snapshot - 模型快照包含模型当前属性数据
* @param {Object} options - 序列化选项可选
* @returns {Object} 处理后的JSON数据
*/
serialize(/*snapshot, options*/) { serialize(/*snapshot, options*/) {
// 调用父类序列化方法获取基础JSON数据
let json = super.serialize(...arguments); let json = super.serialize(...arguments);
// Properties that exist on the model but we don't want sent in the payload // 移除不需要发送到后端的属性:
// - count标签关联内容数量由后端计算前端无需提交
// - url标签URL由后端生成前端无需提交
delete json.count; delete json.count;
delete json.url; delete json.url;
return json; return json;
} }
// if we use `queryRecord` ensure we grab the first record to avoid /**
// DS.SERIALIZER.REST.QUERYRECORD-ARRAY-RESPONSE deprecations * 反序列化响应数据
* 将后端API返回的JSON转换为前端模型可识别的格式
* @param {Store} store - 数据存储实例
* @param {Model} primaryModelClass - 主模型类此处为Tag
* @param {Object} payload - 后端返回的原始数据
* @param {string} id - 模型ID
* @param {string} requestType - 请求类型如findqueryRecord等
* @returns {Object} 标准化后的数据供前端模型使用
*/
normalizeResponse(store, primaryModelClass, payload, id, requestType) { normalizeResponse(store, primaryModelClass, payload, id, requestType) {
// 处理queryRecord请求根据条件查询单条记录的响应
if (requestType === 'queryRecord') { if (requestType === 'queryRecord') {
let singular = primaryModelClass.modelName; const singular = primaryModelClass.modelName; // 模型单数名称(如"tag"
let plural = pluralize(singular); const plural = pluralize(singular); // 模型复数名称(如"tags"
// 后端可能返回复数形式的数组(如{tags: [{...}]}),需转换为单数形式
if (payload[plural]) { if (payload[plural]) {
payload[singular] = payload[plural][0]; payload[singular] = payload[plural][0]; // 取数组第一个元素作为单条记录
delete payload[plural]; delete payload[plural]; // 移除复数字段,避免序列化警告
} }
} }
// 调用父类方法完成最终的标准化处理
return super.normalizeResponse(...arguments); return super.normalizeResponse(...arguments);
} }
} }

@ -1,38 +1,100 @@
{{! 标签编辑/新建页面的主模板,同时支持新建标签和编辑已有标签的功能 }}
{{! 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">
{{! 面包屑导航:显示用户当前位置,提供返回上级页面的入口 }}
{{! 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"}} {{if this.tag.isNew "New tag" "Edit tag"}} {{! svg-jar引入SVG图标组件"arrow-right-small"是图标名称 }}
{{! 作用:作为面包屑导航的分隔符,增强视觉层级 }}
{{svg-jar "arrow-right-small"}} {{! 箭头图标,分隔面包屑导航项 }}
{{! if条件渲染根据标签状态动态显示文本 }}
{{! this.tag.isNew组件逻辑中的属性判断标签是否为新创建未保存 }}
{{! 新建标签时显示"New tag",编辑时显示"Edit tag" }}
{{if this.tag.isNew "New tag" "Edit tag"}}
</div> </div>
{{! 页面标题:明确当前页面的核心内容 }}
{{! 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">
<a href={{this.tagURL}} target="_blank" rel="noopener noreferrer" class="gh-btn gh-btn-icon-right gh-btn-action-icon"><span>View{{svg-jar "arrow-top-right"}}</span></a> {{! 查看标签按钮:在新窗口打开标签的前端页面,方便预览效果 }}
{{! href={{this.tagURL}}this.tagURL是组件逻辑计算的标签访问URL }}
{{! target="_blank":在新窗口打开链接 }}
{{! rel="noopener noreferrer":安全属性,防止新窗口劫持原页面 }}
{{! gh-btn基础按钮样式gh-btn-icon-right图标在文字右侧的样式gh-btn-action-icon辅助操作按钮样式灰色 }}
<a
href={{this.tagURL}}
target="_blank"
rel="noopener noreferrer"
class="gh-btn gh-btn-icon-right gh-btn-action-icon"
>
{{! 按钮文本+图标:"View"提示功能,箭头图标表示外部链接 }}
<span>View{{svg-jar "arrow-top-right"}}</span> {{! 包含"View"文本和外部链接图标 }}
</a>
{{! 保存按钮:用于提交标签数据(新建或更新) }}
{{! GhTaskButtonGhost的任务按钮组件专门绑定异步任务支持加载状态显示 }}
{{! @task={{this.saveTask}}绑定组件中的saveTask异步任务负责保存标签数据到服务器 }}
{{! @type="button"指定按钮类型为button避免触发表单默认提交 }}
{{! gh-btn-primary主要操作按钮样式通常为蓝色突出显示核心功能 }}
{{! @data-test-button="save":测试标识,用于验证保存按钮功能 }}
{{! on-key "cmd+s"绑定快捷键按下Command+SMac或Ctrl+SWindows触发保存 }}
<GhTaskButton <GhTaskButton
@task={{this.saveTask}} @task={{this.saveTask}}
@type="button" @type="button"
@class="gh-btn gh-btn-primary gh-btn-icon" @class="gh-btn gh-btn-primary gh-btn-icon"
@data-test-button="save" @data-test-button="save"
{{on-key "cmd+s"}} {{on-key "cmd+s"}} {{! 绑定快捷键Command+S保存 }}
/> />
</div> </div>
</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 type="button" class="gh-btn gh-btn-red gh-btn-icon" {{on "click" this.confirmDeleteTag}} data-test-button="delete-tag"> {{! 按钮类型为button避免触发表单提交 }}
{{! gh-btn-red红色按钮样式视觉警示用户这是危险操作删除不可恢复 }}
{{! on "click" this.confirmDeleteTag点击事件绑定触发删除确认流程通常弹出模态框 }}
{{! data-test-button="delete-tag":测试标识,用于验证删除按钮功能 }}
<button
type="button"
class="gh-btn gh-btn-red gh-btn-icon"
{{on "click" this.confirmDeleteTag}} {{! 点击触发删除确认流程 }}
data-test-button="delete-tag"
>
<span>Delete tag</span> <span>Delete tag</span>
</button> </button>
</div> </div>

@ -1,12 +1,25 @@
{{! 标签列表页面模板 }}
<section class="gh-canvas"> <section class="gh-canvas">
{{! 页面头部区域:包含标题和操作按钮,设置为粘性定位(滚动时保持在顶部) }}
<GhCanvasHeader class="gh-canvas-header sticky"> <GhCanvasHeader class="gh-canvas-header sticky">
{{! 页面标题:显示"Tags",用于测试标识 }}
<h2 class="gh-canvas-title" data-test-screen-title>Tags</h2> <h2 class="gh-canvas-title" data-test-screen-title>Tags</h2>
{{! 操作按钮区域 }}
<section class="view-actions"> <section class="view-actions">
<LinkTo @route="tag.new" class="gh-btn gh-btn-primary" data-test-button="new-tag"><span>New tag</span></LinkTo> {{! 新建标签按钮:链接到标签新建路由,包含测试标识 }}
<LinkTo
@route="tag.new"
class="gh-btn gh-btn-primary"
data-test-button="new-tag"
>
<span>New tag</span>
</LinkTo>
</section> </section>
</GhCanvasHeader> </GhCanvasHeader>
{{! 内容区域:显示加载状态指示器 }}
<div class="gh-content"> <div class="gh-content">
<GhLoadingSpinner /> <GhLoadingSpinner /> {{! 加载动画组件,数据加载完成前显示 }}
</div> </div>
</section> </section>

@ -1 +1,3 @@
{{! AdminX 后台的文章管理组件入口 }}
{{! 用于在 AdminX 界面中嵌入完整的文章管理功能模块 }}
<AdminX::Posts /> <AdminX::Posts />

@ -1,42 +1,84 @@
{{! 标签列表页面(带筛选功能)模板 }}
{{! 绑定快捷键 "c",触发新建标签操作 }}
<section class="gh-canvas" {{on-key "c" this.newTag}}> <section class="gh-canvas" {{on-key "c" this.newTag}}>
{{! 页面头部(粘性定位,滚动时保持在顶部) }}
<GhCanvasHeader class="gh-canvas-header sticky"> <GhCanvasHeader class="gh-canvas-header sticky">
{{! 页面标题:显示"Tags",用于自动化测试标识 }}
<h2 class="gh-canvas-title" data-test-screen-title>Tags</h2> <h2 class="gh-canvas-title" data-test-screen-title>Tags</h2>
{{! 操作按钮与筛选区域 }}
<section class="view-actions"> <section class="view-actions">
{{! 标签类型筛选按钮组:切换显示"公开标签"和"内部标签" }}
<div class="gh-contentfilter gh-btn-group"> <div class="gh-contentfilter gh-btn-group">
<button class="gh-btn {{if (eq this.type "public") "gh-btn-group-selected"}}" type="button" {{action "changeType" "public"}} data-test-tags-nav="public" data-test-active={{eq this.type "public"}}><span>Public tags</span></button> {{! 公开标签筛选按钮:点击切换到公开标签列表,选中时显示高亮样式 }}
<button class="gh-btn {{if (eq this.type "internal") "gh-btn-group-selected"}}" type="button" {{action "changeType" "internal"}} data-test-tags-nav="internal" data-test-active={{eq this.type "internal"}}><span>Internal tags</span></button> <button
class="gh-btn {{if (eq this.type "public") "gh-btn-group-selected"}}"
type="button"
{{action "changeType" "public"}}
data-test-tags-nav="public"
data-test-active={{eq this.type "public"}}
>
<span>Public tags</span>
</button>
{{! 内部标签筛选按钮:点击切换到内部标签列表,选中时显示高亮样式 }}
<button
class="gh-btn {{if (eq this.type "internal") "gh-btn-group-selected"}}"
type="button"
{{action "changeType" "internal"}}
data-test-tags-nav="internal"
data-test-active={{eq this.type "internal"}}
>
<span>Internal tags</span>
</button>
</div> </div>
<LinkTo @route="tag.new" class="gh-btn gh-btn-primary" data-test-button="new-tag"><span>New tag</span></LinkTo>
{{! 新建标签按钮:点击跳转到标签新建路由 }}
<LinkTo
@route="tag.new"
class="gh-btn gh-btn-primary"
data-test-button="new-tag"
>
<span>New tag</span>
</LinkTo>
</section> </section>
</GhCanvasHeader> </GhCanvasHeader>
{{! 标签列表内容容器 }}
<section class="view-container content-list"> <section class="view-container content-list">
<ol class="tags-list gh-list {{unless this.sortedTags "no-posts"}}"> <ol class="tags-list gh-list {{unless this.sortedTags "no-posts"}}">
{{! 有标签数据时,渲染列表头部和标签项 }}
{{#if this.sortedTags}} {{#if this.sortedTags}}
{{! 列表头部:定义列标题和宽度占比 }}
<li class="gh-list-row header"> <li class="gh-list-row header">
<div class="gh-list-header gh-list-cellwidth-70">Tag</div> <div class="gh-list-header gh-list-cellwidth-70">Tag</div> {{! 标签名称列占70%宽度) }}
<div class="gh-list-header gh-list-cellwidth-10">Slug</div> <div class="gh-list-header gh-list-cellwidth-10">Slug</div> {{! 标签别名列占10%宽度) }}
<div class="gh-list-header gh-list-cellwidth-10">No. of posts</div> <div class="gh-list-header gh-list-cellwidth-10">No. of posts</div> {{! 关联文章数列占10%宽度) }}
<div class="gh-list-header gh-list-cellwidth-10"></div> <div class="gh-list-header gh-list-cellwidth-10"></div> {{! 空列预留操作区域占10%宽度) }}
</li> </li>
{{! 虚拟滚动列表组件:高效渲染大量标签,支持滚动加载更多 }}
<VerticalCollection <VerticalCollection
@items={{this.sortedTags}} @items={{this.sortedTags}} {{! 数据源:已排序的标签列表 }}
@key="id" @key="id" {{! 每个标签的唯一标识字段 }}
@containerSelector=".gh-main" @containerSelector=".gh-main" {{! 滚动容器选择器 }}
@estimateHeight={{60}} @estimateHeight={{60}} {{! 每个列表项的预估高度(用于虚拟滚动计算) }}
@bufferSize={{20}} @bufferSize={{20}} {{! 滚动时预加载的列表项数量 }}
@lastReached={{this.loadMoreTags}} @lastReached={{this.loadMoreTags}} {{! 滚动到底部时触发的"加载更多"方法 }}
as |tag|> as |tag|>
{{! 单个标签列表项组件:传入当前标签数据 }}
<Tags::ListItem @tag={{tag}} data-test-tag={{tag.id}} /> <Tags::ListItem @tag={{tag}} data-test-tag={{tag.id}} />
</VerticalCollection> </VerticalCollection>
{{! 无标签数据时,显示空状态提示 }}
{{else}} {{else}}
<li class="no-posts-box"> <li class="no-posts-box">
<div class="no-posts"> <div class="no-posts">
{{svg-jar "tags-placeholder" class="gh-tags-placeholder"}} {{svg-jar "tags-placeholder" class="gh-tags-placeholder"}} {{! 标签占位图标 }}
<h4>Start organizing your content.</h4> <h4>Start organizing your content.</h4> {{! 空状态提示文本 }}
<LinkTo @route="tag.new" class="gh-btn gh-btn-green"> {{! 新建标签按钮:引导用户创建第一个标签 }}
<span>Create a new tag</span> <LinkTo @route="tag.new" class="gh-btn gh-btn-green">
</LinkTo> <span>Create a new tag</span>
</LinkTo>
</div> </div>
</li> </li>
{{/if}} {{/if}}
@ -44,4 +86,5 @@
</section> </section>
</section> </section>
{{! 路由出口:用于渲染子路由内容(如标签详情、新建标签等,当前模板为父路由时生效) }}
{{outlet}} {{outlet}}

@ -1,12 +1,18 @@
/* eslint-env node */ /* eslint-env node */
/**
* 浏览器兼容性配置
* 用于指定项目需要支持的浏览器版本范围
* 通常被BabelAutoprefixer等工具使用以生成兼容的代码
*/
const browsers = [ const browsers = [
'last 2 Chrome versions', 'last 2 Chrome versions', // 支持Chrome最新的2个版本
'last 2 Firefox versions', 'last 2 Firefox versions', // 支持Firefox最新的2个版本
'last 3 Safari versions', 'last 3 Safari versions', // 支持Safari最新的3个版本
'last 2 Edge versions' 'last 2 Edge versions' // 支持Edge最新的2个版本
]; ];
// 导出浏览器配置,供工具链使用
module.exports = { module.exports = {
browsers browsers
}; };

@ -1,22 +1,55 @@
import {paginatedResponse} from '../utils'; import { paginatedResponse } from '../utils'; // 导入分页响应处理工具函数
/**
* 模拟标签Labels相关的API接口
* 用于前端开发时的本地数据模拟提供标签的CRUD操作接口
* @param {Object} server - Mirage JS服务器实例
*/
export default function mockLabels(server) { export default function mockLabels(server) {
/**
* 模拟创建标签的POST请求
* 路径/labels/
* 功能使用Mirage默认逻辑处理标签创建接收请求数据并返回新创建的标签
*/
server.post('/labels/'); server.post('/labels/');
/**
* 模拟查询标签列表的GET请求
* 路径/labels/
* 功能使用分页工具函数处理响应返回分页格式的标签列表
* 自动处理pagelimit等查询参数返回包含数据和分页元信息的响应
*/
server.get('/labels/', paginatedResponse('labels')); server.get('/labels/', paginatedResponse('labels'));
server.get('/labels/:id/', function ({labels}, {params}) { /**
let {id} = params; * 模拟查询单个标签的GET请求按ID
let label = labels.find(id); * 路径/labels/:id/
* 功能根据ID查询标签若不存在则返回404错误
*/
server.get('/labels/:id/', function ({ labels }, { params }) {
const { id } = params;
const label = labels.find(id); // 从模拟数据库中查找标签
// 若标签存在则返回否则返回404错误响应
return label || new Response(404, {}, { return label || new Response(404, {}, {
errors: [{ errors: [{
type: 'NotFoundError', type: 'NotFoundError',
message: 'Label not found.' message: 'Label not found.' // 错误信息:标签未找到
}] }]
}); });
}); });
/**
* 模拟更新标签的PUT请求
* 路径/labels/:id/
* 功能使用Mirage默认逻辑处理标签更新根据ID更新标签属性并返回更新后的结果
*/
server.put('/labels/:id/'); server.put('/labels/:id/');
/**
* 模拟删除标签的DELETE请求
* 路径/labels/:id/
* 功能使用Mirage默认逻辑处理标签删除根据ID删除标签并返回相应状态
*/
server.del('/labels/:id/'); server.del('/labels/:id/');
} }

@ -1,37 +1,93 @@
import {dasherize} from '@ember/string'; import { dasherize } from '@ember/string'; // 字符串处理工具:转换为连字符格式(如"Hello World"→"hello-world"
import {extractFilterParam, paginateModelCollection} from '../utils'; import { extractFilterParam, paginateModelCollection } from '../utils'; // 工具函数:提取筛选参数、分页处理
import {isBlank} from '@ember/utils'; import { isBlank } from '@ember/utils'; // 工具函数判断值是否为空null/undefined/空字符串等)
/**
* 模拟标签Tags相关的API接口
* 用于前端开发时的本地数据模拟无需依赖真实后端服务
* @param {Object} server - Mirage JS服务器实例
*/
/*
* Mirage tags API config
*
* 中文说明
* - 本模块在 Mirage 配置中定义了与标签相关的 REST 路由GET /tagsPOST /tags 以及
* 对应的处理逻辑目的是在开发或集成测试环境中模拟后端行为
* - 这些路由会调用 Mirage 的模型/工厂来创建查询更新或删除标签数据从而让前端组件
* 在没有真实后端时也能执行完整的交互流程
*/
export default function mockTags(server) { export default function mockTags(server) {
server.post('/tags/', function ({tags}) { /**
* 模拟创建标签的POST请求
* 路径/tags/
* 功能接收标签数据自动生成slug若未提供创建新标签并返回
*/
server.post('/tags/', function ({ tags }) {
// 获取请求中的标签属性(已标准化处理)
let attrs = this.normalizedRequestAttrs(); let attrs = this.normalizedRequestAttrs();
// 若未提供slug但提供了name自动将name转换为slug连字符格式
if (isBlank(attrs.slug) && !isBlank(attrs.name)) { if (isBlank(attrs.slug) && !isBlank(attrs.name)) {
attrs.slug = dasherize(attrs.name); attrs.slug = dasherize(attrs.name);
} }
// NOTE: this does not use the tag factory to fill in blank fields // 创建并返回新标签(注意:此处未使用标签工厂填充默认字段,需手动确保必要字段存在)
return tags.create(attrs); return tags.create(attrs);
}); });
server.get('/tags/slug/:slug/', function ({tags}, {params: {slug}}) { /**
// TODO: remove post_count unless requested? * 模拟通过slug查询标签的GET请求
return tags.findBy({slug}); * 路径/tags/slug/:slug/
* 功能根据slug查询标签并返回TODO优化按需返回post_count字段
*/
server.get('/tags/slug/:slug/', function ({ tags }, { params: { slug } }) {
// 通过slug查找标签并返回
return tags.findBy({ slug });
}); });
server.get('/tags/', function ({tags}, {queryParams}) { /**
const {filter, page = 1, limit = 15} = queryParams; * 模拟查询标签列表的GET请求
* 路径/tags/
* 功能支持筛选按名称分页返回符合条件的标签列表
*/
server.get('/tags/', function ({ tags }, { queryParams }) {
// 解析查询参数筛选条件、页码、每页数量默认第1页每页15条
const { filter, page = 1, limit = 15 } = queryParams;
// 从筛选条件中提取标签名称的筛选值如filter=tags.name:test → 提取"test"
const tagsName = extractFilterParam('tags.name', filter); const tagsName = extractFilterParam('tags.name', filter);
// 获取所有标签
let collection = tags.all(); let collection = tags.all();
// 若有名称筛选条件,过滤出名称包含筛选值的标签(不区分大小写)
if (tagsName) { if (tagsName) {
collection = collection.filter(tag => tag.name.toLowerCase().includes(tagsName.toLowerCase())); collection = collection.filter(tag =>
tag.name.toLowerCase().includes(tagsName.toLowerCase())
);
} }
// 对筛选后的标签列表进行分页处理,并返回标准化的分页响应格式
return paginateModelCollection('tags', collection, page, limit); return paginateModelCollection('tags', collection, page, limit);
}); });
/**
* 模拟查询单个标签的GET请求按ID
* 路径/tags/:id/
* 功能使用Mirage的默认处理逻辑根据ID查询标签
*/
server.get('/tags/:id/'); server.get('/tags/:id/');
/**
* 模拟更新标签的PUT请求
* 路径/tags/:id/
* 功能使用Mirage的默认处理逻辑根据ID更新标签属性
*/
server.put('/tags/:id/'); server.put('/tags/:id/');
/**
* 模拟删除标签的DELETE请求
* 路径/tags/:id/
* 功能使用Mirage的默认处理逻辑根据ID删除标签
*/
server.del('/tags/:id/'); server.del('/tags/:id/');
} }

@ -1,13 +1,57 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone'; // 导入moment时间处理库带时区支持
import {Factory} from 'miragejs'; import { Factory } from 'miragejs'; // 导入Mirage JS的Factory类用于创建模拟数据工厂
/**
* 标签Label数据工厂
* 用于生成标准化的标签模拟数据供Mirage JS服务器使用
* 支持动态生成带索引的属性值模拟真实业务场景中的标签数据
*/
export default Factory.extend({ export default Factory.extend({
createdAt() { return moment.utc().toISOString(); }, /**
name(i) { return `Label ${i}`; }, * 标签创建时间
slug(i) { return `label-${i}`; }, * 动态生成当前UTC时间的ISO格式字符串"2024-05-20T12:34:56.789Z"
updatedAt() { return moment.utc().toISOString(); }, * @returns {string} ISO格式的UTC时间字符串
*/
createdAt() {
return moment.utc().toISOString();
},
/**
* 标签名称
* 动态生成包含当前标签索引i"Label 1"
* @param {number} i - 标签在工厂序列中的索引从1开始
* @returns {string} 带索引的标签名称
*/
name(i) {
return `Label ${i}`;
},
/**
* 标签URL别名slug
* 动态生成包含当前标签索引i"label-1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的slug
*/
slug(i) {
return `label-${i}`;
},
/**
* 标签更新时间
* 动态生成当前UTC时间的ISO格式字符串与创建时间一致模拟刚创建未更新的状态
* @returns {string} ISO格式的UTC时间字符串
*/
updatedAt() {
return moment.utc().toISOString();
},
/**
* 标签关联成员数量
* 默认为{members: 0}关联0个成员
* 实际使用中会被标签序列化器自动更新
* @returns {Object} 包含关联成员数量的对象
*/
count() { count() {
// this gets updated automatically by the label serializer return { members: 0 };
return {members: 0};
} }
}); });

@ -1,18 +1,106 @@
import {Factory} from 'miragejs'; import { Factory } from 'miragejs'; // 引入Mirage JS的Factory类用于创建模拟数据工厂
/*
* Mirage Tag Factory
*
* 中文说明
* - 本工厂用于在开发模式或前端集成测试中生成标签Tag模拟数据
* - Mirage 会使用此工厂在内存数据库中创建标签记录以模拟后端返回的 API 数据
* - 这里生成的字段nameslugfeatureImagemetaTitlemetaDescription
* 用于保证前端组件与交互在没有真实后端的情况下也能正常工作与测试
*/
export default Factory.extend({ export default Factory.extend({
/**
* 标签创建时间
* 默认为固定时间2015-09-11T09:44:29.871Z
*/
createdAt: '2015-09-11T09:44:29.871Z', createdAt: '2015-09-11T09:44:29.871Z',
description(i) { return `Description for tag ${i}.`; },
/**
* 标签描述
* 动态生成包含当前标签索引i"Description for tag 1."
* @param {number} i - 标签在工厂序列中的索引从1开始
* @returns {string} 带索引的描述文本
*/
description(i) {
return `Description for tag ${i}.`;
},
/**
* 标签可见性
* 默认为"public"公开
*/
visibility: 'public', visibility: 'public',
featureImage(i) { return `/content/images/2015/10/tag-${i}.jpg`; },
metaDescription(i) { return `Meta description for tag ${i}.`; }, /**
metaTitle(i) { return `Meta Title for tag ${i}`; }, * 标签封面图URL
name(i) { return `Tag ${i}`; }, * 动态生成包含当前标签索引i"/content/images/2015/10/tag-1.jpg"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的图片URL
*/
featureImage(i) {
return `/content/images/2015/10/tag-${i}.jpg`;
},
/**
* 标签SEO描述meta description
* 动态生成包含当前标签索引i"Meta description for tag 1."
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的SEO描述文本
*/
metaDescription(i) {
return `Meta description for tag ${i}.`;
},
/**
* 标签SEO标题meta title
* 动态生成包含当前标签索引i"Meta Title for tag 1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的SEO标题文本
*/
metaTitle(i) {
return `Meta Title for tag ${i}`;
},
/**
* 标签名称
* 动态生成包含当前标签索引i"Tag 1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的标签名称
*/
name(i) {
return `Tag ${i}`;
},
/**
* 父标签
* 默认为null无父标签
*/
parent: null, parent: null,
slug(i) { return `tag-${i}`; },
/**
* 标签URL别名slug
* 动态生成包含当前标签索引i"tag-1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的slug
*/
slug(i) {
return `tag-${i}`;
},
/**
* 标签更新时间
* 默认为固定时间2015-10-19T16:25:07.756Z
*/
updatedAt: '2015-10-19T16:25:07.756Z', updatedAt: '2015-10-19T16:25:07.756Z',
/**
* 标签关联内容数量
* 默认为{posts: 0}关联0篇文章
* 实际使用中会被标签序列化器自动更新
* @returns {Object} 包含关联文章数量的对象
*/
count() { count() {
// this gets updated automatically by the tag serializer return { posts: 0 };
return {posts: 0};
} }
}); });

@ -1,5 +1,19 @@
import {Model, hasMany} from 'miragejs'; import { Model, hasMany } from 'miragejs'; // 导入Mirage JS的模型基础类和关联关系工具
/**
* 标签Label模型
* 定义标签与其他模型的关联关系用于Mirage JS模拟数据的关系管理
*/
export default Model.extend({ export default Model.extend({
/**
* 定义标签与成员members的一对多关联关系
* 表示一个标签可以关联多个成员
*
* 关联说明
* - 采用hasMany关系当前标签Label拥有多个成员members
* - Mirage JS会自动管理关联数据的CRUD操作例如
* - 当查询标签时可以通过`label.members`获取关联的所有成员
* - 当创建成员并关联标签时标签的成员列表会自动更新
*/
members: hasMany() members: hasMany()
}); });

@ -1,5 +1,28 @@
import {Model, hasMany} from 'miragejs'; /*
* Mirage Tag Model
*
* 中文说明
* - Mirage 中的 Tag 模型用于在前端开发与测试时模拟后端的标签数据模型
* - 通过 `hasMany('post')` 定义与文章posts的多对多/一对多关系方便在测试中通过 `tag.posts` 访问关联文章
* - Mirage 会自动维护关联数据如创建/删除时的引用更新因此测试可以更接近真实后端行为
*/
import { Model, hasMany } from 'miragejs'; // 导入Mirage JS的模型基础类和关联关系工具
/**
* 标签Tag模型
* 定义标签与文章posts的关联关系用于Mirage JS模拟数据的关系管理
*/
export default Model.extend({ export default Model.extend({
/**
* 定义标签与文章的一对多关联关系
* 表示一个标签可以关联多篇文章
*
* 关联说明
* - 采用hasMany关系当前标签Tag拥有多篇文章posts
* - Mirage JS会自动维护关联数据例如
* - 查询标签时可通过`tag.posts`获取该标签关联的所有文章
* - 创建文章并关联标签时标签的文章列表会自动更新
* - 删除标签时可配置是否级联删除关联的文章默认不删除
*/
posts: hasMany() posts: hasMany()
}); });

@ -1,18 +1,36 @@
import BaseSerializer from './application'; import BaseSerializer from './application'; // 导入应用程序基础序列化器
/**
* 标签Label序列化器
* 继承自基础序列化器扩展了标签关联成员数量的动态计算逻辑
*/
export default BaseSerializer.extend({ export default BaseSerializer.extend({
// make the label.count.members value dynamic /**
* 序列化标签模型或模型集合
* 动态更新标签的成员数量count.members确保与实际关联的成员数量一致
* @param {Model|Collection} labelModelOrCollection - 单个标签模型或标签集合
* @param {Object} request - 请求对象包含请求信息
* @returns {Object} 序列化后的标签数据符合API响应格式
*/
serialize(labelModelOrCollection, request) { serialize(labelModelOrCollection, request) {
/**
* 更新单个标签的成员数量
* 将标签关联的成员ID数组长度作为实际成员数量更新到count.members字段
* @param {Model} label - 标签模型实例
*/
let updateMemberCount = (label) => { let updateMemberCount = (label) => {
label.update('count', {members: label.memberIds.length}); label.update('count', { members: label.memberIds.length });
}; };
// 若为单个标签模型,直接更新其成员数量
if (this.isModel(labelModelOrCollection)) { if (this.isModel(labelModelOrCollection)) {
updateMemberCount(labelModelOrCollection); updateMemberCount(labelModelOrCollection);
} else { } else {
// 若为标签集合,遍历每个标签并更新成员数量
labelModelOrCollection.models.forEach(updateMemberCount); labelModelOrCollection.models.forEach(updateMemberCount);
} }
// 调用父类的序列化方法,返回标准化的响应数据
return BaseSerializer.prototype.serialize.call(this, labelModelOrCollection, request); return BaseSerializer.prototype.serialize.call(this, labelModelOrCollection, request);
} }
}); });

@ -1,19 +1,45 @@
import BaseSerializer from './application'; import BaseSerializer from './application';
/*
* Mirage Tag Serializer
*
* 中文说明
* - Mirage 中的 Tag 模型提供序列化逻辑在序列化阶段计算并注入关联文章数量count.posts
* 和访问 URLurl 字段使前端在渲染列表或详情时能正确显示关联计数与跳转链接
* - serialize 方法会支持传入单个模型或模型集合并为每个模型更新动态字段后再调用基类序列化
*/
export default BaseSerializer.extend({ export default BaseSerializer.extend({
// make the tag.count.posts and url values dynamic /**
* 序列化标签模型或模型集合
* 会在序列化前动态更新标签的关联文章数量和访问URL
* @param {Model|Collection} tagModelOrCollection - 单个标签模型或标签集合
* @param {Object} request - 请求对象包含请求相关信息
* @returns {Object} 序列化后的标签数据符合API响应格式
*/
serialize(tagModelOrCollection, request) { serialize(tagModelOrCollection, request) {
/**
* 更新单个标签的动态属性
* 1. 计算关联文章数量根据标签关联的文章ID数组长度
* 2. 生成访问URL结合本地开发服务器地址和标签的slug
* @param {Model} tag - 单个标签模型实例
*/
let updatePost = (tag) => { let updatePost = (tag) => {
// 更新关联文章数量postIds是Mirage自动维护的关联文章ID数组
tag.update('count', {posts: tag.postIds.length}); tag.update('count', {posts: tag.postIds.length});
// 生成标签的访问URL基于本地开发环境地址
tag.update('url', `http://localhost:4200/tag/${tag.slug}/`); tag.update('url', `http://localhost:4200/tag/${tag.slug}/`);
}; };
// 判断传入的是单个模型还是模型集合
if (this.isModel(tagModelOrCollection)) { if (this.isModel(tagModelOrCollection)) {
// 若为单个模型,直接更新其动态属性
updatePost(tagModelOrCollection); updatePost(tagModelOrCollection);
} else { } else {
// 若为模型集合,遍历每个模型并更新动态属性
tagModelOrCollection.models.forEach(updatePost); tagModelOrCollection.models.forEach(updatePost);
} }
// 调用父类的serialize方法完成最终的序列化并返回结果
return BaseSerializer.prototype.serialize.call(this, tagModelOrCollection, request); return BaseSerializer.prototype.serialize.call(this, tagModelOrCollection, request);
} }
}); });

@ -1,361 +1,469 @@
import {Response} from 'miragejs'; import { Response } from 'miragejs';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; // 导入Ember Simple Auth的测试支持工具用于认证和失效会话
import {beforeEach, describe, it} from 'mocha'; import { authenticateSession, invalidateSession } from 'ember-simple-auth/test-support';
import {click, currentRouteName, currentURL, fillIn, find, findAll} from '@ember/test-helpers'; // 导入Mocha测试框架的钩子和断言方法
import {expect} from 'chai'; import { beforeEach, describe, it } from 'mocha';
import {setupApplicationTest} from 'ember-mocha'; // 导入Ember测试辅助函数用于模拟用户交互和获取DOM元素
import {setupMirage} from 'ember-cli-mirage/test-support'; import { click, currentRouteName, currentURL, fillIn, find, findAll } from '@ember/test-helpers';
import {visit} from '../helpers/visit'; // 导入Chai断言库
import { expect } from 'chai';
// 导入Ember应用测试设置工具
import { setupApplicationTest } from 'ember-mocha';
// 导入Mirage JS的测试支持工具
import { setupMirage } from 'ember-cli-mirage/test-support';
// 导入自定义的访问页面辅助函数
import { visit } from '../helpers/visit';
// 描述"标签Tags"验收测试套件
describe('Acceptance: Tags', function () { describe('Acceptance: Tags', function () {
// 设置应用测试钩子和Mirage模拟服务器
let hooks = setupApplicationTest(); let hooks = setupApplicationTest();
setupMirage(hooks); setupMirage(hooks);
// 测试用例:未认证用户访问标签页时重定向到登录页
it('redirects to signin when not authenticated', async function () { it('redirects to signin when not authenticated', async function () {
// 使当前会话失效(模拟未登录状态)
await invalidateSession(); await invalidateSession();
// 访问标签页面
await visit('/tags'); await visit('/tags');
// 断言当前URL为登录页
expect(currentURL()).to.equal('/signin'); expect(currentURL()).to.equal('/signin');
}); });
// 测试用例贡献者Contributor角色用户访问标签页时重定向到文章页
it('redirects to posts page when authenticated as contributor', async function () { it('redirects to posts page when authenticated as contributor', async function () {
let role = this.server.create('role', {name: 'Contributor'}); // 创建"Contributor"角色
this.server.create('user', {roles: [role], slug: 'test-user'}); let role = this.server.create('role', { name: 'Contributor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role], slug: 'test-user' });
// 认证当前会话(模拟登录)
await authenticateSession(); await authenticateSession();
// 访问标签页面
await visit('/tags'); await visit('/tags');
// 断言当前URL为文章页
expect(currentURL(), 'currentURL').to.equal('/posts'); expect(currentURL(), 'currentURL').to.equal('/posts');
}); });
// 测试用例作者Author角色用户访问标签页时重定向到站点设置页
it('redirects to site page when authenticated as author', async function () { it('redirects to site page when authenticated as author', async function () {
let role = this.server.create('role', {name: 'Author'}); // 创建"Author"角色
this.server.create('user', {roles: [role], slug: 'test-user'}); let role = this.server.create('role', { name: 'Author' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role], slug: 'test-user' });
// 认证当前会话
await authenticateSession(); await authenticateSession();
// 访问标签页面
await visit('/tags'); await visit('/tags');
// 断言当前URL为站点设置页
expect(currentURL(), 'currentURL').to.equal('/site'); expect(currentURL(), 'currentURL').to.equal('/site');
}); });
// 描述"以管理员Administrator身份登录"的测试场景
describe('when logged in as administrator', function () { describe('when logged in as administrator', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () { beforeEach(async function () {
let role = this.server.create('role', {name: 'Administrator'}); // 创建"Administrator"角色
this.server.create('user', {roles: [role]}); let role = this.server.create('role', { name: 'Administrator' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
await authenticateSession(); await authenticateSession();
}); });
// 测试用例:分别列出公开标签和内部标签
it('lists public and internal tags separately', async function () { it('lists public and internal tags separately', async function () {
this.server.create('tag', {name: 'B - Third', slug: 'third'}); // 创建4个公开标签
this.server.create('tag', {name: 'Z - Last', slug: 'last'}); this.server.create('tag', { name: 'B - Third', slug: 'third' });
this.server.create('tag', {name: '!A - Second', slug: 'second'}); this.server.create('tag', { name: 'Z - Last', slug: 'last' });
this.server.create('tag', {name: 'A - First', slug: 'first'}); this.server.create('tag', { name: '!A - Second', slug: 'second' });
this.server.create('tag', {name: '#one', slug: 'hash-one', visibility: 'internal'}); this.server.create('tag', { name: 'A - First', slug: 'first' });
this.server.create('tag', {name: '#two', slug: 'hash-two', visibility: 'internal'}); // 创建2个内部标签
this.server.create('tag', { name: '#one', slug: 'hash-one', visibility: 'internal' });
this.server.create('tag', { name: '#two', slug: 'hash-two', visibility: 'internal' });
// 访问标签页面
await visit('tags'); await visit('tags');
// it loads tags list // 断言页面成功加载当前URL为标签页
expect(currentURL(), 'currentURL').to.equal('tags'); expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu // 断言导航菜单中"标签"项处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item') expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active'); .to.have.class('active');
// it defaults to public tags // 断言默认显示公开标签(公开标签按钮处于激活状态)
expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active'); expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active');
expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active'); expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags // 断言公开标签列表数量为4
expect(findAll('[data-test-tag]'), 'public tag list count') expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4); .to.have.length(4);
// tags are in correct order // 断言标签按正确顺序排序(按名称排序)
let tags = findAll('[data-test-tag]'); let tags = findAll('[data-test-tag]');
expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First'); expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First');
expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second'); expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second');
expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third'); expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third');
expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last'); expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last');
// can switch to internal tags // 切换到内部标签视图
await click('[data-test-tags-nav="internal"]'); await click('[data-test-tags-nav="internal"]');
// 断言内部标签列表数量为2
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2); expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
}); });
// 测试用例:可以添加标签
it('can add tags', async function () { it('can add tags', async function () {
// 访问标签页面
await visit('tags'); await visit('tags');
// 断言初始时没有标签
expect(findAll('[data-test-tag]')).to.have.length(0); expect(findAll('[data-test-tag]')).to.have.length(0);
// 点击"新建标签"按钮
await click('[data-test-button="new-tag"]'); await click('[data-test-button="new-tag"]');
// 断言跳转到新建标签页面
expect(currentURL()).to.equal('/tags/new'); expect(currentURL()).to.equal('/tags/new');
// 填写标签名称和slug
await fillIn('[data-test-input="tag-name"]', 'New tag name'); await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug'); await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
// 点击保存按钮
await click('[data-test-button="save"]'); await click('[data-test-button="save"]');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]'); await click('[data-test-link="tags-back"]');
// 断言标签列表中新增了一个标签
expect(findAll('[data-test-tag]')).to.have.length(1); expect(findAll('[data-test-tag]')).to.have.length(1);
// 断言标签名称正确
expect(find('[data-test-tag] [data-test-tag-name]')).to.have.trimmed.text('New tag name'); expect(find('[data-test-tag] [data-test-tag-name]')).to.have.trimmed.text('New tag name');
// 断言标签slug正确
expect(find('[data-test-tag] [data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug'); expect(find('[data-test-tag] [data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
// 断言关联文章数量为0
expect(find('[data-test-tag] [data-test-tag-count]')).to.have.trimmed.text('0 posts'); expect(find('[data-test-tag] [data-test-tag-count]')).to.have.trimmed.text('0 posts');
}); });
// 测试用例:可以编辑标签
it('can edit tags', async function () { it('can edit tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建一个待编辑的标签
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 访问标签页面
await visit('tags'); await visit('tags');
// 点击标签名称进入编辑页
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`); await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// it maintains active state in nav menu // 断言导航菜单中"标签"项仍处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item') expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active'); .to.have.class('active');
// 断言当前URL为标签编辑页
expect(currentURL()).to.equal('/tags/to-be-edited'); expect(currentURL()).to.equal('/tags/to-be-edited');
// 断言表单中初始值正确
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited'); expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited'); expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
// 修改标签名称和slug
await fillIn('[data-test-input="tag-name"]', 'New tag name'); await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug'); await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
// 点击保存按钮
await click('[data-test-button="save"]'); await click('[data-test-button="save"]');
// 从数据库中获取保存后的标签,断言数据已更新
const savedTag = this.server.db.tags.find(tag.id); const savedTag = this.server.db.tags.find(tag.id);
expect(savedTag.name, 'saved tag name').to.equal('New tag name'); expect(savedTag.name, 'saved tag name').to.equal('New tag name');
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug'); expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]'); await click('[data-test-link="tags-back"]');
// 断言标签列表中显示更新后的标签信息
const tagListItem = find('[data-test-tag]'); const tagListItem = find('[data-test-tag]');
expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name'); expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name');
expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug'); expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
}); });
// 测试用例:编辑标签时不会创建重复项
it('does not create duplicates when editing a tag', async function () { it('does not create duplicates when editing a tag', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建一个待编辑的标签
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 访问标签页面
await visit('tags'); await visit('tags');
// Verify we start with one tag // 断言初始时只有一个标签
expect(findAll('[data-test-tag]')).to.have.length(1); expect(findAll('[data-test-tag]')).to.have.length(1);
// 点击标签名称进入编辑页
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`); await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 修改标签名称
await fillIn('[data-test-input="tag-name"]', 'Edited Tag Name'); await fillIn('[data-test-input="tag-name"]', 'Edited Tag Name');
// 点击保存按钮
await click('[data-test-button="save"]'); await click('[data-test-button="save"]');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]'); await click('[data-test-link="tags-back"]');
// Verify we still have only one tag after editing (no duplicates) // 断言编辑后仍只有一个标签(无重复)
expect(findAll('[data-test-tag]')).to.have.length(1); expect(findAll('[data-test-tag]')).to.have.length(1);
// 断言标签名称已更新
expect(find('[data-test-tag] [data-test-tag-name]')).to.have.trimmed.text('Edited Tag Name'); expect(find('[data-test-tag] [data-test-tag-name]')).to.have.trimmed.text('Edited Tag Name');
}); });
// 测试用例:可以删除标签
it('can delete tags', async function () { it('can delete tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建一个待删除的标签
this.server.create('post', {tags: [tag]}); const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 创建一篇关联该标签的文章
this.server.create('post', { tags: [tag] });
// 访问标签页面
await visit('tags'); await visit('tags');
// 点击标签名称进入编辑页
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`); await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 点击删除标签按钮
await click('[data-test-button="delete-tag"]'); await click('[data-test-button="delete-tag"]');
// 定义删除确认弹窗选择器
const tagModal = '[data-test-modal="confirm-delete-tag"]'; const tagModal = '[data-test-modal="confirm-delete-tag"]';
// 断言弹窗已显示
expect(find(tagModal)).to.exist; expect(find(tagModal)).to.exist;
// 断言弹窗中显示正确的关联文章数量
expect(find(`${tagModal} [data-test-text="posts-count"]`)) expect(find(`${tagModal} [data-test-text="posts-count"]`))
.to.have.trimmed.text('1 post'); .to.have.trimmed.text('1 post');
// 点击确认删除按钮
await click(`${tagModal} [data-test-button="confirm"]`); await click(`${tagModal} [data-test-button="confirm"]`);
// 断言弹窗已关闭
expect(find(tagModal)).to.not.exist; expect(find(tagModal)).to.not.exist;
// 断言返回标签列表页
expect(currentURL()).to.equal('/tags'); expect(currentURL()).to.equal('/tags');
// 断言标签已被删除(列表为空)
expect(findAll('[data-test-tag]')).to.have.length(0); expect(findAll('[data-test-tag]')).to.have.length(0);
}); });
// 测试用例可以通过URL中的slug访问标签
it('can load tag via slug in url', async function () { it('can load tag via slug in url', async function () {
this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建一个标签
this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 直接通过slug访问标签编辑页
await visit('tags/to-be-edited'); await visit('tags/to-be-edited');
// 断言当前URL正确
expect(currentURL()).to.equal('tags/to-be-edited'); expect(currentURL()).to.equal('tags/to-be-edited');
// 断言表单中显示正确的标签信息
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited'); expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited'); expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
}); });
// 测试用例访问不存在的标签时重定向到404页面
it('redirects to 404 when tag does not exist', async function () { it('redirects to 404 when tag does not exist', async function () {
// 模拟请求不存在的标签时返回404错误
this.server.get('/tags/slug/unknown/', function () { this.server.get('/tags/slug/unknown/', function () {
return new Response(404, {'Content-Type': 'application/json'}, {errors: [{message: 'Tag not found.', type: 'NotFoundError'}]}); return new Response(404, { 'Content-Type': 'application/json' }, {
errors: [{ message: 'Tag not found.', type: 'NotFoundError' }]
});
}); });
// 访问不存在的标签页面
await visit('tags/unknown'); await visit('tags/unknown');
// 断言当前路由为404错误页
expect(currentRouteName()).to.equal('error404'); expect(currentRouteName()).to.equal('error404');
// 断言URL保持不变显示错误的URL
expect(currentURL()).to.equal('/tags/unknown'); expect(currentURL()).to.equal('/tags/unknown');
}); });
// 测试用例:创建新标签时导航菜单中"标签"项保持激活状态
it('maintains active state in nav menu when creating a new tag', async function () { it('maintains active state in nav menu when creating a new tag', async function () {
// 访问新建标签页面
await visit('tags/new'); await visit('tags/new');
// 断言当前URL正确
expect(currentURL()).to.equal('tags/new'); expect(currentURL()).to.equal('tags/new');
// 断言导航菜单中"标签"项处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item') expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active'); .to.have.class('active');
}); });
}); });
// 描述"以编辑者Editor身份登录"的测试场景
describe('as an editor', function () { describe('as an editor', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () { beforeEach(async function () {
let role = this.server.create('role', {name: 'Editor'}); // 创建"Editor"角色
this.server.create('user', {roles: [role]}); let role = this.server.create('role', { name: 'Editor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
await authenticateSession(); await authenticateSession();
}); });
it('lists public and internal tags separately', async function () {
this.server.create('tag', {name: 'B - Third', slug: 'third'});
this.server.create('tag', {name: 'Z - Last', slug: 'last'});
this.server.create('tag', {name: '!A - Second', slug: 'second'});
this.server.create('tag', {name: 'A - First', slug: 'first'});
this.server.create('tag', {name: '#one', slug: 'hash-one', visibility: 'internal'});
this.server.create('tag', {name: '#two', slug: 'hash-two', visibility: 'internal'});
// 测试用例:分别列出公开标签和内部标签(与管理员权限一致)
it('lists public and internal tags separately', async function () {
// 创建4个公开标签和2个内部标签同管理员测试用例
this.server.create('tag', { name: 'B - Third', slug: 'third' });
this.server.create('tag', { name: 'Z - Last', slug: 'last' });
this.server.create('tag', { name: '!A - Second', slug: 'second' });
this.server.create('tag', { name: 'A - First', slug: 'first' });
this.server.create('tag', { name: '#one', slug: 'hash-one', visibility: 'internal' });
this.server.create('tag', { name: '#two', slug: 'hash-two', visibility: 'internal' });
// 访问标签页面
await visit('tags'); await visit('tags');
// it loads tags list // 断言页面成功加载
expect(currentURL(), 'currentURL').to.equal('tags'); expect(currentURL(), 'currentURL').to.equal('tags');
// 断言导航菜单激活状态
// it highlights nav menu
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item') expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active'); .to.have.class('active');
// 断言默认显示公开标签
// it defaults to public tags
expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active'); expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active');
expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active'); expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active');
// 断言公开标签数量
// it lists all public tags
expect(findAll('[data-test-tag]'), 'public tag list count') expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4); .to.have.length(4);
// 断言标签排序正确
// tags are in correct order
let tags = findAll('[data-test-tag]'); let tags = findAll('[data-test-tag]');
expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First'); expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First');
expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second'); expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second');
expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third'); expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third');
expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last'); expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last');
// 切换到内部标签
// can switch to internal tags
await click('[data-test-tags-nav="internal"]'); await click('[data-test-tags-nav="internal"]');
// 断言内部标签数量
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2); expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
}); });
// 测试用例:可以编辑标签(与管理员权限一致)
it('can edit tags', async function () { it('can edit tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建待编辑标签
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 访问标签页面并进入编辑页
await visit('tags'); await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`); await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// it maintains active state in nav menu // 断言导航菜单激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item') expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active'); .to.have.class('active');
// 断言当前URL
expect(currentURL()).to.equal('/tags/to-be-edited'); expect(currentURL()).to.equal('/tags/to-be-edited');
// 断言初始表单值
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited'); expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited'); expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
// 修改并保存标签
await fillIn('[data-test-input="tag-name"]', 'New tag name'); await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug'); await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
await click('[data-test-button="save"]'); await click('[data-test-button="save"]');
// 断言数据已更新
const savedTag = this.server.db.tags.find(tag.id); const savedTag = this.server.db.tags.find(tag.id);
expect(savedTag.name, 'saved tag name').to.equal('New tag name'); expect(savedTag.name, 'saved tag name').to.equal('New tag name');
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug'); expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
// 返回列表页并断言显示正确
await click('[data-test-link="tags-back"]'); await click('[data-test-link="tags-back"]');
const tagListItem = find('[data-test-tag]'); const tagListItem = find('[data-test-tag]');
expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name'); expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name');
expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug'); expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
}); });
// 测试用例:可以删除标签(与管理员权限一致)
it('can delete tags', async function () { it('can delete tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建待删除标签及关联文章
this.server.create('post', {tags: [tag]}); const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
this.server.create('post', { tags: [tag] });
// 访问标签页面并进入编辑页
await visit('tags'); await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`); await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 点击删除按钮
await click('[data-test-button="delete-tag"]'); await click('[data-test-button="delete-tag"]');
const tagModal = '[data-test-modal="confirm-delete-tag"]'; const tagModal = '[data-test-modal="confirm-delete-tag"]';
// 断言弹窗显示及内容正确
expect(find(tagModal)).to.exist; expect(find(tagModal)).to.exist;
expect(find(`${tagModal} [data-test-text="posts-count"]`)) expect(find(`${tagModal} [data-test-text="posts-count"]`))
.to.have.trimmed.text('1 post'); .to.have.trimmed.text('1 post');
// 确认删除
await click(`${tagModal} [data-test-button="confirm"]`); await click(`${tagModal} [data-test-button="confirm"]`);
// 断言标签已删除
expect(find(tagModal)).to.not.exist; expect(find(tagModal)).to.not.exist;
expect(currentURL()).to.equal('/tags'); expect(currentURL()).to.equal('/tags');
expect(findAll('[data-test-tag]')).to.have.length(0); expect(findAll('[data-test-tag]')).to.have.length(0);
}); });
}); });
// 描述"以超级编辑者Super Editor身份登录"的测试场景
describe('as a super editor', function () { describe('as a super editor', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () { beforeEach(async function () {
let role = this.server.create('role', {name: 'Super Editor'}); // 创建"Super Editor"角色
this.server.create('user', {roles: [role]}); let role = this.server.create('role', { name: 'Super Editor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
await authenticateSession(); await authenticateSession();
}); });
it('lists public and internal tags separately', async function () {
this.server.create('tag', {name: 'B - Third', slug: 'third'});
this.server.create('tag', {name: 'Z - Last', slug: 'last'});
this.server.create('tag', {name: '!A - Second', slug: 'second'});
this.server.create('tag', {name: 'A - First', slug: 'first'});
this.server.create('tag', {name: '#one', slug: 'hash-one', visibility: 'internal'});
this.server.create('tag', {name: '#two', slug: 'hash-two', visibility: 'internal'});
// 测试用例:分别列出公开标签和内部标签(与管理员权限一致)
it('lists public and internal tags separately', async function () {
// 创建4个公开标签和2个内部标签同管理员测试用例
this.server.create('tag', { name: 'B - Third', slug: 'third' });
this.server.create('tag', { name: 'Z - Last', slug: 'last' });
this.server.create('tag', { name: '!A - Second', slug: 'second' });
this.server.create('tag', { name: 'A - First', slug: 'first' });
this.server.create('tag', { name: '#one', slug: 'hash-one', visibility: 'internal' });
this.server.create('tag', { name: '#two', slug: 'hash-two', visibility: 'internal' });
// 访问标签页面
await visit('tags'); await visit('tags');
// it loads tags list // 断言页面加载及标签展示正确(与管理员测试用例一致)
expect(currentURL(), 'currentURL').to.equal('tags'); expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item') expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active'); .to.have.class('active');
// it defaults to public tags
expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active'); expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active');
expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active'); expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags
expect(findAll('[data-test-tag]'), 'public tag list count') expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4); .to.have.length(4);
// tags are in correct order
let tags = findAll('[data-test-tag]'); let tags = findAll('[data-test-tag]');
expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First'); expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First');
expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second'); expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second');
expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third'); expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third');
expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last'); expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last');
// can switch to internal tags
await click('[data-test-tags-nav="internal"]'); await click('[data-test-tags-nav="internal"]');
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2); expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
}); });
// 测试用例:可以编辑标签(与管理员权限一致)
it('can edit tags', async function () { it('can edit tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建待编辑标签(测试步骤与管理员测试用例一致)
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
await visit('tags'); await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`); await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// it maintains active state in nav menu
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item') expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active'); .to.have.class('active');
expect(currentURL()).to.equal('/tags/to-be-edited'); expect(currentURL()).to.equal('/tags/to-be-edited');
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited'); expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited'); expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
@ -368,21 +476,21 @@ describe('Acceptance: Tags', function () {
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug'); expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
await click('[data-test-link="tags-back"]'); await click('[data-test-link="tags-back"]');
const tagListItem = find('[data-test-tag]'); const tagListItem = find('[data-test-tag]');
expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name'); expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name');
expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug'); expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
}); });
// 测试用例:可以删除标签(与管理员权限一致)
it('can delete tags', async function () { it('can delete tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'}); // 创建待删除标签及关联文章(测试步骤与管理员测试用例一致)
this.server.create('post', {tags: [tag]}); const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
this.server.create('post', { tags: [tag] });
await visit('tags'); await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`); await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
await click('[data-test-button="delete-tag"]'); await click('[data-test-button="delete-tag"]');
const tagModal = '[data-test-modal="confirm-delete-tag"]'; const tagModal = '[data-test-modal="confirm-delete-tag"]';
expect(find(tagModal)).to.exist; expect(find(tagModal)).to.exist;
@ -396,4 +504,4 @@ describe('Acceptance: Tags', function () {
expect(findAll('[data-test-tag]')).to.have.length(0); expect(findAll('[data-test-tag]')).to.have.length(0);
}); });
}); });
}); });

@ -1,37 +1,63 @@
/**
* 启用指定的实验室功能标志Labs Flag
* 用于在测试环境中开启特定的实验性功能
* @param {Object} server - Mirage JS服务器实例
* @param {string} flag - 要启用的实验室功能标志名称
*/
export function enableLabsFlag(server, flag) { export function enableLabsFlag(server, flag) {
// 若配置表为空,加载配置 fixtures 数据
if (!server.schema.configs.all().length) { if (!server.schema.configs.all().length) {
server.loadFixtures('configs'); server.loadFixtures('configs');
} }
// 若设置表为空,加载设置 fixtures 数据
if (!server.schema.settings.all().length) { if (!server.schema.settings.all().length) {
server.loadFixtures('settings'); server.loadFixtures('settings');
} }
// 获取第一个配置项,开启开发者实验功能总开关
const config = server.schema.configs.first(); const config = server.schema.configs.first();
config.update({enableDeveloperExperiments: true}); config.update({ enableDeveloperExperiments: true });
const existingSetting = server.db.settings.findBy({key: 'labs'}).value; // 获取现有的实验室功能设置JSON字符串
const existingSetting = server.db.settings.findBy({ key: 'labs' }).value;
// 解析为对象(若不存在则初始化为空对象)
const labsSetting = existingSetting ? JSON.parse(existingSetting) : {}; const labsSetting = existingSetting ? JSON.parse(existingSetting) : {};
// 启用目标功能标志
labsSetting[flag] = true; labsSetting[flag] = true;
server.db.settings.update({key: 'labs'}, {value: JSON.stringify(labsSetting)}); // 更新数据库中的实验室设置转换为JSON字符串存储
server.db.settings.update({ key: 'labs' }, { value: JSON.stringify(labsSetting) });
} }
/**
* 禁用指定的实验室功能标志Labs Flag
* 用于在测试环境中关闭特定的实验性功能
* @param {Object} server - Mirage JS服务器实例
* @param {string} flag - 要禁用的实验室功能标志名称
*/
export function disableLabsFlag(server, flag) { export function disableLabsFlag(server, flag) {
// 若配置表为空,加载配置 fixtures 数据
if (!server.schema.configs.all().length) { if (!server.schema.configs.all().length) {
server.loadFixtures('configs'); server.loadFixtures('configs');
} }
// 若设置表为空,加载设置 fixtures 数据
if (!server.schema.settings.all().length) { if (!server.schema.settings.all().length) {
server.loadFixtures('settings'); server.loadFixtures('settings');
} }
// 获取第一个配置项,确保开发者实验功能总开关开启(避免功能被全局禁用)
const config = server.schema.configs.first(); const config = server.schema.configs.first();
config.update({enableDeveloperExperiments: true}); config.update({ enableDeveloperExperiments: true });
const existingSetting = server.db.settings.findBy({key: 'labs'}).value; // 获取现有的实验室功能设置JSON字符串
const existingSetting = server.db.settings.findBy({ key: 'labs' }).value;
// 解析为对象(若不存在则初始化为空对象)
const labsSetting = existingSetting ? JSON.parse(existingSetting) : {}; const labsSetting = existingSetting ? JSON.parse(existingSetting) : {};
// 禁用目标功能标志
labsSetting[flag] = false; labsSetting[flag] = false;
server.db.settings.update({key: 'labs'}, {value: JSON.stringify(labsSetting)}); // 更新数据库中的实验室设置转换为JSON字符串存储
} server.db.settings.update({ key: 'labs' }, { value: JSON.stringify(labsSetting) });
}

@ -1,60 +1,92 @@
import Pretender from 'pretender'; import Pretender from 'pretender'; // 导入Pretender库用于模拟HTTP请求
import ghostPaths from 'ghost-admin/utils/ghost-paths'; import ghostPaths from 'ghost-admin/utils/ghost-paths'; // 导入Ghost路径工具用于获取API根路径
import {describe, it} from 'mocha'; import { describe, it } from 'mocha'; // 导入Mocha测试框架的描述和测试用例函数
import {expect} from 'chai'; import { expect } from 'chai'; // 导入Chai断言库
import {setupTest} from 'ember-mocha'; import { setupTest } from 'ember-mocha'; // 导入Ember测试设置工具
// 描述"标签适配器Adapter: tag"的集成测试套件
describe('Integration: Adapter: tag', function () { describe('Integration: Adapter: tag', function () {
// 设置测试环境初始化Ember测试容器
setupTest(); setupTest();
// 声明变量:模拟服务器和数据存储服务
let server, store; let server, store;
// 每个测试用例执行前的准备工作
beforeEach(function () { beforeEach(function () {
// 获取Ember的数据存储服务store
store = this.owner.lookup('service:store'); store = this.owner.lookup('service:store');
// 创建Pretender模拟服务器实例用于拦截和模拟API请求
server = new Pretender(); server = new Pretender();
}); });
// 每个测试用例执行后的清理工作
afterEach(function () { afterEach(function () {
// 关闭模拟服务器,避免影响其他测试
server.shutdown(); server.shutdown();
}); });
// 测试用例获取所有标签时从常规API端点加载数据
it('loads tags from regular endpoint when all are fetched', function (done) { it('loads tags from regular endpoint when all are fetched', function (done) {
// 模拟GET请求当请求标签列表API时返回预设的标签数据
server.get(`${ghostPaths().apiRoot}/tags/`, function () { server.get(`${ghostPaths().apiRoot}/tags/`, function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [ return [
{ 200, // HTTP状态码成功
id: 1, { 'Content-Type': 'application/json' }, // 响应头JSON格式
name: 'Tag 1', JSON.stringify({ // 响应体:包含两个标签的数组
slug: 'tag-1' tags: [
}, { {
id: 2, id: 1,
name: 'Tag 2', name: 'Tag 1',
slug: 'tag-2' slug: 'tag-1'
} }, {
]})]; id: 2,
name: 'Tag 2',
slug: 'tag-2'
}
]
})
];
}); });
store.findAll('tag', {reload: true}).then((tags) => { // 使用store查询所有标签强制重新加载
store.findAll('tag', { reload: true }).then((tags) => {
// 断言:查询结果存在
expect(tags).to.be.ok; expect(tags).to.be.ok;
// 断言:第一个标签的名称正确
expect(tags.objectAtContent(0).get('name')).to.equal('Tag 1'); expect(tags.objectAtContent(0).get('name')).to.equal('Tag 1');
// 标记测试完成
done(); done();
}); });
}); });
// 测试用例查询单个标签且传入slug时从slug专属API端点加载数据
it('loads tag from slug endpoint when single tag is queried and slug is passed in', function (done) { it('loads tag from slug endpoint when single tag is queried and slug is passed in', function (done) {
// 模拟GET请求当请求特定slug的标签API时返回预设的标签数据
server.get(`${ghostPaths().apiRoot}/tags/slug/tag-1/`, function () { server.get(`${ghostPaths().apiRoot}/tags/slug/tag-1/`, function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [ return [
{ 200, // HTTP状态码成功
id: 1, { 'Content-Type': 'application/json' }, // 响应头JSON格式
slug: 'tag-1', JSON.stringify({ // 响应体包含指定slug的标签
name: 'Tag 1' tags: [
} {
]})]; id: 1,
slug: 'tag-1',
name: 'Tag 1'
}
]
})
];
}); });
store.queryRecord('tag', {slug: 'tag-1'}).then((tag) => { // 使用store按slug查询单个标签
store.queryRecord('tag', { slug: 'tag-1' }).then((tag) => {
// 断言:查询结果存在
expect(tag).to.be.ok; expect(tag).to.be.ok;
// 断言:标签名称正确
expect(tag.get('name')).to.equal('Tag 1'); expect(tag.get('name')).to.equal('Tag 1');
// 标记测试完成
done(); done();
}); });
}); });
}); });

@ -1,253 +1,358 @@
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import mockPosts from '../../../mirage/config/posts'; import mockPosts from '../../../mirage/config/posts';
import mockTags from '../../../mirage/config/themes'; import mockTags from '../../../mirage/config/themes';
import {click, find, findAll, render, settled, waitUntil} from '@ember/test-helpers'; import { click, find, findAll, render, settled, waitUntil } from '@ember/test-helpers';
import {clickTrigger, selectChoose, typeInSearch} from 'ember-power-select/test-support/helpers'; import { clickTrigger, selectChoose, typeInSearch } from 'ember-power-select/test-support/helpers';
import {describe, it} from 'mocha'; import { describe, it } from 'mocha';
import {expect} from 'chai'; import { expect } from 'chai';
import {setupRenderingTest} from 'ember-mocha'; import { setupRenderingTest } from 'ember-mocha';
import {startMirage} from 'ghost-admin/initializers/ember-cli-mirage'; import { startMirage } from 'ghost-admin/initializers/ember-cli-mirage';
import {timeout} from 'ember-concurrency'; import { timeout } from 'ember-concurrency';
// NOTE: although Mirage has posts<->tags relationship and can respond // 注意尽管Mirage中存在文章与标签的关联关系且能响应带include=tags的请求
// to :post-id/?include=tags all ordering information is lost so we //
// need to build the tags array manually // 中文说明:
// - 本集成测试依赖 Mirage 提供的 tags mock 数据及 API 路由用于验证标签输入组件GhPsmTagsInput
// 在不同场景下(渲染已选标签、本地/服务端搜索、创建/删除标签等)的行为是否正确。
// - Mirage 在模拟时可能丢失后端在排序/pagination 上的一些真实细节(例如特定排序字段),
// 因此测试中在需要断言顺序或构建复杂关联时,会手动调整或重建标签数组以保证一致性。
// - 当修改 Mirage 工厂/序列化器或标签相关 API 时,请同时更新此测试以保持兼容性。
const assignPostWithTags = async function postWithTags(context, ...slugs) { const assignPostWithTags = async function postWithTags(context, ...slugs) {
// 获取ID为1的文章
let post = await context.store.findRecord('post', 1); let post = await context.store.findRecord('post', 1);
// 获取所有标签
let tags = await context.store.findAll('tag'); let tags = await context.store.findAll('tag');
// 为文章添加指定slug的标签
slugs.forEach((slug) => { slugs.forEach((slug) => {
post.get('tags').pushObject(tags.findBy('slug', slug)); post.get('tags').pushObject(tags.findBy('slug', slug));
}); });
// 将文章设置到测试上下文并等待异步操作完成
context.set('post', post); context.set('post', post);
await settled(); await settled();
}; };
// 描述"标签输入组件GhPsmTagsInput"的集成测试套件
describe('Integration: Component: gh-psm-tags-input', function () { describe('Integration: Component: gh-psm-tags-input', function () {
// 设置渲染测试环境
setupRenderingTest(); setupRenderingTest();
// 声明模拟服务器变量
let server; let server;
// 每个测试用例执行前的准备工作
beforeEach(function () { beforeEach(function () {
// 启动Mirage模拟服务器
server = startMirage(); server = startMirage();
// 创建作者用户
let author = server.create('user'); let author = server.create('user');
// 加载文章和标签的模拟数据配置
mockPosts(server); mockPosts(server);
mockTags(server); mockTags(server);
server.create('post', {authors: [author]}); // 创建关联作者的文章
server.create('tag', {name: 'Tag 1', slug: 'one'}); server.create('post', { authors: [author] });
server.create('tag', {name: '#Tag 2', visibility: 'internal', slug: 'two'}); // 创建测试标签
server.create('tag', {name: 'Tag 3', slug: 'three'}); server.create('tag', { name: 'Tag 1', slug: 'one' });
server.create('tag', {name: 'Tag 4', slug: 'four'}); server.create('tag', { name: '#Tag 2', visibility: 'internal', slug: 'two' });
server.create('tag', { name: 'Tag 3', slug: 'three' });
server.create('tag', { name: 'Tag 4', slug: 'four' });
// 将数据存储服务设置到测试上下文
this.set('store', this.owner.lookup('service:store')); this.set('store', this.owner.lookup('service:store'));
}); });
// 每个测试用例执行后的清理工作
afterEach(function () { afterEach(function () {
// 关闭模拟服务器
server.shutdown(); server.shutdown();
}); });
// 测试用例:渲染时显示已选择的标签
it('shows selected tags on render', async function () { it('shows selected tags on render', async function () {
// 为文章分配标签"one"和"three"
await assignPostWithTags(this, 'one', 'three'); await assignPostWithTags(this, 'one', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 获取所有已选择的标签令牌
let selected = findAll('.tag-token'); let selected = findAll('.tag-token');
// 断言显示2个已选择的标签
expect(selected.length).to.equal(2); expect(selected.length).to.equal(2);
// 断言:标签文本正确
expect(selected[0]).to.contain.text('Tag 1'); expect(selected[0]).to.contain.text('Tag 1');
expect(selected[1]).to.contain.text('Tag 3'); expect(selected[1]).to.contain.text('Tag 3');
}); });
// skipped because FF 85 on Linux (CI) is failing. FF 85 on mac is fine. // 测试用例:以字母顺序显示所有标签选项(因浏览器兼容性问题暂时跳过)
// possible difference in `localeCompare()` across systems // 跳过原因Linux上的FF 85CI环境失败mac上的FF 85正常
// 可能是不同系统上`localeCompare()`的实现差异导致
it.skip('exposes all tags as options sorted alphabetically', async function () { it.skip('exposes all tags as options sorted alphabetically', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1)); this.set('post', this.store.findRecord('post', 1));
await settled(); await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger(); await clickTrigger();
await settled(); await settled();
// unsure why settled() is sometimes not catching the update // 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100); await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option'); let options = findAll('.ember-power-select-option');
// 断言显示4个标签选项
expect(options.length).to.equal(4); expect(options.length).to.equal(4);
// 断言:选项按字母顺序排列
expect(options[0]).to.contain.text('Tag 1'); expect(options[0]).to.contain.text('Tag 1');
expect(options[1]).to.contain.text('#Tag 2'); expect(options[1]).to.contain.text('#Tag 2');
expect(options[2]).to.contain.text('Tag 3'); expect(options[2]).to.contain.text('Tag 3');
expect(options[3]).to.contain.text('Tag 4'); expect(options[3]).to.contain.text('Tag 4');
}); });
// 测试用例:如果第一页已加载所有标签,则使用本地搜索
it('uses local search if all tags have been loaded in first page', async function () { it('uses local search if all tags have been loaded in first page', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1)); this.set('post', this.store.findRecord('post', 1));
await settled(); await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger(); await clickTrigger();
await settled(); await settled();
// 记录当前请求次数
const requestCount = server.pretender.handledRequests.length; const requestCount = server.pretender.handledRequests.length;
// 等待选项加载完成
await waitUntil(() => findAll('.ember-power-select-option').length >= 4); await waitUntil(() => findAll('.ember-power-select-option').length >= 4);
// 输入搜索关键词
await typeInSearch('2'); await typeInSearch('2');
await settled(); await settled();
// 断言:搜索未触发新的请求(使用本地搜索)
expect(server.pretender.handledRequests.length).to.equal(requestCount); expect(server.pretender.handledRequests.length).to.equal(requestCount);
}); });
// 测试用例:如果通过滚动加载了所有标签,则使用本地搜索
it('uses local search if all tags have been loaded by scrolling', async function () { it('uses local search if all tags have been loaded by scrolling', async function () {
// create > 1 page of tags. Left-pad the names to ensure they're sorted alphabetically // 创建超过1页的标签150个。左填充名称以确保按字母顺序排序
server.db.tags.remove(); // clear existing tags that will mess with alphabetical sorting server.db.tags.remove(); // 清除可能干扰字母排序的现有标签
server.createList('tag', 150, {name: i => `Tag ${i.toString().padStart(3, '0')}`}); server.createList('tag', 150, { name: i => `Tag ${i.toString().padStart(3, '0')}` });
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1)); this.set('post', this.store.findRecord('post', 1));
await settled(); await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger(); await clickTrigger();
// although we load 100 per page, we'll never have more 50 options rendered // 尽管每页加载100个但由于使用vertical-collection回收DOM元素最多显示50个选项
// because we use vertical-collection to recycle dom elements on scroll await waitUntil(
await waitUntil(() => findAll('.ember-power-select-option').length >= 50, {timeoutMessage: 'Timed out waiting for first page loaded state'}); () => findAll('.ember-power-select-option').length >= 50,
{ timeoutMessage: '等待第一页加载超时' }
);
// scroll to the bottom of the options to load the next page // 滚动到选项底部以加载下一页
const optionsContent = find('.ember-power-select-options'); const optionsContent = find('.ember-power-select-options');
optionsContent.scrollTo({top: optionsContent.scrollHeight}); optionsContent.scrollTo({ top: optionsContent.scrollHeight });
await settled(); await settled();
// wait for second page to be loaded // 等待第二页加载完成
await waitUntil(() => server.pretender.handledRequests.some(r => r.queryParams.page === '2')); await waitUntil(
optionsContent.scrollTo({top: optionsContent.scrollHeight}); () => server.pretender.handledRequests.some(r => r.queryParams.page === '2')
await waitUntil(() => findAll('.ember-power-select-option').some(o => o.textContent.includes('Tag 105')), {timeoutMessage: 'Timed out waiting for second page loaded state'}); );
optionsContent.scrollTo({ top: optionsContent.scrollHeight });
// capture current request count - we test that it doesn't change to indicate a client-side filter await waitUntil(
() => findAll('.ember-power-select-option').some(o => o.textContent.includes('Tag 105')),
{ timeoutMessage: '等待第二页加载超时' }
);
// 记录当前请求次数 - 测试是否未发送新请求(表明使用客户端过滤)
const requestCount = server.pretender.handledRequests.length; const requestCount = server.pretender.handledRequests.length;
// 输入搜索关键词
await typeInSearch('21'); await typeInSearch('21');
await settled(); await settled();
// wait until we're sure we've filtered // 等待过滤完成
await waitUntil(() => findAll('.ember-power-select-option').length <= 5, {timeoutMessage: 'Timed out waiting for filtered state'}); await waitUntil(
() => findAll('.ember-power-select-option').length <= 5,
{ timeoutMessage: '等待过滤状态超时' }
);
// request count should not increase if we've used client-side filtering // 断言:请求次数未增加(使用客户端过滤)
expect(server.pretender.handledRequests.length).to.equal(requestCount); expect(server.pretender.handledRequests.length).to.equal(requestCount);
}); });
// 描述"客户端搜索"的测试场景
describe('client-side search', function () { describe('client-side search', function () {
// 测试用例:匹配小写标签名称的选项
it('matches options on lowercase tag names', async function () { it('matches options on lowercase tag names', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1)); this.set('post', this.store.findRecord('post', 1));
await settled(); await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入搜索关键词
await clickTrigger(); await clickTrigger();
await typeInSearch('2'); await typeInSearch('2');
await settled(); await settled();
// unsure why settled() is sometimes not catching the update // 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100); await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option'); let options = findAll('.ember-power-select-option');
// 断言显示2个匹配选项
expect(options.length).to.equal(2); expect(options.length).to.equal(2);
// 断言:选项包含"Add "2"..."和"Tag 2"
expect(options[0]).to.contain.text('Add "2"...'); expect(options[0]).to.contain.text('Add "2"...');
expect(options[1]).to.contain.text('Tag 2'); expect(options[1]).to.contain.text('Tag 2');
}); });
// 测试用例:精确匹配时隐藏创建选项
it('hides create option on exact matches', async function () { it('hides create option on exact matches', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1)); this.set('post', this.store.findRecord('post', 1));
await settled(); await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入精确匹配的关键词
await clickTrigger(); await clickTrigger();
await typeInSearch('#Tag 2'); await typeInSearch('#Tag 2');
await settled(); await settled();
// unsure why settled() is sometimes not catching the update // 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100); await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option'); let options = findAll('.ember-power-select-option');
// 断言只显示1个精确匹配的选项
expect(options.length).to.equal(1); expect(options.length).to.equal(1);
expect(options[0]).to.contain.text('#Tag 2'); expect(options[0]).to.contain.text('#Tag 2');
}); });
// 测试用例:可以搜索包含单引号的标签
it('can search for tags with single quotes', async function () { it('can search for tags with single quotes', async function () {
server.create('tag', {name: 'O\'Nolan', slug: 'quote-test'}); // 创建包含单引号的标签
server.create('tag', { name: 'O\'Nolan', slug: 'quote-test' });
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1)); this.set('post', this.store.findRecord('post', 1));
await settled(); await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入包含单引号的搜索关键词
await clickTrigger(); await clickTrigger();
await typeInSearch(`O'`); await typeInSearch(`O'`);
await settled(); await settled();
// 获取所有选项
let options = findAll('.ember-power-select-option'); let options = findAll('.ember-power-select-option');
// 断言显示2个匹配选项
expect(options.length).to.equal(2); expect(options.length).to.equal(2);
expect(options[0]).to.contain.text(`Add "O'"...`); expect(options[0]).to.contain.text(`Add "O'"...`);
expect(options[1]).to.contain.text(`O'Nolan`); expect(options[1]).to.contain.text(`O'Nolan`);
}); });
}); });
// 描述"服务器端搜索"的测试场景(暂未实现测试用例)
describe('server-side search', function () { describe('server-side search', function () {
}); });
// 测试用例:高亮显示内部标签
it('highlights internal tags', async function () { it('highlights internal tags', async function () {
// 为文章分配标签"two"(内部标签)和"three"
await assignPostWithTags(this, 'two', 'three'); await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 获取所有已选择的标签令牌
let selected = findAll('.tag-token'); let selected = findAll('.tag-token');
// 断言显示2个已选择的标签
expect(selected.length).to.equal(2); expect(selected.length).to.equal(2);
// 断言:内部标签有特殊样式类,普通标签没有
expect(selected[0]).to.have.class('tag-token--internal'); expect(selected[0]).to.have.class('tag-token--internal');
expect(selected[1]).to.not.have.class('tag-token--internal'); expect(selected[1]).to.not.have.class('tag-token--internal');
}); });
// 描述"更新标签updateTags"的测试场景
describe('updateTags', function () { describe('updateTags', function () {
// 测试用例:修改文章的标签列表
it('modifies post.tags', async function () { it('modifies post.tags', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three'); await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 选择"Tag 1"标签
await selectChoose('.ember-power-select-trigger', 'Tag 1'); await selectChoose('.ember-power-select-trigger', 'Tag 1');
// 断言:文章的标签列表已更新
expect( expect(
this.post.tags.mapBy('name').join(',') this.post.tags.mapBy('name').join(',')
).to.equal('#Tag 2,Tag 3,Tag 1'); ).to.equal('#Tag 2,Tag 3,Tag 1');
}); });
// TODO: skipped due to consistently random failures on Travis // 测试用例未选中时销毁新标签记录因Travis上持续随机失败暂时跳过
// '#ember-basic-dropdown-content-ember17494 Add "New"...' is not a valid selector // 失败原因:选择器无效 '#ember-basic-dropdown-content-ember17494 Add "New"...'
// https://github.com/TryGhost/Ghost/issues/10308 // 相关Issuehttps://github.com/TryGhost/Ghost/issues/10308
it.skip('destroys new tag records when not selected', async function () { it.skip('destroys new tag records when not selected', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three'); await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项,输入新标签名称并选择创建选项
await clickTrigger(); await clickTrigger();
await typeInSearch('New'); await typeInSearch('New');
await settled(); await settled();
await selectChoose('.ember-power-select-trigger', 'Add "New"...'); await selectChoose('.ember-power-select-trigger', 'Add "New"...');
// 断言标签数量增加到5原有4个+新增1个
let tags = await this.store.peekAll('tag'); let tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(5); expect(tags.length).to.equal(5);
// 点击移除最后一个标签(新创建的标签)
let removeBtns = findAll('.ember-power-select-multiple-remove-btn'); let removeBtns = findAll('.ember-power-select-multiple-remove-btn');
await click(removeBtns[removeBtns.length - 1]); await click(removeBtns[removeBtns.length - 1]);
// 断言新标签记录已被销毁标签数量回到4
tags = await this.store.peekAll('tag'); tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(4); expect(tags.length).to.equal(4);
}); });
}); });
// 描述"创建标签createTag"的测试场景
describe('createTag', function () { describe('createTag', function () {
// 测试用例:创建新的标签记录
it('creates new records', async function () { it('creates new records', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three'); await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`); await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项,输入第一个新标签名称并选择创建选项
await clickTrigger(); await clickTrigger();
await typeInSearch('New One'); await typeInSearch('New One');
await settled(); await settled();
await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0); await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0);
// 输入第二个新标签名称并选择创建选项
await typeInSearch('New Two'); await typeInSearch('New Two');
await settled(); await settled();
await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0); await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0);
// 断言标签数量增加到6原有4个+新增2个
let tags = await this.store.peekAll('tag'); let tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(6); expect(tags.length).to.equal(6);
// 断言新创建的标签记录处于isNew状态未保存到服务器
expect(tags.findBy('name', 'New One').isNew).to.be.true; expect(tags.findBy('name', 'New One').isNew).to.be.true;
expect(tags.findBy('name', 'New Two').isNew).to.be.true; expect(tags.findBy('name', 'New Two').isNew).to.be.true;
}); });
}); });
}); });

@ -1,13 +1,23 @@
import {click, findAll, render, triggerKeyEvent} from '@ember/test-helpers'; import { click, findAll, render, triggerKeyEvent } from '@ember/test-helpers';
import {describe, it} from 'mocha'; import { describe, it } from 'mocha';
import {expect} from 'chai'; import { expect } from 'chai';
import {hbs} from 'ember-cli-htmlbars'; import { hbs } from 'ember-cli-htmlbars';
import {setupRenderingTest} from 'ember-mocha'; import { setupRenderingTest } from 'ember-mocha';
/**
* 标签页组件Tabs::Tabs的集成测试
* 验证组件的渲染效果交互行为及特殊配置的功能
*/
describe('Integration: Component: tabs/tabs', function () { describe('Integration: Component: tabs/tabs', function () {
// 设置渲染测试环境
setupRenderingTest(); setupRenderingTest();
/**
* 测试组件基础渲染效果
* 验证初始状态下标签按钮面板的数量选中状态及内容
*/
it('renders', async function () { it('renders', async function () {
// 渲染标签页组件包含2个标签和对应的面板
await render(hbs` await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|> <Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab> <tabs.tab>Tab 1</tabs.tab>
@ -17,27 +27,37 @@ describe('Integration: Component: tabs/tabs', function () {
<tabs.tabPanel>Content 2</tabs.tabPanel> <tabs.tabPanel>Content 2</tabs.tabPanel>
</Tabs::Tabs>`); </Tabs::Tabs>`);
// 获取标签按钮和面板元素
const tabButtons = findAll('.tab'); const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel'); const tabPanels = findAll('.tab-panel');
expect(findAll('.test-tab').length).to.equal(1); // 验证组件容器和列表结构
expect(findAll('.tab-list').length).to.equal(1); expect(findAll('.test-tab').length).to.equal(1); // 组件容器存在
expect(tabPanels.length).to.equal(2); expect(findAll('.tab-list').length).to.equal(1); // 标签列表容器存在
expect(tabButtons.length).to.equal(2); expect(tabPanels.length).to.equal(2); // 面板数量正确
expect(tabButtons.length).to.equal(2); // 标签按钮数量正确
expect(findAll('.tab-selected').length).to.equal(1); // 验证初始选中状态
expect(findAll('.tab-panel-selected').length).to.equal(1); expect(findAll('.tab-selected').length).to.equal(1); // 只有一个选中的标签
expect(tabButtons[0]).to.have.class('tab-selected'); expect(findAll('.tab-panel-selected').length).to.equal(1); // 只有一个选中的面板
expect(tabPanels[0]).to.have.class('tab-panel-selected'); expect(tabButtons[0]).to.have.class('tab-selected'); // 第一个标签默认选中
expect(tabPanels[0]).to.have.class('tab-panel-selected'); // 第一个面板默认选中
// 验证标签按钮文本
expect(tabButtons[0]).to.have.trimmed.text('Tab 1'); expect(tabButtons[0]).to.have.trimmed.text('Tab 1');
expect(tabButtons[1]).to.have.trimmed.text('Tab 2'); expect(tabButtons[1]).to.have.trimmed.text('Tab 2');
// 验证面板内容(未选中的面板内容为空)
expect(tabPanels[0]).to.have.trimmed.text('Content 1'); expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text(''); expect(tabPanels[1]).to.have.trimmed.text('');
}); });
/**
* 测试点击标签时的交互效果
* 验证点击后标签和面板的选中状态及内容切换
*/
it('renders expected content on click', async function () { it('renders expected content on click', async function () {
// 渲染标签页组件
await render(hbs` await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|> <Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab> <tabs.tab>Tab 1</tabs.tab>
@ -50,18 +70,26 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab'); const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel'); const tabPanels = findAll('.tab-panel');
// 点击第二个标签
await click(tabButtons[1]); await click(tabButtons[1]);
// 验证选中状态切换
expect(findAll('.tab-selected').length).to.equal(1); expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1); expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[1]).to.have.class('tab-selected'); expect(tabButtons[1]).to.have.class('tab-selected'); // 第二个标签被选中
expect(tabPanels[1]).to.have.class('tab-panel-selected'); expect(tabPanels[1]).to.have.class('tab-panel-selected'); // 第二个面板被选中
// 验证面板内容切换(未选中的面板内容为空)
expect(tabPanels[0]).to.have.trimmed.text(''); expect(tabPanels[0]).to.have.trimmed.text('');
expect(tabPanels[1]).to.have.trimmed.text('Content 2'); expect(tabPanels[1]).to.have.trimmed.text('Content 2');
}); });
/**
* 测试键盘事件对标签的控制
* 验证方向键HomeEnd键的导航功能
*/
it('renders expected content on keyup event', async function () { it('renders expected content on keyup event', async function () {
// 渲染包含3个标签的组件
await render(hbs` await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|> <Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 0</tabs.tab> <tabs.tab>Tab 0</tabs.tab>
@ -76,34 +104,45 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab'); const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel'); const tabPanels = findAll('.tab-panel');
// 辅助函数:验证指定索引的标签和面板处于选中状态且内容正确
const isTabRenders = (num) => { const isTabRenders = (num) => {
expect(tabButtons[num]).to.have.class('tab-selected'); expect(tabButtons[num]).to.have.class('tab-selected');
expect(tabPanels[num]).to.have.class('tab-panel-selected'); expect(tabPanels[num]).to.have.class('tab-panel-selected');
expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`); expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`);
}; };
// 右方向键导航从0→1→2
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight'); await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight');
await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight'); await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight');
isTabRenders(2); isTabRenders(2);
// 右方向键循环从2→0
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight'); await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight');
isTabRenders(0); isTabRenders(0);
// 左方向键导航从0→2
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft'); await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft');
isTabRenders(2); isTabRenders(2);
// 左方向键导航从2→1
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft'); await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft');
isTabRenders(1); isTabRenders(1);
// Home键跳转到第一个标签
await triggerKeyEvent(tabButtons[0], 'keyup', 'Home'); await triggerKeyEvent(tabButtons[0], 'keyup', 'Home');
isTabRenders(0); isTabRenders(0);
// End键跳转到最后一个标签
await triggerKeyEvent(tabButtons[0], 'keyup', 'End'); await triggerKeyEvent(tabButtons[0], 'keyup', 'End');
isTabRenders(2); isTabRenders(2);
}); });
/**
* 测试forceRender参数的效果
* 验证开启后所有面板内容始终渲染不随选中状态隐藏
*/
it('renders content for all tabs with forceRender option', async function () { it('renders content for all tabs with forceRender option', async function () {
// 渲染开启forceRender的标签页组件
await render(hbs` await render(hbs`
<Tabs::Tabs class="test-tab" @forceRender={{true}} as |tabs|> <Tabs::Tabs class="test-tab" @forceRender={{true}} as |tabs|>
<tabs.tab>Tab 1</tabs.tab> <tabs.tab>Tab 1</tabs.tab>
@ -116,17 +155,21 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab'); const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel'); const tabPanels = findAll('.tab-panel');
// 初始状态:所有面板内容都渲染
expect(tabPanels[0]).to.have.trimmed.text('Content 1'); expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('Content 2'); expect(tabPanels[1]).to.have.trimmed.text('Content 2');
// 点击第二个标签
await click(tabButtons[1]); await click(tabButtons[1]);
// 选中状态正常切换
expect(findAll('.tab-selected').length).to.equal(1); expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1); expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[1]).to.have.class('tab-selected'); expect(tabButtons[1]).to.have.class('tab-selected');
expect(tabPanels[1]).to.have.class('tab-panel-selected'); expect(tabPanels[1]).to.have.class('tab-panel-selected');
// 所有面板内容仍保持渲染forceRender生效
expect(tabPanels[0]).to.have.trimmed.text('Content 1'); expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('Content 2'); expect(tabPanels[1]).to.have.trimmed.text('Content 2');
}); });
}); });

@ -1,29 +1,36 @@
// TODO: remove usage of Ember Data's private `Errors` class when refactoring validations // TODO: 重构验证逻辑时移除对Ember Data私有类`Errors`的使用
// eslint-disable-next-line // eslint-disable-next-line
import DS from 'ember-data'; import DS from 'ember-data';
import EmberObject from '@ember/object'; import EmberObject from '@ember/object';
import Service from '@ember/service'; import Service from '@ember/service';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import {blur, click, fillIn, find, findAll, render} from '@ember/test-helpers'; import { blur, click, fillIn, find, findAll, render } from '@ember/test-helpers';
import {describe, it} from 'mocha'; import { describe, it } from 'mocha';
import {expect} from 'chai'; import { expect } from 'chai';
import {setupRenderingTest} from 'ember-mocha'; import { setupRenderingTest } from 'ember-mocha';
const {Errors} = DS; // 从Ember Data中获取私有Errors类用于模拟验证错误
const { Errors } = DS;
// 配置服务桩提供博客URL
let configStub = Service.extend({ let configStub = Service.extend({
blogUrl: 'http://localhost:2368' blogUrl: 'http://localhost:2368'
}); });
// 媒体查询服务桩:控制移动端视图模拟
let mediaQueriesStub = Service.extend({ let mediaQueriesStub = Service.extend({
maxWidth600: false maxWidth600: false // 默认模拟非移动端
}); });
// 描述"标签表单组件Tags::TagForm"的集成测试套件(当前跳过测试)
describe.skip('Integration: Component: tags/tag-form', function () { describe.skip('Integration: Component: tags/tag-form', function () {
// 设置渲染测试环境
setupRenderingTest(); setupRenderingTest();
// 每个测试用例执行前的准备工作
beforeEach(function () { beforeEach(function () {
/* eslint-disable camelcase */ /* eslint-disable camelcase */
// 创建模拟标签对象,包含基础属性和验证相关字段
let tag = EmberObject.create({ let tag = EmberObject.create({
id: 1, id: 1,
name: 'Test', name: 'Test',
@ -31,38 +38,48 @@ describe.skip('Integration: Component: tags/tag-form', function () {
description: 'Description.', description: 'Description.',
metaTitle: 'Meta Title', metaTitle: 'Meta Title',
metaDescription: 'Meta description', metaDescription: 'Meta description',
errors: Errors.create(), errors: Errors.create(), // 用于存储验证错误
hasValidated: [] hasValidated: [] // 用于记录已验证的字段
}); });
/* eslint-enable camelcase */ /* eslint-enable camelcase */
// 将标签对象和属性设置方法存入测试上下文
this.set('tag', tag); this.set('tag', tag);
this.set('setProperty', function (property, value) { this.set('setProperty', function (property, value) {
// this should be overridden if a call is expected // 若未被覆盖,调用时会打印错误(用于捕获意外调用)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`setProperty called '${property}: ${value}'`); console.error(`setProperty called '${property}: ${value}'`);
}); });
// 注册服务桩,替换真实服务
this.owner.register('service:config', configStub); this.owner.register('service:config', configStub);
this.owner.register('service:media-queries', mediaQueriesStub); this.owner.register('service:media-queries', mediaQueriesStub);
}); });
// 测试用例:表单标题显示正确
it('has the correct title', async function () { it('has the correct title', async function () {
// 渲染标签表单组件
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 断言:现有标签的标题为"Tag settings"
expect(find('.tag-settings-pane h4').textContent, 'existing tag title').to.equal('Tag settings'); expect(find('.tag-settings-pane h4').textContent, 'existing tag title').to.equal('Tag settings');
// 切换为新标签设置isNew属性
this.set('tag.isNew', true); this.set('tag.isNew', true);
// 断言:新标签的标题为"New tag"
expect(find('.tag-settings-pane h4').textContent, 'new tag title').to.equal('New tag'); expect(find('.tag-settings-pane h4').textContent, 'new tag title').to.equal('New tag');
}); });
// 测试用例:正确渲染主要设置项
it('renders main settings', async function () { it('renders main settings', async function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 断言:显示图片上传器
expect(findAll('.gh-image-uploader').length, 'displays image uploader').to.equal(1); expect(findAll('.gh-image-uploader').length, 'displays image uploader').to.equal(1);
// 断言:各字段值正确显示
expect(find('input[name="name"]').value, 'name field value').to.equal('Test'); expect(find('input[name="name"]').value, 'name field value').to.equal('Test');
expect(find('input[name="slug"]').value, 'slug field value').to.equal('test'); expect(find('input[name="slug"]').value, 'slug field value').to.equal('test');
expect(find('textarea[name="description"]').value, 'description field value').to.equal('Description.'); expect(find('textarea[name="description"]').value, 'description field value').to.equal('Description.');
@ -70,40 +87,48 @@ describe.skip('Integration: Component: tags/tag-form', function () {
expect(find('textarea[name="metaDescription"]').value, 'metaDescription field value').to.equal('Meta description'); expect(find('textarea[name="metaDescription"]').value, 'metaDescription field value').to.equal('Meta description');
}); });
// 测试用例:可在主要设置和元数据设置之间切换
it('can switch between main/meta settings', async function () { it('can switch between main/meta settings', async function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 断言:初始状态显示主要设置,隐藏元数据设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed by default').to.be.true; expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed by default').to.be.true;
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden by default').to.be.true; expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden by default').to.be.true;
// 点击"Meta Data"按钮切换到元数据设置
await click('.meta-data-button'); await click('.meta-data-button');
// 断言:切换后隐藏主要设置,显示元数据设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-out-left'), 'main settings are hidden after clicking Meta Data button').to.be.true; expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-out-left'), 'main settings are hidden after clicking Meta Data button').to.be.true;
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta settings are displayed after clicking Meta Data button').to.be.true; expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta settings are displayed after clicking Meta Data button').to.be.true;
// 点击"back"按钮返回主要设置
await click('.back'); await click('.back');
// 断言:返回后显示主要设置,隐藏元数据设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed after clicking "back"').to.be.true; expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed after clicking "back"').to.be.true;
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden after clicking "back"').to.be.true; expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden after clicking "back"').to.be.true;
}); });
// 测试用例:属性采用单向绑定(输入框值变化不直接修改源数据)
it('has one-way binding for properties', async function () { it('has one-way binding for properties', async function () {
this.set('setProperty', function () { // 覆盖setProperty为无操作避免干扰测试
// noop this.set('setProperty', function () {});
});
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 修改各输入框的值
await fillIn('input[name="name"]', 'New name'); await fillIn('input[name="name"]', 'New name');
await fillIn('input[name="slug"]', 'new-slug'); await fillIn('input[name="slug"]', 'new-slug');
await fillIn('textarea[name="description"]', 'New description'); await fillIn('textarea[name="description"]', 'New description');
await fillIn('input[name="metaTitle"]', 'New metaTitle'); await fillIn('input[name="metaTitle"]', 'New metaTitle');
await fillIn('textarea[name="metaDescription"]', 'New metaDescription'); await fillIn('textarea[name="metaDescription"]', 'New metaDescription');
// 断言:源标签对象的属性未被修改(单向绑定生效)
expect(this.get('tag.name'), 'tag name').to.equal('Test'); expect(this.get('tag.name'), 'tag name').to.equal('Test');
expect(this.get('tag.slug'), 'tag slug').to.equal('test'); expect(this.get('tag.slug'), 'tag slug').to.equal('test');
expect(this.get('tag.description'), 'tag description').to.equal('Description.'); expect(this.get('tag.description'), 'tag description').to.equal('Description.');
@ -111,19 +136,23 @@ describe.skip('Integration: Component: tags/tag-form', function () {
expect(this.get('tag.metaDescription'), 'tag metaDescription').to.equal('Meta description'); expect(this.get('tag.metaDescription'), 'tag metaDescription').to.equal('Meta description');
}); });
// 测试用例所有字段失焦时触发setProperty动作
it('triggers setProperty action on blur of all fields', async function () { it('triggers setProperty action on blur of all fields', async function () {
let lastSeenProperty = ''; let lastSeenProperty = '';
let lastSeenValue = ''; let lastSeenValue = '';
// 覆盖setProperty记录最后一次调用的属性和值
this.set('setProperty', function (property, value) { this.set('setProperty', function (property, value) {
lastSeenProperty = property; lastSeenProperty = property;
lastSeenValue = value; lastSeenValue = value;
}); });
// 辅助函数测试字段失焦时是否正确触发setProperty
let testSetProperty = async (selector, expectedProperty, expectedValue) => { let testSetProperty = async (selector, expectedProperty, expectedValue) => {
await click(selector); await click(selector); // 聚焦字段
await fillIn(selector, expectedValue); await fillIn(selector, expectedValue); // 输入值
await blur(selector); await blur(selector); // 失焦
// 断言:触发的属性和值正确
expect(lastSeenProperty, 'property').to.equal(expectedProperty); expect(lastSeenProperty, 'property').to.equal(expectedProperty);
expect(lastSeenValue, 'value').to.equal(expectedValue); expect(lastSeenValue, 'value').to.equal(expectedValue);
}; };
@ -132,6 +161,7 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 测试所有字段
await testSetProperty('input[name="name"]', 'name', 'New name'); await testSetProperty('input[name="name"]', 'name', 'New name');
await testSetProperty('input[name="slug"]', 'slug', 'new-slug'); await testSetProperty('input[name="slug"]', 'slug', 'new-slug');
await testSetProperty('textarea[name="description"]', 'description', 'New description'); await testSetProperty('textarea[name="description"]', 'description', 'New description');
@ -139,10 +169,12 @@ describe.skip('Integration: Component: tags/tag-form', function () {
await testSetProperty('textarea[name="metaDescription"]', 'metaDescription', 'New metaDescription'); await testSetProperty('textarea[name="metaDescription"]', 'metaDescription', 'New metaDescription');
}); });
// 测试用例:显示已验证字段的错误信息
it('displays error messages for validated fields', async function () { it('displays error messages for validated fields', async function () {
let errors = this.get('tag.errors'); let errors = this.get('tag.errors');
let hasValidated = this.get('tag.hasValidated'); let hasValidated = this.get('tag.hasValidated');
// 为各字段添加验证错误并标记为已验证
errors.add('name', 'must be present'); errors.add('name', 'must be present');
hasValidated.push('name'); hasValidated.push('name');
@ -162,91 +194,121 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 验证name字段错误状态
let nameFormGroup = find('input[name="name"]').closest('.form-group'); let nameFormGroup = find('input[name="name"]').closest('.form-group');
expect(nameFormGroup, 'name form group has error state').to.have.class('error'); expect(nameFormGroup, 'name form group has error state').to.have.class('error');
expect(nameFormGroup.querySelector('.response'), 'name form group has error message').to.exist; expect(nameFormGroup.querySelector('.response'), 'name form group has error message').to.exist;
// 验证slug字段错误状态
let slugFormGroup = find('input[name="slug"]').closest('.form-group'); let slugFormGroup = find('input[name="slug"]').closest('.form-group');
expect(slugFormGroup, 'slug form group has error state').to.have.class('error'); expect(slugFormGroup, 'slug form group has error state').to.have.class('error');
expect(slugFormGroup.querySelector('.response'), 'slug form group has error message').to.exist; expect(slugFormGroup.querySelector('.response'), 'slug form group has error message').to.exist;
// 验证description字段错误状态
let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group'); let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group');
expect(descriptionFormGroup, 'description form group has error state').to.have.class('error'); expect(descriptionFormGroup, 'description form group has error state').to.have.class('error');
// 验证metaTitle字段错误状态
let metaTitleFormGroup = find('input[name="metaTitle"]').closest('.form-group'); let metaTitleFormGroup = find('input[name="metaTitle"]').closest('.form-group');
expect(metaTitleFormGroup, 'metaTitle form group has error state').to.have.class('error'); expect(metaTitleFormGroup, 'metaTitle form group has error state').to.have.class('error');
expect(metaTitleFormGroup.querySelector('.response'), 'metaTitle form group has error message').to.exist; expect(metaTitleFormGroup.querySelector('.response'), 'metaTitle form group has error message').to.exist;
// 验证metaDescription字段错误状态
let metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group'); let metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group');
expect(metaDescriptionFormGroup, 'metaDescription form group has error state').to.have.class('error'); expect(metaDescriptionFormGroup, 'metaDescription form group has error state').to.have.class('error');
expect(metaDescriptionFormGroup.querySelector('.response'), 'metaDescription form group has error message').to.exist; expect(metaDescriptionFormGroup.querySelector('.response'), 'metaDescription form group has error message').to.exist;
}); });
// 测试用例:显示文本字段的字符计数
it('displays char count for text fields', async function () { it('displays char count for text fields', async function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 验证描述字段的字符计数("Description." 共12个字符
let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group'); let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group');
expect(descriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('12'); expect(descriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('12');
// 验证元描述字段的字符计数("Meta description" 共16个字符
let metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group'); let metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group');
expect(metaDescriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('16'); expect(metaDescriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('16');
}); });
// 测试用例正确渲染SEO标题预览
it('renders SEO title preview', async function () { it('renders SEO title preview', async function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 断言存在metaTitle时显示metaTitle
expect(find('.seo-preview-title').textContent, 'displays meta title if present').to.equal('Meta Title'); expect(find('.seo-preview-title').textContent, 'displays meta title if present').to.equal('Meta Title');
// 移除metaTitle
this.set('tag.metaTitle', ''); this.set('tag.metaTitle', '');
// 断言无metaTitle时回退到标签名称
expect(find('.seo-preview-title').textContent, 'falls back to tag name without metaTitle').to.equal('Test'); expect(find('.seo-preview-title').textContent, 'falls back to tag name without metaTitle').to.equal('Test');
// 设置超长名称150个x
this.set('tag.name', (new Array(151).join('x'))); this.set('tag.name', (new Array(151).join('x')));
// 断言标题被截断为70字符+省略号
let expectedLength = 70 + '…'.length; let expectedLength = 70 + '…'.length;
expect(find('.seo-preview-title').textContent.length, 'cuts title to max 70 chars').to.equal(expectedLength); expect(find('.seo-preview-title').textContent.length, 'cuts title to max 70 chars').to.equal(expectedLength);
}); });
// 测试用例正确渲染SEO URL预览
it('renders SEO URL preview', async function () { it('renders SEO URL preview', async function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 断言URL预览包含博客地址、标签路径和slug
expect(find('.seo-preview-link').textContent, 'adds url and tag prefix').to.equal('http://localhost:2368/tag/test/'); expect(find('.seo-preview-link').textContent, 'adds url and tag prefix').to.equal('http://localhost:2368/tag/test/');
// 设置超长slug150个x
this.set('tag.slug', (new Array(151).join('x'))); this.set('tag.slug', (new Array(151).join('x')));
// 断言slug被截断为70字符+省略号
let expectedLength = 70 + '…'.length; let expectedLength = 70 + '…'.length;
expect(find('.seo-preview-link').textContent.length, 'cuts slug to max 70 chars').to.equal(expectedLength); expect(find('.seo-preview-link').textContent.length, 'cuts slug to max 70 chars').to.equal(expectedLength);
}); });
// 测试用例正确渲染SEO描述预览
it('renders SEO description preview', async function () { it('renders SEO description preview', async function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 断言存在metaDescription时显示metaDescription
expect(find('.seo-preview-description').textContent, 'displays meta description if present').to.equal('Meta description'); expect(find('.seo-preview-description').textContent, 'displays meta description if present').to.equal('Meta description');
// 移除metaDescription
this.set('tag.metaDescription', ''); this.set('tag.metaDescription', '');
// 断言无metaDescription时回退到描述
expect(find('.seo-preview-description').textContent, 'falls back to tag description without metaDescription').to.equal('Description.'); expect(find('.seo-preview-description').textContent, 'falls back to tag description without metaDescription').to.equal('Description.');
// 设置超长描述499个x
this.set('tag.description', (new Array(500).join('x'))); this.set('tag.description', (new Array(500).join('x')));
// 断言描述被截断为156字符+省略号
let expectedLength = 156 + '…'.length; let expectedLength = 156 + '…'.length;
expect(find('.seo-preview-description').textContent.length, 'cuts description to max 156 chars').to.equal(expectedLength); expect(find('.seo-preview-description').textContent.length, 'cuts description to max 156 chars').to.equal(expectedLength);
}); });
// 测试用例:接收新标签时重置表单状态
it('resets if a new tag is received', async function () { it('resets if a new tag is received', async function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 切换到元数据设置面板
await click('.meta-data-button'); await click('.meta-data-button');
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta data pane is shown').to.be.true; expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta data pane is shown').to.be.true;
this.set('tag', EmberObject.create({id: '2'})); // 设置新的标签对象
this.set('tag', EmberObject.create({ id: '2' }));
// 断言:表单重置为显示主要设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'resets to main settings').to.be.true; expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'resets to main settings').to.be.true;
}); });
// 测试用例:点击删除按钮时触发删除模态框
it('triggers delete tag modal on delete click', async function () { it('triggers delete tag modal on delete click', async function () {
let openModalFired = false; let openModalFired = false;
// 覆盖openModal方法标记为已触发
this.set('openModal', () => { this.set('openModal', () => {
openModalFired = true; openModalFired = true;
}); });
@ -254,12 +316,16 @@ describe.skip('Integration: Component: tags/tag-form', function () {
await render(hbs` await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} @showDeleteTagModal={{this.openModal}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} @showDeleteTagModal={{this.openModal}} />
`); `);
// 点击删除按钮
await click('.settings-menu-delete-button'); await click('.settings-menu-delete-button');
// 断言:删除模态框触发方法被调用
expect(openModalFired).to.be.true; expect(openModalFired).to.be.true;
}); });
// 测试用例:移动端显示标签返回箭头链接
it('shows tags arrow link on mobile', async function () { it('shows tags arrow link on mobile', async function () {
// 获取媒体查询服务并设置为移动端宽度≤600px
let mediaQueries = this.owner.lookup('service:media-queries'); let mediaQueries = this.owner.lookup('service:media-queries');
mediaQueries.set('maxWidth600', true); mediaQueries.set('maxWidth600', true);
@ -267,6 +333,7 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} /> <Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`); `);
// 断言:移动端显示标签返回链接
expect(findAll('.tag-settings-pane .settings-menu-header .settings-menu-header-action').length, 'tags link is shown').to.equal(1); expect(findAll('.tag-settings-pane .settings-menu-header .settings-menu-header-action').length, 'tags link is shown').to.equal(1);
}); });
}); });

@ -1,71 +1,111 @@
import {describe, it} from 'mocha'; import { describe, it } from 'mocha';
import {expect} from 'chai'; import { expect } from 'chai';
import {setupMirage} from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import {setupTest} from 'ember-mocha'; import { setupTest } from 'ember-mocha';
/**
* 标签模型Model: tag的集成测试
* 验证标签模型在各种操作下对搜索内容过期状态的影响
*/
describe('Integration: Model: tag', function () { describe('Integration: Model: tag', function () {
// 设置测试环境和Mirage模拟服务器
const hooks = setupTest(); const hooks = setupTest();
setupMirage(hooks); setupMirage(hooks);
// 声明数据存储服务变量
let store; let store;
// 每个测试用例执行前的准备工作
beforeEach(function () { beforeEach(function () {
// 获取Ember的数据存储服务store
store = this.owner.lookup('service:store'); store = this.owner.lookup('service:store');
}); });
// 描述"搜索过期search expiry"的测试场景
describe('search expiry', function () { describe('search expiry', function () {
// 声明搜索服务变量
let search; let search;
// 每个子测试用例执行前的准备工作
beforeEach(function () { beforeEach(function () {
// 获取搜索服务
search = this.owner.lookup('service:search'); search = this.owner.lookup('service:search');
// 初始设置搜索内容为未过期状态
search.isContentStale = false; search.isContentStale = false;
}); });
// 测试用例:创建标签时使搜索内容过期
it('expires on create', async function () { it('expires on create', async function () {
// 创建新标签记录
const tagModel = await store.createRecord('tag'); const tagModel = await store.createRecord('tag');
tagModel.name = 'Test tag'; tagModel.name = 'Test tag';
// 保存标签到服务器
await tagModel.save(); await tagModel.save();
// 断言搜索内容过期标志为true创建操作触发过期
expect(search.isContentStale, 'stale flag after save').to.be.true; expect(search.isContentStale, 'stale flag after save').to.be.true;
}); });
// 测试用例:删除标签时使搜索内容过期
it('expires on delete', async function () { it('expires on delete', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag'); const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id); const tagModel = await store.find('tag', serverTag.id);
// 删除标签
await tagModel.destroyRecord(); await tagModel.destroyRecord();
// 断言搜索内容过期标志为true删除操作触发过期
expect(search.isContentStale, 'stale flag after delete').to.be.true; expect(search.isContentStale, 'stale flag after delete').to.be.true;
}); });
// 测试用例:修改标签名称时使搜索内容过期
it('expires when name changed', async function () { it('expires when name changed', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag'); const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id); const tagModel = await store.find('tag', serverTag.id);
// 修改标签名称
tagModel.name = 'New name'; tagModel.name = 'New name';
// 保存修改
await tagModel.save(); await tagModel.save();
// 断言搜索内容过期标志为true名称修改触发过期
expect(search.isContentStale, 'stale flag after save').to.be.true; expect(search.isContentStale, 'stale flag after save').to.be.true;
}); });
// 测试用例修改标签URLslug时使搜索内容过期
it('expires when url changed', async function () { it('expires when url changed', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag'); const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id); const tagModel = await store.find('tag', serverTag.id);
// 修改标签slugURL的一部分
tagModel.slug = 'new-slug'; tagModel.slug = 'new-slug';
// 保存修改
await tagModel.save(); await tagModel.save();
// 断言搜索内容过期标志为trueURL修改触发过期
expect(search.isContentStale, 'stale flag after save').to.be.true; expect(search.isContentStale, 'stale flag after save').to.be.true;
}); });
// 测试用例:修改非名称字段时不使搜索内容过期
it('does not expire on non-name change', async function () { it('does not expire on non-name change', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag'); const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id); const tagModel = await store.find('tag', serverTag.id);
// 修改标签描述(非名称相关字段)
tagModel.description = 'New description'; tagModel.description = 'New description';
// 保存修改
await tagModel.save(); await tagModel.save();
// 断言搜索内容过期标志为false非名称修改不触发过期
expect(search.isContentStale, 'stale flag after save').to.be.false; expect(search.isContentStale, 'stale flag after save').to.be.false;
}); });
}); });
}); });

@ -1,13 +1,24 @@
import {describe, it} from 'mocha'; import { describe, it } from 'mocha';
import {expect} from 'chai'; import { expect } from 'chai';
import {setupTest} from 'ember-mocha'; import { setupTest } from 'ember-mocha';
/**
* 标签模型Model: tag的单元测试
* 验证模型的基础属性和行为
*/
describe('Unit: Model: tag', function () { describe('Unit: Model: tag', function () {
// 设置单元测试环境
setupTest(); setupTest();
/**
* 测试用例标签模型的验证类型validationType"tag"
* 验证模型在验证逻辑中使用正确的类型标识
*/
it('has a validation type of "tag"', function () { it('has a validation type of "tag"', function () {
// 从数据存储服务创建一个标签模型实例
let model = this.owner.lookup('service:store').createRecord('tag'); let model = this.owner.lookup('service:store').createRecord('tag');
// 断言模型的validationType属性值为"tag"
expect(model.get('validationType')).to.equal('tag'); expect(model.get('validationType')).to.equal('tag');
}); });
}); });

@ -1,50 +1,106 @@
// # Tags Helper // # Tags Helper
// Usage: `{{tags}}`, `{{tags separator=' - '}}` // 用法: `{{tags}}`, `{{tags separator=' - '}}`
// //
// Returns a string of the tags on the post. // 返回文章标签的字符串形式
// By default, tags are separated by commas. // 默认情况下,标签之间用逗号分隔
// //
// Note that the standard {{#each tags}} implementation is unaffected by this helper // 中文说明:
const {urlService} = require('../services/proxy'); // - 该 helper 在主题模板中用于渲染单个文章/页面的标签列表。
const {SafeString, escapeExpression, templates} = require('../services/handlebars'); // - 支持的参数说明:
// - autolink (boolean|string): 是否将标签渲染为链接(默认 true。传入字符串 'false' 可关闭。
// - separator (string): 标签之间的分隔符,默认使用 ", ".
// - prefix / suffix (string): 输出的前缀与后缀,默认空字符串。
// - limit / from / to (number): 用于对标签进行切片(注意 from 是 1-based 索引);
// 如果同时指定 limit 与 from会使用 limit+from 计算默认的 to 值。
// - visibility (string): 通过 helpers 的 visibility 工具进行过滤public/internal
//
// 实现细节:
// - 先通过 visibility.filter 对传入的 tags 进行可见性过滤并对每一项应用 processTag
// processTag 根据 autolink 决定生成带 <a> 链接的 HTML 或纯文本(已转义)。
// - 对结果数组应用 from/to/limit 的裁切逻辑(将 from 转为 0-based然后用 separator 拼接。
// - 最终使用 SafeString 返回,保证已生成的 HTML例如标签链接不会被 Handlebars 再次转义。
//
// 注意:此 helper 仅负责渲染表现层,标签的 URL 生成依赖于 `urlService.getUrlByResourceId`
// 如果修改了 URL 规则或可见性规则,应同步更新此 helper 的实现。
const { urlService } = require('../services/proxy');
const { SafeString, escapeExpression, templates } = require('../services/handlebars');
const isString = require('lodash/isString'); const isString = require('lodash/isString');
const ghostHelperUtils = require('@tryghost/helpers').utils; const ghostHelperUtils = require('@tryghost/helpers').utils;
module.exports = function tags(options) { module.exports = function tags(options) {
// 处理可选参数,确保参数对象存在
options = options || {}; options = options || {};
options.hash = options.hash || {}; options.hash = options.hash || {};
// 解析参数:自动链接(默认开启)
// 如果autolink参数是字符串且值为'false',则关闭自动链接
const autolink = !(isString(options.hash.autolink) && options.hash.autolink === 'false'); const autolink = !(isString(options.hash.autolink) && options.hash.autolink === 'false');
// 解析参数:分隔符(默认逗号加空格)
const separator = isString(options.hash.separator) ? options.hash.separator : ', '; const separator = isString(options.hash.separator) ? options.hash.separator : ', ';
// 解析参数:前缀(默认空)
const prefix = isString(options.hash.prefix) ? options.hash.prefix : ''; const prefix = isString(options.hash.prefix) ? options.hash.prefix : '';
// 解析参数:后缀(默认空)
const suffix = isString(options.hash.suffix) ? options.hash.suffix : ''; const suffix = isString(options.hash.suffix) ? options.hash.suffix : '';
// 解析参数:限制数量(默认无限制)
const limit = options.hash.limit ? parseInt(options.hash.limit, 10) : undefined; const limit = options.hash.limit ? parseInt(options.hash.limit, 10) : undefined;
// 初始化输出字符串
let output = ''; let output = '';
// 解析参数起始位置默认11-based索引
let from = options.hash.from ? parseInt(options.hash.from, 10) : 1; let from = options.hash.from ? parseInt(options.hash.from, 10) : 1;
// 解析参数:结束位置(默认无)
let to = options.hash.to ? parseInt(options.hash.to, 10) : undefined; let to = options.hash.to ? parseInt(options.hash.to, 10) : undefined;
/**
* 创建标签列表数组
* @param {Array} tagsList - 标签数组
* @returns {Array} 处理后的标签字符串数组
*/
function createTagList(tagsList) { function createTagList(tagsList) {
/**
* 处理单个标签
* @param {Object} tag - 标签对象
* @returns {string} 处理后的标签字符串带链接或纯文本
*/
function processTag(tag) { function processTag(tag) {
// 如果开启自动链接,返回带链接的标签;否则返回纯文本标签
return autolink ? templates.link({ return autolink ? templates.link({
url: urlService.getUrlByResourceId(tag.id, {withSubdirectory: true}), url: urlService.getUrlByResourceId(tag.id, { withSubdirectory: true }), // 获取标签的URL
text: escapeExpression(tag.name) text: escapeExpression(tag.name) // 转义标签名称防止XSS
}) : escapeExpression(tag.name); }) : escapeExpression(tag.name);
} }
// 根据可见性筛选标签并应用processTag处理
return ghostHelperUtils.visibility.filter(tagsList, options.hash.visibility, processTag); return ghostHelperUtils.visibility.filter(tagsList, options.hash.visibility, processTag);
} }
// 如果存在标签且标签数组不为空
if (this.tags && this.tags.length) { if (this.tags && this.tags.length) {
// 生成处理后的标签列表数组
output = createTagList(this.tags); output = createTagList(this.tags);
from -= 1; // From uses 1-indexed, but array uses 0-indexed.
// 转换起始位置为0-based索引因为from参数是1-based
from -= 1;
// 计算结束位置如果指定了to则用to否则用limit+from否则用数组长度
to = to || limit + from || output.length; to = to || limit + from || output.length;
// 截取指定范围的标签,并使用分隔符拼接
output = output.slice(from, to).join(separator); output = output.slice(from, to).join(separator);
} }
// 如果有输出内容,添加前缀和后缀
if (output) { if (output) {
output = prefix + output + suffix; output = prefix + output + suffix;
} }
// 返回安全字符串防止Handlebars自动转义HTML
return new SafeString(output); return new SafeString(output);
}; };

@ -1,7 +1,7 @@
const urlUtils = require('../../../shared/url-utils'); const urlUtils = require('../../../shared/url-utils');
const models = require('../../models'); const models = require('../../models');
const getPostServiceInstance = require('../../services/posts/posts-service'); const getPostServiceInstance = require('../../services/posts/posts-service');
const allowedIncludes = [ const allowedIncludes = [ //允许包含的关联数据
'tags', 'tags',
'authors', 'authors',
'authors.roles', 'authors.roles',
@ -18,12 +18,13 @@ const allowedIncludes = [
'post_revisions', 'post_revisions',
'post_revisions.author' 'post_revisions.author'
]; ];
const unsafeAttrs = ['status', 'authors', 'visibility']; const unsafeAttrs = ['status', 'authors', 'visibility']; //不安全属性列表
const postsService = getPostServiceInstance(); const postsService = getPostServiceInstance(); //文章服务实例
/** /**
* @param {string} event * @param {string} event
* 根据文章状态变更事件生成缓存失效头信息
*/ */
function getCacheHeaderFromEventString(event, dto) { function getCacheHeaderFromEventString(event, dto) {
if (event === 'published_updated' || event === 'unpublished') { if (event === 'published_updated' || event === 'unpublished') {
@ -44,76 +45,98 @@ function getCacheHeaderFromEventString(event, dto) {
} }
} }
/** @type {import('@tryghost/api-framework').Controller} */ /**
* Ghost CMS 文章 API 控制器
* 提供对文章的各种操作的api 控制器
* 这个控制器实现了完整的文章 CRUD创建读取更新删除操作
* 以及批量操作数据导出等高级功能它遵循 Ghost API 框架规范
* 每个端点都包含完整的配置权限控制参数验证缓存管理等
*
* @type {import('@tryghost/api-framework').Controller}
*/
const controller = { const controller = {
// 控制器文档名称,用于 API 文档生成
docName: 'posts', docName: 'posts',
/**
* 获取文章列表端点
* 支持分页过滤排序字段选择等高级查询功能
*/
browse: { browse: {
headers: { headers: {
cacheInvalidate: false cacheInvalidate: false // 列表查询不缓存失效
}, },
options: [ options: [
'include', 'include', // 包含关联数据(标签、作者等)
'filter', 'filter', // 过滤条件
'fields', 'fields', // 选择返回字段
'collection', 'collection', // 集合过滤
'formats', 'formats', // 内容格式
'limit', 'limit', // 分页大小
'order', 'order', // 排序方式
'page', 'page', // 页码
'debug', 'debug', // 调试模式
'absolute_urls' 'absolute_urls' // 绝对URL
], ],
validation: { validation: {
options: { options: {
include: { include: {
values: allowedIncludes values: allowedIncludes // 只允许预定义的关联数据
}, },
formats: { formats: {
values: models.Post.allowedFormats values: models.Post.allowedFormats // 只允许支持的格式
} }
} }
}, },
permissions: { permissions: {
unsafeAttrs: unsafeAttrs unsafeAttrs: unsafeAttrs // 限制不安全属性修改
}, },
query(frame) { query(frame) {
return postsService.browsePosts(frame.options); return postsService.browsePosts(frame.options); // 调用服务层
} }
}, },
/**
* 导出文章分析数据为 CSV 格式
* 用于数据分析和报表生成
*/
exportCSV: { exportCSV: {
options: [ options: [
'limit', 'limit', // 导出数量限制
'filter', 'filter', // 过滤条件
'order' 'order' // 排序方式
], ],
headers: { headers: {
disposition: { disposition: {
type: 'csv', type: 'csv', // 文件类型
value() { value() {
const datetime = (new Date()).toJSON().substring(0, 10); const datetime = (new Date()).toJSON().substring(0, 10);
return `post-analytics.${datetime}.csv`; return `post-analytics.${datetime}.csv`; // 带时间戳的文件名
} }
}, },
cacheInvalidate: false cacheInvalidate: false // 导出操作不缓存失效
}, },
response: { response: {
format: 'plain' format: 'plain' // 纯文本响应格式
}, },
permissions: { permissions: {
method: 'browse' method: 'browse' // 复用浏览权限
}, },
validation: {}, validation: {},
async query(frame) { async query(frame) {
return { return {
data: await postsService.export(frame) data: await postsService.export(frame) // 调用导出服务
}; };
} }
}, },
/**
* 获取单篇文章详情
* 支持通过 IDslug UUID 查询
*/
read: { read: {
headers: { headers: {
cacheInvalidate: false cacheInvalidate: false // 单篇文章查询不缓存失效
}, },
options: [ options: [
'include', 'include',
@ -121,14 +144,14 @@ const controller = {
'formats', 'formats',
'debug', 'debug',
'absolute_urls', 'absolute_urls',
// NOTE: only for internal context // 内部上下文专用选项
'forUpdate', 'forUpdate', // 用于更新操作
'transacting' 'transacting' // 事务处理
], ],
data: [ data: [
'id', 'id', // 文章ID
'slug', 'slug', // 文章别名
'uuid' 'uuid' // 全局唯一标识
], ],
validation: { validation: {
options: { options: {
@ -144,19 +167,23 @@ const controller = {
unsafeAttrs: unsafeAttrs unsafeAttrs: unsafeAttrs
}, },
query(frame) { query(frame) {
return postsService.readPost(frame); return postsService.readPost(frame); // 调用读取服务
} }
}, },
/**
* 创建新文章
* 状态码 201 表示创建成功
*/
add: { add: {
statusCode: 201, statusCode: 201, // 创建成功状态码
headers: { headers: {
cacheInvalidate: false cacheInvalidate: false // 默认不缓存失效
}, },
options: [ options: [
'include', 'include',
'formats', 'formats',
'source' 'source' // 内容来源HTML
], ],
validation: { validation: {
options: { options: {
@ -164,7 +191,7 @@ const controller = {
values: allowedIncludes values: allowedIncludes
}, },
source: { source: {
values: ['html'] values: ['html'] // 只支持HTML源
} }
} }
}, },
@ -173,6 +200,7 @@ const controller = {
}, },
async query(frame) { async query(frame) {
const model = await models.Post.add(frame.data.posts[0], frame.options); const model = await models.Post.add(frame.data.posts[0], frame.options);
// 如果文章状态为已发布,则失效所有缓存
if (model.get('status') === 'published') { if (model.get('status') === 'published') {
frame.setHeader('X-Cache-Invalidate', '/*'); frame.setHeader('X-Cache-Invalidate', '/*');
} }
@ -181,22 +209,26 @@ const controller = {
} }
}, },
/**
* 编辑文章
* 支持智能缓存失效和事件处理
*/
edit: { edit: {
headers: { headers: {
/** @type {boolean | {value: string}} */ /** @type {boolean | {value: string}} */
cacheInvalidate: false cacheInvalidate: false // 初始不缓存失效
}, },
options: [ options: [
'include', 'include',
'id', 'id', // 必须提供文章ID
'formats', 'formats',
'source', 'source',
'email_segment', 'email_segment', // 邮件分段
'newsletter', 'newsletter', // 新闻稿设置
'force_rerender', 'force_rerender', // 强制重新渲染
'save_revision', 'save_revision', // 保存修订版本
'convert_to_lexical', 'convert_to_lexical', // 转换为Lexical格式
// NOTE: only for internal context // 内部上下文专用选项
'forUpdate', 'forUpdate',
'transacting' 'transacting'
], ],
@ -206,7 +238,7 @@ const controller = {
values: allowedIncludes values: allowedIncludes
}, },
id: { id: {
required: true required: true // ID为必填项
}, },
source: { source: {
values: ['html'] values: ['html']
@ -218,12 +250,12 @@ 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', '/*'); // 失效所有缓存
} else if (cacheInvalidate?.value) { } else if (cacheInvalidate?.value) {
frame.setHeader('X-Cache-Invalidate', cacheInvalidate.value); frame.setHeader('X-Cache-Invalidate', cacheInvalidate.value); // 失效特定URL缓存
} }
} }
}); });
@ -232,62 +264,74 @@ const controller = {
} }
}, },
/**
* 批量编辑文章
* 基于过滤条件对多篇文章执行相同操作
*/
bulkEdit: { bulkEdit: {
statusCode: 200, statusCode: 200, // 操作成功状态码
headers: { headers: {
cacheInvalidate: true cacheInvalidate: true // 批量操作需要缓存失效
}, },
options: [ options: [
'filter' 'filter' // 必须提供过滤条件
], ],
data: [ data: [
'action', 'action', // 操作类型(必填)
'meta' 'meta' // 操作元数据
], ],
validation: { validation: {
data: { data: {
action: { action: {
required: true required: true // 操作类型为必填项
} }
}, },
options: { options: {
filter: { filter: {
required: true required: true // 过滤条件为必填项
} }
} }
}, },
permissions: { permissions: {
method: 'edit' method: 'edit' // 复用编辑权限
}, },
async query(frame) { async query(frame) {
return await postsService.bulkEdit(frame.data.bulk, frame.options); return await postsService.bulkEdit(frame.data.bulk, frame.options);
} }
}, },
/**
* 批量删除文章
* 基于过滤条件删除多篇文章
*/
bulkDestroy: { bulkDestroy: {
statusCode: 200, statusCode: 200,
headers: { headers: {
cacheInvalidate: true cacheInvalidate: true // 删除操作需要缓存失效
}, },
options: [ options: [
'filter' 'filter' // 必须提供过滤条件
], ],
permissions: { permissions: {
method: 'destroy' method: 'destroy' // 复用删除权限
}, },
async query(frame) { async query(frame) {
return await postsService.bulkDestroy(frame.options); return await postsService.bulkDestroy(frame.options);
} }
}, },
/**
* 删除单篇文章
* 状态码 204 表示无内容返回删除成功
*/
destroy: { destroy: {
statusCode: 204, statusCode: 204, // 删除成功状态码(无内容)
headers: { headers: {
cacheInvalidate: true cacheInvalidate: true // 删除操作需要缓存失效
}, },
options: [ options: [
'include', 'include',
'id' 'id' // 必须提供文章ID
], ],
validation: { validation: {
options: { options: {
@ -295,7 +339,7 @@ const controller = {
values: allowedIncludes values: allowedIncludes
}, },
id: { id: {
required: true required: true // ID为必填项
} }
} }
}, },
@ -303,34 +347,39 @@ const controller = {
unsafeAttrs: unsafeAttrs unsafeAttrs: unsafeAttrs
}, },
query(frame) { query(frame) {
return models.Post.destroy({...frame.options, require: true}); return models.Post.destroy({...frame.options, require: true}); // 直接调用模型层
} }
}, },
/**
* 复制文章
* 创建文章的副本保留原文章内容但生成新的标识
*/
copy: { copy: {
statusCode: 201, statusCode: 201, // 创建成功状态码
headers: { headers: {
location: { location: {
// 生成复制后文章的位置URL
resolve: postsService.generateCopiedPostLocationFromUrl resolve: postsService.generateCopiedPostLocationFromUrl
}, },
cacheInvalidate: false cacheInvalidate: false // 复制操作不缓存失效
}, },
options: [ options: [
'id', 'id', // 必须提供原文章ID
'formats' 'formats' // 内容格式
], ],
validation: { validation: {
id: { id: {
required: true required: true // ID为必填项
} }
}, },
permissions: { permissions: {
method: 'add' method: 'add' // 复用添加权限
}, },
async query(frame) { async query(frame) {
return postsService.copyPost(frame); return postsService.copyPost(frame); // 调用复制服务
} }
} }
}; };
module.exports = controller; module.exports = controller;

@ -1,10 +1,36 @@
/*
* Public Tags API controller
*
* Responsibilities:
* - Provide public endpoints for browsing and reading tags
* - Use models.TagPublic for public-safe queries/serialization
* - Validate allowed include parameters (e.g. count.posts)
* - Integrate cache provided by tagsPublicService when available
*
* Note: this controller is intentionally separate from the admin
* endpoints to ensure public/unauthenticated access uses the
* appropriate models/serialization and caching layers.
*
* 中文说明
* 本文件为公开无需管理员权限标签相关 API 的控制器实现
* - browse / read 两个方法暴露给前端或第三方在公共场景下读取标签数据
* - 为了保证公开接口的安全性与性能使用 `models.TagPublic`只包含可公开的字段与序列化规则
* - 在允许的 include 参数上会进行验证目前仅允许 `count.posts`以避免不安全或昂贵的嵌入查询
* - 若存在 `tagsPublicService.api.cache`browse 方法会使用它来启用缓存以提高性能 cacheInvalidate header 默认为 false控制器本身不负责主动失效缓存
*
* 注意事项
* - 管理端admin和公开端分离是刻意的设计避免权限混淆与敏感字段外泄
* - 若修改了可公开的字段集合或序列化逻辑请同时更新对应的 models 与本处允许的 include 列表
*/
const tpl = require('@tryghost/tpl'); const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const models = require('../../models'); const models = require('../../models');
const tagsPublicService = require('../../services/tags-public'); const tagsPublicService = require('../../services/tags-public');
// 允许通过 include 参数包含的关联计数(仅允许对外公开的项)
const ALLOWED_INCLUDES = ['count.posts']; const ALLOWED_INCLUDES = ['count.posts'];
// 本地化/模板消息(可以用于抛出用户友好的错误信息)
const messages = { const messages = {
tagNotFound: 'Tag not found.' tagNotFound: 'Tag not found.'
}; };

@ -1,3 +1,24 @@
/*
* Admin Tags API controller
*
* Responsibilities:
* - Provide admin endpoints for managing tags (browse/read/add/edit/destroy)
* - Enforce permission checks for admin operations
* - Validate allowed include parameters (e.g. count.posts)
* - Delegate core data operations to models.Tag
* - Trigger cache invalidation headers on changes
*/
/*
* 中文说明
* 本文件为管理端admin标签相关 API 控制器提供对标签的增删改查接口
* - browse / read: 管理后台读取标签列表或单一标签可包含 count.posts 等允许的 include
* - add / edit / destroy: 管理员权限下的写操作会调用 `models.Tag` 执行数据变更
*
* 关键注意事项
* - 写操作会在必要时设置 `X-Cache-Invalidate` 以通知 CDN/缓存层清理缓存read/browse 默认不主动失效缓存
* - 本控制器启用了权限检查permissions: true只有具备管理员权限的请求才能执行写操作
* - 如果修改了可包含的 include 列表或序列化规则请同步更新 `ALLOWED_INCLUDES` 常量与模型/序列化逻辑
*/
const tpl = require('@tryghost/tpl'); const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const models = require('../../models'); const models = require('../../models');

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

@ -5,14 +5,14 @@ const metrics = require('@tryghost/metrics');
const sentry = require('../../../shared/sentry'); const sentry = require('../../../shared/sentry');
const states = { const states = { //定义数据库状态
READY: 0, READY: 0,
NEEDS_INITIALISATION: 1, NEEDS_INITIALISATION: 1,
NEEDS_MIGRATION: 2, NEEDS_MIGRATION: 2,
ERROR: 3 ERROR: 3
}; };
const printState = ({state}) => { const printState = ({state}) => { //打印当前数据库状态
if (state === states.READY) { if (state === states.READY) {
logging.info('Database is in a ready state.'); logging.info('Database is in a ready state.');
} }
@ -37,12 +37,12 @@ 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
if (error.code === 'DB_NOT_INITIALISED') { if (error.code === 'DB_NOT_INITIALISED') {
state = states.NEEDS_INITIALISATION; state = states.NEEDS_INITIALISATION;
@ -71,12 +71,12 @@ class DatabaseStateManager {
}); });
} }
sentry.captureException(errorToThrow); sentry.captureException(errorToThrow);//记录错误信息
throw errorToThrow; throw errorToThrow;
} }
} }
async makeReady() { async makeReady() { //将数据库状态设置为READY
try { try {
let state = await this.getState(); let state = await this.getState();
@ -111,7 +111,7 @@ class DatabaseStateManager {
state = await this.getState(); state = await this.getState();
printState({state}); printState({state});
} catch (error) { } catch (error) { //对错误状态进行处理
let errorToThrow = error; let errorToThrow = error;
if (!errors.utils.isGhostError(error)) { if (!errors.utils.isGhostError(error)) {
errorToThrow = new errors.InternalServerError({ errorToThrow = new errors.InternalServerError({

@ -12,6 +12,7 @@ const exporter = require('../exporter');
* @param {object} exportResult * @param {object} exportResult
* @param {string} exportResult.filename * @param {string} exportResult.filename
* @param {object} exportResult.data * @param {object} exportResult.data
* 文件写入功能
*/ */
const writeExportFile = async (exportResult) => { const writeExportFile = async (exportResult) => {
const filename = path.resolve(urlUtils.urlJoin(config.get('paths').contentPath, 'data', exportResult.filename)); const filename = path.resolve(urlUtils.urlJoin(config.get('paths').contentPath, 'data', exportResult.filename));
@ -21,7 +22,8 @@ const writeExportFile = async (exportResult) => {
}; };
/** /**
* @param {string} filename * @param {string} fileName
* 文件读取功能
*/ */
const readBackup = async (filename) => { const readBackup = async (filename) => {
const parsedFileName = path.parse(filename); const parsedFileName = path.parse(filename);
@ -43,10 +45,11 @@ const readBackup = async (filename) => {
* *
* @param {Object} options * @param {Object} options
* @returns {Promise<String> | null} * @returns {Promise<String> | null}
* 数据库备份功能
*/ */
const backup = async function backup(options = {}) { const backup = async function backup(options = {}) {
// do not create backup if disabled in config (this is intended for large customers who will OOM node) // do not create backup if disabled in config (this is intended for large customers who will OOM node)
if (config.get('disableJSBackups')) { if (config.get('disableJSBackups')) { //检查是否禁用了备份功能
logging.info('Database backup is disabled in Ghost config'); logging.info('Database backup is disabled in Ghost config');
return null; return null;
} }

@ -8,17 +8,17 @@ const config = require('../../../shared/config');
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
/** @type {knex.Knex} */ /** @type {knex.Knex} */
let knexInstance; let knexInstance; //执行的时候才会被赋予一个Knex实例
// @TODO: // @TODO:
// - if you require this file before config file was loaded, // - if you require this file before config file was loaded,
// - then this file is cached and you have no chance to connect to the db anymore // - then this file is cached and you have no chance to connect to the db anymore
// - bring dynamic into this file (db.connect()) // - bring dynamic into this file (db.connect())
function configure(dbConfig) { function configure(dbConfig) {
const client = dbConfig.client; const client = dbConfig.client; //获取但概念的客户端
if (client === 'sqlite3') { if (client === 'sqlite3') { //向后兼容性如果使用的是sqlite3客户端
// Backwards compatibility with old knex behaviour // Backwards compatibility with old knex behaviour
dbConfig.useNullAsDefault = Object.prototype.hasOwnProperty.call(dbConfig, 'useNullAsDefault') ? dbConfig.useNullAsDefault : true; dbConfig.useNullAsDefault = Object.prototype.hasOwnProperty.call(dbConfig, 'useNullAsDefault') ? dbConfig.useNullAsDefault : true;
// Enables foreign key checks and delete on cascade // Enables foreign key checks and delete on cascade
@ -38,6 +38,7 @@ function configure(dbConfig) {
// In the default SQLite test config we set the path to /tmp/ghost-test.db, // In the default SQLite test config we set the path to /tmp/ghost-test.db,
// but this won't work on Windows, so we need to replace the /tmp bit with // but this won't work on Windows, so we need to replace the /tmp bit with
// the Windows temp folder // the Windows temp folder
// 在windows系统下的兼容性处理
const filename = dbConfig.connection.filename; const filename = dbConfig.connection.filename;
if (process.platform === 'win32' && _.isString(filename) && filename.match(/^\/tmp/)) { if (process.platform === 'win32' && _.isString(filename) && filename.match(/^\/tmp/)) {
dbConfig.connection.filename = filename.replace(/^\/tmp/, os.tmpdir()); dbConfig.connection.filename = filename.replace(/^\/tmp/, os.tmpdir());
@ -47,18 +48,19 @@ function configure(dbConfig) {
if (client === 'mysql2') { if (client === 'mysql2') {
dbConfig.connection.timezone = 'Z'; dbConfig.connection.timezone = 'Z';
dbConfig.connection.charset = 'utf8mb4'; dbConfig.connection.charset = 'utf8mb4'; //编码方式的设置
dbConfig.connection.decimalNumbers = true; dbConfig.connection.decimalNumbers = true; //是否将MySQL的DECIMAL类型转换为JavaScript的Number类型
if (process.env.REQUIRE_INFILE_STREAM) { if (process.env.REQUIRE_INFILE_STREAM) { //是否要求启用infile流
if (process.env.NODE_ENV === 'development' || process.env.ALLOW_INFILE_STREAM) { if (process.env.NODE_ENV === 'development' || process.env.ALLOW_INFILE_STREAM) { //如果是在开发环境下或者允许启用infile流
dbConfig.connection.infileStreamFactory = path => fs.createReadStream(path); dbConfig.connection.infileStreamFactory = path => fs.createReadStream(path);
} else { } else {//如果不是在开发环境下并且不允许启用infile流
throw new errors.InternalServerError({message: 'MySQL infile streaming is required to run the current process, but is not allowed. Run the script in development mode or set ALLOW_INFILE_STREAM=1.'}); throw new errors.InternalServerError({message: 'MySQL infile streaming is required to run the current process, but is not allowed. Run the script in development mode or set ALLOW_INFILE_STREAM=1.'});
} }
} }
} }
//如果前两个if都没成功的话就会返回原始的dbConfig对象
//返回数据库配置对象
return dbConfig; return dbConfig;
} }

@ -1,32 +1,70 @@
/**
* 数据库导出文件名生成模块
*
* 负责为 Ghost 数据库备份文件生成智能安全的文件名
* 文件名格式{站点标题}.ghost.{时间戳}.json
*
* @module exporter/export-filename
*/
const _ = require('lodash'); const _ = require('lodash');
const logging = require('@tryghost/logging'); const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const security = require('@tryghost/security'); const security = require('@tryghost/security');
const models = require('../../models'); const models = require('../../models');
/**
* 数据库模型查询选项配置
* 使用内部上下文权限访问设置数据
*/
const modelOptions = {context: {internal: true}}; const modelOptions = {context: {internal: true}};
/**
* 生成数据库导出文件的文件名
*
* 文件名生成规则
* 1. 如果提供了自定义文件名直接使用
* 2. 否则生成格式{站点标题}.ghost.{-----}.json
* 3. 包含安全过滤和错误处理机制
*
* @param {Object} [options] - 配置选项
* @param {string} [options.filename] - 自定义文件名不含后缀
* @param {Object} [options.transacting] - 事务对象
* @returns {Promise<string>} 生成的完整文件名包含 .json 后缀
*
*/
const exportFileName = async function exportFileName(options) { const exportFileName = async function exportFileName(options) {
// 生成当前时间戳,格式:年-月-日-时-分-秒
const datetime = require('moment')().format('YYYY-MM-DD-HH-mm-ss'); const datetime = require('moment')().format('YYYY-MM-DD-HH-mm-ss');
let title = ''; let title = ''; // 站点标题部分,默认为空
// 确保 options 参数不为空
options = options || {}; options = options || {};
// custom filename if (options.filename) { //对文件名进行处理
if (options.filename) {
return options.filename + '.json'; return options.filename + '.json';
} }
try { try {
const settingsTitle = await models.Settings.findOne({key: 'title'}, _.merge({}, modelOptions, _.pick(options, 'transacting'))); /**
* 从数据库查询站点标题设置
* 使用内部权限上下文支持事务传递
*/
const settingsTitle = await models.Settings.findOne(
{key: 'title'},
_.merge({}, modelOptions, _.pick(options, 'transacting'))
);
if (settingsTitle) { // 如果成功获取到站点标题,进行安全过滤处理
title = security.string.safe(settingsTitle.get('value')) + '.'; if (settingsTitle) {
title = security.string.safe(settingsTitle.get('value')) + '.'; //对站点标题进行安全性过滤,移除一些可能会出问题的字符
} }
return title + 'ghost.' + datetime + '.json'; return title + 'ghost.' + datetime + '.json'; //返回完整的文件名格式
} catch (err) { } catch (err) {
logging.error(new errors.InternalServerError({err: err})); logging.error(new errors.InternalServerError({err: err})); //错误处理机制,记录错误日志
// 错误情况下返回默认文件名ghost.{时间戳}.json
return 'ghost.' + datetime + '.json'; return 'ghost.' + datetime + '.json';
} }
}; };

@ -9,12 +9,12 @@ const {sequence} = require('@tryghost/promise');
const messages = { const messages = {
errorExportingData: 'Error exporting data' errorExportingData: 'Error exporting data'
}; };
//负责将数据库内容到处为可移植的json格式
const { 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)) {
@ -23,25 +23,25 @@ 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);
}); });
}; };
const doExport = async function doExport(options) { const doExport = async function doExport(options) {//导出主函数
options = options || {include: []}; options = options || {include: []};//默认选项,包含所有表
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);
})); }));
const exportData = { const exportData = {
meta: { meta: { //导出元数据包含导出时间和Ghost版本
exported_on: new Date().getTime(), exported_on: new Date().getTime(),
version: ghostVersion.full version: ghostVersion.full
}, },
@ -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 {

@ -1,4 +1,5 @@
// NOTE: these tables can be optionally included to have full db-like export // NOTE: these tables can be optionally included to have full db-like export
//比较重要的表,需要导出进行操作
const BACKUP_TABLES = [ const BACKUP_TABLES = [
'actions', 'actions',
'api_keys', 'api_keys',

@ -4,7 +4,7 @@ const config = require('../../../../shared/config');
const urlUtils = require('../../../../shared/url-utils'); const urlUtils = require('../../../../shared/url-utils');
const storage = require('../../../adapters/storage'); const storage = require('../../../adapters/storage');
let ImageHandler; let ImageHandler;
//各种类型文本的导入处理程序
ImageHandler = { ImageHandler = {
type: 'images', type: 'images',
extensions: config.get('uploads').images.extensions, extensions: config.get('uploads').images.extensions,
@ -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;

@ -52,10 +52,12 @@ let defaults = {
contentTypes: ['application/zip', 'application/x-zip-compressed'], contentTypes: ['application/zip', 'application/x-zip-compressed'],
directories: [] directories: []
}; };
/* Ghost
* 负责导入图片媒体文件内容文件Revue数据JSON数据Markdown数据等
*/
class ImportManager { class ImportManager {
constructor() { constructor() {
const mediaHandler = new ImporterContentFileHandler({ const mediaHandler = new ImporterContentFileHandler({//媒体文件导入处理程序
type: 'media', type: 'media',
// @NOTE: making the second parameter strict folder "content/media" brakes the glob pattern // @NOTE: making the second parameter strict folder "content/media" brakes the glob pattern
// in the importer, so we need to keep it as general "content" unless // in the importer, so we need to keep it as general "content" unless
@ -69,7 +71,7 @@ class ImportManager {
storage: mediaStorage storage: mediaStorage
}); });
const filesHandler = new ImporterContentFileHandler({ const filesHandler = new ImporterContentFileHandler({//文件导入处理程序
type: 'files', type: 'files',
// @NOTE: making the second parameter strict folder "content/files" brakes the glob pattern // @NOTE: making the second parameter strict folder "content/files" brakes the glob pattern
// in the importer, so we need to keep it as general "content" unless // in the importer, so we need to keep it as general "content" unless
@ -82,12 +84,12 @@ class ImportManager {
urlUtils: urlUtils, urlUtils: urlUtils,
storage: fileStorage storage: fileStorage
}); });
//导入器初始化
const imageImporter = new ContentFileImporter({ const imageImporter = new ContentFileImporter({
type: 'images', type: 'images',
store: imageStorage store: imageStorage
}); });
const mediaImporter = new ContentFileImporter({ const mediaImporter = new ContentFileImporter({
type: 'media', type: 'media',
store: mediaStorage store: mediaStorage
}); });
@ -98,25 +100,25 @@ class ImportManager {
}); });
/** /**
* @type {Importer[]} importers * @type {Importer[]} importers 导入器数组包含图片导入器媒体文件导入器内容文件导入器Revue导入器和数据导入器
*/ */
this.importers = [imageImporter, mediaImporter, contentFilesImporter, RevueImporter, DataImporter]; this.importers = [imageImporter, mediaImporter, contentFilesImporter, RevueImporter, DataImporter];
/** /**
* @type {Handler[]} * @type {Handler[]} handlers 处理程序数组包含图片处理程序媒体文件处理程序文件处理程序Revue处理程序和JSON处理程序
*/ */
this.handlers = [ImageHandler, mediaHandler, filesHandler, RevueHandler, JSONHandler, MarkdownHandler]; this.handlers = [ImageHandler, mediaHandler, filesHandler, RevueHandler, JSONHandler, MarkdownHandler];
// Keep track of file to cleanup at the end // Keep track of file to cleanup at the end
/** /**
* @type {?string} * @type {?string} fileToDelete 待删除的文件路径初始值为null
*/ */
this.fileToDelete = null; this.fileToDelete = null;
} }
/** /**
* Get an array of all the file extensions for which we have handlers * Get an array of all the file extensions for which we have handlers
* @returns {string[]} * @returns {string[]} extensions 所有支持的文件扩展名数组
*/ */
getExtensions() { getExtensions() {
return _.union(_.flatMap(this.handlers, 'extensions'), defaults.extensions); return _.union(_.flatMap(this.handlers, 'extensions'), defaults.extensions);
@ -124,7 +126,8 @@ 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[]} * @returns {string[]} contentTypes 所有支持的文件MIME类型数组
* 获取导入管理器的所有支持类型
*/ */
getContentTypes() { getContentTypes() {
return _.union(_.flatMap(this.handlers, 'contentTypes'), defaults.contentTypes); return _.union(_.flatMap(this.handlers, 'contentTypes'), defaults.contentTypes);
@ -132,7 +135,7 @@ class ImportManager {
/** /**
* Get an array of directories for which we have handlers * Get an array of directories for which we have handlers
* @returns {string[]} * @returns {string[]} directories 所有支持的文件目录数组
*/ */
getDirectories() { getDirectories() {
return _.union(_.flatMap(this.handlers, 'directories'), defaults.directories); return _.union(_.flatMap(this.handlers, 'directories'), defaults.directories);
@ -140,8 +143,8 @@ class ImportManager {
/** /**
* Convert items into a glob string * Convert items into a glob string
* @param {String[]} items * @param {String[]} items 要转换的文件扩展名数组
* @returns {String} * @returns {String} globPattern 转换后的文件扩展名glob模式字符串
*/ */
getGlobPattern(items) { getGlobPattern(items) {
return '+(' + _.reduce(items, function (memo, ext) { return '+(' + _.reduce(items, function (memo, ext) {
@ -150,9 +153,9 @@ class ImportManager {
} }
/** /**
* @param {String[]} extensions * @param {String[]} extensions 要匹配的文件扩展名数组
* @param {Number} [level] * @param {Number} [level=ROOT_OR_SINGLE_DIR] 匹配级别默认值为ROOT_OR_SINGLE_DIR
* @returns {String} * @returns {String} globPattern 转换后的文件扩展名glob模式字符串
*/ */
getExtensionGlob(extensions, level) { getExtensionGlob(extensions, level) {
const prefix = level === ALL_DIRS ? '**/*' : const prefix = level === ALL_DIRS ? '**/*' :
@ -163,9 +166,9 @@ class ImportManager {
/** /**
* *
* @param {String[]} directories * @param {String[]} directories 要匹配的文件目录数组
* @param {Number} [level] * @param {Number} [level=ROOT_OR_SINGLE_DIR] 匹配级别默认值为ROOT_OR_SINGLE_DIR
* @returns {String} * @returns {String} globPattern 转换后的文件目录glob模式字符串
*/ */
getDirectoryGlob(directories, level) { getDirectoryGlob(directories, level) {
const prefix = level === ALL_DIRS ? '**/' : const prefix = level === ALL_DIRS ? '**/' :
@ -617,4 +620,4 @@ class ImportManager {
/** /**
* @typedef {Object} ImportResult * @typedef {Object} ImportResult
*/ */
module.exports = new ImportManager(); module.exports = new ImportManager();

@ -1,73 +1,102 @@
const _ = require('lodash'); //这里是基本的外部链接,让之后的一些操作可以直接进行使用
const logging = require('@tryghost/logging'); const _ = require('lodash'); // 工具库
const errors = require('@tryghost/errors'); const logging = require('@tryghost/logging'); // 日志
const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); // 错误处理
const db = require('../db'); const tpl = require('@tryghost/tpl'); // 模板
const DatabaseInfo = require('@tryghost/database-info'); const db = require('../db'); // 数据库连接
const schema = require('./schema'); const DatabaseInfo = require('@tryghost/database-info'); // 数据库信息
const schema = require('./schema'); // 数据库模型里面各种表的定义
const messages = {
const messages = { //相关的错误提示
hasPrimaryKeySQLiteError: 'Must use hasPrimaryKeySQLite on an SQLite3 database', hasPrimaryKeySQLiteError: 'Must use hasPrimaryKeySQLite on an SQLite3 database',
hasForeignSQLite3: 'Must use hasForeignSQLite3 on an SQLite3 database', hasForeignSQLite3: 'Must use hasForeignSQLite3 on an SQLite3 database',
noSupportForDatabase: 'No support for database client {client}' noSupportForDatabase: 'No support for database client {client}'
}; };
//这个地方都是实现sql语句功能的函数
/** /**
* @param {string} tableName * 根据schema.js中的定义来创建数据库中的表
* @param {import('knex').knex.TableBuilder} tableBuilder *
* @param {string} columnName * @param {string} tableName - 表名用于从 schema.js 中获取字段定义
* @param {object} [columnSpec] * @param {Object} tableBuilder - Knex.js 的表构建器对象用于创建字段
* @param {string} columnName - 字段名
* @param {Object} [columnSpec] - 字段定义对象如果未提供则从 schema.js 中获取
* @returns {void}
*/ */
function addTableColumn(tableName, tableBuilder, columnName, columnSpec = schema[tableName][columnName]) { function addTableColumn(tableName, tableBuilder, columnName, columnSpec = schema[tableName][columnName]) {
let column; let column; // Knex.js 字段构建器对象,用于链式调用字段约束方法
// creation distinguishes between text with fieldtype, string with maxlength and all others // 字段类型处理:区分 text带 fieldtype、string带 maxlength和其他基本类型
if (columnSpec.type === 'text' && Object.prototype.hasOwnProperty.call(columnSpec, 'fieldtype')) { if (columnSpec.type === 'text' && Object.prototype.hasOwnProperty.call(columnSpec, 'fieldtype')) {
// 处理 text 类型,支持指定 fieldtype如 'text', 'longtext' 等)
column = tableBuilder[columnSpec.type](columnName, columnSpec.fieldtype); column = tableBuilder[columnSpec.type](columnName, columnSpec.fieldtype);
} else if (columnSpec.type === 'string') { } else if (columnSpec.type === 'string') {
// 处理 string 类型,支持指定最大长度
if (Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength')) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength')) {
column = tableBuilder[columnSpec.type](columnName, columnSpec.maxlength); column = tableBuilder[columnSpec.type](columnName, columnSpec.maxlength);
} else { } else {
// 默认使用 191 作为字符串长度(兼容 MySQL 的索引限制)
column = tableBuilder[columnSpec.type](columnName, 191); column = tableBuilder[columnSpec.type](columnName, 191);
} }
} else { } else {
// 处理其他基本类型integer, boolean, dateTime 等)
column = tableBuilder[columnSpec.type](columnName); column = tableBuilder[columnSpec.type](columnName);
} }
// === 字段约束处理 ===
// 空值约束:控制字段是否允许 NULL 值
if (Object.prototype.hasOwnProperty.call(columnSpec, 'nullable') && columnSpec.nullable === true) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'nullable') && columnSpec.nullable === true) {
column.nullable(); column.nullable(); // 允许 NULL 值
} else { } else {
column.nullable(false); column.nullable(false); // 不允许 NULL 值(默认)
} }
// 主键约束:将字段设置为主键
if (Object.prototype.hasOwnProperty.call(columnSpec, 'primary') && columnSpec.primary === true) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'primary') && columnSpec.primary === true) {
column.primary(); column.primary();
} }
// 唯一约束:确保字段值在表中唯一
if (Object.prototype.hasOwnProperty.call(columnSpec, 'unique') && columnSpec.unique) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'unique') && columnSpec.unique) {
column.unique(); column.unique();
} }
// 无符号约束:适用于整数类型,确保值为非负数
if (Object.prototype.hasOwnProperty.call(columnSpec, 'unsigned') && columnSpec.unsigned) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'unsigned') && columnSpec.unsigned) {
column.unsigned(); column.unsigned();
} }
// 外键引用:建立字段与其他表的关联
if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) {
// check if table exists? // 注意:这里没有检查被引用的表是否存在,需要在调用前确保
column.references(columnSpec.references); column.references(columnSpec.references);
} }
// 外键约束名:为外键约束指定名称
if (Object.prototype.hasOwnProperty.call(columnSpec, 'constraintName')) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'constraintName')) {
column.withKeyName(columnSpec.constraintName); column.withKeyName(columnSpec.constraintName);
} }
// 级联删除策略:控制关联记录删除时的行为
if (Object.prototype.hasOwnProperty.call(columnSpec, 'cascadeDelete') && columnSpec.cascadeDelete === true) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'cascadeDelete') && columnSpec.cascadeDelete === true) {
column.onDelete('CASCADE'); column.onDelete('CASCADE'); // 级联删除:删除主记录时自动删除关联记录
} else if (Object.prototype.hasOwnProperty.call(columnSpec, 'setNullDelete') && columnSpec.setNullDelete === true) { } else if (Object.prototype.hasOwnProperty.call(columnSpec, 'setNullDelete') && columnSpec.setNullDelete === true) {
column.onDelete('SET NULL'); column.onDelete('SET NULL'); // 设为 NULL删除主记录时将外键设为 NULL
} }
// 默认值:为字段设置默认值
if (Object.prototype.hasOwnProperty.call(columnSpec, 'defaultTo')) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'defaultTo')) {
column.defaultTo(columnSpec.defaultTo); column.defaultTo(columnSpec.defaultTo);
} }
// 索引:为字段创建索引以提高查询性能
if (Object.prototype.hasOwnProperty.call(columnSpec, 'index') && columnSpec.index === true) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'index') && columnSpec.index === true) {
column.index(); column.index();
} }
} }
//用函数将sql语句的功能进行封装方便之后使用
/** /**
* @param {string} tableName * @param {string} tableName
@ -285,7 +314,7 @@ async function dropUnique(tableName, columns, transaction = db.knex) {
/** /**
* Checks if a foreign key exists in a table over the given columns. * Checks if a foreign key exists in a table over the given columns.
* *
* @param {Object} configuration - contains all configuration for this function * @param {Object} configuration - contains all configuration for this function
* @param {string} configuration.fromTable - name of the table to add the foreign key to * @param {string} configuration.fromTable - name of the table to add the foreign key to
* @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.fromColumn - column of the table to add the foreign key to
@ -376,7 +405,7 @@ async function addForeign({fromTable, fromColumn, toTable, toColumn, constraintN
* @param {Object} configuration - contains all configuration for this function * @param {Object} configuration - contains all configuration for this function
* @param {string} configuration.fromTable - name of the table to add the foreign key to * @param {string} configuration.fromTable - name of the table to add the foreign key to
* @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.fromColumn - column of the table to add the foreign key to
* @param {string} configuration.toTable - name of the table to point the foreign key to * @param {string} configuration.toTable - name of the table to point the foreign key to
* @param {string} configuration.toColumn - column of the table to point the foreign key to * @param {string} configuration.toColumn - column of the table to point the foreign key to
* @param {string} [configuration.constraintName] - name of the FK to delete * @param {string} [configuration.constraintName] - name of the FK to delete
* @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference * @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference
@ -553,8 +582,28 @@ async function getColumns(table, transaction = db.knex) {
return Promise.reject(tpl(messages.noSupportForDatabase, {client: client})); return Promise.reject(tpl(messages.noSupportForDatabase, {client: client}));
} }
/**
* 创建字段迁移脚本的高阶函数
*
* 这是一个高级的数据库迁移工具函数用于创建幂等的字段迁移脚本
* 通过检查数据库当前状态确保迁移操作只在必要时执行避免重复操作
*
*/
function createColumnMigration(...migrations) { function createColumnMigration(...migrations) {
/**
* 执行单个迁移操作的内部函数
*
* @param {import('knex').Knex} conn - 数据库连接对象
* @param {Object} migration - 迁移配置对象
* @param {string} migration.table - 表名
* @param {string} migration.column - 字段名
* @param {Function} migration.dbIsInCorrectState - 状态检查函数返回布尔值
* @param {Function} migration.operation - 要执行的操作函数 addColumndropColumn
* @param {string} migration.operationVerb - 操作动词用于日志记录
* @param {Object} [migration.columnDefinition] - 字段定义仅用于添加字段操作
*/
async function runColumnMigration(conn, migration) { async function runColumnMigration(conn, migration) {
// 解构迁移配置参数
const { const {
table, table,
column, column,
@ -564,44 +613,70 @@ function createColumnMigration(...migrations) {
columnDefinition columnDefinition
} = migration; } = migration;
// 检查字段是否存在
const hasColumn = await conn.schema.hasColumn(table, column); const hasColumn = await conn.schema.hasColumn(table, column);
// 根据状态检查函数判断是否需要执行操作
const isInCorrectState = dbIsInCorrectState(hasColumn); const isInCorrectState = dbIsInCorrectState(hasColumn);
if (isInCorrectState) { if (isInCorrectState) {
// 如果数据库已处于正确状态,跳过操作并记录警告日志
logging.warn(`${operationVerb} ${table}.${column} column - skipping as table is correct`); logging.warn(`${operationVerb} ${table}.${column} column - skipping as table is correct`);
} else { } else {
// 如果数据库需要更新,执行操作并记录信息日志
logging.info(`${operationVerb} ${table}.${column} column`); logging.info(`${operationVerb} ${table}.${column} column`);
await operation(table, column, conn, columnDefinition); await operation(table, column, conn, columnDefinition);
} }
} }
/**
* 返回的迁移执行函数
*
* @param {import('knex').Knex} conn - 数据库连接对象
* @returns {Promise} 异步执行所有迁移操作
*/
return async function columnMigration(conn) { return async function columnMigration(conn) {
// 按顺序执行所有迁移配置
for (const migration of migrations) { for (const migration of migrations) {
await runColumnMigration(conn, migration); await runColumnMigration(conn, migration);
} }
}; };
} }
/**
* 数据库模式操作命令模块导出
* 这个地方是对上面封装sql语句的函数进行一个成列
*/
module.exports = { module.exports = {
createTable, // 表操作命令
deleteTable, createTable, // 创建表:根据 schema.js 定义创建新表
getTables, deleteTable, // 删除表:删除指定的表
getIndexes,
addUnique, // 元数据查询命令
dropUnique, getTables, // 获取表列表:查询数据库中所有表名
addIndex, getIndexes, // 获取索引:查询指定表的所有索引
dropIndex, getColumns, // 获取字段:查询指定表的所有字段名
addPrimaryKey,
addForeign, // 约束操作命令
dropForeign, addUnique, // 添加唯一约束:为字段添加唯一性约束
addColumn, dropUnique, // 删除唯一约束:移除字段的唯一性约束
renameColumn, addIndex, // 添加索引:为字段创建索引以提高查询性能
dropColumn, dropIndex, // 删除索引:移除字段的索引
setNullable, addPrimaryKey, // 添加主键:将字段设置为主键
dropNullable, addForeign, // 添加外键:建立表之间的关联关系
getColumns, dropForeign, // 删除外键:移除表之间的关联关系
createColumnMigration,
// 字段操作命令
addColumn, // 添加字段:向表中添加新字段
renameColumn, // 重命名字段:修改字段名称
dropColumn, // 删除字段:从表中移除字段
setNullable, // 设为可空:允许字段为 NULL 值
dropNullable, // 设为非空:不允许字段为 NULL 值
// 高级功能
createColumnMigration, // 创建字段迁移:生成字段变更的迁移脚本
// 测试专用函数(仅供内部测试使用)
// NOTE: below are exposed for testing purposes only // NOTE: below are exposed for testing purposes only
_hasForeignSQLite: hasForeignSQLite, _hasForeignSQLite: hasForeignSQLite, // 检查 SQLite 外键存在性(测试用)
_hasPrimaryKeySQLite: hasPrimaryKeySQLite _hasPrimaryKeySQLite: hasPrimaryKeySQLite // 检查 SQLite 主键存在性(测试用)
}; };

@ -4,3 +4,4 @@ const defaultSettingsPath = config.get('paths').defaultSettings;
const defaultSettings = require(defaultSettingsPath); const defaultSettings = require(defaultSettingsPath);
module.exports = defaultSettings; module.exports = defaultSettings;
//一些默认配置文件

@ -1,13 +1,13 @@
const _ = require('lodash'); const _ = require('lodash'); //引入lodash库用于处理数组、对象等数据结构
const logging = require('@tryghost/logging'); const logging = require('@tryghost/logging');
const {sequence} = require('@tryghost/promise'); const {sequence} = require('@tryghost/promise');
const models = require('../../../models'); const models = require('../../../models');
const baseUtils = require('../../../models/base/utils'); const baseUtils = require('../../../models/base/utils');
const moment = require('moment'); const moment = require('moment');
//把写在 JSON/JS 里的“蓝图”变成真正的数据库记录,并建立好它们之间的关联。
class FixtureManager { class FixtureManager {
/** /**
* Create a new FixtureManager instance * Create a new FixtureManager instance
* *
@ -194,22 +194,23 @@ class FixtureManager {
* @param {String} objName * @param {String} objName
* @returns {Object} fixture relation * @returns {Object} fixture relation
*/ */
/**
* 查找特定对象的权限关系
*
* 用于设置角色权限管理员对文章有所有权限
*/
findPermissionRelationsForObject(objName, role) { findPermissionRelationsForObject(objName, role) {
// Make a copy and delete any entries we don't want const foundRelation = this.findRelationFixture('Role', 'Permission');
const foundRelation = _.cloneDeep(this.findRelationFixture('Role', 'Permission'));
// 过滤只保留指定对象的权限
_.each(foundRelation.entries, (entry, key) => { _.each(foundRelation.entries, (entry, key) => {
_.each(entry, (perm, obj) => { _.each(entry, (perm, obj) => {
if (obj !== objName) { if (obj !== objName) {
delete entry[obj]; delete entry[obj]; // 移除其他对象权限
} }
}); });
if (_.isEmpty(entry) || (role && role !== key)) {
delete foundRelation.entries[key];
}
}); });
return foundRelation; return foundRelation;
} }
@ -382,52 +383,49 @@ class FixtureManager {
* @param {{from, to, entries}} relationFixture * @param {{from, to, entries}} relationFixture
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
/**
* 创建模型之间的关联关系
*
* 处理多对多一对多等复杂关系
* 避免重复关联检查是否已存在相同关系
*
* @param {{from, to, entries}} relationFixture - 关系配置
*/
async addFixturesForRelation(relationFixture, options) { async addFixturesForRelation(relationFixture, options) {
const ops = []; // 获取关联双方的现有数据
let max = 0;
const data = await this.fetchRelationData(relationFixture, options); const data = await this.fetchRelationData(relationFixture, options);
_.each(relationFixture.entries, (entry, key) => { _.each(relationFixture.entries, (entry, key) => {
const fromItem = data.from.find(FixtureManager.matchFunc(relationFixture.from.match, key)); // 查找源模型
const fromItem = data.from.find(
// CASE: You add new fixtures e.g. a new role in a new release. FixtureManager.matchFunc(relationFixture.from.match, key)
// As soon as an **older** migration script wants to add permissions for any resource, it iterates over the );
// permissions for each role. But if the role does not exist yet, it won't find the matching db entry and breaks.
if (!fromItem) {
logging.warn('Skip: Target database entry not found for key: ' + key);
return Promise.resolve();
}
_.each(entry, (value, entryKey) => { _.each(entry, (value, entryKey) => {
let toItems = data.to.filter(FixtureManager.matchFunc(relationFixture.to.match, entryKey, value)); // 查找目标模型
max += toItems.length; let toItems = data.to.filter(
FixtureManager.matchFunc(relationFixture.to.match, entryKey, value)
// Remove any duplicates that already exist in the collection );
// 移除已存在的关联(避免重复)
toItems = _.reject(toItems, (item) => { toItems = _.reject(toItems, (item) => {
return fromItem return fromItem.related(relationFixture.from.relation)
.related(relationFixture.from.relation) .find((model) => /* 检查是否已关联 */);
.find((model) => {
const objectToMatch = FixtureManager.matchObj(relationFixture.to.match, item);
return Object.keys(objectToMatch).every((keyToCheck) => {
return model.get(keyToCheck) === objectToMatch[keyToCheck];
});
});
}); });
if (toItems && toItems.length > 0) { // 创建新关联
ops.push(function addRelationItems() { if (toItems.length > 0) {
return baseUtils.attach( ops.push(() => baseUtils.attach(
models[relationFixture.from.Model || relationFixture.from.model], models[relationFixture.from.model],
fromItem.id, fromItem.id,
relationFixture.from.relation, relationFixture.from.relation,
toItems, toItems,
options options
); ));
});
} }
}); });
}); });
}
const result = await sequence(ops); const result = await sequence(ops);
return {expected: max, done: _(result).map('length').sum()}; return {expected: max, done: _(result).map('length').sum()};
@ -472,4 +470,4 @@ class FixtureManager {
} }
} }
module.exports = FixtureManager; module.exports = FixtureManager;

@ -8,140 +8,153 @@
* Text = length 65535 (64 KiB) * Text = length 65535 (64 KiB)
* Long text = length 1,000,000,000 * Long text = length 1,000,000,000
*/ */
/*
* primary 主键
* unique 唯一
* defaultTo 默认值
* nullable 是否为空
* validations 验证规则
* isIn 枚举值defaultTo为当前枚举值中的一个
* isUUID 是否为UUID
* validations 验证规则看当前字段是否符合规则不符合就拒写
* visibility 可见性验证仅给定人员可见
* slug:url 别名 type:内容类型post,page)
*/
module.exports = { module.exports = {
newsletters: { newsletters: { // 邮件通讯表 - 存储邮件通讯相关的配置信息
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true}, // 内部主键(对外不可见)
uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}}, uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}}, // 外部唯一标志即使被泄露还有id保护无法被外部人员窃取相关数据
name: {type: 'string', maxlength: 191, nullable: false, unique: true}, name: {type: 'string', maxlength: 191, nullable: false, unique: true}, // 邮件通讯名称,必须唯一
description: {type: 'string', maxlength: 2000, nullable: true}, description: {type: 'string', maxlength: 2000, nullable: true}, // 邮件通讯描述,可为空
feedback_enabled: {type: 'boolean', nullable: false, defaultTo: false}, feedback_enabled: {type: 'boolean', nullable: false, defaultTo: false}, // 是否启用反馈功能,默认关闭
slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, // URL友好的标识符必须唯一
sender_name: {type: 'string', maxlength: 191, nullable: true}, sender_name: {type: 'string', maxlength: 191, nullable: true}, // 发件人名称,可为空
sender_email: {type: 'string', maxlength: 191, nullable: true}, sender_email: {type: 'string', maxlength: 191, nullable: true}, // 发件人邮箱地址,可为空
sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter'}, sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter'}, // 回复地址,默认为'newsletter'
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active', validations: {isIn: [['active', 'archived']]}}, status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active', validations: {isIn: [['active', 'archived']]}}, // 状态:活跃或已归档
visibility: { visibility: {
type: 'string', type: 'string',
maxlength: 50, maxlength: 50,
nullable: false, nullable: false,
defaultTo: 'members' defaultTo: 'members'//设置可见性,仅会员可见
}, },
subscribe_on_signup: {type: 'boolean', nullable: false, defaultTo: true}, subscribe_on_signup: {type: 'boolean', nullable: false, defaultTo: true}, // 用户注册时是否自动订阅邮件通讯默认为true自动订阅
sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}, sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}, // 排序顺序,用于控制显示顺序
header_image: {type: 'string', maxlength: 2000, nullable: true}, header_image: {type: 'string', maxlength: 2000, nullable: true}, // 头部图片URL可为空
show_header_icon: {type: 'boolean', nullable: false, defaultTo: true}, show_header_icon: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示头部图标,默认显示
show_header_title: {type: 'boolean', nullable: false, defaultTo: true}, show_header_title: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示头部标题,默认显示
show_excerpt: {type: 'boolean', nullable: false, defaultTo: false}, show_excerpt: {type: 'boolean', nullable: false, defaultTo: false}, // 是否显示摘要,默认不显示
title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, // 标题字体类别:衬线体或无衬线体
title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}}, title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}}, // 标题对齐方式:居中或左对齐
show_feature_image: {type: 'boolean', nullable: false, defaultTo: true}, show_feature_image: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示特色图片,默认显示
body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, // 正文字体类别:衬线体或无衬线体
footer_content: {type: 'text', maxlength: 1000000000, nullable: true}, footer_content: {type: 'text', maxlength: 1000000000, nullable: true}, // 页脚内容,支持长文本
show_badge: {type: 'boolean', nullable: false, defaultTo: true}, show_badge: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示徽章,默认显示
show_header_name: {type: 'boolean', nullable: false, defaultTo: true}, show_header_name: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示头部名称,默认显示
show_post_title_section: {type: 'boolean', nullable: false, defaultTo: true}, show_post_title_section: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示文章标题区域,默认显示
show_comment_cta: {type: 'boolean', nullable: false, defaultTo: true}, show_comment_cta: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示评论行动号召,默认显示
show_subscription_details: {type: 'boolean', nullable: false, defaultTo: false}, show_subscription_details: {type: 'boolean', nullable: false, defaultTo: false}, // 是否显示订阅详情,默认不显示
show_latest_posts: {type: 'boolean', nullable: false, defaultTo: false}, show_latest_posts: {type: 'boolean', nullable: false, defaultTo: false}, // 是否显示最新文章,默认不显示
background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'light'}, background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'light'}, // 背景颜色,默认为浅色
post_title_color: {type: 'string', maxlength: 50, nullable: true}, post_title_color: {type: 'string', maxlength: 50, nullable: true}, // 文章标题颜色,可为空
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false}, // 创建时间,不能为空
updated_at: {type: 'dateTime', nullable: true}, updated_at: {type: 'dateTime', nullable: true}, // 更新时间,可为空
button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}}, button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}}, // 按钮圆角样式:方形、圆角或药丸形
button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}}, button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}}, // 按钮样式:填充或轮廓
title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}}, title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}}, // 标题字体粗细:正常、中等、半粗体、粗体
link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}}, link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}}, // 链接样式:下划线、常规、粗体
image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}}, image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}}, // 图片圆角样式:方形或圆角
header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'transparent'}, header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'transparent'}, // 头部背景颜色,默认为透明
section_title_color: {type: 'string', maxlength: 50, nullable: true}, section_title_color: {type: 'string', maxlength: 50, nullable: true}, // 区域标题颜色,可为空
divider_color: {type: 'string', maxlength: 50, nullable: true}, divider_color: {type: 'string', maxlength: 50, nullable: true}, // 分隔线颜色,可为空
button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, // 按钮颜色,默认为强调色
link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'} link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'} // 链接颜色,默认为强调色
}, },
// 内容管理器,对内容的各种信息进行存储管理
posts: { posts: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true}, // 主键ID唯一标识文章
uuid: {type: 'string', maxlength: 36, nullable: false, index: true, validations: {isUUID: true}}, uuid: {type: 'string', maxlength: 36, nullable: false, index: true, validations: {isUUID: true}}, // 全局唯一标识符,用于外部引用
title: {type: 'string', maxlength: 2000, nullable: false, validations: {isLength: {max: 255}}}, title: {type: 'string', maxlength: 2000, nullable: false, validations: {isLength: {max: 255}}}, // 文章标题最大长度255字符
slug: {type: 'string', maxlength: 191, nullable: false}, slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, // URL别名用于在URL中标识文章
mobiledoc: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, mobiledoc: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 旧编辑器JSON格式内容
lexical: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, lexical: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 新编辑器JSON格式内容
html: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, html: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 编译之后的HTML内容
comment_id: {type: 'string', maxlength: 50, nullable: true}, comment_id: {type: 'string', maxlength: 50, nullable: true}, // 评论ID用于外部评论系统集成
plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 纯文本摘要,用于搜索和预览
feature_image: {type: 'string', maxlength: 2000, nullable: true}, feature_image: {type: 'string', maxlength: 2000, nullable: true}, // 封面图地址
featured: {type: 'boolean', nullable: false, defaultTo: false}, featured: {type: 'boolean', nullable: false, defaultTo: false}, // 是否置顶,默认为否,需要手动设置
type: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'post', validations: {isIn: [['post', 'page']]}}, type: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'post', validations: {isIn: [['post', 'page']]}}, // 内容类型post为文章page为页面
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'draft', validations: {isIn: [['published', 'draft', 'scheduled', 'sent']]}}, status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'draft', validations: {isIn: [['published', 'draft', 'scheduled', 'sent']]}}, // 文章状态:已发布、草稿、定时发布、已发送
// NOTE: unused at the moment and reserved for future features // NOTE: unused at the moment and reserved for future features
locale: {type: 'string', maxlength: 6, nullable: true}, locale: {type: 'string', maxlength: 6, nullable: true}, // 语言区域设置,预留字段
visibility: { visibility: {
type: 'string', type: 'string',
maxlength: 50, maxlength: 50,
nullable: false, nullable: false,
defaultTo: 'public' defaultTo: 'public' // 可见性设置,默认为公开
}, },
email_recipient_filter: { email_recipient_filter: {
type: 'text', type: 'text',
maxlength: 1000000000, maxlength: 1000000000,
nullable: false nullable: false // 邮件接收者筛选条件
}, },
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false}, // 创建时间
updated_at: {type: 'dateTime', nullable: true, index: true}, updated_at: {type: 'dateTime', nullable: true, index: true}, // 更新时间,有索引便于排序
published_at: {type: 'dateTime', nullable: true, index: true}, published_at: {type: 'dateTime', nullable: true, index: true}, // 发布时间,有索引便于排序
published_by: {type: 'string', maxlength: 24, nullable: true}, published_by: {type: 'string', maxlength: 24, nullable: true}, // 发布者ID
custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}, custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}, // 自定义摘要最大300字符
codeinjection_head: {type: 'text', maxlength: 65535, nullable: true}, codeinjection_head: {type: 'text', maxlength: 65535, nullable: true}, // 头部代码注入,用于在<head>标签中添加自定义代码
codeinjection_foot: {type: 'text', maxlength: 65535, nullable: true}, codeinjection_foot: {type: 'text', maxlength: 65535, nullable: true}, // 底部代码注入,用于在<body>结束前添加自定义代码
custom_template: {type: 'string', maxlength: 100, nullable: true}, custom_template: {type: 'string', maxlength: 100, nullable: true}, // 自定义模板名称
canonical_url: {type: 'text', maxlength: 2000, nullable: true}, canonical_url: {type: 'text', maxlength: 2000, nullable: true}, // 规范URL用于SEO避免重复内容
newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'}, newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'}, // 关联的邮件通讯ID
show_title_and_feature_image: {type: 'boolean', nullable: false, defaultTo: true}, show_title_and_feature_image: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示标题和封面图,默认为是
'@@INDEXES@@': [ '@@INDEXES@@': [
['type','status','updated_at'] ['type','status','updated_at'] // 复合索引:按类型、状态、更新时间排序,提高查询性能
], ],
'@@UNIQUE_CONSTRAINTS@@': [ '@@UNIQUE_CONSTRAINTS@@': [
['slug', 'type'] ['slug', 'type'] // 唯一约束同一类型的文章不能有相同的slug
] ]
}, },
posts_meta: { posts_meta: { //用于存储文章的额外元数据
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},//主键ID唯一标识文章元数据
post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id', unique: true}, post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id', unique: true}, // 关联的文章ID唯一标识
og_image: {type: 'string', maxlength: 2000, nullable: true}, og_image: {type: 'string', maxlength: 2000, nullable: true}, // Open Graph 图片URL
og_title: {type: 'string', maxlength: 300, nullable: true}, og_title: {type: 'string', maxlength: 300, nullable: true}, // Open Graph 标题最大300字符
og_description: {type: 'string', maxlength: 500, nullable: true}, og_description: {type: 'string', maxlength: 500, nullable: true}, // Open Graph 描述最大500字符
twitter_image: {type: 'string', maxlength: 2000, nullable: true}, twitter_image: {type: 'string', maxlength: 2000, nullable: true}, // Twitter 图片URL
twitter_title: {type: 'string', maxlength: 300, nullable: true}, twitter_title: {type: 'string', maxlength: 300, nullable: true}, // Twitter 标题最大300字符
twitter_description: {type: 'string', maxlength: 500, nullable: true}, twitter_description: {type: 'string', maxlength: 500, nullable: true}, // Twitter 描述最大500字符
meta_title: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}, meta_title: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}},// SEO 标题最大300字符
meta_description: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 500}}}, meta_description: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 500}}},// SEO 描述最大500字符
email_subject: {type: 'string', maxlength: 300, nullable: true}, email_subject: {type: 'string', maxlength: 300, nullable: true},// 邮件主题最大300字符
frontmatter: {type: 'text', maxlength: 65535, nullable: true}, frontmatter: {type: 'text', maxlength: 65535, nullable: true},// 文章的Frontmatter内容用于自定义元数据
feature_image_alt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 191}}}, feature_image_alt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 191}}},// 封面图替代文本最大191字符
feature_image_caption: {type: 'text', maxlength: 65535, nullable: true}, feature_image_caption: {type: 'text', maxlength: 65535, nullable: true},// 封面图标题,用于图片描述
email_only: {type: 'boolean', nullable: false, defaultTo: false} email_only: {type: 'boolean', nullable: false, defaultTo: false}, // 是否仅通过邮件发送,默认为否
}, },
// NOTE: this is the staff table // NOTE: this is the staff table
users: { users: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},//主键ID唯一标识用户
name: {type: 'string', maxlength: 191, nullable: false}, name: {type: 'string', maxlength: 191, nullable: false}, // 用户名
slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, slug: {type: 'string', maxlength: 191, nullable: false, unique: true},//用户slug唯一标识
password: {type: 'string', maxlength: 60, nullable: false}, password: {type: 'string', maxlength: 60, nullable: false},//用户密码
email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}}, email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},//用户邮箱,唯一标识
profile_image: {type: 'string', maxlength: 2000, nullable: true}, profile_image: {type: 'string', maxlength: 2000, nullable: true},// 用户头像URL
cover_image: {type: 'string', maxlength: 2000, nullable: true}, cover_image: {type: 'string', maxlength: 2000, nullable: true},//用户封面图URL
bio: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 250}}}, bio: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 250}}},//用户简介最大250字符
website: {type: 'string', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}}, website: {type: 'string', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}},//用户网站URL
location: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 150}}}, location: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 150}}},//用户位置最大150字符
facebook: {type: 'string', maxlength: 2000, nullable: true}, facebook: {type: 'string', maxlength: 2000, nullable: true},//用户Facebook链接
twitter: {type: 'string', maxlength: 2000, nullable: true}, twitter: {type: 'string', maxlength: 2000, nullable: true},
threads: {type: 'string', maxlength: 191, nullable: true}, threads: {type: 'string', maxlength: 191, nullable: true},//用户Threads链接
bluesky: {type: 'string', maxlength: 191, nullable: true}, bluesky: {type: 'string', maxlength: 191, nullable: true},//用户Bluesky链接
mastodon: {type: 'string', maxlength: 191, nullable: true}, mastodon: {type: 'string', maxlength: 191, nullable: true},//用户Mastodon链接
tiktok: {type: 'string', maxlength: 191, nullable: true}, tiktok: {type: 'string', maxlength: 191, nullable: true},//用户TikTok链接
youtube: {type: 'string', maxlength: 191, nullable: true}, youtube: {type: 'string', maxlength: 191, nullable: true}, //用户YouTube链接
instagram: {type: 'string', maxlength: 191, nullable: true}, instagram: {type: 'string', maxlength: 191, nullable: true},//用户Instagram链接
linkedin: {type: 'string', maxlength: 191, nullable: true}, linkedin: {type: 'string', maxlength: 191, nullable: true},//用户LinkedIn链接
accessibility: {type: 'text', maxlength: 65535, nullable: true}, accessibility: {type: 'text', maxlength: 65535, nullable: true},//用户可访问性设置
status: { status: {
type: 'string', type: 'string',
maxlength: 50, maxlength: 50,
@ -160,7 +173,7 @@ module.exports = {
} }
}, },
// NOTE: unused at the moment and reserved for future features // NOTE: unused at the moment and reserved for future features
locale: {type: 'string', maxlength: 6, nullable: true}, locale: {type: 'string', maxlength: 6, nullable: true},//用户语言设置
visibility: { visibility: {
type: 'string', type: 'string',
maxlength: 50, maxlength: 50,
@ -184,25 +197,25 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
posts_authors: { posts_authors: { // 文章作者关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id'}, post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id'},
author_id: {type: 'string', maxlength: 24, nullable: false, references: 'users.id'}, author_id: {type: 'string', maxlength: 24, nullable: false, references: 'users.id'},
sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0} sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}
}, },
roles: { roles: {// 角色
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 50, nullable: false, unique: true}, name: {type: 'string', maxlength: 50, nullable: false, unique: true},
description: {type: 'string', maxlength: 2000, nullable: true}, description: {type: 'string', maxlength: 2000, nullable: true},
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
roles_users: { roles_users: { // 角色用户关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
role_id: {type: 'string', maxlength: 24, nullable: false}, role_id: {type: 'string', maxlength: 24, nullable: false},
user_id: {type: 'string', maxlength: 24, nullable: false} user_id: {type: 'string', maxlength: 24, nullable: false}
}, },
permissions: { permissions: {// 权限
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 50, nullable: false, unique: true}, name: {type: 'string', maxlength: 50, nullable: false, unique: true},
object_type: {type: 'string', maxlength: 50, nullable: false}, object_type: {type: 'string', maxlength: 50, nullable: false},
@ -211,17 +224,17 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
permissions_users: { permissions_users: {// 权限用户关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
user_id: {type: 'string', maxlength: 24, nullable: false}, user_id: {type: 'string', maxlength: 24, nullable: false},
permission_id: {type: 'string', maxlength: 24, nullable: false} permission_id: {type: 'string', maxlength: 24, nullable: false}
}, },
permissions_roles: { permissions_roles: {// 权限角色关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
role_id: {type: 'string', maxlength: 24, nullable: false}, role_id: {type: 'string', maxlength: 24, nullable: false},
permission_id: {type: 'string', maxlength: 24, nullable: false} permission_id: {type: 'string', maxlength: 24, nullable: false}
}, },
settings: { settings: {// 设置
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
group: { group: {
type: 'string', type: 'string',
@ -265,7 +278,7 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
tags: { tags: {// 标签
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 191, nullable: false, validations: {matches: /^([^,]|$)/}}, name: {type: 'string', maxlength: 191, nullable: false, validations: {matches: /^([^,]|$)/}},
slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
@ -294,7 +307,7 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
posts_tags: { posts_tags: {// 文章标签关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id'}, post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id'},
tag_id: {type: 'string', maxlength: 24, nullable: false, references: 'tags.id'}, tag_id: {type: 'string', maxlength: 24, nullable: false, references: 'tags.id'},
@ -303,7 +316,7 @@ module.exports = {
['post_id','tag_id'] ['post_id','tag_id']
] ]
}, },
invites: { invites: { // 邀请
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
role_id: {type: 'string', maxlength: 24, nullable: false}, role_id: {type: 'string', maxlength: 24, nullable: false},
status: { status: {
@ -319,14 +332,14 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
brute: { brute: {// 暴力破解
key: {type: 'string', maxlength: 191, primary: true}, key: {type: 'string', maxlength: 191, primary: true},
firstRequest: {type: 'bigInteger'}, firstRequest: {type: 'bigInteger'},
lastRequest: {type: 'bigInteger'}, lastRequest: {type: 'bigInteger'},
lifetime: {type: 'bigInteger'}, lifetime: {type: 'bigInteger'},
count: {type: 'integer'} count: {type: 'integer'}
}, },
sessions: { sessions: {// 会话
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
session_id: {type: 'string', maxlength: 32, nullable: false, unique: true}, session_id: {type: 'string', maxlength: 32, nullable: false, unique: true},
user_id: {type: 'string', maxlength: 24, nullable: false}, user_id: {type: 'string', maxlength: 24, nullable: false},
@ -334,7 +347,7 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
integrations: { integrations: {// 集成
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
type: { type: {
type: 'string', type: 'string',
@ -350,12 +363,12 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true} updated_at: {type: 'dateTime', nullable: true}
}, },
webhooks: { webhooks: {//用于实现事件驱动的外部系统集成当系统中发生特定时间的时候会自动向配置的外部url发送http通知
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
event: {type: 'string', maxlength: 50, nullable: false, validations: {isLowercase: true}}, event: {type: 'string', maxlength: 50, nullable: false, validations: {isLowercase: true}},// 事件类型
target_url: {type: 'string', maxlength: 2000, nullable: false}, target_url: {type: 'string', maxlength: 2000, nullable: false},// 目标url
name: {type: 'string', maxlength: 191, nullable: true}, name: {type: 'string', maxlength: 191, nullable: true},// 名称
secret: {type: 'string', maxlength: 191, nullable: true}, secret: {type: 'string', maxlength: 191, nullable: true},// 密钥
// @NOTE: the defaultTo does not make sense to set on DB layer as it leads to unnecessary maintenance every major release // @NOTE: the defaultTo does not make sense to set on DB layer as it leads to unnecessary maintenance every major release
// would be ideal if we can remove the default and instead have "isIn" validation checking if it's a valid version e.g: 'v3', 'v4', 'canary' // would be ideal if we can remove the default and instead have "isIn" validation checking if it's a valid version e.g: 'v3', 'v4', 'canary'
api_version: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'v2'}, api_version: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'v2'},
@ -376,14 +389,14 @@ module.exports = {
nullable: false, nullable: false,
validations: {isIn: [['content', 'admin']]} validations: {isIn: [['content', 'admin']]}
}, },
secret: { secret: { //密钥字符串
type: 'string', type: 'string',
maxlength: 191, maxlength: 191,
nullable: false, nullable: false,
unique: true, unique: true,//防止出现重复
validations: {isLength: {min: 26, max: 128}} validations: {isLength: {min: 26, max: 128}}
}, },
role_id: {type: 'string', maxlength: 24, nullable: true}, role_id: {type: 'string', maxlength: 24, nullable: true}, // 角色ID关联到roles表定义密钥的权限级别
// integration_id is nullable to allow "internal" API keys that don't show in the UI // integration_id is nullable to allow "internal" API keys that don't show in the UI
integration_id: {type: 'string', maxlength: 24, nullable: true}, integration_id: {type: 'string', maxlength: 24, nullable: true},
user_id: {type: 'string', maxlength: 24, nullable: true}, user_id: {type: 'string', maxlength: 24, nullable: true},
@ -414,7 +427,7 @@ module.exports = {
feature_image_caption: {type: 'text', maxlength: 65535, nullable: true}, feature_image_caption: {type: 'text', maxlength: 65535, nullable: true},
custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}} custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}
}, },
members: { members: {// 会员
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, id: {type: 'string', maxlength: 24, nullable: false, primary: true},
uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}}, uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}},
transient_id: {type: 'string', maxlength: 191, nullable: false, unique: true}, transient_id: {type: 'string', maxlength: 191, nullable: false, unique: true},
@ -437,7 +450,7 @@ module.exports = {
last_commented_at: {type: 'dateTime', nullable: true}, last_commented_at: {type: 'dateTime', nullable: true},
created_at: {type: 'dateTime', nullable: false}, created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}, updated_at: {type: 'dateTime', nullable: true},
'@@INDEXES@@': [ '@@INDEXES@@': [ // 索引
['email_disabled'] ['email_disabled']
] ]
}, },
@ -1090,4 +1103,4 @@ module.exports = {
member_id: {type: 'string', maxlength: 24, nullable: true, references: 'members.id', unique: false, setNullDelete: true}, member_id: {type: 'string', maxlength: 24, nullable: true, references: 'members.id', unique: false, setNullDelete: true},
created_at: {type: 'dateTime', nullable: false} created_at: {type: 'dateTime', nullable: false}
} }
}; };

@ -25,28 +25,38 @@ const messages = {
* *
* ## on model add * ## on model add
* - validate everything to catch required fields * - validate everything to catch required fields
*/
/**
* 数据库模式验证函数 - 根据schema定义验证模型数据的完整性
*
* 这个函数负责验证传入的模型数据是否符合数据库表的schema定义
* 包括数据类型长度限制必填字段等约束条件如果不通过就统一抛错误防止数据污染
*/ */
function validateSchema(tableName, model, options) { function validateSchema(tableName, model, options) {
options = options || {}; options = options || {};
const columns = _.keys(schema[tableName]); const columns = _.keys(schema[tableName]); // 获取表中所有列名
let validationErrors = []; let validationErrors = []; // 存储验证错误
_.each(columns, function each(columnKey) { _.each(columns, function each(columnKey) { // 遍历表中的每一列进行验证
let message = ''; // KEEP: Validator.js only validates strings. let message = ''; // KEEP: Validator.js only validates strings.
// 将字段值转换为字符串进行验证Validator.js只验证字符串
const strVal = _.toString(model.get(columnKey)); const strVal = _.toString(model.get(columnKey));
// 如果是更新操作且字段未改变,则跳过验证(优化性能)
if (options.method !== 'insert' && !_.has(model.changed, columnKey)) { if (options.method !== 'insert' && !_.has(model.changed, columnKey)) {
return; return;
} }
// ==================== 必填字段验证 ====================
// check nullable // 检查非空约束字段不可为空、不是text类型、没有默认值
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') && if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') &&
schema[tableName][columnKey].nullable !== true && schema[tableName][columnKey].nullable !== true && // 字段不可为空
Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') && Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') &&
schema[tableName][columnKey].type !== 'text' && schema[tableName][columnKey].type !== 'text' && // 排除text类型允许空字符串
!Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo') !Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo') // 没有默认值
) { ) {
// 检查字段值是否为空
if (validator.isEmpty(strVal)) { if (validator.isEmpty(strVal)) {
message = tpl(messages.valueCannotBeBlank, { message = tpl(messages.valueCannotBeBlank, {
tableName: tableName, tableName: tableName,
@ -54,14 +64,16 @@ function validateSchema(tableName, model, options) {
}); });
validationErrors.push(new errors.ValidationError({ validationErrors.push(new errors.ValidationError({
message: message, message: message,
context: tableName + '.' + columnKey context: tableName + '.' + columnKey // 错误上下文:表名.字段名
})); }));
} }
} }
// ==================== 布尔字段验证 ====================
// validate boolean columns // 验证布尔类型字段
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')
&& schema[tableName][columnKey].type === 'boolean') { && schema[tableName][columnKey].type === 'boolean') {
// 检查值是否为有效的布尔值或空值
if (!(validator.isBoolean(strVal) || validator.isEmpty(strVal))) { if (!(validator.isBoolean(strVal) || validator.isEmpty(strVal))) {
message = tpl(messages.valueMustBeBoolean, { message = tpl(messages.valueMustBeBoolean, {
tableName: tableName, tableName: tableName,
@ -73,15 +85,18 @@ function validateSchema(tableName, model, options) {
})); }));
} }
// CASE: ensure we transform 0|1 to false|true // CASE: 确保将0|1转换为false|true数据标准化
if (!validator.isEmpty(strVal)) { if (!validator.isEmpty(strVal)) {
model.set(columnKey, !!model.get(columnKey)); model.set(columnKey, !!model.get(columnKey)); // 强制转换为布尔值
} }
} }
// TODO: check if mandatory values should be enforced // TODO: 检查是否应该强制执行必填值
// 当字段值不为null或undefined时进行进一步验证
if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) { if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) {
// check length
// ==================== 长度限制验证 ====================
// 检查字段最大长度限制
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'maxlength')) { if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'maxlength')) {
if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) { if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) {
message = tpl(messages.valueExceedsMaxLength, message = tpl(messages.valueExceedsMaxLength,
@ -97,12 +112,16 @@ function validateSchema(tableName, model, options) {
} }
} }
// check validations objects // ==================== 自定义验证规则 ====================
// 执行schema中定义的自定义验证规则
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) { if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) {
validationErrors = validationErrors.concat(validator.validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName)); validationErrors = validationErrors.concat(
validator.validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName)
);
} }
// check type // ==================== 数据类型验证 ====================
// 检查整数类型字段
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')) { if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')) {
if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) { if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) {
message = tpl(messages.valueIsNotInteger, { message = tpl(messages.valueIsNotInteger, {
@ -118,10 +137,13 @@ function validateSchema(tableName, model, options) {
} }
}); });
// ==================== 验证结果处理 ====================
// 如果有验证错误使用Promise.reject返回错误数组
if (validationErrors.length !== 0) { if (validationErrors.length !== 0) {
return Promise.reject(validationErrors); return Promise.reject(validationErrors);
} }
// 验证通过返回成功的Promise
return Promise.resolve(); return Promise.resolve();
} }
module.exports = validateSchema; module.exports = validateSchema;

@ -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)}));

@ -1,3 +1,30 @@
/*
* Tag model
*
* Responsibilities:
* - Represent the 'tags' table via Bookshelf/ghostBookshelf
* - Handle slug generation and normalization on save
* - Provide relations to posts (belongsToMany)
* - Convert transform-ready urls to absolute on parse, and prepare urls on write
* - Emit tag.* events on create/update/delete for webhooks and internals
* - Provide a custom destroy which detaches post relations before deleting
*
* Notes:
* - Many services call into models.Tag (e.g. PostsService) to create or link tags.
* - countRelations defines how to include posts counts when requested via include=count.posts
*/
/*
* 中文说明
* 本模型封装了 tags 表的数据库映射与行为提供给服务层与 API 层使用
* - 在保存时处理 slug 的生成与规范化onSaving并根据 name 前缀处理 visibility例如以 `#` 开头视为内部标签
* - URL 字段在读取/写入时进行转换parse / formatOnWrite以便模板/序列化使用绝对/transform-ready URL
* - 定义与 posts 的多对多关系posts并在删除标签前负责解除与所有帖子的关联自定义 destroy 实现以防止孤立的关联数据
* - 触发生命周期事件tag.added / tag.edited / tag.deleted webhook事件系统或其他内部服务订阅
*
* 注意
* - 许多业务逻辑例如 PostsService会调用此模型创建或关联标签修改模型的行为可能影响这些调用点
* - 当需要在公开 API 中返回统计信息include=count.postscountRelations 提供了构造查询的方法注意在公共上下文中对帖子的过滤published,type=post
*/
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');
@ -98,6 +125,11 @@ Tag = ghostBookshelf.Model.extend({
model.emitChange('deleted', options); model.emitChange('deleted', options);
}, },
// onSaving hook runs before persisting a tag.
// Responsibilities:
// - Ensure a name exists when only slug is supplied (e.g. nested creation)
// - Detect internal tags (name starts with '#') and set visibility
// - Generate a unique slug when necessary using the shared generator
onSaving: function onSaving(newTag, attr, options) { onSaving: function onSaving(newTag, attr, options) {
const self = this; const self = this;
@ -126,10 +158,12 @@ Tag = ghostBookshelf.Model.extend({
} }
}, },
// Relationship: tags <-> posts (many-to-many)
posts: function posts() { posts: function posts() {
return this.belongsToMany('Post'); return this.belongsToMany('Post');
}, },
// toJSON: normalize attributes when serializing model instances
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);
@ -141,6 +175,7 @@ Tag = ghostBookshelf.Model.extend({
}, },
defaultColumnsToFetch() { defaultColumnsToFetch() {
// By default, fetching minimal columns for lightweight queries
return ['id']; return ['id'];
} }
}, { }, {
@ -166,6 +201,7 @@ Tag = ghostBookshelf.Model.extend({
return options; return options;
}, },
// Configure how to compute relation counts when include=count.posts is requested
countRelations() { countRelations() {
return { return {
posts(modelOrCollection, options) { posts(modelOrCollection, options) {
@ -186,6 +222,11 @@ Tag = ghostBookshelf.Model.extend({
}; };
}, },
// Custom destroy implementation which:
// - Fetches the tag with related posts
// - Detaches all posts from the tag (posts_tags entries)
// - Deletes the tag row
// This prevents orphaned relations and mirrors expected semantics when deleting a tag.
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 = ['posts']; options.withRelated = ['posts'];

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save