0.3 #3

Merged
m3i46ogeb merged 3 commits from main into develop 3 months ago

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

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

@ -1,11 +1,20 @@
# 是否允许创建空白问题(不使用预设模板的问题)
# 设置为true表示用户可以直接提交自定义内容的问题
blank_issues_enabled: true
# 联系链接配置:提供额外的支持和资源入口
contact_links:
- name: 🚑 Help & Support
url: https://forum.ghost.org
about: Please use the community forum for questions
- name: 💡 Features & Ideas
url: https://forum.ghost.org/c/Ideas
about: Please vote for & post new ideas in the the forum
- name: 📖 Documentation
url: https://ghost.org/docs/
about: Tutorials & reference guides for themes, the API and more
# 第一个链接:帮助与支持
- name: 🚑 Help & Support # 链接名称,带救护车图标表示紧急支持
url: https://forum.ghost.org # 链接地址Ghost社区论坛
about: Please use the community forum for questions # 说明:请在社区论坛提问
# 第二个链接:功能与想法建议
- name: 💡 Features & Ideas # 链接名称,带灯泡图标表示创意建议
url: https://forum.ghost.org/c/Ideas # 链接地址论坛的Ideas分类
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 exec = require('util').promisify(require('child_process').exec);
const path = require('path');
// 引入GitHub Actions核心模块用于设置输出和语义化版本处理工具
const core = require('@actions/core');
const semver = require('semver');
// 异步自执行函数:处理版本号更新逻辑
(async () => {
// 1. 定位并读取core模块的package.json文件
const corePackageJsonPath = path.join(__dirname, '../../ghost/core/package.json');
const corePackageJson = require(corePackageJsonPath);
// 2. 获取当前版本号并打印
const current_version = corePackageJson.version;
console.log(`Current version: ${current_version}`);
// 3. 获取命令行传入的第一个参数(用于判断版本类型)
const firstArg = process.argv[2];
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') {
const bumpedVersion = semver.inc(current_version, 'minor');
newVersion = `${bumpedVersion}-pre-g${buildString}`;
// 对于canary或six类型升级minor版本并添加预发布标签包含Git哈希
const bumpedVersion = semver.inc(current_version, 'minor'); // 升级次要版本如1.2.3 → 1.3.0
newVersion = `${bumpedVersion}-pre-g${buildString}`; // 格式1.3.0-pre-gabc123
} 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;
}
// 6. 统一添加+moya后缀可能用于标识自定义构建
newVersion += '+moya';
console.log('newVersion', newVersion);
// 7. 更新core模块的package.json版本号并写入文件
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 adminPackageJson = require(adminPackageJsonPath);
adminPackageJson.version = newVersion;
@ -40,6 +56,7 @@ const semver = require('semver');
console.log('Version bumped to', newVersion);
core.setOutput('BUILD_VERSION', newVersion);
core.setOutput('GIT_COMMIT_HASH', buildString)
})();
// 9. 设置GitHub Actions输出变量供后续步骤使用
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');
cleanYarnCache();
resetNxCache();
deleteNodeModules();
deleteBuildArtifacts();
console.log('Cleanup complete!');
// 执行一系列清理操作
cleanYarnCache(); // 清理Yarn缓存
resetNxCache(); // 重置NX构建缓存
deleteNodeModules(); // 删除所有node_modules目录
deleteBuildArtifacts(); // 删除构建产物
console.log('Cleanup complete!'); // 清理完成提示
/**
* 删除项目中的构建产物
* 包括ghost目录下所有名为build的文件夹和tsconfig.tsbuildinfo文件
*/
function deleteBuildArtifacts() {
console.log('Deleting all build artifacts...');
try {
// 查找ghost目录下所有名为build的目录并递归删除
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', {
stdio: 'inherit'
});
} catch (error) {
console.error('Failed to delete build artifacts:', error);
process.exit(1);
process.exit(1); // 执行失败时退出进程状态码1表示错误
}
}
/**
* 删除项目中所有node_modules目录
* 用于彻底清理已安装的依赖包
*/
function deleteNodeModules() {
console.log('Deleting all node_modules directories...');
try {
// 从当前目录开始查找所有node_modules目录并递归删除
// -prune确保不会进入已找到的node_modules目录内部继续查找
execSync('find . -name "node_modules" -type d -prune -exec rm -rf \'{}\' +', {
stdio: 'inherit'
});
@ -34,9 +48,14 @@ function deleteNodeModules() {
}
}
/**
* 重置NX构建工具的缓存
* NX是用于 monorepo 项目的构建系统缓存可能影响构建结果
*/
function resetNxCache() {
console.log('Resetting NX cache...');
try {
// 删除NX的缓存目录
execSync('rm -rf .nxcache .nx');
} catch (error) {
console.error('Failed to reset NX cache:', error);
@ -44,12 +63,17 @@ function resetNxCache() {
}
}
/**
* 清理Yarn包管理器的缓存
* 移除缓存的依赖包副本避免旧版本缓存影响安装
*/
function cleanYarnCache() {
console.log('Cleaning yarn cache...');
try {
// 删除Yarn缓存目录下的所有内容
execSync('rm -rf .yarncache/* .yarncachecopy/*');
} catch (error) {
console.error('Failed to clean yarn cache:', error);
process.exit(1);
}
}
}

@ -3,9 +3,9 @@ const fs = require('fs/promises');
const exec = require('util').promisify(require('child_process').exec);
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 = {
'@tryghost/portal': 'portal',
'@tryghost/sodo-search': 'sodoSearch',
@ -14,14 +14,20 @@ const CONFIG_KEYS = {
'@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 packageJson = require(packageJsonPath);
// 从package.json中提取应用名称和当前版本
const APP_NAME = packageJson.name;
const APP_VERSION = packageJson.version;
/**
* 安全执行命令的工具函数
* 捕获错误并返回包含stdout和stderr的对象避免进程直接崩溃
*/
async function safeExec(command) {
try {
return await exec(command);
@ -33,106 +39,143 @@ async function safeExec(command) {
}
}
/**
* 确保当前应用在允许发布的列表中
* 检查APP_NAME是否存在于CONFIG_KEYS的键名中
*/
async function ensureEnabledApp() {
const ENABLED_APPS = Object.keys(CONFIG_KEYS);
if (!ENABLED_APPS.includes(APP_NAME)) {
console.error(`${APP_NAME} is not enabled, please modify ${__filename}`);
process.exit(1);
process.exit(1); // 非允许的应用,退出进程
}
}
/**
* 确保不在main分支上执行发布
* 防止直接在主分支上修改代码
*/
async function ensureNotOnMain() {
const currentGitBranch = await safeExec(`git branch --show-current`);
if (currentGitBranch.stderr) {
console.error(`There was an error checking the current git branch`)
console.error(`${currentGitBranch.stderr}`);
process.exit(1);
process.exit(1); // 分支检查失败,退出进程
}
if (currentGitBranch.stdout.trim() === 'main') {
console.error(`The release can not be done on the "main" branch`)
process.exit(1);
process.exit(1); // 在main分支上退出进程
}
}
/**
* 确保当前git工作区干净无未提交的更改
* 避免发布时包含未跟踪的修改
*/
async function ensureCleanGit() {
const localGitChanges = await safeExec(`git status --porcelain`);
if (localGitChanges.stderr) {
console.error(`There was an error checking the local git status`)
console.error(`${localGitChanges.stderr}`);
process.exit(1);
process.exit(1); // 状态检查失败,退出进程
}
if (localGitChanges.stdout) {
console.error(`You have local git changes - are you sure you're ready to release?`)
console.error(`${localGitChanges.stdout}`);
process.exit(1);
process.exit(1); // 有未提交的更改,退出进程
}
}
/**
* 获取用户输入的版本更新类型patch/minor/major并计算新版本号
* 默认使用patch更新
*/
async function getNewVersion() {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const bumpTypeInput = await rl.question('Is this a patch, minor or major (patch)? ');
rl.close();
const bumpType = bumpTypeInput.trim().toLowerCase() || 'patch';
if (!['patch', 'minor', 'major'].includes(bumpType)) {
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) {
// 定位全局配置文件defaults.json
const defaultConfigPath = path.resolve(__dirname, '../../ghost/core/core/shared/config/defaults.json');
const defaultConfig = require(defaultConfigPath);
// 获取当前应用在配置文件中的键名
const configKey = CONFIG_KEYS[APP_NAME];
// 更新配置中的版本只保留major和minor
defaultConfig[configKey].version = `${semver.major(newVersion)}.${semver.minor(newVersion)}`;
// 写回配置文件保留4空格缩进
await fs.writeFile(defaultConfigPath, JSON.stringify(defaultConfig, null, 4) + '\n');
}
/**
* 更新当前应用的package.json版本号
*/
async function updatePackageJson(newVersion) {
const newPackageJson = Object.assign({}, packageJson, {
version: newVersion
});
// 写回package.json保留2空格缩进
await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2) + '\n');
}
/**
* 生成更新日志Changelog
* 包含i18n更新信息和相关的提交记录
*/
async function getChangelog(newVersion) {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const i18nChangesInput = await rl.question('Does this release contain i18n updates (Y/n)? ');
rl.close();
// 判断是否包含国际化更新
const i18nChanges = i18nChangesInput.trim().toLowerCase() !== 'n';
let changelogItems = [];
// 如果有i18n更新添加到更新日志
if (i18nChanges) {
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 -- .`);
if (lastFiftyCommits.stderr) {
console.error(`There was an error getting the last 50 commits`);
process.exit(1);
process.exit(1); // 获取提交记录失败,退出进程
}
// 解析提交记录,查找上一次发布的提交
const lastFiftyCommitsList = lastFiftyCommits.stdout.split('\n');
const releaseRegex = new RegExp(`Released ${APP_NAME} v${APP_VERSION}`);
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);
});
if (indexOfLastRelease === -1) {
// 未找到上一次发布记录使用最近20条相关提交作为 fallback
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__" -- .`);
if (recentCommits.stderr) {
console.error(`There was an error getting recent commits`);
@ -141,25 +184,28 @@ async function getChangelog(newVersion) {
const recentCommitsList = recentCommits.stdout.split('__SPLIT__');
// 筛选包含Linear链接的提交通常是已处理的任务
const recentCommitsWhichMentionLinear = recentCommitsList.filter((commitBlock) => {
return commitBlock.includes('https://linear.app/ghost');
});
// 生成提交记录的GitHub链接
const commitChangelogItems = recentCommitsWhichMentionLinear.map((commitBlock) => {
const lines = commitBlock.split('\n');
if (!lines.length || !lines[0].trim()) {
return null; // Skip entries with no hash
return null; // 跳过无效条目
}
const hash = lines[0].trim();
return `https://github.com/TryGhost/Ghost/commit/${hash}`;
}).filter(Boolean); // Filter out any null entries
}).filter(Boolean); // 过滤空值
changelogItems.push(...commitChangelogItems);
} else {
// 找到上一次发布记录,获取两次发布之间的提交
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__" -- .`);
if (commitsSinceLastRelease.stderr) {
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__');
// 筛选包含Linear链接的提交
const commitsSinceLastReleaseWhichMentionLinear = commitsSinceLastReleaseList.filter((commitBlock) => {
return commitBlock.includes('https://linear.app/ghost');
});
// 生成提交记录的GitHub链接
const commitChangelogItems = commitsSinceLastReleaseWhichMentionLinear.map((commitBlock) => {
const lines = commitBlock.split('\n');
if (!lines.length || !lines[0].trim()) {
return null; // Skip entries with no hash
return null; // 跳过无效条目
}
const hash = lines[0].trim();
return `https://github.com/TryGhost/Ghost/commit/${hash}`;
}).filter(Boolean); // Filter out any null entries
}).filter(Boolean); // 过滤空值
changelogItems.push(...commitChangelogItems);
}
// 格式化更新日志列表
const changelogList = changelogItems.map(item => ` - ${item}`).join('\n');
return `Changelog for v${APP_VERSION} -> ${newVersion}: \n${changelogList}`;
}
/**
* 主函数执行发布流程
*/
async function main() {
await ensureEnabledApp();
await ensureNotOnMain();
await ensureCleanGit();
// 前置检查
await ensureEnabledApp(); // 检查应用是否允许发布
await ensureNotOnMain(); // 检查是否不在main分支
await ensureCleanGit(); // 检查工作区是否干净
console.log(`Running release for ${APP_NAME}`);
console.log(`Current version is ${APP_VERSION}`);
// 获取新版本号
const newVersion = await getNewVersion();
console.log(`Bumping to version ${newVersion}`);
// 生成更新日志
const changelog = await getChangelog(newVersion);
// 更新package.json并提交
await updatePackageJson(newVersion);
await exec(`git add package.json`);
// 更新全局配置并提交
await updateConfig(newVersion);
await exec(`git add ../../ghost/core/core/shared/config/defaults.json`);
// 创建发布提交
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`)
}
main();
// 执行主函数
main();

@ -1,21 +1,25 @@
# 工作流名称:为 Issues 添加标签
name: 'Label Issues'
# 触发条件:定义工作流在哪些事件发生时运行
on:
workflow_dispatch:
workflow_dispatch: # 允许手动触发工作流
issues:
types: [opened, closed, labeled]
types: [opened, closed, labeled] # 当 issues 被打开、关闭或添加标签时触发
pull_request_target:
types: [closed, labeled]
types: [closed, labeled] # 当 PR 被关闭或添加标签时触发(使用 target 分支上下文)
schedule:
- cron: '0 * * * *'
- cron: '0 * * * *' # 定时触发,每小时执行一次(分钟 小时 日 月 周)
# 权限设置:工作流运行时拥有的权限
permissions:
issues: write
pull-requests: write
issues: write # 允许对 issues 执行写操作(如添加标签)
pull-requests: write # 允许对 PR 执行写操作(如添加标签)
jobs:
action:
runs-on: ubuntu-latest
if: github.repository_owner == 'TryGhost'
action: # 定义一个名为 action 的任务
runs-on: ubuntu-latest # 任务运行在最新版 Ubuntu 系统上
if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为 TryGhost 时才执行
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
# 触发条件当目标分支的PR被打开且修改了特定迁移相关路径时触发
on:
pull_request_target:
types: [opened]
types: [opened] # 仅在PR被打开时触发
paths:
- 'ghost/core/core/server/data/schema/**'
- 'ghost/core/core/server/data/migrations/versions/**'
- 'ghost/core/core/server/data/schema/**' # 监控schema目录下的所有文件变更
- 'ghost/core/core/server/data/migrations/versions/**' # 监控迁移版本目录下的所有文件变更
jobs:
createComment:
runs-on: ubuntu-latest
if: github.repository_owner == 'TryGhost'
name: Add migration review requirements
createComment: # 定义一个名为createComment的任务
runs-on: ubuntu-latest # 任务运行在最新版Ubuntu系统上
if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为TryGhost时执行
name: Add migration review requirements # 任务名称:添加迁移审查要求
steps:
- uses: actions/github-script@v7
# 步骤1为PR添加"migration"标签
- uses: actions/github-script@v7 # 使用GitHub官方脚本动作
with:
script: |
# 调用GitHub API为当前PR添加标签
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["migration"]
issue_number: context.issue.number, # 获取当前PR的编号
owner: context.repo.owner, # 仓库所有者(从上下文获取)
repo: context.repo.repo, # 仓库名称(从上下文获取)
labels: ["migration"] # 要添加的标签
})
# 步骤2在PR中添加迁移审查清单评论
- uses: peter-evans/create-or-update-comment@ac8e6509d7545ebc2e5e7c35eaa12195c2f77adc
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
issue-number: ${{ github.event.pull_request.number }} # 指定要评论的PR编号
body: | # 评论内容(迁移审查清单)
It looks like this PR contains a migration 👀
Here's the checklist for reviewing migrations:
@ -54,4 +62,4 @@ jobs:
- [ ] Mass updates/inserts are batched appropriately
- [ ] Does not loop over large tables/datasets
- [ ] 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'
# 触发条件
on:
workflow_dispatch:
workflow_dispatch: # 允许手动触发工作流
schedule:
- cron: '0 6 * * *'
- cron: '0 6 * * *' # 定时触发每天早上6点执行UTC时间
jobs:
stale:
if: github.repository_owner == 'TryGhost'
runs-on: ubuntu-latest
stale: # 定义名为stale的任务处理过时PR
if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为TryGhost时执行
runs-on: ubuntu-latest # 任务运行在最新版Ubuntu系统上
steps:
# 使用官方的stale动作处理过时PR
- uses: actions/stale@v9
with:
# 标记PR为过时的提示消息
stale-pr-message: |
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.
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
days-before-pr-close: 7
exempt-pr-labels: 'feature,pinned,needs:triage'
stale-pr-label: 'stale'
only-labels: 'affects:i18n' # 仅对带有affects:i18n标签的PR生效
days-before-pr-stale: 21 # PR闲置21天3周后标记为过时
days-before-pr-close: 7 # 标记为过时后7天自动关闭
exempt-pr-labels: 'feature,pinned,needs:triage' # 这些标签的PR不会被标记为过时
stale-pr-label: 'stale' # 标记过时PR时添加的标签
# 关闭PR时的提示消息
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'
# 触发条件
on:
workflow_dispatch:
workflow_dispatch: # 允许手动触发工作流
schedule:
- cron: '0 6 * * *'
- cron: '0 6 * * *' # 定时触发每天早上6点执行UTC时间
jobs:
stale:
if: github.repository_owner == 'TryGhost'
runs-on: ubuntu-latest
stale: # 定义名为stale的任务处理过时的issues和PRs
if: github.repository_owner == 'TryGhost' # 仅当仓库所有者为TryGhost时执行
runs-on: ubuntu-latest # 任务运行在最新版Ubuntu系统上
steps:
# 使用官方的stale动作处理过时内容
- uses: actions/stale@v9
with:
# 标记issue为过时的提示消息
stale-issue-message: |
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.
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: |
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 🙂
# 免于标记为过时的issue标签这些标签的issue不会被处理
exempt-issue-labels: 'feature,pinned,needs:triage'
# 免于标记为过时的PR标签这些标签的PR不会被处理
exempt-pr-labels: 'feature,pinned,needs:triage'
days-before-stale: 113
days-before-pr-stale: 358
stale-issue-label: 'stale'
stale-pr-label: 'stale'
close-issue-reason: 'not_planned'
days-before-stale: 113 # issue闲置113天后标记为过时
days-before-pr-stale: 358 # PR闲置358天后标记为过时
stale-issue-label: 'stale' # 标记过时issue时添加的标签
stale-pr-label: 'stale' # 标记过时PR时添加的标签
close-issue-reason: 'not_planned' # 关闭issue时的原因GitHub内置选项

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

File diff suppressed because it is too large Load Diff

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

@ -1,39 +1,65 @@
import React, {useRef} from 'react';
import {Button, LoadingIndicator} from '@tryghost/shade';
import React, { useRef } from 'react';
// 导入UI组件按钮组件和加载指示器组件
import { Button, LoadingIndicator } from '@tryghost/shade';
// 定义显示回复按钮的属性接口
interface ShowRepliesButtonProps {
count?: number;
onClick: () => void;
variant?: 'default' | 'expand' | 'loadMore';
preserveScroll?: boolean;
loading?: boolean;
count?: number; // 可选,回复的数量
onClick: () => void; // 点击按钮时触发的回调函数
variant?: 'default' | 'expand' | 'loadMore'; // 可选,按钮文本变体
preserveScroll?: boolean; // 可选是否在点击后保持滚动位置默认true
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);
/**
*
* @returns
*/
const getButtonText = () => {
// 当有明确的回复数量且数量大于0时显示包含数量的文本
if (count && count > 0) {
return `Show ${count} more ${count === 1 ? 'reply' : 'replies'}`;
}
// 根据不同的按钮变体返回对应文本
switch (variant) {
case 'expand':
return 'Show replies';
case 'loadMore':
return 'Show more replies';
default:
return 'Show replies';
case 'expand':
return 'Show replies';
case 'loadMore':
return 'Show more replies';
default:
return 'Show replies';
}
};
/**
*
*
*/
const handleClick = () => {
if (preserveScroll) {
// 查找自定义滚动容器优先或使用window作为滚动容器
const container = document.querySelector('[data-scrollable-container]') as HTMLElement;
const scrollTop = container ? container.scrollTop : window.scrollY;
// 执行点击回调(如加载更多回复)
onClick();
// 在下一次DOM更新周期中恢复滚动位置
setTimeout(() => {
if (container) {
container.scrollTop = scrollTop;
@ -42,30 +68,36 @@ const ShowRepliesButton: React.FC<ShowRepliesButtonProps> = ({count, onClick, va
}
}, 0);
} else {
// 不需要保持滚动位置时,直接执行回调
onClick();
}
};
return (
// 按钮容器,包含装饰线和按钮本身
<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='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>
{/* 主按钮组件 */}
<Button
className='hover:text-blue-800 text-sm font-medium text-blue-600'
variant="ghost"
variant="ghost" // 幽灵按钮样式(无背景色,仅文字交互)
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
(e.target as HTMLElement).blur();
handleClick();
e.preventDefault(); // 阻止默认事件
e.stopPropagation(); // 阻止事件冒泡
(e.target as HTMLElement).blur(); // 移除按钮焦点状态
handleClick(); // 执行点击处理逻辑
}}
>
{/* 加载状态显示加载指示器和文本,否则显示按钮文本 */}
{loading ? (
<div className='flex items-center gap-2'>
<LoadingIndicator size='sm' />
<LoadingIndicator size='sm' /> {/* 小型加载指示器 */}
<span>Loading...</span>
</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 ActivityItem from '../activities/ActivityItem';
import FollowButton from './FollowButton';
import APAvatar from './APAvatar'; // 头像组件,用于显示用户头像及相关信息
import ActivityItem from '../activities/ActivityItem'; // 活动项容器组件
import FollowButton from './FollowButton'; // 关注/取消关注按钮组件
import React from 'react';
import {type Account} from '../../api/activitypub';
import {Skeleton} from '@tryghost/shade';
import {useNavigate} from '@tryghost/admin-x-framework';
import {useSuggestedProfilesForUser} from '@hooks/use-activity-pub-queries';
import { type Account } from '../../api/activitypub'; // 导入Account类型定义描述用户账号信息
import { Skeleton } from '@tryghost/shade'; // 骨架屏组件,用于加载状态显示
import { useNavigate } from '@tryghost/admin-x-framework'; // 路由导航钩子
import { useSuggestedProfilesForUser } from '@hooks/use-activity-pub-queries'; // 获取推荐用户列表的自定义钩子
// 推荐用户项组件的属性接口
interface SuggestedProfileProps {
profile: Account;
update: (id: string, updated: Partial<Account>) => void;
isLoading: boolean;
profile: Account; // 用户账号信息
update: (id: string, updated: Partial<Account>) => void; // 更新用户信息的回调函数
isLoading: boolean; // 是否处于加载状态
}
/**
*
*
*/
export const SuggestedProfile: React.FC<SuggestedProfileProps & {
onOpenChange?: (open: boolean) => void;
}> = ({profile, update, isLoading, onOpenChange}) => {
onOpenChange?: (open: boolean) => void; // 控制父组件弹窗/面板显示状态的回调
}> = ({ profile, update, isLoading, onOpenChange }) => {
// 处理关注操作的回调
const onFollow = () => {
update(profile.id, {
followedByMe: true,
followerCount: profile.followerCount + 1
followedByMe: true, // 更新为已关注状态
followerCount: profile.followerCount + 1 // 粉丝数+1
});
};
// 处理取消关注操作的回调
const onUnfollow = () => {
update(profile.id, {
followedByMe: false,
followerCount: profile.followerCount - 1
followedByMe: false, // 更新为未关注状态
followerCount: profile.followerCount - 1 // 粉丝数-1
});
};
const navigate = useNavigate();
const navigate = useNavigate(); // 路由导航实例
return (
<ActivityItem
key={profile.id}
// 点击用户项导航到用户主页,并关闭可能的父级弹窗
onClick={() => {
onOpenChange?.(false);
navigate(`/profile/${profile.handle}`);
}}
>
<APAvatar author={
{
icon: {
url: profile.avatarUrl
},
{/* 用户头像组件 */}
<APAvatar
author={{
icon: { url: profile.avatarUrl },
name: profile.name,
handle: profile.handle
}
} onClick={() => onOpenChange?.(false)} />
}}
onClick={() => onOpenChange?.(false)} // 点击头像关闭父级弹窗
/>
{/* 用户信息区域 */}
<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>
{!isLoading ?
{/* 关注按钮 - 加载状态显示骨架屏 */}
{!isLoading ? (
<FollowButton
className='ml-auto'
following={profile.followedByMe}
handle={profile.handle}
following={profile.followedByMe} // 当前关注状态
handle={profile.handle} // 用户账号标识
type='secondary'
onFollow={onFollow}
onUnfollow={onUnfollow}
/> :
onFollow={onFollow} // 关注回调
onUnfollow={onUnfollow} // 取消关注回调
/>
) : (
<div className='inline-flex items-center'>
<Skeleton className='w-12' />
</div>
}
)}
</ActivityItem>
);
};
/**
*
*
*/
export const SuggestedProfiles: React.FC<{
onOpenChange?: (open: boolean) => void;
}> = ({onOpenChange}) => {
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfilesForUser('index', 5);
const {data: suggestedProfilesData = [], isLoading: isLoadingSuggestedProfiles} = suggestedProfilesQuery;
onOpenChange?: (open: boolean) => void; // 控制父组件弹窗/面板显示状态的回调
}> = ({ onOpenChange }) => {
// 获取推荐用户列表及更新方法最多5个用户
const { suggestedProfilesQuery, updateSuggestedProfile } = useSuggestedProfilesForUser('index', 5);
const {
data: suggestedProfilesData = [], // 推荐用户数据,默认空数组
isLoading: isLoadingSuggestedProfiles // 是否正在加载
} = suggestedProfilesQuery;
return (
<div className='mb-[-15px] flex flex-col gap-3 pt-2'>
<div className='flex flex-col'>
{/* 加载状态时显示5个骨架项否则显示实际推荐用户 */}
{(isLoadingSuggestedProfiles ? Array(5).fill(null) : (suggestedProfilesData || [])).map((profile, index) => (
<React.Fragment key={profile?.id || `loading-${index}`}>
<SuggestedProfile
isLoading={isLoadingSuggestedProfiles}
// 加载状态时传入默认空对象,避免报错
profile={profile || {
id: '',
name: '',
@ -93,7 +122,7 @@ export const SuggestedProfiles: React.FC<{
followingCount: 0,
followedByMe: false
}}
update={updateSuggestedProfile}
update={updateSuggestedProfile} // 传递更新方法
onOpenChange={onOpenChange}
/>
</React.Fragment>
@ -101,4 +130,4 @@ export const SuggestedProfiles: React.FC<{
</div>
</div>
);
};
};

@ -1,37 +1,66 @@
import * as React from 'react';
// 导入反馈框组件
import FeedbackBox from './FeedbackBox';
// 导入新建笔记模态框组件
import NewNoteModal from '@src/components/modals/NewNoteModal';
// 导入推荐内容组件
import Recommendations from './Recommendations';
// 导入搜索相关组件
import Search from '@src/components/modals/Search';
import SearchInput from '../Header/SearchInput';
// 导入侧边栏菜单链接组件
import SidebarMenuLink from './SidebarMenuLink';
import {Button, Dialog, DialogContent, DialogTrigger, LucideIcon} from '@tryghost/shade';
import {useCurrentUser} from '@tryghost/admin-x-framework/api/currentUser';
import {useFeatureFlags} from '@src/lib/feature-flags';
import {useLocation} from '@tryghost/admin-x-framework';
import {useNotificationsCountForUser, useResetNotificationsCountForUser} from '@src/hooks/use-activity-pub-queries';
// 导入UI组件按钮、对话框及相关组件、Lucide图标库
import { Button, Dialog, DialogContent, DialogTrigger, LucideIcon } from '@tryghost/shade';
// 导入获取当前用户信息的钩子
import { useCurrentUser } from '@tryghost/admin-x-framework/api/currentUser';
// 导入特性标志相关钩子
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 {
isMobileSidebarOpen: boolean;
onCloseMobileSidebar: () => void;
isMobileSidebarOpen: boolean; // 移动端侧边栏是否打开
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 {data: notificationsCount} = useNotificationsCountForUser(currentUser?.slug || '');
// 获取通知计数
const { data: notificationsCount } = useNotificationsCountForUser(currentUser?.slug || '');
// 重置通知计数的方法
const resetNotificationsCount = useResetNotificationsCountForUser(currentUser?.slug || '');
// Reset count when on notifications page
/**
*
*
*/
React.useEffect(() => {
if (location.pathname === '/notifications' && notificationsCount && notificationsCount > 0) {
resetNotificationsCount.mutate();
}
}, [location.pathname, notificationsCount, resetNotificationsCount]);
/**
*
*
* 使useCallback
*/
const handleNotificationsClick = React.useCallback(() => {
if (notificationsCount && notificationsCount > 0) {
resetNotificationsCount.mutate();
@ -39,21 +68,29 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
}, [notificationsCount, resetNotificationsCount]);
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='isolate flex w-full flex-col items-start gap-6 pl-6 pt-6'>
{/* 搜索框区域 */}
<div className='flex h-[52px] w-full items-center'>
<Dialog open={isSearchOpen} onOpenChange={setIsSearchOpen}>
<DialogTrigger className='w-full'>
<SearchInput />
<SearchInput /> {/* 搜索输入框 */}
</DialogTrigger>
<DialogContent>
{/* 搜索结果展示组件 */}
<Search query={searchQuery} setQuery={setSearchQuery} onOpenChange={setIsSearchOpen} />
</DialogContent>
</Dialog>
</div>
{/* 导航菜单链接列表 */}
<div className='flex w-full flex-col gap-px'>
<SidebarMenuLink to='/reader'>
<LucideIcon.BookOpen size={18} strokeWidth={1.5} />
@ -64,7 +101,7 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
Notes
</SidebarMenuLink>
<SidebarMenuLink
count={location.pathname !== '/notifications' ? notificationsCount : undefined}
count={location.pathname !== '/notifications' ? notificationsCount : undefined} // 非通知页显示未读计数
to='/notifications'
onClick={handleNotificationsClick}
>
@ -84,6 +121,8 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
Preferences
</SidebarMenuLink>
</div>
{/* 新建笔记按钮(包裹在模态框组件中) */}
<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'>
<LucideIcon.FilePen />
@ -91,8 +130,10 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
</Button>
</NewNoteModal>
{/* 推荐内容区域 */}
<Recommendations />
{/* 特性标志展示(仅显示开启的标志,用于开发/调试) */}
{allFlags.map((flag) => {
if (flags[flag]) {
return (
@ -104,9 +145,11 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
</div>
);
}
return (<></>);
return null;
})}
</div>
{/* 底部反馈框 */}
<div className='sticky bottom-0 flex items-center gap-2 bg-white pb-4 pl-4 dark:bg-black'>
<FeedbackBox />
</div>
@ -115,6 +158,7 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
);
};
// 设置组件显示名称(便于调试)
Sidebar.displayName = 'Sidebar';
export default Sidebar;
export default Sidebar;

@ -1,52 +1,68 @@
import * as React from 'react';
import {Button, ButtonProps, cn, formatNumber} from '@tryghost/shade';
import {Link, resetScrollPosition, useLocation, useNavigationStack} from '@tryghost/admin-x-framework';
// 导入UI组件及工具函数按钮组件、按钮属性类型、类名组合函数、数字格式化函数
import { Button, ButtonProps, cn, formatNumber } from '@tryghost/shade';
// 导入路由相关工具:链接组件、滚动位置重置函数、路由位置钩子、导航栈钩子
import { Link, resetScrollPosition, useLocation, useNavigationStack } from '@tryghost/admin-x-framework';
// 侧边栏菜单链接组件的属性接口,继承按钮属性并扩展路由和计数相关属性
interface SidebarButtonProps extends ButtonProps {
to?: string;
children: React.ReactNode;
count?: number;
to?: string; // 路由路径(可选,用于导航链接)
children: React.ReactNode; // 子元素(图标和文本)
count?: number; // 数量标记(可选,如未读通知数)
}
/**
*
*
*/
const SidebarMenuLink = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
({to, children, count, ...props}, ref) => {
const location = useLocation();
const {resetStack} = useNavigationStack();
({ to, children, count, ...props }, ref) => {
const location = useLocation(); // 获取当前路由位置
const { resetStack } = useNavigationStack(); // 获取重置导航栈的方法
// 组合链接的CSS类名
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]',
// 当前页面高亮样式:背景色、文本色加深(深色模式适配)
(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 ? (
<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'
)}>
{formatNumber(count)}
{formatNumber(count)} {/* 格式化数字显示如1000→1k */}
</span>
) : null;
// 当提供路由路径时,渲染为链接组件
if (to) {
return (
<Button className={linkClass} variant='ghost' asChild>
<Link to={to} onClick={() => {
resetStack();
resetScrollPosition(to);
}}>
{children}
{badge}
<Link
to={to}
onClick={() => {
resetStack(); // 重置导航栈(清除历史记录)
resetScrollPosition(to); // 跳转到目标路由时重置滚动位置
}}
>
{children} {/* 显示图标和文本 */}
{badge} {/* 显示数量徽章(如有) */}
</Link>
</Button>
);
}
// 未提供路由路径时,渲染为普通按钮
return (
<Button
ref={ref}
ref={ref} // 转发ref到按钮元素
className={linkClass}
variant='ghost'
onClick={props.onClick}
{...props}
onClick={props.onClick} // 传递点击事件
{...props} // 传递其他按钮属性
>
{children}
{badge}
@ -55,6 +71,7 @@ const SidebarMenuLink = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
}
);
// 设置组件显示名称便于React DevTools识别
SidebarMenuLink.displayName = 'SidebarMenuLink';
export default SidebarMenuLink;
export default SidebarMenuLink;

@ -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 TagsContent from './components/TagsContent';
import TagsHeader from './components/TagsHeader';
import TagsLayout from './components/TagsLayout';
import TagsList from './components/TagsList';
import {Button, LoadingIndicator, LucideIcon} from '@tryghost/shade';
import {useBrowseTags} from '@tryghost/admin-x-framework/api/tags';
import {useLocation} from '@tryghost/admin-x-framework';
import { Button, LoadingIndicator, LucideIcon } from '@tryghost/shade';
import { useBrowseTags } from '@tryghost/admin-x-framework/api/tags';
import { useLocation } from '@tryghost/admin-x-framework';
/**
*
*
*/
const Tags: React.FC = () => {
const {search} = useLocation();
// 获取当前URL的查询参数用于确定标签筛选类型
const { search } = useLocation();
const qs = new URLSearchParams(search);
// 从查询参数中获取标签类型(默认为'public'公开标签)
const type = qs.get('type') ?? 'public';
// 调用标签浏览API根据可见性类型筛选标签
const {
data,
isError,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage
data, // 接口返回的标签数据
isError, // 接口请求是否出错
isLoading, // 接口是否正在加载(首次)
isFetchingNextPage, // 是否正在加载下一页数据
fetchNextPage, // 加载下一页数据的函数
hasNextPage // 是否有下一页数据
} = useBrowseTags({
filter: {
visibility: type
visibility: type // 筛选条件:按可见性类型
}
});
return (
<TagsLayout>
{/* 标签页面头部,显示当前选中的标签类型标签 */}
<TagsHeader currentTab={type} />
{/* 标签内容区域 */}
<TagsContent>
{/* 加载状态:显示加载指示器 */}
{isLoading ? (
<div className="flex h-full items-center justify-center">
<LoadingIndicator size="lg" />
</div>
) : isError ? (
{/* 错误状态:显示错误信息和重试按钮 */}
<div className="mb-16 flex h-full flex-col items-center justify-center">
<h2 className="mb-2 text-xl font-medium">
Error loading tags
@ -46,6 +71,7 @@ const Tags: React.FC = () => {
</Button>
</div>
) : !data?.tags.length ? (
{/* 空状态:当没有标签时显示引导创建标签 */}
<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} />
<h2 className="text-xl font-medium">
@ -56,12 +82,13 @@ const Tags: React.FC = () => {
</Button>
</div>
) : (
{/* 列表状态:显示标签列表,支持分页加载 */}
<TagsList
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
items={data?.tags ?? []}
totalItems={data?.meta?.pagination?.total ?? 0}
fetchNextPage={fetchNextPage} // 加载下一页的回调
hasNextPage={hasNextPage} // 是否有更多数据
isFetchingNextPage={isFetchingNextPage} // 是否正在加载下一页
items={data?.tags ?? []} // 标签数据列表
totalItems={data?.meta?.pagination?.total ?? 0} // 总标签数量
/>
)}
</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 {Button, Header, PageMenu, PageMenuItem} from '@tryghost/shade';
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 {
Button,
LucideIcon,
@ -9,16 +17,26 @@ import {
TableRow,
formatNumber
} from '@tryghost/shade';
import {Tag} from '@tryghost/admin-x-framework/api/tags';
import {forwardRef, useRef} from 'react';
import {useInfiniteVirtualScroll} from './VirtualTable/useInfiniteVirtualScroll';
import { Tag } from '@tryghost/admin-x-framework/api/tags';
import { forwardRef, useRef } from 'react';
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">
<td className="flex lg:table-cell" style={{height}} />
<td className="flex lg:table-cell" style={{ height }} />
</tr>
);
/**
*
* TODO: React 19forwardRef
*/
// TODO: Remove forwardRef once we have upgraded to React 19
const PlaceholderRow = forwardRef<HTMLTableRowElement>(function PlaceholderRow(
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({
items,
totalItems,
@ -50,8 +78,11 @@ function TagsList({
isFetchingNextPage?: boolean;
fetchNextPage: () => void;
}) {
// 父容器引用,用于计算虚拟滚动区域
const parentRef = useRef<HTMLDivElement>(null);
const {visibleItems, spaceBefore, spaceAfter} = useInfiniteVirtualScroll({
// 调用虚拟滚动钩子,获取可见项和间隔高度
const { visibleItems, spaceBefore, spaceAfter } = useInfiniteVirtualScroll({
items,
totalItems,
hasNextPage,
@ -63,6 +94,7 @@ function TagsList({
return (
<div ref={parentRef}>
<Table className="flex table-fixed flex-col lg:table">
{/* 桌面端表头 */}
<TableHeader className="hidden lg:!visible lg:!table-header-group">
<TableRow>
<TableHead className="w-auto px-4">
@ -75,9 +107,15 @@ function TagsList({
<TableHead className="w-20 px-4"></TableHead>
</TableRow>
</TableHeader>
{/* 表格内容区域 */}
<TableBody className="flex flex-col lg:table-row-group">
{/* 顶部空白间隔(用于虚拟滚动定位) */}
<SpacerRow height={spaceBefore} />
{visibleItems.map(({key, virtualItem, item, props}) => {
{/* 渲染可见的标签行 */}
{visibleItems.map(({ key, virtualItem, item, props }) => {
// 判断是否需要渲染占位行(数据尚未加载时)
const shouldRenderPlaceholder =
virtualItem.index > items.length - 1;
@ -85,12 +123,14 @@ function TagsList({
return <PlaceholderRow key={key} {...props} />;
}
// 渲染实际标签行(响应式布局)
return (
<TableRow
key={key}
{...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"
>
{/* 标签名称和描述(移动端占满一行,桌面端占多列) */}
<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
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}
</span>
</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">
<span className="block truncate">
{item.slug}
</span>
</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">
{item.count?.posts ? (
<a
@ -121,6 +165,8 @@ function TagsList({
</span>
)}
</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">
<Button
aria-hidden="true"
@ -135,6 +181,8 @@ function TagsList({
</TableRow>
);
})}
{/* 底部空白间隔(用于虚拟滚动定位) */}
<SpacerRow height={spaceAfter} />
</TableBody>
</Table>
@ -142,4 +190,4 @@ function TagsList({
);
}
export default TagsList;
export default TagsList;

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

@ -1,10 +1,27 @@
import ApplicationAdapter from 'ghost-admin/adapters/application';
import SlugUrl from 'ghost-admin/utils/slug-url';
/**
* Label 适配器
* 继承自应用程序基础适配器用于处理标签(Label)模型与后端API的交互
* 主要扩展了URL构建逻辑支持基于slug参数生成URL
*/
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方法生成基础URL
let url = super.buildURL(...arguments);
// 使用SlugUrl工具处理URL结合query参数如slug生成最终URL
return SlugUrl(url, query);
}
}
}

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

@ -1,10 +1,27 @@
import ApplicationAdapter from 'ghost-admin/adapters/application';
import SlugUrl from 'ghost-admin/utils/slug-url';
/**
* 标签Tag适配器
* 继承自应用程序基础适配器用于处理标签模型与后端API的交互
* 主要扩展了URL构建逻辑支持基于slug参数生成友好URL
*/
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方法生成基础URL
let url = super.buildURL(...arguments);
// 使用SlugUrl工具处理URL结合query参数如slug生成最终的语义化URL
return SlugUrl(url, query);
}
}
}

@ -1,28 +1,55 @@
import Controller from '@ember/controller';
// 导入删除标签模态框组件
import DeleteTagModal from '../components/tags/delete-tag-modal';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
// 导入Ember动作装饰器
import { action } from '@ember/object';
// 导入注入装饰器(用于注入配置)
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 {
// 注入模态框服务(用于打开删除确认弹窗)
@service modals;
// 注入通知服务(用于显示操作结果提示)
@service notifications;
// 注入路由服务(用于路由跳转)
@service router;
// 注入标签管理服务(用于管理标签列表数据)
@service tagsManager;
// 注入应用配置用于获取博客基础URL等信息
@inject config;
/**
* 获取当前标签模型
* 简化模板中对模型的访问this.tag 等价于 this.model
* @returns {Model} 标签模型实例
*/
get tag() {
return this.model;
}
/**
* 计算标签的访问URL
* 优先使用标签的canonicalUrl否则通过博客URL和标签slug拼接
* 确保URL以斜杠结尾
* @returns {string} 标签的完整访问URL
*/
get tagURL() {
const blogUrl = this.config.blogUrl;
const tagSlug = this.tag?.slug || '';
const blogUrl = this.config.blogUrl; // 从配置中获取博客基础URL
const tagSlug = this.tag?.slug || ''; // 获取标签的slugURL别名
// 构建标签URL优先使用规范URL否则拼接基础URL和slug
let tagURL = this.tag?.canonicalUrl || `${blogUrl}/tag/${tagSlug}`;
// 确保URL以斜杠结尾标准化URL格式
if (!tagURL.endsWith('/')) {
tagURL += '/';
}
@ -30,35 +57,51 @@ export default class TagController extends Controller {
return tagURL;
}
/**
* 打开删除标签确认弹窗的动作
* 点击"删除标签"按钮时触发显示确认弹窗并传入当前标签数据
*/
@action
confirmDeleteTag() {
return this.modals.open(DeleteTagModal, {
tag: this.model
tag: this.model // 向弹窗组件传递当前标签模型
});
}
@task({drop: true})
/**
* 保存标签的并发任务
* 处理标签的新建或编辑保存逻辑包含错误处理和成功后的路由跳转
* 使用drop策略新任务触发时若当前任务正在运行则忽略新任务
*/
@task({ drop: true })
*saveTask() {
let {tag} = this;
let { tag } = this;
try {
// 若标签模型存在验证错误,不执行保存操作
if (tag.get('errors').length !== 0) {
return;
}
// 记录标签是否为新建状态(用于后续逻辑区分)
const wasNew = tag.isNew;
// 执行保存操作Ember Data的save方法返回Promise
yield tag.save();
// 若为新建标签,将其添加到标签列表数据中(更新缓存)
if (wasNew) {
this.tagsManager.tagsScreenInfinityModel?.pushObjects([tag]);
}
// replace 'new' route with 'tag' route
// 保存成功后,将"新建标签"路由替换为"标签详情"路由(避免回退到新建页)
this.replaceRoute('tag', tag);
return tag;
return tag; // 返回保存后的标签模型
} catch (error) {
// 保存失败时显示API错误通知指定key避免重复显示相同错误
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 {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
/**
* 标签列表控制器TagsController
* 处理标签列表页面的逻辑包括筛选排序加载更多新建标签等交互
*/
export default class TagsController extends Controller {
// 注入无限滚动服务(用于处理列表分页加载)
@service infinity;
// 注入路由服务(用于页面跳转)
@service router;
// 注入标签管理服务(用于标签排序等共享逻辑)
@service tagsManager;
// 路由参数配置:将"type"作为查询参数(控制标签筛选类型)
queryParams = ['type'];
// 跟踪标签筛选类型(默认筛选"公开标签"
@tracked type = 'public';
/**
* 获取标签列表数据源
* 简化模板中对模型的访问this.tags 等价于 this.model
* @returns {Array} 标签模型数组
*/
get tags() {
return this.model;
}
/**
* 获取筛选后的标签列表
* 1. 去重避免新标签在分页请求后重复显示
* 2. 过滤仅保留非新建非删除状态且符合当前筛选类型的标签
* @returns {Array} 去重并筛选后的标签数组
*/
get filteredTags() {
// new tags are preemptively added to the client-side tagsScreenInfinityModel,
// 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
// 用Map去重以标签ID为键确保每个标签只出现一次
const tagMap = new Map();
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)) {
tagMap.set(tag.id, tag);
}
});
// 将Map的值转为数组返回去重后的结果
return [...tagMap.values()];
}
/**
* 获取排序后的标签列表
* 调用标签管理服务的排序方法对筛选后的标签进行排序
* @returns {Array} 排序后的标签数组
*/
get sortedTags() {
return this.tagsManager.sortTags(this.filteredTags);
}
/**
* 切换标签筛选类型的动作
* 点击"公开标签/内部标签"按钮时触发更新筛选类型
* @param {string} type - 筛选类型"public" "internal"
*/
@action
changeType(type) {
this.type = type;
}
/**
* 跳转到新建标签页面的动作
* 点击"New tag"按钮或按快捷键"c"时触发
*/
@action
newTag() {
this.router.transitionTo('tag.new');
}
/**
* 加载更多标签的动作
* 滚动到列表底部时触发通过无限滚动服务加载下一页标签
*/
@action
loadMoreTags() {
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 {equal} from '@ember/object/computed';
import {inject as service} from '@ember/service';
// 导入Ember计算属性工具用于定义等值判断计算属性
import { equal } from '@ember/object/computed';
// 导入Ember服务注入工具用于注入依赖服务
import { inject as service } from '@ember/service';
/**
* 标签Tag模型
* 继承自Ember Data的基础Model集成验证能力用于描述标签的属性和行为
* 对应后端标签数据支持属性管理可见性控制搜索缓存更新等功能
*/
export default Model.extend(ValidationEngine, {
// 注入搜索服务,用于操作搜索缓存
search: service(),
// 注入特性服务,用于特性开关控制(当前未在方法中使用,预留扩展)
feature: service(),
// 指定验证规则类型,对应后端/前端的"tag"验证配置
validationType: 'tag',
name: attr('string'),
slug: attr('string'),
url: attr('string'),
description: attr('string'),
metaTitle: attr('string'),
metaDescription: attr('string'),
twitterImage: attr('string'),
twitterTitle: attr('string'),
twitterDescription: attr('string'),
ogImage: attr('string'),
ogTitle: attr('string'),
ogDescription: attr('string'),
codeinjectionHead: attr('string'),
codeinjectionFoot: attr('string'),
canonicalUrl: attr('string'),
accentColor: attr('string'),
featureImage: attr('string'),
visibility: attr('string', {defaultValue: 'public'}),
createdAtUTC: attr('moment-utc'),
updatedAtUTC: attr('moment-utc'),
count: attr('raw'),
// ------------------------------
// 标签核心属性(与后端字段对应)
// ------------------------------
name: attr('string'), // 标签名称(必填,如"技术"
slug: attr('string'), // 标签URL别名如"tech"用于生成友好URL
url: attr('string'), // 标签完整URL如"https://xxx.com/tag/tech"
description: attr('string'), // 标签描述(可选)
metaTitle: attr('string'), // 标签SEO标题可选用于页面<head>的title标签
metaDescription: attr('string'), // 标签SEO描述可选用于页面<head>的meta description
// 社交媒体Twitter相关属性
twitterImage: attr('string'), // Twitter分享时的封面图URL
twitterTitle: attr('string'), // Twitter分享标题
twitterDescription: attr('string'), // Twitter分享描述
// 社交媒体Open Graph相关属性
ogImage: attr('string'), // OG分享时的封面图URL
ogTitle: attr('string'), // OG分享标题
ogDescription: attr('string'), // OG分享描述
// 代码注入相关属性(用于在标签页注入自定义脚本/样式)
codeinjectionHead: attr('string'), // 注入到<head>的代码
codeinjectionFoot: attr('string'), // 注入到</body>前的代码
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'),
// 判断标签是否为"公开可见"visibility === 'public'
isPublic: equal('visibility', 'public'),
feature: service(),
// ------------------------------
// 标签行为方法
// ------------------------------
/**
* 更新标签可见性
* 根据标签名称是否以"#"开头自动设置可见性
* - "#"开头 "internal"内部标签仅管理员可见
* - 其他情况 "public"公开标签前端可展示
*/
updateVisibility() {
// 正则表达式:匹配以"#"开头的名称(支持"#"后紧跟其他字符,如"#内部标签"
let internalRegex = /^#.?/;
this.set('visibility', internalRegex.test(this.name) ? 'internal' : 'public');
},
/**
* 重写模型默认save方法
* 扩展功能
* 1. 名称变更时自动更新可见性
* 2. 名称/URL变更或标签删除时清理搜索缓存
* @returns {Promise<Model>} 保存成功后的模型实例
*/
save() {
// 判断标签名称是否有变更changedAttributes()返回变更的属性键值对)
const nameChanged = !!this.changedAttributes().name;
// 若名称变更且标签未被删除,更新可见性
if (nameChanged && !this.isDeleted) {
this.updateVisibility();
}
const {url} = this;
// 保存当前URL用于后续判断URL是否变更
const { url } = this;
// 调用父类save方法执行实际保存返回Promise
return this._super(...arguments).then((savedModel) => {
// 判断保存后URL是否变更与保存前对比
const urlChanged = url !== savedModel.url;
// 若名称变更、URL变更或标签被删除清理搜索服务的内容缓存
if (nameChanged || urlChanged || this.isDeleted) {
this.search.expireContent();
}
// 返回保存后的模型实例
return savedModel;
});
}
});
});

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

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

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

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

@ -1,35 +1,66 @@
/* eslint-disable camelcase */
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 {
/**
* 属性映射配置
* 定义前端模型属性与后端API字段的对应关系
*/
attrs = {
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
createdAtUTC: { key: 'created_at' }, // 前端createdAtUTC属性对应后端created_at字段
updatedAtUTC: { key: 'updated_at' } // 前端updatedAtUTC属性对应后端updated_at字段
};
/**
* 序列化方法
* 将前端标签模型转换为后端API所需的JSON格式
* @param {Snapshot} snapshot - 模型快照包含模型当前属性数据
* @param {Object} options - 序列化选项可选
* @returns {Object} 处理后的JSON数据
*/
serialize(/*snapshot, options*/) {
// 调用父类序列化方法获取基础JSON数据
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.url;
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) {
// 处理queryRecord请求根据条件查询单条记录的响应
if (requestType === 'queryRecord') {
let singular = primaryModelClass.modelName;
let plural = pluralize(singular);
const singular = primaryModelClass.modelName; // 模型单数名称(如"tag"
const plural = pluralize(singular); // 模型复数名称(如"tags"
// 后端可能返回复数形式的数组(如{tags: [{...}]}),需转换为单数形式
if (payload[plural]) {
payload[singular] = payload[plural][0];
delete payload[plural];
payload[singular] = payload[plural][0]; // 取数组第一个元素作为单条记录
delete payload[plural]; // 移除复数字段,避免序列化警告
}
}
// 调用父类方法完成最终的标准化处理
return super.normalizeResponse(...arguments);
}
}
}

@ -1,38 +1,63 @@
{{! 标签编辑/新建页面的主模板 }}
<section class="gh-canvas">
<form class="mb15">
{{! 页面头部区域:包含面包屑导航、标题和操作按钮 }}
<GhCanvasHeader class="gh-canvas-header">
<div class="flex flex-column">
{{! 面包屑导航:显示当前位置,点击"Tags"可返回标签列表页 }}
<div class="gh-canvas-breadcrumb">
<LinkTo @route="tags" data-test-link="tags-back">
Tags
</LinkTo>
{{svg-jar "arrow-right-small"}} {{if this.tag.isNew "New tag" "Edit tag"}}
{{svg-jar "arrow-right-small"}} {{! 箭头图标,分隔面包屑 }}
{{! 根据标签状态显示"New tag"或"Edit tag" }}
{{if this.tag.isNew "New tag" "Edit tag"}}
</div>
{{! 页面标题:新建时显示"New tag",编辑时显示标签名称 }}
<h2 class="gh-canvas-title" data-test-screen-title>
{{if this.tag.isNew "New tag" this.tag.name}}
</h2>
</div>
{{! 操作按钮区域 }}
<section class="view-actions">
<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>
{{! 查看标签按钮:点击在新窗口打开标签页面(仅编辑时有效) }}
<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> {{! 包含"View"文本和外部链接图标 }}
</a>
{{! 保存按钮使用任务按钮组件关联保存任务支持cmd+s快捷键 }}
<GhTaskButton
@task={{this.saveTask}}
@type="button"
@class="gh-btn gh-btn-primary gh-btn-icon"
@data-test-button="save"
{{on-key "cmd+s"}}
{{on-key "cmd+s"}} {{! 绑定快捷键Command+S保存 }}
/>
</div>
</section>
</GhCanvasHeader>
{{! 标签表单组件:传入当前标签模型,用于编辑或新建标签的具体内容 }}
<Tags::TagForm @tag={{this.model}} />
</form>
{{! 删除按钮:仅在编辑现有标签时显示 }}
{{#unless this.tag.isNew}}
<div>
<button type="button" class="gh-btn gh-btn-red gh-btn-icon" {{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>
</button>
</div>

@ -1,12 +1,25 @@
{{! 标签列表页面模板 }}
<section class="gh-canvas">
{{! 页面头部区域:包含标题和操作按钮,设置为粘性定位(滚动时保持在顶部) }}
<GhCanvasHeader class="gh-canvas-header sticky">
{{! 页面标题:显示"Tags",用于测试标识 }}
<h2 class="gh-canvas-title" data-test-screen-title>Tags</h2>
{{! 操作按钮区域 }}
<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>
</GhCanvasHeader>
{{! 内容区域:显示加载状态指示器 }}
<div class="gh-content">
<GhLoadingSpinner />
<GhLoadingSpinner /> {{! 加载动画组件,数据加载完成前显示 }}
</div>
</section>
</section>

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

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

@ -1,12 +1,18 @@
/* eslint-env node */
/**
* 浏览器兼容性配置
* 用于指定项目需要支持的浏览器版本范围
* 通常被BabelAutoprefixer等工具使用以生成兼容的代码
*/
const browsers = [
'last 2 Chrome versions',
'last 2 Firefox versions',
'last 3 Safari versions',
'last 2 Edge versions'
'last 2 Chrome versions', // 支持Chrome最新的2个版本
'last 2 Firefox versions', // 支持Firefox最新的2个版本
'last 3 Safari versions', // 支持Safari最新的3个版本
'last 2 Edge versions' // 支持Edge最新的2个版本
];
// 导出浏览器配置,供工具链使用
module.exports = {
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) {
/**
* 模拟创建标签的POST请求
* 路径/labels/
* 功能使用Mirage默认逻辑处理标签创建接收请求数据并返回新创建的标签
*/
server.post('/labels/');
/**
* 模拟查询标签列表的GET请求
* 路径/labels/
* 功能使用分页工具函数处理响应返回分页格式的标签列表
* 自动处理pagelimit等查询参数返回包含数据和分页元信息的响应
*/
server.get('/labels/', paginatedResponse('labels'));
server.get('/labels/:id/', function ({labels}, {params}) {
let {id} = params;
let label = labels.find(id);
/**
* 模拟查询单个标签的GET请求按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, {}, {
errors: [{
type: 'NotFoundError',
message: 'Label not found.'
message: 'Label not found.' // 错误信息:标签未找到
}]
});
});
/**
* 模拟更新标签的PUT请求
* 路径/labels/:id/
* 功能使用Mirage默认逻辑处理标签更新根据ID更新标签属性并返回更新后的结果
*/
server.put('/labels/:id/');
/**
* 模拟删除标签的DELETE请求
* 路径/labels/:id/
* 功能使用Mirage默认逻辑处理标签删除根据ID删除标签并返回相应状态
*/
server.del('/labels/:id/');
}
}

@ -1,37 +1,93 @@
import {dasherize} from '@ember/string';
import {extractFilterParam, paginateModelCollection} from '../utils';
import {isBlank} from '@ember/utils';
import { dasherize } from '@ember/string'; // 字符串处理工具:转换为连字符格式(如"Hello World"→"hello-world"
import { extractFilterParam, paginateModelCollection } from '../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) {
server.post('/tags/', function ({tags}) {
/**
* 模拟创建标签的POST请求
* 路径/tags/
* 功能接收标签数据自动生成slug若未提供创建新标签并返回
*/
server.post('/tags/', function ({ tags }) {
// 获取请求中的标签属性(已标准化处理)
let attrs = this.normalizedRequestAttrs();
// 若未提供slug但提供了name自动将name转换为slug连字符格式
if (isBlank(attrs.slug) && !isBlank(attrs.name)) {
attrs.slug = dasherize(attrs.name);
}
// NOTE: this does not use the tag factory to fill in blank fields
// 创建并返回新标签(注意:此处未使用标签工厂填充默认字段,需手动确保必要字段存在)
return tags.create(attrs);
});
server.get('/tags/slug/:slug/', function ({tags}, {params: {slug}}) {
// TODO: remove post_count unless requested?
return tags.findBy({slug});
/**
* 模拟通过slug查询标签的GET请求
* 路径/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);
// 获取所有标签
let collection = tags.all();
// 若有名称筛选条件,过滤出名称包含筛选值的标签(不区分大小写)
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);
});
/**
* 模拟查询单个标签的GET请求按ID
* 路径/tags/:id/
* 功能使用Mirage的默认处理逻辑根据ID查询标签
*/
server.get('/tags/:id/');
/**
* 模拟更新标签的PUT请求
* 路径/tags/:id/
* 功能使用Mirage的默认处理逻辑根据ID更新标签属性
*/
server.put('/tags/:id/');
/**
* 模拟删除标签的DELETE请求
* 路径/tags/:id/
* 功能使用Mirage的默认处理逻辑根据ID删除标签
*/
server.del('/tags/:id/');
}
}

@ -1,13 +1,57 @@
import moment from 'moment-timezone';
import {Factory} from 'miragejs';
import moment from 'moment-timezone'; // 导入moment时间处理库带时区支持
import { Factory } from 'miragejs'; // 导入Mirage JS的Factory类用于创建模拟数据工厂
/**
* 标签Label数据工厂
* 用于生成标准化的标签模拟数据供Mirage JS服务器使用
* 支持动态生成带索引的属性值模拟真实业务场景中的标签数据
*/
export default Factory.extend({
createdAt() { return moment.utc().toISOString(); },
name(i) { return `Label ${i}`; },
slug(i) { return `label-${i}`; },
updatedAt() { return moment.utc().toISOString(); },
/**
* 标签创建时间
* 动态生成当前UTC时间的ISO格式字符串"2024-05-20T12:34:56.789Z"
* @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() {
// 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({
/**
* 标签创建时间
* 默认为固定时间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',
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}`; },
name(i) { return `Tag ${i}`; },
/**
* 标签封面图URL
* 动态生成包含当前标签索引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,
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',
/**
* 标签关联内容数量
* 默认为{posts: 0}关联0篇文章
* 实际使用中会被标签序列化器自动更新
* @returns {Object} 包含关联文章数量的对象
*/
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({
/**
* 定义标签与成员members的一对多关联关系
* 表示一个标签可以关联多个成员
*
* 关联说明
* - 采用hasMany关系当前标签Label拥有多个成员members
* - Mirage JS会自动管理关联数据的CRUD操作例如
* - 当查询标签时可以通过`label.members`获取关联的所有成员
* - 当创建成员并关联标签时标签的成员列表会自动更新
*/
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({
/**
* 定义标签与文章的一对多关联关系
* 表示一个标签可以关联多篇文章
*
* 关联说明
* - 采用hasMany关系当前标签Tag拥有多篇文章posts
* - Mirage JS会自动维护关联数据例如
* - 查询标签时可通过`tag.posts`获取该标签关联的所有文章
* - 创建文章并关联标签时标签的文章列表会自动更新
* - 删除标签时可配置是否级联删除关联的文章默认不删除
*/
posts: hasMany()
});
});

@ -1,18 +1,36 @@
import BaseSerializer from './application';
import BaseSerializer from './application'; // 导入应用程序基础序列化器
/**
* 标签Label序列化器
* 继承自基础序列化器扩展了标签关联成员数量的动态计算逻辑
*/
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) {
/**
* 更新单个标签的成员数量
* 将标签关联的成员ID数组长度作为实际成员数量更新到count.members字段
* @param {Model} label - 标签模型实例
*/
let updateMemberCount = (label) => {
label.update('count', {members: label.memberIds.length});
label.update('count', { members: label.memberIds.length });
};
// 若为单个标签模型,直接更新其成员数量
if (this.isModel(labelModelOrCollection)) {
updateMemberCount(labelModelOrCollection);
} else {
// 若为标签集合,遍历每个标签并更新成员数量
labelModelOrCollection.models.forEach(updateMemberCount);
}
// 调用父类的序列化方法,返回标准化的响应数据
return BaseSerializer.prototype.serialize.call(this, labelModelOrCollection, request);
}
});
});

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

@ -1,361 +1,469 @@
import {Response} from 'miragejs';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {click, currentRouteName, currentURL, fillIn, find, findAll} from '@ember/test-helpers';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {visit} from '../helpers/visit';
import { Response } from 'miragejs';
// 导入Ember Simple Auth的测试支持工具用于认证和失效会话
import { authenticateSession, invalidateSession } from 'ember-simple-auth/test-support';
// 导入Mocha测试框架的钩子和断言方法
import { beforeEach, describe, it } from 'mocha';
// 导入Ember测试辅助函数用于模拟用户交互和获取DOM元素
import { click, currentRouteName, currentURL, fillIn, find, findAll } from '@ember/test-helpers';
// 导入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 () {
// 设置应用测试钩子和Mirage模拟服务器
let hooks = setupApplicationTest();
setupMirage(hooks);
// 测试用例:未认证用户访问标签页时重定向到登录页
it('redirects to signin when not authenticated', async function () {
// 使当前会话失效(模拟未登录状态)
await invalidateSession();
// 访问标签页面
await visit('/tags');
// 断言当前URL为登录页
expect(currentURL()).to.equal('/signin');
});
// 测试用例贡献者Contributor角色用户访问标签页时重定向到文章页
it('redirects to posts page when authenticated as contributor', async function () {
let role = this.server.create('role', {name: 'Contributor'});
this.server.create('user', {roles: [role], slug: 'test-user'});
// 创建"Contributor"角色
let role = this.server.create('role', { name: 'Contributor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role], slug: 'test-user' });
// 认证当前会话(模拟登录)
await authenticateSession();
// 访问标签页面
await visit('/tags');
// 断言当前URL为文章页
expect(currentURL(), 'currentURL').to.equal('/posts');
});
// 测试用例作者Author角色用户访问标签页时重定向到站点设置页
it('redirects to site page when authenticated as author', async function () {
let role = this.server.create('role', {name: 'Author'});
this.server.create('user', {roles: [role], slug: 'test-user'});
// 创建"Author"角色
let role = this.server.create('role', { name: 'Author' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role], slug: 'test-user' });
// 认证当前会话
await authenticateSession();
// 访问标签页面
await visit('/tags');
// 断言当前URL为站点设置页
expect(currentURL(), 'currentURL').to.equal('/site');
});
// 描述"以管理员Administrator身份登录"的测试场景
describe('when logged in as administrator', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () {
let role = this.server.create('role', {name: 'Administrator'});
this.server.create('user', {roles: [role]});
// 创建"Administrator"角色
let role = this.server.create('role', { name: 'Administrator' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
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'});
// 创建4个公开标签
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' });
// 创建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');
// it loads tags list
// 断言页面成功加载当前URL为标签页
expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu
// 断言导航菜单中"标签"项处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.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="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags
// 断言公开标签列表数量为4
expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4);
// tags are in correct order
// 断言标签按正确顺序排序(按名称排序)
let tags = findAll('[data-test-tag]');
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[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');
// can switch to internal tags
// 切换到内部标签视图
await click('[data-test-tags-nav="internal"]');
// 断言内部标签列表数量为2
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
});
// 测试用例:可以添加标签
it('can add tags', async function () {
// 访问标签页面
await visit('tags');
// 断言初始时没有标签
expect(findAll('[data-test-tag]')).to.have.length(0);
// 点击"新建标签"按钮
await click('[data-test-button="new-tag"]');
// 断言跳转到新建标签页面
expect(currentURL()).to.equal('/tags/new');
// 填写标签名称和slug
await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
// 点击保存按钮
await click('[data-test-button="save"]');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]');
// 断言标签列表中新增了一个标签
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');
// 断言标签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');
});
// 测试用例:可以编辑标签
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 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')
.to.have.class('active');
// 断言当前URL为标签编辑页
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-slug"]')).to.have.value('to-be-edited');
// 修改标签名称和slug
await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
// 点击保存按钮
await click('[data-test-button="save"]');
// 从数据库中获取保存后的标签,断言数据已更新
const savedTag = this.server.db.tags.find(tag.id);
expect(savedTag.name, 'saved tag name').to.equal('New tag name');
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]');
// 断言标签列表中显示更新后的标签信息
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-slug]')).to.have.trimmed.text('new-tag-slug');
});
// 测试用例:编辑标签时不会创建重复项
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');
// Verify we start with one tag
// 断言初始时只有一个标签
expect(findAll('[data-test-tag]')).to.have.length(1);
// 点击标签名称进入编辑页
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 修改标签名称
await fillIn('[data-test-input="tag-name"]', 'Edited Tag Name');
// 点击保存按钮
await click('[data-test-button="save"]');
// 点击返回标签列表链接
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(find('[data-test-tag] [data-test-tag-name]')).to.have.trimmed.text('Edited Tag Name');
});
// 测试用例:可以删除标签
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 click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 点击删除标签按钮
await click('[data-test-button="delete-tag"]');
// 定义删除确认弹窗选择器
const tagModal = '[data-test-modal="confirm-delete-tag"]';
// 断言弹窗已显示
expect(find(tagModal)).to.exist;
// 断言弹窗中显示正确的关联文章数量
expect(find(`${tagModal} [data-test-text="posts-count"]`))
.to.have.trimmed.text('1 post');
// 点击确认删除按钮
await click(`${tagModal} [data-test-button="confirm"]`);
// 断言弹窗已关闭
expect(find(tagModal)).to.not.exist;
// 断言返回标签列表页
expect(currentURL()).to.equal('/tags');
// 断言标签已被删除(列表为空)
expect(findAll('[data-test-tag]')).to.have.length(0);
});
// 测试用例可以通过URL中的slug访问标签
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');
// 断言当前URL正确
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-slug"]')).to.have.value('to-be-edited');
});
// 测试用例访问不存在的标签时重定向到404页面
it('redirects to 404 when tag does not exist', async function () {
// 模拟请求不存在的标签时返回404错误
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');
// 断言当前路由为404错误页
expect(currentRouteName()).to.equal('error404');
// 断言URL保持不变显示错误的URL
expect(currentURL()).to.equal('/tags/unknown');
});
// 测试用例:创建新标签时导航菜单中"标签"项保持激活状态
it('maintains active state in nav menu when creating a new tag', async function () {
// 访问新建标签页面
await visit('tags/new');
// 断言当前URL正确
expect(currentURL()).to.equal('tags/new');
// 断言导航菜单中"标签"项处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
});
});
// 描述"以编辑者Editor身份登录"的测试场景
describe('as an editor', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () {
let role = this.server.create('role', {name: 'Editor'});
this.server.create('user', {roles: [role]});
// 创建"Editor"角色
let role = this.server.create('role', { name: 'Editor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
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');
// it loads tags list
// 断言页面成功加载
expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu
// 断言导航菜单激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.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="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags
// 断言公开标签数量
expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4);
// tags are in correct order
// 断言标签排序正确
let tags = findAll('[data-test-tag]');
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[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');
// can switch to internal tags
// 切换到内部标签
await click('[data-test-tags-nav="internal"]');
// 断言内部标签数量
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
});
// 测试用例:可以编辑标签(与管理员权限一致)
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 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')
.to.have.class('active');
// 断言当前URL
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-slug"]')).to.have.value('to-be-edited');
// 修改并保存标签
await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
await click('[data-test-button="save"]');
// 断言数据已更新
const savedTag = this.server.db.tags.find(tag.id);
expect(savedTag.name, 'saved tag name').to.equal('New tag name');
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
// 返回列表页并断言显示正确
await click('[data-test-link="tags-back"]');
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-slug]')).to.have.trimmed.text('new-tag-slug');
});
// 测试用例:可以删除标签(与管理员权限一致)
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 click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 点击删除按钮
await click('[data-test-button="delete-tag"]');
const tagModal = '[data-test-modal="confirm-delete-tag"]';
// 断言弹窗显示及内容正确
expect(find(tagModal)).to.exist;
expect(find(`${tagModal} [data-test-text="posts-count"]`))
.to.have.trimmed.text('1 post');
// 确认删除
await click(`${tagModal} [data-test-button="confirm"]`);
// 断言标签已删除
expect(find(tagModal)).to.not.exist;
expect(currentURL()).to.equal('/tags');
expect(findAll('[data-test-tag]')).to.have.length(0);
});
});
// 描述"以超级编辑者Super Editor身份登录"的测试场景
describe('as a super editor', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () {
let role = this.server.create('role', {name: 'Super Editor'});
this.server.create('user', {roles: [role]});
// 创建"Super Editor"角色
let role = this.server.create('role', { name: 'Super Editor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
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');
// it loads tags list
// 断言页面加载及标签展示正确(与管理员测试用例一致)
expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.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="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags
expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4);
// tags are in correct order
let tags = findAll('[data-test-tag]');
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[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');
// can switch to internal tags
await click('[data-test-tags-nav="internal"]');
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
});
// 测试用例:可以编辑标签(与管理员权限一致)
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 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')
.to.have.class('active');
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-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');
await click('[data-test-link="tags-back"]');
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-slug]')).to.have.trimmed.text('new-tag-slug');
});
// 测试用例:可以删除标签(与管理员权限一致)
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 click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
await click('[data-test-button="delete-tag"]');
const tagModal = '[data-test-modal="confirm-delete-tag"]';
expect(find(tagModal)).to.exist;
@ -396,4 +504,4 @@ describe('Acceptance: Tags', function () {
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) {
// 若配置表为空,加载配置 fixtures 数据
if (!server.schema.configs.all().length) {
server.loadFixtures('configs');
}
// 若设置表为空,加载设置 fixtures 数据
if (!server.schema.settings.all().length) {
server.loadFixtures('settings');
}
// 获取第一个配置项,开启开发者实验功能总开关
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) : {};
// 启用目标功能标志
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) {
// 若配置表为空,加载配置 fixtures 数据
if (!server.schema.configs.all().length) {
server.loadFixtures('configs');
}
// 若设置表为空,加载设置 fixtures 数据
if (!server.schema.settings.all().length) {
server.loadFixtures('settings');
}
// 获取第一个配置项,确保开发者实验功能总开关开启(避免功能被全局禁用)
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) : {};
// 禁用目标功能标志
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 ghostPaths from 'ghost-admin/utils/ghost-paths';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
import Pretender from 'pretender'; // 导入Pretender库用于模拟HTTP请求
import ghostPaths from 'ghost-admin/utils/ghost-paths'; // 导入Ghost路径工具用于获取API根路径
import { describe, it } from 'mocha'; // 导入Mocha测试框架的描述和测试用例函数
import { expect } from 'chai'; // 导入Chai断言库
import { setupTest } from 'ember-mocha'; // 导入Ember测试设置工具
// 描述"标签适配器Adapter: tag"的集成测试套件
describe('Integration: Adapter: tag', function () {
// 设置测试环境初始化Ember测试容器
setupTest();
// 声明变量:模拟服务器和数据存储服务
let server, store;
// 每个测试用例执行前的准备工作
beforeEach(function () {
// 获取Ember的数据存储服务store
store = this.owner.lookup('service:store');
// 创建Pretender模拟服务器实例用于拦截和模拟API请求
server = new Pretender();
});
// 每个测试用例执行后的清理工作
afterEach(function () {
// 关闭模拟服务器,避免影响其他测试
server.shutdown();
});
// 测试用例获取所有标签时从常规API端点加载数据
it('loads tags from regular endpoint when all are fetched', function (done) {
// 模拟GET请求当请求标签列表API时返回预设的标签数据
server.get(`${ghostPaths().apiRoot}/tags/`, function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [
{
id: 1,
name: 'Tag 1',
slug: 'tag-1'
}, {
id: 2,
name: 'Tag 2',
slug: 'tag-2'
}
]})];
return [
200, // HTTP状态码成功
{ 'Content-Type': 'application/json' }, // 响应头JSON格式
JSON.stringify({ // 响应体:包含两个标签的数组
tags: [
{
id: 1,
name: 'Tag 1',
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.objectAtContent(0).get('name')).to.equal('Tag 1');
// 标记测试完成
done();
});
});
// 测试用例查询单个标签且传入slug时从slug专属API端点加载数据
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 () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [
{
id: 1,
slug: 'tag-1',
name: 'Tag 1'
}
]})];
return [
200, // HTTP状态码成功
{ 'Content-Type': 'application/json' }, // 响应头JSON格式
JSON.stringify({ // 响应体包含指定slug的标签
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.get('name')).to.equal('Tag 1');
// 标记测试完成
done();
});
});
});
});

@ -1,253 +1,358 @@
import hbs from 'htmlbars-inline-precompile';
import mockPosts from '../../../mirage/config/posts';
import mockTags from '../../../mirage/config/themes';
import {click, find, findAll, render, settled, waitUntil} from '@ember/test-helpers';
import {clickTrigger, selectChoose, typeInSearch} from 'ember-power-select/test-support/helpers';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupRenderingTest} from 'ember-mocha';
import {startMirage} from 'ghost-admin/initializers/ember-cli-mirage';
import {timeout} from 'ember-concurrency';
// NOTE: although Mirage has posts<->tags relationship and can respond
// to :post-id/?include=tags all ordering information is lost so we
// need to build the tags array manually
import { click, find, findAll, render, settled, waitUntil } from '@ember/test-helpers';
import { clickTrigger, selectChoose, typeInSearch } from 'ember-power-select/test-support/helpers';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { setupRenderingTest } from 'ember-mocha';
import { startMirage } from 'ghost-admin/initializers/ember-cli-mirage';
import { timeout } from 'ember-concurrency';
// 注意尽管Mirage中存在文章与标签的关联关系且能响应带include=tags的请求
//
// 中文说明:
// - 本集成测试依赖 Mirage 提供的 tags mock 数据及 API 路由用于验证标签输入组件GhPsmTagsInput
// 在不同场景下(渲染已选标签、本地/服务端搜索、创建/删除标签等)的行为是否正确。
// - Mirage 在模拟时可能丢失后端在排序/pagination 上的一些真实细节(例如特定排序字段),
// 因此测试中在需要断言顺序或构建复杂关联时,会手动调整或重建标签数组以保证一致性。
// - 当修改 Mirage 工厂/序列化器或标签相关 API 时,请同时更新此测试以保持兼容性。
const assignPostWithTags = async function postWithTags(context, ...slugs) {
// 获取ID为1的文章
let post = await context.store.findRecord('post', 1);
// 获取所有标签
let tags = await context.store.findAll('tag');
// 为文章添加指定slug的标签
slugs.forEach((slug) => {
post.get('tags').pushObject(tags.findBy('slug', slug));
});
// 将文章设置到测试上下文并等待异步操作完成
context.set('post', post);
await settled();
};
// 描述"标签输入组件GhPsmTagsInput"的集成测试套件
describe('Integration: Component: gh-psm-tags-input', function () {
// 设置渲染测试环境
setupRenderingTest();
// 声明模拟服务器变量
let server;
// 每个测试用例执行前的准备工作
beforeEach(function () {
// 启动Mirage模拟服务器
server = startMirage();
// 创建作者用户
let author = server.create('user');
// 加载文章和标签的模拟数据配置
mockPosts(server);
mockTags(server);
server.create('post', {authors: [author]});
server.create('tag', {name: 'Tag 1', slug: 'one'});
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'});
// 创建关联作者的文章
server.create('post', { authors: [author] });
// 创建测试标签
server.create('tag', { name: 'Tag 1', slug: 'one' });
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'));
});
// 每个测试用例执行后的清理工作
afterEach(function () {
// 关闭模拟服务器
server.shutdown();
});
// 测试用例:渲染时显示已选择的标签
it('shows selected tags on render', async function () {
// 为文章分配标签"one"和"three"
await assignPostWithTags(this, 'one', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 获取所有已选择的标签令牌
let selected = findAll('.tag-token');
// 断言显示2个已选择的标签
expect(selected.length).to.equal(2);
// 断言:标签文本正确
expect(selected[0]).to.contain.text('Tag 1');
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 () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger();
await settled();
// unsure why settled() is sometimes not catching the update
// 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言显示4个标签选项
expect(options.length).to.equal(4);
// 断言:选项按字母顺序排列
expect(options[0]).to.contain.text('Tag 1');
expect(options[1]).to.contain.text('#Tag 2');
expect(options[2]).to.contain.text('Tag 3');
expect(options[3]).to.contain.text('Tag 4');
});
// 测试用例:如果第一页已加载所有标签,则使用本地搜索
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));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger();
await settled();
// 记录当前请求次数
const requestCount = server.pretender.handledRequests.length;
// 等待选项加载完成
await waitUntil(() => findAll('.ember-power-select-option').length >= 4);
// 输入搜索关键词
await typeInSearch('2');
await settled();
// 断言:搜索未触发新的请求(使用本地搜索)
expect(server.pretender.handledRequests.length).to.equal(requestCount);
});
// 测试用例:如果通过滚动加载了所有标签,则使用本地搜索
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
server.db.tags.remove(); // clear existing tags that will mess with alphabetical sorting
server.createList('tag', 150, {name: i => `Tag ${i.toString().padStart(3, '0')}`});
// 创建超过1页的标签150个。左填充名称以确保按字母顺序排序
server.db.tags.remove(); // 清除可能干扰字母排序的现有标签
server.createList('tag', 150, { name: i => `Tag ${i.toString().padStart(3, '0')}` });
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger();
// although we load 100 per page, we'll never have more 50 options rendered
// because we use vertical-collection to recycle dom elements on scroll
await waitUntil(() => findAll('.ember-power-select-option').length >= 50, {timeoutMessage: 'Timed out waiting for first page loaded state'});
// 尽管每页加载100个但由于使用vertical-collection回收DOM元素最多显示50个选项
await waitUntil(
() => 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');
optionsContent.scrollTo({top: optionsContent.scrollHeight});
optionsContent.scrollTo({ top: optionsContent.scrollHeight });
await settled();
// wait for second page to be loaded
await waitUntil(() => server.pretender.handledRequests.some(r => r.queryParams.page === '2'));
optionsContent.scrollTo({top: optionsContent.scrollHeight});
await waitUntil(() => findAll('.ember-power-select-option').some(o => o.textContent.includes('Tag 105')), {timeoutMessage: 'Timed out waiting for second page loaded state'});
// capture current request count - we test that it doesn't change to indicate a client-side filter
// 等待第二页加载完成
await waitUntil(
() => server.pretender.handledRequests.some(r => r.queryParams.page === '2')
);
optionsContent.scrollTo({ top: optionsContent.scrollHeight });
await waitUntil(
() => findAll('.ember-power-select-option').some(o => o.textContent.includes('Tag 105')),
{ timeoutMessage: '等待第二页加载超时' }
);
// 记录当前请求次数 - 测试是否未发送新请求(表明使用客户端过滤)
const requestCount = server.pretender.handledRequests.length;
// 输入搜索关键词
await typeInSearch('21');
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);
});
// 描述"客户端搜索"的测试场景
describe('client-side search', function () {
// 测试用例:匹配小写标签名称的选项
it('matches options on lowercase tag names', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入搜索关键词
await clickTrigger();
await typeInSearch('2');
await settled();
// unsure why settled() is sometimes not catching the update
// 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言显示2个匹配选项
expect(options.length).to.equal(2);
// 断言:选项包含"Add "2"..."和"Tag 2"
expect(options[0]).to.contain.text('Add "2"...');
expect(options[1]).to.contain.text('Tag 2');
});
// 测试用例:精确匹配时隐藏创建选项
it('hides create option on exact matches', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入精确匹配的关键词
await clickTrigger();
await typeInSearch('#Tag 2');
await settled();
// unsure why settled() is sometimes not catching the update
// 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言只显示1个精确匹配的选项
expect(options.length).to.equal(1);
expect(options[0]).to.contain.text('#Tag 2');
});
// 测试用例:可以搜索包含单引号的标签
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));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入包含单引号的搜索关键词
await clickTrigger();
await typeInSearch(`O'`);
await settled();
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言显示2个匹配选项
expect(options.length).to.equal(2);
expect(options[0]).to.contain.text(`Add "O'"...`);
expect(options[1]).to.contain.text(`O'Nolan`);
});
});
// 描述"服务器端搜索"的测试场景(暂未实现测试用例)
describe('server-side search', function () {
});
// 测试用例:高亮显示内部标签
it('highlights internal tags', async function () {
// 为文章分配标签"two"(内部标签)和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 获取所有已选择的标签令牌
let selected = findAll('.tag-token');
// 断言显示2个已选择的标签
expect(selected.length).to.equal(2);
// 断言:内部标签有特殊样式类,普通标签没有
expect(selected[0]).to.have.class('tag-token--internal');
expect(selected[1]).to.not.have.class('tag-token--internal');
});
// 描述"更新标签updateTags"的测试场景
describe('updateTags', function () {
// 测试用例:修改文章的标签列表
it('modifies post.tags', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 选择"Tag 1"标签
await selectChoose('.ember-power-select-trigger', 'Tag 1');
// 断言:文章的标签列表已更新
expect(
this.post.tags.mapBy('name').join(',')
).to.equal('#Tag 2,Tag 3,Tag 1');
});
// TODO: skipped due to consistently random failures on Travis
// '#ember-basic-dropdown-content-ember17494 Add "New"...' is not a valid selector
// https://github.com/TryGhost/Ghost/issues/10308
// 测试用例未选中时销毁新标签记录因Travis上持续随机失败暂时跳过
// 失败原因:选择器无效 '#ember-basic-dropdown-content-ember17494 Add "New"...'
// 相关Issuehttps://github.com/TryGhost/Ghost/issues/10308
it.skip('destroys new tag records when not selected', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项,输入新标签名称并选择创建选项
await clickTrigger();
await typeInSearch('New');
await settled();
await selectChoose('.ember-power-select-trigger', 'Add "New"...');
// 断言标签数量增加到5原有4个+新增1个
let tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(5);
// 点击移除最后一个标签(新创建的标签)
let removeBtns = findAll('.ember-power-select-multiple-remove-btn');
await click(removeBtns[removeBtns.length - 1]);
// 断言新标签记录已被销毁标签数量回到4
tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(4);
});
});
// 描述"创建标签createTag"的测试场景
describe('createTag', function () {
// 测试用例:创建新的标签记录
it('creates new records', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项,输入第一个新标签名称并选择创建选项
await clickTrigger();
await typeInSearch('New One');
await settled();
await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0);
// 输入第二个新标签名称并选择创建选项
await typeInSearch('New Two');
await settled();
await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0);
// 断言标签数量增加到6原有4个+新增2个
let tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(6);
// 断言新创建的标签记录处于isNew状态未保存到服务器
expect(tags.findBy('name', 'New One').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 {describe, it} from 'mocha';
import {expect} from 'chai';
import {hbs} from 'ember-cli-htmlbars';
import {setupRenderingTest} from 'ember-mocha';
import { click, findAll, render, triggerKeyEvent } from '@ember/test-helpers';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { hbs } from 'ember-cli-htmlbars';
import { setupRenderingTest } from 'ember-mocha';
/**
* 标签页组件Tabs::Tabs的集成测试
* 验证组件的渲染效果交互行为及特殊配置的功能
*/
describe('Integration: Component: tabs/tabs', function () {
// 设置渲染测试环境
setupRenderingTest();
/**
* 测试组件基础渲染效果
* 验证初始状态下标签按钮面板的数量选中状态及内容
*/
it('renders', async function () {
// 渲染标签页组件包含2个标签和对应的面板
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
@ -17,27 +27,37 @@ describe('Integration: Component: tabs/tabs', function () {
<tabs.tabPanel>Content 2</tabs.tabPanel>
</Tabs::Tabs>`);
// 获取标签按钮和面板元素
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
expect(findAll('.test-tab').length).to.equal(1);
expect(findAll('.tab-list').length).to.equal(1);
expect(tabPanels.length).to.equal(2);
expect(tabButtons.length).to.equal(2);
// 验证组件容器和列表结构
expect(findAll('.test-tab').length).to.equal(1); // 组件容器存在
expect(findAll('.tab-list').length).to.equal(1); // 标签列表容器存在
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(tabButtons[0]).to.have.class('tab-selected');
expect(tabPanels[0]).to.have.class('tab-panel-selected');
// 验证初始选中状态
expect(findAll('.tab-selected').length).to.equal(1); // 只有一个选中的标签
expect(findAll('.tab-panel-selected').length).to.equal(1); // 只有一个选中的面板
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[1]).to.have.trimmed.text('Tab 2');
// 验证面板内容(未选中的面板内容为空)
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('');
});
/**
* 测试点击标签时的交互效果
* 验证点击后标签和面板的选中状态及内容切换
*/
it('renders expected content on click', async function () {
// 渲染标签页组件
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
@ -50,18 +70,26 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
// 点击第二个标签
await click(tabButtons[1]);
// 验证选中状态切换
expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[1]).to.have.class('tab-selected');
expect(tabPanels[1]).to.have.class('tab-panel-selected');
expect(tabButtons[1]).to.have.class('tab-selected'); // 第二个标签被选中
expect(tabPanels[1]).to.have.class('tab-panel-selected'); // 第二个面板被选中
// 验证面板内容切换(未选中的面板内容为空)
expect(tabPanels[0]).to.have.trimmed.text('');
expect(tabPanels[1]).to.have.trimmed.text('Content 2');
});
/**
* 测试键盘事件对标签的控制
* 验证方向键HomeEnd键的导航功能
*/
it('renders expected content on keyup event', async function () {
// 渲染包含3个标签的组件
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 0</tabs.tab>
@ -76,34 +104,45 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
// 辅助函数:验证指定索引的标签和面板处于选中状态且内容正确
const isTabRenders = (num) => {
expect(tabButtons[num]).to.have.class('tab-selected');
expect(tabPanels[num]).to.have.class('tab-panel-selected');
expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`);
};
// 右方向键导航从0→1→2
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight');
await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight');
isTabRenders(2);
// 右方向键循环从2→0
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight');
isTabRenders(0);
// 左方向键导航从0→2
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft');
isTabRenders(2);
// 左方向键导航从2→1
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft');
isTabRenders(1);
// Home键跳转到第一个标签
await triggerKeyEvent(tabButtons[0], 'keyup', 'Home');
isTabRenders(0);
// End键跳转到最后一个标签
await triggerKeyEvent(tabButtons[0], 'keyup', 'End');
isTabRenders(2);
});
/**
* 测试forceRender参数的效果
* 验证开启后所有面板内容始终渲染不随选中状态隐藏
*/
it('renders content for all tabs with forceRender option', async function () {
// 渲染开启forceRender的标签页组件
await render(hbs`
<Tabs::Tabs class="test-tab" @forceRender={{true}} as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
@ -116,17 +155,21 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
// 初始状态:所有面板内容都渲染
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('Content 2');
// 点击第二个标签
await click(tabButtons[1]);
// 选中状态正常切换
expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[1]).to.have.class('tab-selected');
expect(tabPanels[1]).to.have.class('tab-panel-selected');
// 所有面板内容仍保持渲染forceRender生效
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
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
import DS from 'ember-data';
import EmberObject from '@ember/object';
import Service from '@ember/service';
import hbs from 'htmlbars-inline-precompile';
import {blur, click, fillIn, find, findAll, render} from '@ember/test-helpers';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupRenderingTest} from 'ember-mocha';
import { blur, click, fillIn, find, findAll, render } from '@ember/test-helpers';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { setupRenderingTest } from 'ember-mocha';
const {Errors} = DS;
// 从Ember Data中获取私有Errors类用于模拟验证错误
const { Errors } = DS;
// 配置服务桩提供博客URL
let configStub = Service.extend({
blogUrl: 'http://localhost:2368'
});
// 媒体查询服务桩:控制移动端视图模拟
let mediaQueriesStub = Service.extend({
maxWidth600: false
maxWidth600: false // 默认模拟非移动端
});
// 描述"标签表单组件Tags::TagForm"的集成测试套件(当前跳过测试)
describe.skip('Integration: Component: tags/tag-form', function () {
// 设置渲染测试环境
setupRenderingTest();
// 每个测试用例执行前的准备工作
beforeEach(function () {
/* eslint-disable camelcase */
// 创建模拟标签对象,包含基础属性和验证相关字段
let tag = EmberObject.create({
id: 1,
name: 'Test',
@ -31,38 +38,48 @@ describe.skip('Integration: Component: tags/tag-form', function () {
description: 'Description.',
metaTitle: 'Meta Title',
metaDescription: 'Meta description',
errors: Errors.create(),
hasValidated: []
errors: Errors.create(), // 用于存储验证错误
hasValidated: [] // 用于记录已验证的字段
});
/* eslint-enable camelcase */
// 将标签对象和属性设置方法存入测试上下文
this.set('tag', tag);
this.set('setProperty', function (property, value) {
// this should be overridden if a call is expected
// 若未被覆盖,调用时会打印错误(用于捕获意外调用)
// eslint-disable-next-line no-console
console.error(`setProperty called '${property}: ${value}'`);
});
// 注册服务桩,替换真实服务
this.owner.register('service:config', configStub);
this.owner.register('service:media-queries', mediaQueriesStub);
});
// 测试用例:表单标题显示正确
it('has the correct title', async function () {
// 渲染标签表单组件
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言:现有标签的标题为"Tag settings"
expect(find('.tag-settings-pane h4').textContent, 'existing tag title').to.equal('Tag settings');
// 切换为新标签设置isNew属性
this.set('tag.isNew', true);
// 断言:新标签的标题为"New tag"
expect(find('.tag-settings-pane h4').textContent, 'new tag title').to.equal('New tag');
});
// 测试用例:正确渲染主要设置项
it('renders main settings', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言:显示图片上传器
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="slug"]').value, 'slug field value').to.equal('test');
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');
});
// 测试用例:可在主要设置和元数据设置之间切换
it('can switch between main/meta settings', async function () {
await render(hbs`
<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-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');
// 断言:切换后隐藏主要设置,显示元数据设置
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;
// 点击"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-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 () {
this.set('setProperty', function () {
// noop
});
// 覆盖setProperty为无操作避免干扰测试
this.set('setProperty', function () {});
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 修改各输入框的值
await fillIn('input[name="name"]', 'New name');
await fillIn('input[name="slug"]', 'new-slug');
await fillIn('textarea[name="description"]', 'New description');
await fillIn('input[name="metaTitle"]', 'New metaTitle');
await fillIn('textarea[name="metaDescription"]', 'New metaDescription');
// 断言:源标签对象的属性未被修改(单向绑定生效)
expect(this.get('tag.name'), 'tag name').to.equal('Test');
expect(this.get('tag.slug'), 'tag slug').to.equal('test');
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');
});
// 测试用例所有字段失焦时触发setProperty动作
it('triggers setProperty action on blur of all fields', async function () {
let lastSeenProperty = '';
let lastSeenValue = '';
// 覆盖setProperty记录最后一次调用的属性和值
this.set('setProperty', function (property, value) {
lastSeenProperty = property;
lastSeenValue = value;
});
// 辅助函数测试字段失焦时是否正确触发setProperty
let testSetProperty = async (selector, expectedProperty, expectedValue) => {
await click(selector);
await fillIn(selector, expectedValue);
await blur(selector);
await click(selector); // 聚焦字段
await fillIn(selector, expectedValue); // 输入值
await blur(selector); // 失焦
// 断言:触发的属性和值正确
expect(lastSeenProperty, 'property').to.equal(expectedProperty);
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}} />
`);
// 测试所有字段
await testSetProperty('input[name="name"]', 'name', 'New name');
await testSetProperty('input[name="slug"]', 'slug', 'new-slug');
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');
});
// 测试用例:显示已验证字段的错误信息
it('displays error messages for validated fields', async function () {
let errors = this.get('tag.errors');
let hasValidated = this.get('tag.hasValidated');
// 为各字段添加验证错误并标记为已验证
errors.add('name', 'must be present');
hasValidated.push('name');
@ -162,91 +194,121 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 验证name字段错误状态
let nameFormGroup = find('input[name="name"]').closest('.form-group');
expect(nameFormGroup, 'name form group has error state').to.have.class('error');
expect(nameFormGroup.querySelector('.response'), 'name form group has error message').to.exist;
// 验证slug字段错误状态
let slugFormGroup = find('input[name="slug"]').closest('.form-group');
expect(slugFormGroup, 'slug form group has error state').to.have.class('error');
expect(slugFormGroup.querySelector('.response'), 'slug form group has error message').to.exist;
// 验证description字段错误状态
let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group');
expect(descriptionFormGroup, 'description form group has error state').to.have.class('error');
// 验证metaTitle字段错误状态
let metaTitleFormGroup = find('input[name="metaTitle"]').closest('.form-group');
expect(metaTitleFormGroup, 'metaTitle form group has error state').to.have.class('error');
expect(metaTitleFormGroup.querySelector('.response'), 'metaTitle form group has error message').to.exist;
// 验证metaDescription字段错误状态
let metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group');
expect(metaDescriptionFormGroup, 'metaDescription form group has error state').to.have.class('error');
expect(metaDescriptionFormGroup.querySelector('.response'), 'metaDescription form group has error message').to.exist;
});
// 测试用例:显示文本字段的字符计数
it('displays char count for text fields', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 验证描述字段的字符计数("Description." 共12个字符
let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group');
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');
expect(metaDescriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('16');
});
// 测试用例正确渲染SEO标题预览
it('renders SEO title preview', async function () {
await render(hbs`
<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');
// 移除metaTitle
this.set('tag.metaTitle', '');
// 断言无metaTitle时回退到标签名称
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')));
// 断言标题被截断为70字符+省略号
let expectedLength = 70 + '…'.length;
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 () {
await render(hbs`
<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/');
// 设置超长slug150个x
this.set('tag.slug', (new Array(151).join('x')));
// 断言slug被截断为70字符+省略号
let expectedLength = 70 + '…'.length;
expect(find('.seo-preview-link').textContent.length, 'cuts slug to max 70 chars').to.equal(expectedLength);
});
// 测试用例正确渲染SEO描述预览
it('renders SEO description preview', async function () {
await render(hbs`
<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');
// 移除metaDescription
this.set('tag.metaDescription', '');
// 断言无metaDescription时回退到描述
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')));
// 断言描述被截断为156字符+省略号
let expectedLength = 156 + '…'.length;
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 () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 切换到元数据设置面板
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;
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;
});
// 测试用例:点击删除按钮时触发删除模态框
it('triggers delete tag modal on delete click', async function () {
let openModalFired = false;
// 覆盖openModal方法标记为已触发
this.set('openModal', () => {
openModalFired = true;
});
@ -254,12 +316,16 @@ describe.skip('Integration: Component: tags/tag-form', function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} @showDeleteTagModal={{this.openModal}} />
`);
// 点击删除按钮
await click('.settings-menu-delete-button');
// 断言:删除模态框触发方法被调用
expect(openModalFired).to.be.true;
});
// 测试用例:移动端显示标签返回箭头链接
it('shows tags arrow link on mobile', async function () {
// 获取媒体查询服务并设置为移动端宽度≤600px
let mediaQueries = this.owner.lookup('service:media-queries');
mediaQueries.set('maxWidth600', true);
@ -267,6 +333,7 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<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);
});
});
});

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

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

@ -1,50 +1,106 @@
// # 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');
const {SafeString, escapeExpression, templates} = require('../services/handlebars');
// 中文说明:
// - 该 helper 在主题模板中用于渲染单个文章/页面的标签列表。
// - 支持的参数说明:
// - 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 ghostHelperUtils = require('@tryghost/helpers').utils;
module.exports = function tags(options) {
// 处理可选参数,确保参数对象存在
options = options || {};
options.hash = options.hash || {};
// 解析参数:自动链接(默认开启)
// 如果autolink参数是字符串且值为'false',则关闭自动链接
const autolink = !(isString(options.hash.autolink) && options.hash.autolink === 'false');
// 解析参数:分隔符(默认逗号加空格)
const separator = isString(options.hash.separator) ? options.hash.separator : ', ';
// 解析参数:前缀(默认空)
const prefix = isString(options.hash.prefix) ? options.hash.prefix : '';
// 解析参数:后缀(默认空)
const suffix = isString(options.hash.suffix) ? options.hash.suffix : '';
// 解析参数:限制数量(默认无限制)
const limit = options.hash.limit ? parseInt(options.hash.limit, 10) : undefined;
// 初始化输出字符串
let output = '';
// 解析参数起始位置默认11-based索引
let from = options.hash.from ? parseInt(options.hash.from, 10) : 1;
// 解析参数:结束位置(默认无)
let to = options.hash.to ? parseInt(options.hash.to, 10) : undefined;
/**
* 创建标签列表数组
* @param {Array} tagsList - 标签数组
* @returns {Array} 处理后的标签字符串数组
*/
function createTagList(tagsList) {
/**
* 处理单个标签
* @param {Object} tag - 标签对象
* @returns {string} 处理后的标签字符串带链接或纯文本
*/
function processTag(tag) {
// 如果开启自动链接,返回带链接的标签;否则返回纯文本标签
return autolink ? templates.link({
url: urlService.getUrlByResourceId(tag.id, {withSubdirectory: true}),
text: escapeExpression(tag.name)
url: urlService.getUrlByResourceId(tag.id, { withSubdirectory: true }), // 获取标签的URL
text: escapeExpression(tag.name) // 转义标签名称防止XSS
}) : escapeExpression(tag.name);
}
// 根据可见性筛选标签并应用processTag处理
return ghostHelperUtils.visibility.filter(tagsList, options.hash.visibility, processTag);
}
// 如果存在标签且标签数组不为空
if (this.tags && this.tags.length) {
// 生成处理后的标签列表数组
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;
// 截取指定范围的标签,并使用分隔符拼接
output = output.slice(from, to).join(separator);
}
// 如果有输出内容,添加前缀和后缀
if (output) {
output = prefix + output + suffix;
}
// 返回安全字符串防止Handlebars自动转义HTML
return new SafeString(output);
};
};

@ -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 errors = require('@tryghost/errors');
const models = require('../../models');
const tagsPublicService = require('../../services/tags-public');
// 允许通过 include 参数包含的关联计数(仅允许对外公开的项)
const ALLOWED_INCLUDES = ['count.posts'];
// 本地化/模板消息(可以用于抛出用户友好的错误信息)
const messages = {
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 errors = require('@tryghost/errors');
const models = require('../../models');

@ -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 tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
@ -98,6 +125,11 @@ Tag = ghostBookshelf.Model.extend({
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) {
const self = this;
@ -126,10 +158,12 @@ Tag = ghostBookshelf.Model.extend({
}
},
// Relationship: tags <-> posts (many-to-many)
posts: function posts() {
return this.belongsToMany('Post');
},
// toJSON: normalize attributes when serializing model instances
toJSON: function toJSON(unfilteredOptions) {
const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, unfilteredOptions);
@ -141,6 +175,7 @@ Tag = ghostBookshelf.Model.extend({
},
defaultColumnsToFetch() {
// By default, fetching minimal columns for lightweight queries
return ['id'];
}
}, {
@ -166,6 +201,7 @@ Tag = ghostBookshelf.Model.extend({
return options;
},
// Configure how to compute relation counts when include=count.posts is requested
countRelations() {
return {
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) {
const options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
options.withRelated = ['posts'];

@ -16,6 +16,21 @@ const messages = {
postNotFound: 'Post not found.'
};
/*
* PostsService
*
* 中文说明
* - PostsService 负责与 posts 资源相关的业务逻辑和操作浏览读取编辑批量操作导出等
* - 与标签Tag相关的交互点主要集中在批量添加标签#bulkAddTags复制文章时复制标签引用
* copyPost 中的 tags 字段以及在批量销毁/编辑时需清理或维护 posts_tags 联合表
* - 对标签的批量添加操作会
* 1. 在事务中为不存在的标签调用 models.Tag.add 创建新标签保证原子性
* 2. 查询符合 filter 的文章 id 列表
* 3. 构建 posts_tags 的插入数据使用 ObjectId 生成关联记录的 id并写入数据库
* 4. 调用 Post.addActions('edited', ...) 来记录编辑动作并触发相应的事件
* - 在修改这类逻辑时需要注意事务transacting和并发一致性以及 posts_tags 表的去重/排序逻辑
*/
class PostsService {
constructor({urlUtils, models, isSet, stats, emailService, postsExporter}) {
this.urlUtils = urlUtils;

@ -9,52 +9,70 @@ const testUtils = require('../../utils');
const dbUtils = require('../../utils/db-utils');
const localUtils = require('./utils');
// 标签内容API测试套件验证标签相关API的功能和响应格式
describe('Tags Content API', function () {
let request;
let request; // 用于发送HTTP请求的supertest代理
// 测试前的初始化工作
before(async function () {
await localUtils.startGhost();
await localUtils.startGhost(); // 启动Ghost服务
// 创建指向Ghost服务URL的请求代理
request = supertest.agent(config.get('url'));
// 初始化测试数据用户、文章、标签、API密钥等
await testUtils.initFixtures('users', 'user:inactive', 'posts', 'tags:extra', 'api_keys');
});
// 每个测试用例执行后恢复配置
afterEach(async function () {
await configUtils.restore();
});
// 获取有效的API访问密钥用于鉴权
const validKey = localUtils.getValidKey();
// 测试用例:能够请求标签列表
it('Can request tags', async function () {
// 发送GET请求获取标签列表附带API密钥
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.public)
.expect(200);
.set('Origin', testUtils.API.getURL()) // 设置请求源
.expect('Content-Type', /json/) // 验证响应为JSON格式
.expect('Cache-Control', testUtils.cacheRules.public) // 验证缓存控制头
.expect(200); // 验证HTTP状态码为200
// 验证缓存失效头不存在
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
// 验证响应结构包含tags数组
should.exist(jsonResponse.tags);
// 检查响应顶层结构是否符合API规范
localUtils.API.checkResponse(jsonResponse, 'tags');
// 验证返回4个标签与测试数据一致
jsonResponse.tags.should.have.length(4);
// 检查单个标签的响应结构包含url字段
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
// 检查分页元数据结构是否符合规范
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
// Default order 'name asc' check
// the ordering difference is described in https://github.com/TryGhost/Ghost/issues/6104
// this condition should be removed once issue mentioned above ^ is resolved
// 验证默认排序(按名称升序)
// 排序差异说明:https://github.com/TryGhost/Ghost/issues/6104
// 上述问题解决后可移除数据库类型判断
if (dbUtils.isMySQL()) {
// MySQL环境下的排序结果
jsonResponse.tags[0].name.should.eql('bacon');
jsonResponse.tags[3].name.should.eql('kitchen sink');
} else {
// 非MySQL环境下的排序结果
jsonResponse.tags[0].name.should.eql('Getting Started');
jsonResponse.tags[3].name.should.eql('kitchen sink');
}
// 验证标签URL的有效性包含协议和主机
should.exist(res.body.tags[0].url);
should.exist(url.parse(res.body.tags[0].url).protocol);
should.exist(url.parse(res.body.tags[0].url).host);
});
// 测试用例能够使用limit=all请求所有标签
it('Can request tags with limit=all', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?limit=all&key=${validKey}`))
.set('Origin', testUtils.API.getURL())
@ -66,11 +84,13 @@ describe('Tags Content API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.tags);
localUtils.API.checkResponse(jsonResponse, 'tags');
// 验证返回所有4个标签limit=all生效
jsonResponse.tags.should.have.length(4);
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
});
// 测试用例:能够限制返回的标签数量
it('Can limit tags to receive', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?limit=3&key=${validKey}`))
.set('Origin', testUtils.API.getURL())
@ -82,11 +102,13 @@ describe('Tags Content API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.tags);
localUtils.API.checkResponse(jsonResponse, 'tags');
// 验证只返回3个标签limit=3生效
jsonResponse.tags.should.have.length(3);
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
});
// 测试用例:能够包含标签关联的文章计数
it('Can include post count', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}&include=count.posts`))
.set('Origin', testUtils.API.getURL())
@ -99,13 +121,14 @@ describe('Tags Content API', function () {
should.exist(jsonResponse.tags);
jsonResponse.tags.should.be.an.Array().with.lengthOf(4);
// Each tag should have the correct count
// 验证每个标签的文章计数正确(与测试数据一致)
_.find(jsonResponse.tags, {name: 'Getting Started'}).count.posts.should.eql(7);
_.find(jsonResponse.tags, {name: 'kitchen sink'}).count.posts.should.eql(2);
_.find(jsonResponse.tags, {name: 'bacon'}).count.posts.should.eql(2);
_.find(jsonResponse.tags, {name: 'chorizo'}).count.posts.should.eql(1);
});
// 测试用例能够筛选多个字段并验证url字段有效性
it('Can use multiple fields and have valid url fields', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}&fields=url,name`))
.set('Origin', testUtils.API.getURL())
@ -117,14 +140,17 @@ describe('Tags Content API', function () {
assert(jsonResponse.tags);
// 辅助函数:通过名称查找标签
const getTag = name => jsonResponse.tags.find(tag => tag.name === name);
// 验证每个标签的URL以预期路径结尾
assert(getTag('Getting Started').url.endsWith('/tag/getting-started/'));
assert(getTag('kitchen sink').url.endsWith('/tag/kitchen-sink/'));
assert(getTag('bacon').url.endsWith('/tag/bacon/'));
assert(getTag('chorizo').url.endsWith('/tag/chorizo/'));
});
// 测试用例能够只返回url字段并验证其有效性
it('Can use single url field and have valid url fields', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}&fields=url`))
.set('Origin', testUtils.API.getURL())
@ -136,11 +162,13 @@ describe('Tags Content API', function () {
assert(jsonResponse.tags);
// 辅助函数通过URL路径查找标签
const getTag = path => jsonResponse.tags.find(tag => tag.url.endsWith(path));
// 验证所有预期标签的URL存在
assert(getTag('/tag/getting-started/'));
assert(getTag('/tag/kitchen-sink/'));
assert(getTag('/tag/bacon/'));
assert(getTag('/tag/chorizo/'));
});
});
});

@ -1,39 +1,55 @@
const {agentProvider, mockManager, fixtureManager, matchers} = require('../utils/e2e-framework');
const {anyGhostAgent, anyObjectId, anyISODateTime, anyString, anyContentVersion, anyNumber, anyLocalURL} = matchers;
const { agentProvider, mockManager, fixtureManager, matchers } = require('../utils/e2e-framework');
// 导入匹配器用于验证响应中的动态值如ID、时间戳等
const { anyGhostAgent, anyObjectId, anyISODateTime, anyString, anyContentVersion, anyNumber, anyLocalURL } = matchers;
// 标签快照定义标签对象的基础结构用于验证webhook响应中的标签数据
const tagSnapshot = {
created_at: anyISODateTime,
description: anyString,
id: anyObjectId,
updated_at: anyISODateTime
created_at: anyISODateTime, // 匹配任意ISO格式的创建时间
description: anyString, // 匹配任意字符串格式的描述
id: anyObjectId, // 匹配任意有效的对象ID
updated_at: anyISODateTime // 匹配任意ISO格式的更新时间
};
// 描述"tag.* 事件"的端到端测试套件
describe('tag.* events', function () {
let adminAPIAgent;
let webhookMockReceiver;
let adminAPIAgent; // 管理员API代理用于发送管理员权限的请求
let webhookMockReceiver; // webhook模拟接收器用于捕获和验证webhook请求
// 测试套件启动前的准备工作
before(async function () {
// 获取管理员API代理
adminAPIAgent = await agentProvider.getAdminAPIAgent();
// 初始化集成测试数据用于webhook
await fixtureManager.init('integrations');
// 以管理员身份登录
await adminAPIAgent.loginAsOwner();
});
// 每个测试用例执行前的准备工作
beforeEach(function () {
// 创建新的webhook模拟接收器
webhookMockReceiver = mockManager.mockWebhookRequests();
});
// 每个测试用例执行后的清理工作
afterEach(function () {
// 恢复所有模拟
mockManager.restore();
});
// 测试用例tag.added事件被正确触发
it('tag.added event is triggered', async function () {
// 定义webhook接收URL
const webhookURL = 'https://test-webhook-receiver.com/tag-added/';
// 模拟该URL的webhook接收
await webhookMockReceiver.mock(webhookURL);
// 插入一个监听"tag.added"事件的webhook
await fixtureManager.insertWebhook({
event: 'tag.added',
url: webhookURL
});
// 创建一个新标签
await adminAPIAgent
.post('tags/')
.body({
@ -43,31 +59,36 @@ describe('tag.* events', function () {
description: 'Test Description'
}]
})
.expectStatus(201);
.expectStatus(201); // 验证创建成功201 Created
// 等待webhook请求被接收
await webhookMockReceiver.receivedRequest();
// 验证webhook请求的头部和体部符合预期
webhookMockReceiver
.matchHeaderSnapshot({
'content-version': anyContentVersion,
'content-length': anyNumber,
'user-agent': anyGhostAgent
'content-version': anyContentVersion, // 匹配任意有效的内容版本
'content-length': anyNumber, // 匹配任意数字的内容长度
'user-agent': anyGhostAgent // 匹配任意Ghost客户端标识
})
.matchBodySnapshot({
tag: {
current: {...tagSnapshot, url: anyLocalURL}
current: {...tagSnapshot, url: anyLocalURL} // 当前标签数据包含快照字段和本地URL
}
});
});
// 测试用例tag.deleted事件被正确触发
it('tag.deleted event is triggered', async function () {
const webhookURL = 'https://test-webhook-receiver.com/tag-deleted/';
await webhookMockReceiver.mock(webhookURL);
// 插入一个监听"tag.deleted"事件的webhook
await fixtureManager.insertWebhook({
event: 'tag.deleted',
url: webhookURL
});
// 先创建一个标签用于后续删除
const res = await adminAPIAgent
.post('tags/')
.body({
@ -79,14 +100,18 @@ describe('tag.* events', function () {
})
.expectStatus(201);
// 获取新创建标签的ID
const id = res.body.tags[0].id;
// 删除该标签
await adminAPIAgent
.delete('tags/' + id)
.expectStatus(204);
.expectStatus(204); // 验证删除成功204 No Content
// 等待webhook请求被接收
await webhookMockReceiver.receivedRequest();
// 验证webhook请求的头部和体部符合预期
webhookMockReceiver
.matchHeaderSnapshot({
'content-version': anyContentVersion,
@ -95,20 +120,23 @@ describe('tag.* events', function () {
})
.matchBodySnapshot({
tag: {
current: {},
previous: tagSnapshot
current: {}, // 删除后当前数据为空
previous: tagSnapshot // 包含删除前的标签快照数据
}
});
});
// 测试用例tag.edited事件被正确触发
it('tag.edited event is triggered', async function () {
const webhookURL = 'https://test-webhook-receiver.com/tag-edited/';
await webhookMockReceiver.mock(webhookURL);
// 插入一个监听"tag.edited"事件的webhook
await fixtureManager.insertWebhook({
event: 'tag.edited',
url: webhookURL
});
// 先创建一个标签用于后续编辑
const res = await adminAPIAgent
.post('tags/')
.body({
@ -119,22 +147,27 @@ describe('tag.* events', function () {
}]
})
.expectStatus(201);
// 获取新创建标签的ID
const id = res.body.tags[0].id;
// 准备更新后的标签数据
const updatedTag = res.body.tags[0];
updatedTag.name = 'Updated Tag 3';
updatedTag.slug = 'updated-tag-3';
// 发送更新请求
await adminAPIAgent
.put('tags/' + id)
.body({
tags: [updatedTag]
})
.expectStatus(200);
.expectStatus(200); // 验证更新成功200 OK
// 等待webhook请求被接收
await webhookMockReceiver.receivedRequest();
// 验证webhook请求的头部和体部符合预期
webhookMockReceiver
.matchHeaderSnapshot({
'content-version': anyContentVersion,
@ -143,9 +176,9 @@ describe('tag.* events', function () {
})
.matchBodySnapshot({
tag: {
current: {...tagSnapshot, url: anyLocalURL},
previous: {updated_at: anyISODateTime}
current: {...tagSnapshot, url: anyLocalURL}, // 更新后的当前标签数据
previous: {updated_at: anyISODateTime} // 包含更新前的更新时间
}
});
});
});
});

@ -5,219 +5,301 @@ const urlService = require('../../../../core/server/services/url');
const models = require('../../../../core/server/models');
const tagsHelper = require('../../../../core/frontend/helpers/tags');
/**
* {{tags}} 助手函数的单元测试
* 验证标签助手在不同参数配置下的渲染结果
*/
describe('{{tags}} helper', function () {
// 声明URL服务的存根用于模拟URL生成
let urlServiceGetUrlByResourceIdStub;
// 测试套件启动前初始化模型
before(function () {
models.init();
});
// 每个测试用例执行前创建URL服务的存根
beforeEach(function () {
urlServiceGetUrlByResourceIdStub = sinon.stub(urlService, 'getUrlByResourceId');
});
// 每个测试用例执行后恢复所有存根
afterEach(function () {
sinon.restore();
});
/**
* 测试用例可以返回标签字符串
* 验证默认配置下标签的拼接格式
*/
it('can return string with tags', function () {
// 创建测试标签
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {autolink: 'false'}});
// 调用标签助手(关闭自动链接)
const rendered = tagsHelper.call({ tags: tags }, { hash: { autolink: 'false' } });
should.exist(rendered);
// 验证渲染结果为逗号分隔的标签名
String(rendered).should.equal('foo, bar');
});
/**
* 测试用例可以使用不同的分隔符
* 验证separator参数的效果
*/
it('can use a different separator', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {separator: '|', autolink: 'false'}});
// 使用|作为分隔符
const rendered = tagsHelper.call({ tags: tags }, { hash: { separator: '|', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('haunted|ghost');
});
/**
* 测试用例可以为多个标签添加前缀
* 验证prefix参数的效果
*/
it('can add a single prefix to multiple tags', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {prefix: 'on ', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { prefix: 'on ', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('on haunted, ghost');
});
/**
* 测试用例可以为多个标签添加后缀
* 验证suffix参数的效果
*/
it('can add a single suffix to multiple tags', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {suffix: ' forever', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { suffix: ' forever', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('haunted, ghost forever');
});
/**
* 测试用例可以同时添加前缀和后缀
* 验证prefix和suffix参数的组合效果
*/
it('can add a prefix and suffix to multiple tags', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {suffix: ' forever', prefix: 'on ', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { suffix: ' forever', prefix: 'on ', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('on haunted, ghost forever');
});
/**
* 测试用例可以添加包含HTML的前缀和后缀
* 验证HTML内容在前缀/后缀中的保留
*/
it('can add a prefix and suffix with HTML', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {suffix: ' &bull;', prefix: '&hellip; ', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { suffix: ' &bull;', prefix: '&hellip; ', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('&hellip; haunted, ghost &bull;');
});
/**
* 测试用例无标签时不添加前缀或后缀
* 验证边界条件下的处理
*/
it('does not add prefix or suffix if no tags exist', function () {
const rendered = tagsHelper.call({}, {hash: {prefix: 'on ', suffix: ' forever', autolink: 'false'}});
const rendered = tagsHelper.call({}, { hash: { prefix: 'on ', suffix: ' forever', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('');
});
/**
* 测试用例可以自动链接标签到标签页面
* 验证autolink默认开启时的链接生成
*/
it('can autolink tags to tag pages', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
// 模拟URL服务返回的标签页面URL
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url 1');
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url 2');
const rendered = tagsHelper.call({tags: tags});
const rendered = tagsHelper.call({ tags: tags });
should.exist(rendered);
// 验证渲染结果为带链接的标签
String(rendered).should.equal('<a href="tag url 1">foo</a>, <a href="tag url 2">bar</a>');
});
/**
* 测试用例可以限制输出的标签数量为1
* 验证limit参数的效果
*/
it('can limit no. tags output to 1', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url 1');
const rendered = tagsHelper.call({tags: tags}, {hash: {limit: '1'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { limit: '1' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url 1">foo</a>');
});
/**
* 测试用例可以从指定位置开始列出标签
* 验证from参数的效果
*/
it('can list tags from a specified no.', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url 2');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2'}});
// 从第2个标签开始输出
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url 2">bar</a>');
});
/**
* 测试用例可以输出到指定位置的标签
* 验证to参数的效果
*/
it('can list tags to a specified no.', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url x');
const rendered = tagsHelper.call({tags: tags}, {hash: {to: '1'}});
// 输出到第1个标签
const rendered = tagsHelper.call({ tags: tags }, { hash: { to: '1' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url x">foo</a>');
});
/**
* 测试用例可以输出指定范围内的标签
* 验证from和to参数的组合效果
*/
it('can list tags in a range', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b');
urlServiceGetUrlByResourceIdStub.withArgs(tags[2].id).returns('tag url c');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', to: '3'}});
// 输出第2到第3个标签
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2', to: '3' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url b">bar</a>, <a href="tag url c">baz</a>');
});
/**
* 测试用例可以限制标签数量并从指定位置开始
* 验证from和limit参数的组合效果
*/
it('can limit no. tags and output from 2', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', limit: '1'}});
// 从第2个标签开始输出1个标签
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2', limit: '1' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url b">bar</a>');
});
/**
* 测试用例在指定范围内输出标签时忽略limit参数
* 验证fromto参数优先于limit参数
*/
it('can list tags in a range (ignore limit)', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url a');
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b');
urlServiceGetUrlByResourceIdStub.withArgs(tags[2].id).returns('tag url c');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '1', to: '3', limit: '2'}});
// 范围为1-3时忽略limit=2
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '1', to: '3', limit: '2' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url a">foo</a>, <a href="tag url b">bar</a>, <a href="tag url c">baz</a>');
});
/**
* 描述"内部标签Internal tags"的测试场景
* 验证不同可见性配置下的标签渲染
*/
describe('Internal tags', function () {
// 准备包含内部标签和普通标签的测试数据
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: '#bar', slug: 'hash-bar', visibility: 'internal'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'}),
testUtils.DataGenerator.forKnex.createTag({name: 'buzz', slug: 'buzz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: '#bar', slug: 'hash-bar', visibility: 'internal' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'buzz', slug: 'buzz' })
];
// 准备全是内部标签的测试数据
const tags1 = [
testUtils.DataGenerator.forKnex.createTag({name: '#foo', slug: 'hash-foo-bar', visibility: 'internal'}),
testUtils.DataGenerator.forKnex.createTag({name: '#bar', slug: 'hash-bar', visibility: 'internal'})
testUtils.DataGenerator.forKnex.createTag({ name: '#foo', slug: 'hash-foo-bar', visibility: 'internal' }),
testUtils.DataGenerator.forKnex.createTag({ name: '#bar', slug: 'hash-bar', visibility: 'internal' })
];
// 每个子测试用例执行前设置URL服务的返回值
beforeEach(function () {
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('1');
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('2');
@ -226,8 +308,12 @@ describe('{{tags}} helper', function () {
urlServiceGetUrlByResourceIdStub.withArgs(tags[4].id).returns('5');
});
/**
* 测试用例默认不输出内部标签
* 验证默认可见性配置
*/
it('will not output internal tags by default', function () {
const rendered = tagsHelper.call({tags: tags});
const rendered = tagsHelper.call({ tags: tags });
String(rendered).should.equal(
'<a href="1">foo</a>, ' +
@ -237,8 +323,12 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例正确应用from和limit参数忽略内部标签
* 验证参数在包含内部标签时的处理逻辑
*/
it('should still correctly apply from & limit tags', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', limit: '2'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2', limit: '2' } });
String(rendered).should.equal(
'<a href="3">bar</a>, ' +
@ -246,8 +336,12 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例visibility="all"时输出所有标签
* 验证显示所有标签的配置
*/
it('should output all tags with visibility="all"', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {visibility: 'all'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { visibility: 'all' } });
String(rendered).should.equal(
'<a href="1">foo</a>, ' +
@ -258,8 +352,12 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例visibility="public,internal"时输出所有标签
* 验证多可见性组合的配置
*/
it('should output all tags with visibility property set with visibility="public,internal"', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {visibility: 'public,internal'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { visibility: 'public,internal' } });
should.exist(rendered);
String(rendered).should.equal(
@ -271,18 +369,26 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例visibility="internal"时只输出内部标签
* 验证只显示内部标签的配置
*/
it('Should output only internal tags with visibility="internal"', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {visibility: 'internal'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { visibility: 'internal' } });
should.exist(rendered);
String(rendered).should.equal('<a href="2">#bar</a>');
});
/**
* 测试用例所有标签都是内部标签时输出空
* 验证默认配置下无可见标签的处理
*/
it('should output nothing if all tags are internal', function () {
const rendered = tagsHelper.call({tags: tags1}, {hash: {prefix: 'stuff'}});
const rendered = tagsHelper.call({ tags: tags1 }, { hash: { prefix: 'stuff' } });
should.exist(rendered);
String(rendered).should.equal('');
});
});
});
});
Loading…
Cancel
Save