feat: add AirShelf core implementation
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
output/
|
||||
node_modules/
|
||||
@@ -0,0 +1,76 @@
|
||||
# Visual Parity QA
|
||||
|
||||
这个目录用于逐页校对 `电商AI平台` 设计稿代码和 `core/frontend` 实现页的像素差。
|
||||
|
||||
## 原则
|
||||
|
||||
1. 每开发完一个页面,先截设计稿,再截实现页。
|
||||
2. 两张图使用同一浏览器、同一视口、同一 deviceScaleFactor。
|
||||
3. 输出 `design.png`、`implementation.png`、`diff.png` 和 `report.json`。
|
||||
4. `diffPixels` 必须逐轮下降; 页面验收目标是 0,如字体渲染造成非业务差异,必须在报告里单独记录。
|
||||
5. 视频生成真实测试不进入这个阶段,只校对页面和普通交互。
|
||||
|
||||
## 实现层
|
||||
|
||||
`core/frontend/public/exact/` 是从 `电商AI平台/` 同步到 `core` 内的精确镜像实现层,用于保证每个设计稿页面都能在当前前端服务中以真实 URL 打开并进行像素验收。
|
||||
|
||||
已 React 化并直接接入主 SPA 的页面:
|
||||
|
||||
- 登录: `/`
|
||||
- 注册: `/#register`
|
||||
- 工作台: `?visual=dashboard` / `/#dashboard`
|
||||
|
||||
其余页面先通过 `/exact/*.html` 达成 1:1 视觉还原,后续再按功能模块逐步替换为真实接口驱动的 React/Django 页面。替换时必须继续跑同名 visual parity 项,直到 `diffPixels=0`。
|
||||
|
||||
## 页面比对命令
|
||||
|
||||
先启动前端:
|
||||
|
||||
```bash
|
||||
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
再执行像素比对:
|
||||
|
||||
```bash
|
||||
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/qa/visual-parity
|
||||
npm install
|
||||
npm run compare:login
|
||||
npm run compare:register
|
||||
npm run compare:dashboard
|
||||
npm run compare:all
|
||||
```
|
||||
|
||||
输出目录:
|
||||
|
||||
```text
|
||||
core/qa/visual-parity/output/
|
||||
```
|
||||
|
||||
## 页面复刻队列
|
||||
|
||||
| 优先级 | 页面 | 设计稿 | 关键子页面/交互 | 实现入口 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P0 | 登录 | `电商AI平台/login.html` | 密码可见、忘记密码 toast、SSO toast、注册入口 | `/` 未登录态 | `1440x900 diffPixels=0` |
|
||||
| P0 | 注册 | `电商AI平台/register.html` | 团队注册、返回登录 | `/#register` 未登录态 | `1440x900 diffPixels=0` |
|
||||
| P0 | 工作台 | `电商AI平台/index.html` | 侧栏、顶部余额/消息、快捷入口、最近项目 | `?visual=dashboard` / `/#dashboard` | `1440x900 diffPixels=0` |
|
||||
| P0 | 商品库 | `电商AI平台/products.html` | 列表/卡片、新建商品 drawer、删除/筛选 | `/exact/products.html` | `1440x900 diffPixels=0` |
|
||||
| P0 | 商品详情 | `电商AI平台/product-detail.html` | 编辑、素材区、创建视频项目入口 | `/exact/product-detail.html` | `1440x900 diffPixels=0` |
|
||||
| P0 | 上传创建商品 | `电商AI平台/product-create-upload.html` | 跳转商品库并自动打开新建商品 drawer | `/exact/product-create-upload.html` | `1440x900 diffPixels=0` |
|
||||
| P0 | 视频项目 | `电商AI平台/projects.html` | 表格/卡片视图、删除确认 modal、进入 pipeline | `/exact/projects.html` | `1440x900 diffPixels=0` |
|
||||
| P0 | 新建视频项目 | `电商AI平台/projects-new.html` | 商品选择、模板选择、素材库 modal、创建确认 | `/exact/projects-new.html` | `1440x900 diffPixels=0` |
|
||||
| P0 | 生产管线 | `电商AI平台/pipeline.html` | Stage 1-5、资产详情、演员/场景库、额度预检、视频详情、导出 | `/exact/pipeline.html#stage-1..5` | `5/5 stages diffPixels=0`,不测真实生视频 |
|
||||
| P1 | 资产库 | `电商AI平台/library.html` | 上传、筛选、预览/删除 | `/exact/library.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 账户/额度 | `电商AI平台/account.html` | 充值、交易流水、充值 modal | `/exact/account.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 团队 | `电商AI平台/team.html` | 成员、角色、月限额 modal | `/exact/team.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 消息中心 | `电商AI平台/messages.html` | 消息详情、日志展开、通知设置跳转 | `/exact/messages.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 图片工具入口 | `电商AI平台/asset-factory.html` | 三类工具入口、任务列表 | `/exact/asset-factory.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 图片创作 | `电商AI平台/image-optimize.html` | 参数面板、重复任务 modal、返回 | `/exact/image-optimize.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 模特上身图 | `电商AI平台/model-photo.html` | 模特选择、方案入口、生成参数 | `/exact/model-photo.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 模特方案 A | `电商AI平台/model-photo-demo-a.html` | 方案预览/采用 | `/exact/model-photo-demo-a.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 模特方案 B | `电商AI平台/model-photo-demo-b.html` | 方案预览/采用 | `/exact/model-photo-demo-b.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 平台套图 | `电商AI平台/platform-cover.html` | 平台规格、批量生成 | `/exact/platform-cover.html` | `1440x900 diffPixels=0` |
|
||||
| P1 | 设置 | `电商AI平台/settings.html` | 资料、安全、通知策略 `#sec-notify` | `/exact/settings.html#sec-profile/security/notify/pref/display` | `5/5 sections diffPixels=0` |
|
||||
|
||||
`电商AI平台/design-system.html` 作为 token/component 参考,不作为业务页面验收目标。`电商AI平台/_archive` 下旧稿默认不纳入复刻,除非后续明确要求恢复旧版本。
|
||||
@@ -0,0 +1,84 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../../..");
|
||||
const designRoot = "file:///Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/电商AI平台";
|
||||
const targetRoot = "http://127.0.0.1:5173/exact";
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(here, "pages.json"), "utf8"));
|
||||
|
||||
function arg(name, fallback = "") {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function asUrl(root, value) {
|
||||
if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("file://")) return value;
|
||||
return `${root}/${value}`;
|
||||
}
|
||||
|
||||
const viewport = arg("viewport", "1440x900");
|
||||
const only = arg("only", "");
|
||||
const pages = only ? manifest.filter((item) => item.name.includes(only)) : manifest;
|
||||
const results = [];
|
||||
|
||||
for (const item of pages) {
|
||||
const source = asUrl(designRoot, item.source);
|
||||
const target = item.target || asUrl(targetRoot, item.targetPath || item.source);
|
||||
const args = [
|
||||
path.join(here, "compare-page.mjs"),
|
||||
"--name", item.name,
|
||||
"--source", source,
|
||||
"--target", target,
|
||||
"--viewport", viewport,
|
||||
"--clear-target-storage"
|
||||
];
|
||||
console.log(`\n[visual-parity] ${item.name}`);
|
||||
const run = spawnSync(process.execPath, args, {
|
||||
cwd: here,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
if (run.stdout) process.stdout.write(run.stdout);
|
||||
if (run.stderr) process.stderr.write(run.stderr);
|
||||
const reportFile = path.join(repoRoot, "core/qa/visual-parity/output", `${item.name}.report.json`);
|
||||
let report = null;
|
||||
if (fs.existsSync(reportFile)) {
|
||||
report = JSON.parse(fs.readFileSync(reportFile, "utf8"));
|
||||
}
|
||||
results.push({
|
||||
name: item.name,
|
||||
ok: run.status === 0 && report?.pass === true,
|
||||
status: run.status,
|
||||
diffPixels: report?.diffPixels ?? null,
|
||||
diffRatio: report?.diffRatio ?? null,
|
||||
note: item.note || ""
|
||||
});
|
||||
}
|
||||
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
const summary = {
|
||||
viewport,
|
||||
total: results.length,
|
||||
passed: results.length - failed.length,
|
||||
failed: failed.length,
|
||||
results
|
||||
};
|
||||
|
||||
const summaryPath = path.join(repoRoot, "core/qa/visual-parity/output/all.report.json");
|
||||
fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
|
||||
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
|
||||
|
||||
console.log("\n[visual-parity] summary");
|
||||
console.table(results.map((item) => ({
|
||||
page: item.name,
|
||||
ok: item.ok,
|
||||
diffPixels: item.diffPixels,
|
||||
diffRatio: item.diffRatio
|
||||
})));
|
||||
|
||||
if (failed.length) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
import pixelmatch from "pixelmatch";
|
||||
import { PNG } from "pngjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
function arg(name, fallback = "") {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function boolArg(name) {
|
||||
return process.argv.includes(`--${name}`);
|
||||
}
|
||||
|
||||
function parseViewport(value) {
|
||||
const [width, height] = value.split("x").map((item) => Number.parseInt(item, 10));
|
||||
if (!width || !height) {
|
||||
throw new Error(`Invalid --viewport "${value}". Expected WIDTHxHEIGHT, for example 1440x900.`);
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function ensureUrl(value) {
|
||||
if (!value) throw new Error("Missing required URL argument.");
|
||||
if (value.startsWith("file://") || value.startsWith("http://") || value.startsWith("https://")) return value;
|
||||
return `file://${path.resolve(repoRoot, value)}`;
|
||||
}
|
||||
|
||||
function readPng(file) {
|
||||
return PNG.sync.read(fs.readFileSync(file));
|
||||
}
|
||||
|
||||
async function preparePage(page, url, shouldClearStorage) {
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
if (shouldClearStorage) {
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
if ("fonts" in document) await document.fonts.ready;
|
||||
});
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
const source = ensureUrl(arg("source"));
|
||||
const target = ensureUrl(arg("target"));
|
||||
const name = arg("name", "page");
|
||||
const viewport = parseViewport(arg("viewport", "1440x900"));
|
||||
const outDir = path.resolve(repoRoot, arg("out", "core/qa/visual-parity/output"));
|
||||
const threshold = Number.parseFloat(arg("threshold", "0.03"));
|
||||
const clearTargetStorage = boolArg("clear-target-storage");
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: "light",
|
||||
reducedMotion: "reduce"
|
||||
});
|
||||
|
||||
const sourcePage = await context.newPage();
|
||||
const targetPage = await context.newPage();
|
||||
|
||||
const sourcePath = path.join(outDir, `${name}.design.png`);
|
||||
const targetPath = path.join(outDir, `${name}.implementation.png`);
|
||||
const diffPath = path.join(outDir, `${name}.diff.png`);
|
||||
const reportPath = path.join(outDir, `${name}.report.json`);
|
||||
|
||||
await preparePage(sourcePage, source, false);
|
||||
await preparePage(targetPage, target, clearTargetStorage);
|
||||
|
||||
await sourcePage.screenshot({ path: sourcePath, fullPage: false });
|
||||
await targetPage.screenshot({ path: targetPath, fullPage: false });
|
||||
await browser.close();
|
||||
|
||||
const sourcePng = readPng(sourcePath);
|
||||
const targetPng = readPng(targetPath);
|
||||
|
||||
if (sourcePng.width !== targetPng.width || sourcePng.height !== targetPng.height) {
|
||||
throw new Error(`Screenshot size mismatch: design ${sourcePng.width}x${sourcePng.height}, implementation ${targetPng.width}x${targetPng.height}`);
|
||||
}
|
||||
|
||||
const diff = new PNG({ width: sourcePng.width, height: sourcePng.height });
|
||||
const diffPixels = pixelmatch(sourcePng.data, targetPng.data, diff.data, sourcePng.width, sourcePng.height, {
|
||||
threshold,
|
||||
includeAA: false
|
||||
});
|
||||
fs.writeFileSync(diffPath, PNG.sync.write(diff));
|
||||
|
||||
const totalPixels = sourcePng.width * sourcePng.height;
|
||||
const report = {
|
||||
name,
|
||||
source,
|
||||
target,
|
||||
viewport,
|
||||
threshold,
|
||||
diffPixels,
|
||||
totalPixels,
|
||||
diffRatio: Number((diffPixels / totalPixels).toFixed(6)),
|
||||
pass: diffPixels === 0,
|
||||
artifacts: {
|
||||
design: path.relative(repoRoot, sourcePath),
|
||||
implementation: path.relative(repoRoot, targetPath),
|
||||
diff: path.relative(repoRoot, diffPath)
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "airshelf-visual-parity",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "airshelf-visual-parity",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.57.0",
|
||||
"pngjs": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pixelmatch": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
|
||||
"integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pixelmatch": "bin/pixelmatch"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "airshelf-visual-parity",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"compare:login": "node compare-page.mjs --name login --source \"file:///Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/电商AI平台/login.html\" --target \"http://127.0.0.1:5173/\" --viewport 1440x900 --clear-target-storage",
|
||||
"compare:register": "node compare-page.mjs --name register --source \"file:///Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/电商AI平台/register.html\" --target \"http://127.0.0.1:5173/#register\" --viewport 1440x900 --clear-target-storage",
|
||||
"compare:dashboard": "node compare-page.mjs --name dashboard --source \"file:///Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/电商AI平台/index.html\" --target \"http://127.0.0.1:5173/?visual=dashboard\" --viewport 1440x900 --clear-target-storage",
|
||||
"compare:all": "node compare-all.mjs --viewport 1440x900"
|
||||
},
|
||||
"dependencies": {
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.57.0",
|
||||
"pngjs": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
[
|
||||
{
|
||||
"name": "login",
|
||||
"source": "login.html",
|
||||
"target": "http://127.0.0.1:5173/",
|
||||
"note": "React exact login"
|
||||
},
|
||||
{
|
||||
"name": "register",
|
||||
"source": "register.html",
|
||||
"target": "http://127.0.0.1:5173/#register",
|
||||
"note": "React exact register"
|
||||
},
|
||||
{
|
||||
"name": "dashboard",
|
||||
"source": "index.html",
|
||||
"target": "http://127.0.0.1:5173/?visual=dashboard",
|
||||
"note": "React exact dashboard shell"
|
||||
},
|
||||
{ "name": "products", "source": "products.html", "targetPath": "products.html" },
|
||||
{ "name": "products-create-drawer", "source": "product-create-upload.html", "targetPath": "product-create-upload.html" },
|
||||
{ "name": "product-detail", "source": "product-detail.html", "targetPath": "product-detail.html" },
|
||||
{ "name": "projects", "source": "projects.html", "targetPath": "projects.html" },
|
||||
{ "name": "projects-new", "source": "projects-new.html", "targetPath": "projects-new.html" },
|
||||
{ "name": "pipeline-stage-1", "source": "pipeline.html#stage-1", "targetPath": "pipeline.html#stage-1" },
|
||||
{ "name": "pipeline-stage-2", "source": "pipeline.html#stage-2", "targetPath": "pipeline.html#stage-2" },
|
||||
{ "name": "pipeline-stage-3", "source": "pipeline.html#stage-3", "targetPath": "pipeline.html#stage-3" },
|
||||
{ "name": "pipeline-stage-4", "source": "pipeline.html#stage-4", "targetPath": "pipeline.html#stage-4" },
|
||||
{ "name": "pipeline-stage-5", "source": "pipeline.html#stage-5", "targetPath": "pipeline.html#stage-5" },
|
||||
{ "name": "library", "source": "library.html", "targetPath": "library.html" },
|
||||
{ "name": "account", "source": "account.html", "targetPath": "account.html" },
|
||||
{ "name": "team", "source": "team.html", "targetPath": "team.html" },
|
||||
{ "name": "messages", "source": "messages.html", "targetPath": "messages.html" },
|
||||
{ "name": "asset-factory", "source": "asset-factory.html", "targetPath": "asset-factory.html" },
|
||||
{ "name": "image-optimize", "source": "image-optimize.html", "targetPath": "image-optimize.html" },
|
||||
{ "name": "model-photo", "source": "model-photo.html", "targetPath": "model-photo.html" },
|
||||
{ "name": "model-photo-demo-a", "source": "model-photo-demo-a.html", "targetPath": "model-photo-demo-a.html" },
|
||||
{ "name": "model-photo-demo-b", "source": "model-photo-demo-b.html", "targetPath": "model-photo-demo-b.html" },
|
||||
{ "name": "platform-cover", "source": "platform-cover.html", "targetPath": "platform-cover.html" },
|
||||
{ "name": "settings-profile", "source": "settings.html#sec-profile", "targetPath": "settings.html#sec-profile" },
|
||||
{ "name": "settings-security", "source": "settings.html#sec-security", "targetPath": "settings.html#sec-security" },
|
||||
{ "name": "settings-notify", "source": "settings.html#sec-notify", "targetPath": "settings.html#sec-notify" },
|
||||
{ "name": "settings-pref", "source": "settings.html#sec-pref", "targetPath": "settings.html#sec-pref" },
|
||||
{ "name": "settings-display", "source": "settings.html#sec-display", "targetPath": "settings.html#sec-display" }
|
||||
]
|
||||
Reference in New Issue
Block a user