<!DOCTYPE html>
|
<html lang="zh-TW">
|
<head>
|
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>DFM to Vue Preview</title>
|
|
<!-- 引入 Vue 3 -->
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
<!-- 引入 Tailwind CSS 用於即時預覽 -->
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- 使用 vue3-sfc-loader 來動態編譯 .vue 檔案 -->
|
<script src="https://cdn.jsdelivr.net/npm/vue3-sfc-loader@0.8.4/dist/vue3-sfc-loader.js"></script>
|
|
<!-- 加入 TypeScript 的 Babel transpiler 用於即時編譯 .ts -->
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
<style>
|
body, html {
|
margin: 0;
|
padding: 0;
|
height: 100%;
|
background-color: #f3f4f6; /* Tailwind gray-100 */
|
font-family: sans-serif;
|
}
|
|
#app {
|
display: flex;
|
flex-direction: column;
|
height: 100vh;
|
}
|
|
/* 上方清單區塊 (1.4.1.1) */
|
.list-section {
|
background-color: #ffffff;
|
border-bottom: 1px solid #d1d5db; /* gray-300 */
|
padding: 16px;
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
z-index: 10;
|
}
|
|
.component-btn {
|
background-color: #3b82f6; /* blue-500 */
|
color: white;
|
padding: 8px 16px;
|
border-radius: 4px;
|
border: none;
|
cursor: pointer;
|
font-size: 14px;
|
margin-right: 8px;
|
transition: background-color 0.2s;
|
}
|
|
.component-btn:hover {
|
background-color: #2563eb; /* blue-600 */
|
}
|
|
.component-btn.active {
|
background-color: #1e40af; /* blue-800 */
|
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.2);
|
}
|
|
/* 下方預覽區塊 (1.4.1.2) */
|
.preview-section {
|
flex: 1;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
padding: 24px;
|
overflow: auto;
|
background-color: #e5e7eb; /* Tailwind gray-200 */
|
}
|
|
/* 模擬 Windows 視窗外框 */
|
.preview-container {
|
background-color: white;
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
border: 1px solid #9ca3af;
|
border-radius: 4px;
|
overflow: hidden;
|
display: flex;
|
flex-direction: column;
|
/* 讓內部的子元件決定自己的寬高,但不能超過螢幕 */
|
max-width: 100%;
|
max-height: 100%;
|
}
|
|
.window-title {
|
background-color: #0058b0;
|
color: white;
|
padding: 6px 10px;
|
font-size: 12px;
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
user-select: none;
|
}
|
|
.window-controls span {
|
display: inline-block;
|
width: 16px;
|
height: 16px;
|
background-color: #ccc;
|
margin-left: 4px;
|
border-radius: 2px;
|
text-align: center;
|
line-height: 14px;
|
color: black;
|
cursor: pointer;
|
font-size: 10px;
|
font-weight: bold;
|
}
|
|
.window-controls span:hover {
|
background-color: #e81123;
|
color: white;
|
}
|
|
/* 載入中動畫 */
|
.loader {
|
border: 4px solid #f3f3f3;
|
border-top: 4px solid #3b82f6;
|
border-radius: 50%;
|
width: 30px;
|
height: 30px;
|
animation: spin 1s linear infinite;
|
}
|
|
@keyframes spin {
|
0% {
|
transform: rotate(0deg);
|
}
|
100% {
|
transform: rotate(360deg);
|
}
|
}
|
</style>
|
</head>
|
<body>
|
|
<div id="app">
|
<!-- 1.4.1.1: List button 區塊 -->
|
<div class="list-section">
|
<h2 class="text-lg font-bold text-gray-700 mb-3">Converted Vue Components</h2>
|
<div class="flex flex-wrap gap-2">
|
<button
|
v-for="comp in availableComponents"
|
:key="comp.id"
|
@click="loadComponent(comp)"
|
:class="['component-btn', { active: currentComponent && currentComponent.id === comp.id }]"
|
>
|
{{ comp.name }}
|
</button>
|
</div>
|
</div>
|
|
<!-- 1.4.1.2: Preview viewer 區塊 -->
|
<div class="preview-section relative">
|
<div v-if="isLoading"
|
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-200 bg-opacity-75 z-20">
|
<div class="loader mb-4"></div>
|
<div class="text-gray-600 font-medium">Loading component...</div>
|
</div>
|
|
<div v-else-if="!currentComponent" class="text-gray-500 text-lg">
|
Please select a component from the top menu to preview.
|
</div>
|
|
<div v-else class="preview-container">
|
<!-- 模擬 Windows 視窗標題列 -->
|
<div class="window-title">
|
<span>{{ currentComponent.windowTitle }}</span>
|
<div class="window-controls">
|
<span>_</span>
|
<span>□</span>
|
<span @click="closePreview">x</span>
|
</div>
|
</div>
|
|
<!-- 動態載入的 Vue 元件將會渲染在這裡 -->
|
<component :is="dynamicComponent"></component>
|
</div>
|
</div>
|
</div>
|
|
<script type="module">
|
const {loadModule} = window['vue3-sfc-loader'];
|
|
// 用來暫存修改過的檔案內容
|
const virtualFileSystem = {};
|
|
// 為了避免重複宣告 `ref`, `onMounted` 導致 SyntaxError
|
// 我們在全域環境中先宣告好 Vue 的依賴,只宣告一次
|
if (!window.__VUE_SETUP__) {
|
window.__VUE_SETUP__ = true;
|
const script = document.createElement('script');
|
script.textContent = `
|
// 全域提供 Vue API,避免在每個動態腳本中重複 let/const
|
window.ref = Vue.ref;
|
window.reactive = Vue.reactive;
|
window.computed = Vue.computed;
|
window.onMounted = Vue.onMounted;
|
window.onUnmounted = Vue.onUnmounted;
|
window.watch = Vue.watch;
|
window.defineComponent = Vue.defineComponent;
|
`;
|
document.head.appendChild(script);
|
}
|
|
// SFC Loader 設定,加入對 TS 的編譯支援
|
const options = {
|
moduleCache: {
|
vue: Vue
|
},
|
async getFile(url) {
|
// 處理虛擬 URL
|
if (url.startsWith('virtual-url-')) {
|
const content = virtualFileSystem[url];
|
if (content) {
|
return {
|
getContentData: asBinary => asBinary ? new TextEncoder().encode(content) : content,
|
type: '.vue'
|
};
|
}
|
}
|
|
// 處理相依的 .ts / .js 邏輯檔案
|
if (url.endsWith('.ts') || url.endsWith('.js')) {
|
const res = await fetch(url);
|
if (!res.ok) throw Object.assign(new Error(res.statusText + ' ' + url), {res});
|
const code = await res.text();
|
|
// 用 Babel 拔除 TypeScript 型別,並轉成 ES5
|
const transpiled = Babel.transform(code, {
|
presets: ['typescript'],
|
filename: url // 加入 filename 參數解決 Babel 錯誤
|
}).code;
|
|
// 將 export 轉化為掛載到 window
|
let modifiedCode = transpiled.replace(/export\s+(?:function|const)\s+(use\w+Logic)/g, 'window.$1 = function');
|
|
return {
|
getContentData: asBinary => asBinary ? new TextEncoder().encode(modifiedCode) : modifiedCode,
|
type: '.js'
|
};
|
}
|
|
const res = await fetch(url);
|
if (!res.ok) throw Object.assign(new Error(res.statusText + ' ' + url), {res});
|
|
return {
|
getContentData: asBinary => asBinary ? res.arrayBuffer() : res.text(),
|
}
|
},
|
addStyle(textContent) {
|
const style = Object.assign(document.createElement('style'), {textContent});
|
const ref = document.head.getElementsByTagName('style')[0] || null;
|
document.head.insertBefore(style, ref);
|
},
|
// 設定處理 `<script lang="ts">` 的勾子
|
handleModule(type, getContentData, path, options) {
|
switch (type) {
|
case '.ts':
|
const code = getContentData(false);
|
const jsCode = Babel.transform(code, {
|
presets: ['typescript'],
|
filename: path || 'file.ts' // 加入 filename 參數解決 Babel 錯誤
|
}).code;
|
return {getContentData: _ => jsCode, type: '.js'};
|
}
|
}
|
};
|
|
const app = Vue.createApp({
|
data() {
|
return {
|
availableComponents: [
|
{
|
id: 'ErrList',
|
name: 'ErrList.vue',
|
vuePath: './ErrList/ErrList.vue',
|
jsPath: './ErrList/ErrList.ts',
|
windowTitle: '檢核失敗原因畫面'
|
},
|
{
|
id: 'PatchFom',
|
name: 'PatchFom.vue',
|
vuePath: './PatchFom/PatchFom.vue',
|
jsPath: './PatchFom/PatchFom.ts',
|
windowTitle: '空白頁設定'
|
},
|
{
|
id: 'CB_IMGPSScanImp',
|
name: 'CB_IMGPSScanImp.vue',
|
vuePath: './CB_IMGPSScanImp/CB_IMGPSScanImp.vue',
|
jsPath: './CB_IMGPSScanImp/CB_IMGPSScanImp.ts',
|
windowTitle: 'CB_IMGPSScanX'
|
}
|
],
|
currentComponent: null,
|
dynamicComponent: null,
|
isLoading: false
|
}
|
},
|
methods: {
|
async loadComponent(comp) {
|
if (this.currentComponent && this.currentComponent.id === comp.id) return;
|
|
this.isLoading = true;
|
this.currentComponent = comp;
|
this.dynamicComponent = null;
|
|
try {
|
// 1. 載入並執行 TypeScript 邏輯檔案
|
const res = await fetch(comp.jsPath);
|
if (!res.ok) throw new Error(`Failed to load ${comp.jsPath}`);
|
const tsCode = await res.text();
|
|
// 透過 Babel 將 TypeScript 轉譯為 JavaScript
|
const jsCode = Babel.transform(tsCode, {
|
presets: ['typescript'],
|
filename: comp.jsPath
|
}).code;
|
|
// 移除 import vue,將 export 改為 window 掛載
|
// 注意:這裡不宣告 const { ref, ... } = Vue,因為我們在全域已經掛載了 window.ref
|
// 只需要把 ts 轉譯後的 var _vue = require("vue"); 之類的給清除
|
let modifiedCode = jsCode
|
.replace(/import\s+\{.*?\}\s+from\s+['"]vue['"];/g, '')
|
.replace(/(var|let|const)\s+\w+\s*=\s*require\(['"]vue['"]\);/g, '')
|
// 將 babel 轉譯後可能產生的 _vue.ref 換回全域的 ref
|
.replace(/_vue\./g, '')
|
.replace(/export\s+(?:function|const)\s+(use\w+Logic)/g, 'window.$1 = function');
|
|
const scriptId = `script-${comp.id}`;
|
let oldScript = document.getElementById(scriptId);
|
if (oldScript) oldScript.remove();
|
|
const script = document.createElement('script');
|
script.id = scriptId;
|
|
// 使用 IIFE (立即執行函式) 包裝,避免區域變數互相污染
|
script.textContent = `
|
(function() {
|
${modifiedCode}
|
})();
|
`;
|
document.head.appendChild(script);
|
|
// 2. 載入 Vue SFC
|
this.dynamicComponent = Vue.markRaw(Vue.defineAsyncComponent(() => {
|
return fetch(comp.vuePath)
|
.then(res => res.text())
|
.then(content => {
|
// 替換 .vue 裡面的 import
|
const modifiedContent = content.replace(
|
/import\s+\{\s*(use\w+Logic)\s*\}\s+from\s+['"].*?\.ts['"];/g,
|
'const { $1 } = window;'
|
);
|
|
const virtualUrl = `virtual-url-${comp.id}-${Date.now()}.vue`;
|
virtualFileSystem[virtualUrl] = modifiedContent;
|
|
return loadModule(virtualUrl, options);
|
});
|
}));
|
} catch (err) {
|
console.error(`Error loading component ${comp.name}:`, err);
|
alert(`載入元件失敗: ${err.message}`);
|
this.currentComponent = null;
|
} finally {
|
this.isLoading = false;
|
}
|
},
|
closePreview() {
|
this.currentComponent = null;
|
this.dynamicComponent = null;
|
}
|
}
|
});
|
|
app.mount('#app');
|
</script>
|
</body>
|
</html>
|