Files
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

218 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Minimal static dev server for the component showcase site.
Usage (from repo root): node site/dev-server.js
Then open: http://127.0.0.1:3311/site/
Serves the repo root so /site, /frameworks and /.design_library are all reachable. */
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
/* 默认端口 3311;KOLE_PORT 仅供验证脚本起独立实例(避免与正在运行的 3311 抢端口)。
非法值(非整数 / 越界 / 空串)一律回落到 3311,保持既有行为。 */
const PORT = (() => {
const raw = process.env.KOLE_PORT;
if (raw === undefined || raw === '') return 3311;
const n = Number(raw);
return Number.isInteger(n) && n > 0 && n < 65536 ? n : 3311;
})();
const HOST = '127.0.0.1';
const ROOT = path.resolve(process.cwd());
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.vue': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon'
};
const SECURITY_HEADERS = {
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'self'",
"form-action 'self'"
].join('; '),
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
};
const SENSITIVE_SEGMENT = /^(?:\.git|\.env(?:\..*)?|backup(?:s)?|log(?:s)?)$/i;
const SENSITIVE_FILE = /\.(?:bak|backup|log)$/i;
function isSensitivePath(relativePath) {
if (!relativePath || relativePath === '..' || path.isAbsolute(relativePath)) return true;
const segments = relativePath.split(path.sep);
return segments.some((segment) => SENSITIVE_SEGMENT.test(segment) || SENSITIVE_FILE.test(segment));
}
const PUBLIC_ROOTS = new Set([
'site',
'frameworks',
/* 移动端平台实现目录 + PC×uni-app 端实现目录(与 PC 侧 frameworks/ 物理隔离,见 PLATFORMS.md) */
'frameworks-mobile',
'frameworks-uniapp-pc',
'.design_library',
'tests',
'index.html',
'sitemap.xml',
/* 版本清单:站点根在部署前缀下时 URL 是 <prefix>/versions.json,仓库里对应根目录这一份 */
'versions.json',
]);
const PUBLIC_SPEC_FILES = new Set(Array.from({ length: 10 }, (_, i) => `组件${i + 1}.txt`));
/* 历史版本文档站快照(tools/snapshot-site.mjs 产出):仓库根的 <x.y.z>/ 目录,
内部结构是 site/ + frameworks/ + .design_library/,URL 形如 /1.4.1/site/… */
const VERSION_DIR = /^\d+\.\d+\.\d+$/;
function isPublicPath(relativePath) {
if (!relativePath || relativePath === '.' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return false;
const segments = relativePath.split(path.sep);
const top = segments[0];
if (top === 'index.html' || top === 'sitemap.xml' || top === 'versions.json') return segments.length === 1;
if (top === 'site' || top === 'frameworks' || top === 'tests') return true;
if (top === 'frameworks-mobile' || top === 'frameworks-uniapp-pc') return true;
if (top === '.design_library') return true;
if (VERSION_DIR.test(top)) return true;
// 兼容旧版文档站:仅允许仓库根的 10 份规范原文,其余根文件一律拒绝
return segments.length === 1 && PUBLIC_SPEC_FILES.has(top);
}
function writeResponse(res, statusCode, headers, body) {
res.writeHead(statusCode, { ...SECURITY_HEADERS, ...headers });
res.end(body);
}
const server = http.createServer((req, res) => {
const rawPath = req.url.split('?')[0];
// 原始路径含编码形态的 「.」「/」或 NUL 一律拒绝:
// - %2e/%2f(含大小写变体与双重编码)解码后会把 /%2e%2e/x 归一化成合法根路径
// - %00 / %2500 解码后是 NUL,会让 fs.readFile 同步抛 TypeError 打挂进程
if (/%(?:2e|2f|252e|252f|00|2500)/i.test(rawPath)) {
writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden');
return;
}
let urlPath;
try {
urlPath = decodeURIComponent(rawPath).replace(/\\/g, '/');
} catch (e) {
writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request');
return;
}
// 解码后再校验一次 NUL(双保险):无论编码形态如何(含裸 NUL、平台解码差异),
// 都不得把 NUL 带进后面的文件路径
if (urlPath.indexOf('\u0000') !== -1) {
writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request');
return;
}
if (urlPath === '/' || urlPath === '') {
writeResponse(res, 302, { Location: '/site/' });
return;
}
// 版本清单只留部署根一份(仓库根 versions.json):快照自带的那份是归档当时的快照,
// 之后新归档的版本不会进去,会让快照页误判成单版本(实测:切到旧版后回不到最新版)。
// 因此 /<x.y.z>/versions.json 也指向根那份,而不是回落到该版壳。
if (urlPath === '/versions.json' || /^\/\d+\.\d+\.\d+\/versions\.json$/.test(urlPath)) {
urlPath = '/versions.json';
} else if (urlPath.endsWith('/')) {
urlPath += 'index.html';
}
// 根路径与常见入口自动跳转到站点,避免裸访问 404
if (urlPath === '/index.html') {
writeResponse(res, 302, { Location: '/site/' });
return;
}
// 历史版本快照的裸入口:/<x.y.z>/ 与 /<x.y.z>/index.html 都送到该版的站点壳
const verRoot = urlPath.match(/^\/(\d+\.\d+\.\d+)\/(?:index\.html)?$/);
if (verRoot) {
writeResponse(res, 302, { Location: `/${verRoot[1]}/site/` });
return;
}
const filePath = path.resolve(ROOT, `.${urlPath}`);
const relativePath = path.relative(ROOT, filePath);
// 开发服务只开放站点运行所需的顶层目录;仓库元数据、配置、脚本一律拒绝
if (!isPublicPath(relativePath) || isSensitivePath(relativePath)) {
writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden');
return;
}
// 文档站用 History API 路由(URL 里没有 #):/site/component/button/h5 这类深层路径
// 磁盘上没有对应文件,回落到 SPA 壳 site/index.html,由 app.js 按 pathname 渲染。
// 与 nginx.conf 的 location /site/ 行为一致;只对无扩展名的路径生效 —— 带扩展名的
// 缺失资源按真 404,避免把一份 HTML 当成 JS/CSS 发出去。
// 历史版本快照(/<x.y.z>/site/…)走同一条规则,回落到它自己那份壳。
const spaMatch = urlPath.match(/^((?:\/\d+\.\d+\.\d+)?\/site\/)/);
const isSpaRoute = !!spaMatch
&& !/\.[a-z0-9]+$/i.test(urlPath)
&& !fs.existsSync(filePath);
const servePath = isSpaRoute ? path.join(ROOT, spaMatch[1], 'index.html') : filePath;
// fs.readFile 会对含 NUL 的路径**同步**抛 ERR_INVALID_ARG_VALUE;异常若逃逸出请求
// 回调会打挂进程(单个畸形请求即可 DoS)。这里兜住同步抛错,转成 400 响应。
try {
fs.readFile(servePath, (err, buf) => {
if (err) {
writeResponse(res, 404, { 'Content-Type': 'text/plain; charset=utf-8' }, '404 Not Found: ' + urlPath);
return;
}
writeResponse(res, 200, {
'Content-Type': TYPES[path.extname(servePath).toLowerCase()] || 'application/octet-stream',
/* 版本清单每次构建都会重写,缓存它会让菜单停在旧版本列表上 */
'Cache-Control': path.basename(servePath) === 'versions.json' ? 'no-store' : 'no-cache'
}, buf);
});
} catch (e) {
writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request');
}
});
/* 启动:默认 3311;被占用时自动向上找下一个空闲端口(最多 10 档),
而不是直接 EADDRINUSE 退出 —— 实测踩过:上一个改动前启动的实例还占着 3311,
新实例静默没起来,浏览器里看到的是旧白名单(新目录 403),极易误判成"构建没产出"。
KOLE_PORT 显式指定时不回退(脚本/CI 依赖固定端口,拿不到就该报错退出)。 */
let listenAttempts = 0;
/* 只注册一次,端口取 server.address() —— 回退后闭包里的旧端口值是错的(实测先打印了 3311)。 */
server.on('listening', () => {
const base = `http://${HOST}:${server.address().port}`;
console.log(`Kole UI showcase serving at ${base}/site/`);
console.log('');
console.log(' 访问入口:');
console.log(` PC 文档站 ${base}/site/`);
console.log(` 移动端文档站 ${base}/site/m/`);
console.log(` PC 组件总览 ${base}/site/overview`);
console.log(` PC 测试总览 ${base}/tests/index.html`);
console.log(` 移动端测试总览 ${base}/tests/mobile/index.html`);
console.log(` 回归收集器(PC / 移动端) ${base}/tests/_collect.html · ${base}/tests/mobile/_collect.html`);
console.log('');
console.log(' 提示:需要固定端口时用 KOLE_PORT=13511 node site/dev-server.js;');
console.log(` 回归与门禁脚本用 REG_BASE=${base} 指向本实例。`);
});
function listen(port) {
server.removeAllListeners('error');
server.on('error', (e) => {
const explicit = process.env.KOLE_PORT !== undefined && process.env.KOLE_PORT !== '';
if (e.code === 'EADDRINUSE' && !explicit && listenAttempts < 10) {
listenAttempts++;
console.warn(`端口 ${port} 已被占用,改用 ${port + 1} 启动(KOLE_PORT 可固定端口)`);
listen(port + 1);
return;
}
console.error('Server error:', e.message);
process.exit(1);
});
server.listen(port, HOST);
}
listen(PORT);