72 lines
5.6 KiB
JavaScript
72 lines
5.6 KiB
JavaScript
// Isolated local visual harness: compiles the real AppIcon/BottomNav components.
|
|
// No login, production API, remote assets, or user data is used.
|
|
import { createServer } from 'node:http';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { parse, compileScript } from 'vue/compiler-sfc';
|
|
import ts from 'typescript';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const root = new URL('../', import.meta.url);
|
|
const read = (path) => readFileSync(new URL(path, root), 'utf8');
|
|
const transpile = (content) => ts.transpileModule(content, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020 },
|
|
}).outputText;
|
|
const descriptors = new Map(['AppIcon', 'BottomNav'].map((name) => [
|
|
name, parse(read(`src/components/${name}.vue`), { filename: `${name}.vue` }).descriptor,
|
|
]));
|
|
const componentModule = (name) => transpile(compileScript(descriptors.get(name), {
|
|
id: `preview-${name}`, inlineTemplate: true,
|
|
templateOptions: { compilerOptions: { isCustomElement: (tag) => ['view', 'text'].includes(tag) } },
|
|
}).content).replace(/from ['"]vue['"]/g, 'from "/vue.js"')
|
|
.replace(/from ['"]\.\.\/utils\/icon-mask['"]/g, 'from "/icon-mask.js"')
|
|
.replace(/from ['"]\.\/AppIcon.vue['"]/g, 'from "/AppIcon.js"')
|
|
.replace(/from ['"]\.\.\/utils\/app-state['"]/g, 'from "/state.js"');
|
|
const css = [...descriptors.values()].flatMap((item) => item.styles.map((style) => style.content)).join('\n')
|
|
.replace(/(-?\d+(?:\.\d+)?)rpx/g, 'calc(var(--preview-rpx) * $1)');
|
|
// Include the actual App-vue button defaults: auto margins/overflow:hidden are
|
|
// essential to reproduce badge clipping (plain HTML buttons hide this bug).
|
|
const nativeCSS = read('node_modules/@dcloudio/uni-app-plus/dist/style.css');
|
|
const nativeButtonRule = [...nativeCSS.matchAll(/uni-button\{([^{}]+)\}/g)]
|
|
.find((match) => match[1].includes('margin-left:auto'))?.[1];
|
|
if (!nativeButtonRule) throw new Error('Native button defaults not found');
|
|
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>App 图标回归预览</title>
|
|
<style>button{${nativeButtonRule}}:root{--preview-rpx:calc(min(100vw,560px) / 750)}*{box-sizing:border-box}body{margin:0;padding:20px 18px 140px;max-width:560px;background:#141147;color:#fff;font:15px system-ui}view{display:block}button{border:0;font:inherit}button::after{border:0}h1{font-size:22px}p{color:#bcb5e1}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.sample{display:flex;flex-direction:column;align-items:center;gap:9px;padding:14px 2px;border-radius:12px;background:#ffffff0d}.sample label{font-size:11px}.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:16px 0}.controls button{padding:8px;background:#8660dd;color:white;border-radius:8px}.palette{font-size:24px;color:#ff83bb}.light{background:#fff;color:#17152d;padding:15px;border-radius:12px;display:flex;gap:20px;margin:16px 0}#preview-action{font-size:12px;color:#c4b1ff}${css}</style>
|
|
<div id="app"></div><script type="module">
|
|
import {createApp,h,ref} from '/vue.js';
|
|
import {ICON_PATHS} from '/icon-mask.js';
|
|
import {clientState} from '/state.js';
|
|
window.uni={navigateTo:({url})=>action.value=url,reLaunch:({url})=>action.value=url};
|
|
window.addEventListener('click',(event)=>{const button=event.target.closest('.nav-item');if(button)button.dispatchEvent(new Event('tap'));});
|
|
const action=ref('仅图标预览,不连接任何业务接口');
|
|
const {default:AppIcon}=await import('/AppIcon.js');
|
|
const {default:BottomNav}=await import('/BottomNav.js');
|
|
createApp({setup(){const filled=ref(false),pink=ref(false);return()=>h('main',{},[
|
|
h('h1','App 图标回归预览'),h('p','实际 AppIcon / BottomNav 组件 · 图标内置,无外部图片请求'),
|
|
h('div',{class:'controls'},[h('button',{onClick:()=>filled.value=!filled.value},filled.value?'切换空心':'切换实心'),h('button',{onClick:()=>pink.value=!pink.value},'切换颜色')]),
|
|
h('div',{class:'controls'},[0,1,9,99,100].map(count=>h('button',{onClick:()=>clientState.unreadMessages=count},'未读 '+count))),
|
|
h('div',{class:'grid',style:{color:pink.value?'#ff83bb':'#c4b1ff'}},Object.keys(ICON_PATHS).map(name=>h('div',{class:'sample'},[h(AppIcon,{name,size:28,filled:filled.value}),h('label',name)]))),
|
|
h('div',{class:'light'},['back','pin','filter','search','heart'].map(name=>h(AppIcon,{name,size:24,filled:name==='heart'}))),
|
|
h('div',{class:'palette'},[h(AppIcon,{name:'heart',filled:true}),h(AppIcon,{name:'add',size:'2em'})]),
|
|
h('p',{id:'preview-action'},action.value),h(BottomNav,{active:'home'})
|
|
])}}).mount('#app');
|
|
</script></html>`;
|
|
|
|
const server = createServer((req, res) => {
|
|
const path = new URL(req.url, 'http://localhost').pathname;
|
|
const modules = {
|
|
'/vue.js': () => readFileSync(require.resolve('vue/dist/vue.runtime.esm-browser.js'), 'utf8'),
|
|
'/icon-mask.js': () => transpile(read('src/utils/icon-mask.ts')),
|
|
'/AppIcon.js': () => componentModule('AppIcon'),
|
|
'/BottomNav.js': () => componentModule('BottomNav'),
|
|
'/state.js': () => 'import {reactive} from "/vue.js";export const clientState = reactive({ unreadMessages: 2 });',
|
|
};
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
if (path === '/') { res.setHeader('Content-Type', 'text/html;charset=utf-8'); res.end(html); }
|
|
else if (modules[path]) { res.setHeader('Content-Type', 'application/javascript;charset=utf-8'); res.end(modules[path]()); }
|
|
else { res.statusCode = 404; res.end('Not found'); }
|
|
});
|
|
server.listen(Number(process.env.ICON_PREVIEW_PORT || 5197), '127.0.0.1', () => {
|
|
console.log(`Icon preview: http://127.0.0.1:${server.address().port}/`);
|
|
});
|