Files
aurora-admin/site/playground.js
T
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

313 lines
13 KiB
JavaScript
Raw 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.
/* Kole UI · 在线测试(独立页)
编辑 H5 演示源码 → iframe srcdoc 实时重渲染;数据与文档站同源(同目录 data.json)。
零运行时依赖。
── 预览帧的安全边界(2026-09-20 真实浏览器实测) ──────────────────────────
预览里跑的是用户随手改的代码,按「不可信内容」对待,三层隔离:
1) sandbox="allow-scripts" 且**不给** allow-same-origin —— 帧是不透明源。帧内读
parent/top、document.cookie、localStorage 全是 SecurityError;顶层跳转、
window.open、表单提交、下载全被拦;探针服务器上收不到任何帧发起的请求。
2) 帧内注入 meta CSP,**只写比父页更严的项**(connect-src / form-action / frame-src
一律 'none'):帧内彻底发不出网络请求(fetch / sendBeacon / WebSocket / 表单)。
两条策略取交集,所以这里写的每一项都只会收紧 —— 父页 CSP 继续负责"允许什么",
本页不重复声明,避免把 'self' 在不透明源下的解析差异带进来。
3) 帧内注入 <base href="../frameworks/">:srcdoc 的基准 URL 默认是父页 /site/,
演示源码里的 "./Xxx.css" 会被解析成 /site/Xxx.css(404)→ 预览丢掉组件样式
(实测 79 个组件里 36 个命中)。钉到演示原页所在目录后,相对资源解析与
「演示原页」完全一致。
帧内的脚本报错与被沙箱拦下的请求经 postMessage 回传本页,在预览上方按纯文本提示:
预览不再静默白屏、也不静默丢样式。父页只认「来源严格等于本页预览帧」的消息,
且只取字符串渲染,预览里的代码无法借此碰到本页 DOM。
───────────────────────────────────────────────────────────────────────── */
'use strict';
(function () {
var editor = document.getElementById('pg-editor');
var frame = document.getElementById('pg-frame');
var select = document.getElementById('pg-select');
var nameEl = document.getElementById('pg-name');
var statusEl = document.getElementById('pg-status');
var errorEl = document.getElementById('pg-error');
var noteEl = document.getElementById('pg-note');
var resetBtn = document.getElementById('pg-reset');
var copyBtn = document.getElementById('pg-copy');
var reloadBtn = document.getElementById('pg-reload');
var openLink = document.getElementById('pg-open');
var backLink = document.getElementById('pg-back');
var RENDER_DELAY = 300;
var DEFAULT_SLUG = 'button';
/* 演示原页住在 /frameworks/:srcdoc 里必须以它为基准,否则 ./ 相对引用全错 */
var FRAME_BASE = '../frameworks/';
/* 只列比父页更严的三条(父页是 connect-src/form-action/frame-src 'self') */
var PREVIEW_CSP = "connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'";
/* 帧内上报器:跑在用户代码之前,捕获脚本报错与策略拦截 */
var REPORTER = '<script>(function(){var n=0;function r(k,t){if(n++>2)return;try{' +
'parent.postMessage({kole:"pg-report",k:k,t:String(t==null?"":t).slice(0,200)},"*")}catch(e){}}' +
'window.addEventListener("error",function(e){r("err",e.message||"脚本错误")});' +
'window.addEventListener("unhandledrejection",function(e){var x=e.reason;r("err",(x&&x.message)||x)});' +
'document.addEventListener("securitypolicyviolation",function(e){' +
'r("csp",(e.violatedDirective||"")+" "+String(e.blockedURI||""))})})();<\/script>';
/* 父页侧上限:坏代码刷屏不能拖累本页 */
var MAX_REPORTS = 3;
var comps = [];
var bySlug = {};
var current = null;
var pristine = '';
var timer = null;
var reportCount = 0;
var pendingLoads = 0;
var rendered = false;
/* 每次 load 领一个号:回落成 fetch 时是异步的,两个请求乱序返回会把 A 的源码落到 B 头上 */
var loadSeq = 0;
function showError(msg) {
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
}
function clearError() {
errorEl.textContent = '';
errorEl.classList.add('hidden');
}
function showNote(msg) {
noteEl.textContent = msg;
noteEl.classList.remove('hidden');
}
function clearNote() {
noteEl.textContent = '';
noteEl.classList.add('hidden');
}
function stamp() {
var d = new Date();
function p(n) { return (n < 10 ? '0' : '') + n; }
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
function isDirty() { return editor.value !== pristine; }
function setStatus(text) { statusEl.textContent = text; }
/* 与文档站共用 localStorage['kole-mode'](light | dark | auto),默认 auto 跟随系统 */
function readMode() {
try {
var v = localStorage.getItem('kole-mode');
return v === 'light' || v === 'dark' ? v : 'auto';
} catch (e) { return 'auto'; }
}
function isDarkNow() {
var mode = readMode();
if (mode === 'dark') return true;
if (mode === 'auto') {
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
return false;
}
function applyMode() {
document.documentElement.classList.toggle('kole-dark', isDarkNow());
}
/* DOCTYPE 留在最前(否则掉进怪异模式),注入内容紧随其后;
没有 DOCTYPE 时直接前置 —— 解析器会把 base/meta/script 收进隐含的 <head>。
注入的 <base> 是文档里第一个 base,用户代码里再写 base 也覆盖不了它。 */
function withInjection(html) {
var inject = '<base href="' + FRAME_BASE + '">' +
'<meta http-equiv="Content-Security-Policy" content="' + PREVIEW_CSP + '">' +
(isDarkNow() ? '<script>document.documentElement.className+=" kole-dark";<\/script>' : '') +
REPORTER;
var m = /^\s*<!doctype[^>]*>/i.exec(html);
if (m) return html.slice(0, m[0].length) + inject + html.slice(m[0].length);
return inject + html;
}
/* 预览帧里点链接 / <meta refresh> 会让帧自己跳走 —— 跳走即预览失效,而父页读不到
它去了哪(不透明源)。父页唯一能感知的是 load:凡是"没打算让它加载"的那次,就地重建。 */
frame.addEventListener('load', function () {
if (!rendered) return;
if (pendingLoads > 0) { pendingLoads--; return; }
render('已重置预览内的跳转');
});
window.addEventListener('message', function (e) {
/* 来源严格等于本页预览帧:帧的 window 引用无法被预览内代码伪造 */
if (e.source !== frame.contentWindow) return;
var d = e.data;
if (!d || d.kole !== 'pg-report' || typeof d.t !== 'string') return;
if (reportCount >= MAX_REPORTS) return;
reportCount++;
showNote((d.k === 'csp' ? '预览内请求被沙箱拦截:' : '预览内脚本报错:') + d.t);
});
function render(reason) {
clearTimeout(timer);
reportCount = 0;
clearNote();
pendingLoads++;
rendered = true;
frame.srcdoc = withInjection(editor.value);
setStatus((isDirty() ? '已同步(已修改)' : '已同步') + ' ' + stamp() + (reason ? ' · ' + reason : ''));
}
function scheduleRender() {
clearTimeout(timer);
setStatus('编辑中…');
timer = setTimeout(function () { render(); }, RENDER_DELAY);
}
/* data.json 里的 sources.html 多数是内联的;缺失时回落到 frameworks/ 下的演示原文件 */
function sourceOf(c, done) {
if (c.sources && typeof c.sources.html === 'string' && c.sources.html) { done(c.sources.html, null); return; }
fetch(c.files.html, { cache: 'no-store' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function (t) { done(t, null); })
.catch(function (err) { done('', err); });
}
/* 切换组件只改"当前组件"的那几处显示,编辑器内容由 load 的回调决定要不要动 */
function applyComponent(c) {
current = c;
select.value = c.slug;
nameEl.textContent = c.name;
document.title = c.name + ' · 在线测试 · Kole UI';
openLink.href = c.files.html;
/* 回站内走 hash 形态是有意的:本页不引 app.js,拿不到 go() 的降级逻辑,
file:// 下 pushState 不可用、相对路径 component/<slug> 还会直接 404 ——
路由侧 normalizeLegacyHash 会把这条就地改写成无 # 的地址。 */
backLink.href = 'index.html#/component/' + c.slug;
}
function load(slug, push) {
var c = bySlug[slug] || bySlug[DEFAULT_SLUG] || comps[0];
if (!c) return;
var prev = current;
var seq = ++loadSeq;
/* 防抖里挂着的那个定时器属于上一个组件,别让它落到新组件身上 */
clearTimeout(timer);
clearError();
clearNote();
setStatus('加载源码…');
applyComponent(c);
if (push !== false) {
try { history.replaceState(null, '', '?slug=' + encodeURIComponent(c.slug)); } catch (e) { /* file:// 下忽略 */ }
}
sourceOf(c, function (text, err) {
if (seq !== loadSeq) return; /* 已被更晚的一次切换取代,丢弃这次结果 */
if (err || !text) {
/* 失败一律不改状态:否则用户刚改了一半的代码会被清空 */
if (prev) applyComponent(prev);
else { nameEl.textContent = '加载失败'; select.value = ''; }
setStatus('加载失败');
showError('读取 H5 源码失败:' + (err && err.message ? err.message : '未知错误') +
'。请先跑 build-site.ps1,并通过 dev-server 访问本页。');
return;
}
pristine = text;
editor.value = text;
render();
});
}
function copyText(text, btn, okMsg) {
function done() {
var old = btn.textContent;
btn.textContent = okMsg;
setTimeout(function () { btn.textContent = old; }, 1200);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, function () { done(); });
return;
}
editor.select();
try { document.execCommand('copy'); } catch (e) { /* 忽略:无剪贴板权限 */ }
done();
}
/* 编辑器里 Tab 缩进两格;Ctrl/Cmd+S 立即同步(浏览器保存对话框在本页没有意义) */
editor.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = editor.selectionStart;
var end = editor.selectionEnd;
editor.value = editor.value.slice(0, start) + ' ' + editor.value.slice(end);
editor.selectionStart = editor.selectionEnd = start + 2;
scheduleRender();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
render();
}
});
editor.addEventListener('input', scheduleRender);
resetBtn.addEventListener('click', function () {
if (!pristine) return;
editor.value = pristine;
render('已复位');
});
copyBtn.addEventListener('click', function () { copyText(editor.value, copyBtn, '已复制 ✓'); });
reloadBtn.addEventListener('click', function () { render('手动重载'); });
/* 改过的代码只在本页内存里,切组件会连编辑器一起换掉 —— 先问一句,别静默丢 */
select.addEventListener('change', function () {
if (isDirty() && !window.confirm('当前组件未复位的修改会随切换丢失,确定切换?')) {
select.value = current ? current.slug : '';
return;
}
load(select.value);
});
/* 刷新 / 关页同样会丢:只在"改过还没复位"时拦一下 */
window.addEventListener('beforeunload', function (e) {
if (!isDirty()) return;
e.preventDefault();
e.returnValue = '';
});
applyMode();
if (window.matchMedia) {
var media = window.matchMedia('(prefers-color-scheme: dark)');
var onPref = function () {
applyMode();
if (readMode() === 'auto') render('已跟随系统主题');
};
if (media.addEventListener) media.addEventListener('change', onPref);
else if (media.addListener) media.addListener(onPref);
}
/* 文档站切主题(kole-mode)时本页的预览帧跟着反色 */
window.addEventListener('storage', function (e) {
if (e.key !== 'kole-mode') return;
applyMode();
render('已跟随站点主题');
});
fetch('data.json', { cache: 'no-store' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (data) {
comps = (data && data.components) || [];
if (!comps.length) throw new Error('empty');
bySlug = {};
var opts = document.createDocumentFragment();
comps.forEach(function (c) {
bySlug[c.slug] = c;
var o = document.createElement('option');
o.value = c.slug;
o.textContent = c.name;
opts.appendChild(o);
});
select.appendChild(opts);
var want = '';
try { want = new URLSearchParams(location.search).get('slug') || ''; } catch (e) { /* 忽略 */ }
load(want || DEFAULT_SLUG, false);
})
.catch(function (err) {
nameEl.textContent = '加载失败';
setStatus('数据未就绪');
showError('读取 data.json 失败(' + (err && err.message ? err.message : '未知错误') +
')。请先跑 build-site.ps1,并通过 dev-server 访问本页。');
});
})();