Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 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 未做部分)
313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
/* 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 访问本页。');
|
||
});
|
||
})();
|