|
|
|
@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
class="h-screen flex"
|
|
|
|
|
|
|
|
style="font-family: Inter, ui-sans-serif, system-ui"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<!-- Sidebar -->
|
|
|
|
|
|
|
|
<el-drawer
|
|
|
|
|
|
|
|
v-model="sidebarOpen"
|
|
|
|
|
|
|
|
direction="ltr"
|
|
|
|
|
|
|
|
size="360px"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<div class="p-4 h-full flex flex-col">
|
|
|
|
|
|
|
|
<div class="flex items-center justify-between mb-3">
|
|
|
|
|
|
|
|
<h3 class="text-lg font-semibold">历史翻译</h3>
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
|
|
@click="exportHistory"
|
|
|
|
|
|
|
|
>导出 JSON</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="importFileInput.click()"
|
|
|
|
|
|
|
|
>导入</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
ref="importFileInput"
|
|
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
|
|
accept="application/json"
|
|
|
|
|
|
|
|
class="hidden"
|
|
|
|
|
|
|
|
@change="onImportFile"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex-1 overflow-auto">
|
|
|
|
|
|
|
|
<el-collapse v-model:active="activeCollapse">
|
|
|
|
|
|
|
|
<el-collapse-item
|
|
|
|
|
|
|
|
v-for="item in history"
|
|
|
|
|
|
|
|
:key="item.id"
|
|
|
|
|
|
|
|
:title="item.title || `未命名 - ${formatTime(item.ts)}`"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<div class="mb-2">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="item.title"
|
|
|
|
|
|
|
|
placeholder="输入标题(可选)"
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@change="saveHistory"
|
|
|
|
|
|
|
|
></el-input>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="mb-2 text-sm text-gray-600">预览:</div>
|
|
|
|
|
|
|
|
<pre
|
|
|
|
|
|
|
|
class="whitespace-pre-wrap break-words bg-gray-50 p-2 rounded text-sm"
|
|
|
|
|
|
|
|
style="max-height: 120px; overflow: auto"
|
|
|
|
|
|
|
|
>{{ item.scss }}</pre
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<div class="flex gap-2 mt-2">
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="loadHistory(item)"
|
|
|
|
|
|
|
|
>载入</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
|
|
@click="applyTranslation(item)"
|
|
|
|
|
|
|
|
>查看 CSS</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
|
|
@click="deleteHistory(item.id)"
|
|
|
|
|
|
|
|
>删除</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</el-collapse-item>
|
|
|
|
|
|
|
|
</el-collapse>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="mt-3 text-right">
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="clearHistory"
|
|
|
|
|
|
|
|
>清空历史</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</el-drawer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Main area -->
|
|
|
|
|
|
|
|
<div class="flex-1 flex flex-col">
|
|
|
|
|
|
|
|
<!-- Top bar -->
|
|
|
|
|
|
|
|
<div class="flex items-center justify-between p-4 border-b bg-white">
|
|
|
|
|
|
|
|
<div class="flex items-center gap-4">
|
|
|
|
|
|
|
|
<h2 class="text-xl font-medium">SCSS → CSS 实时翻译器</h2>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
|
|
<el-switch
|
|
|
|
|
|
|
|
v-model="autoCompile"
|
|
|
|
|
|
|
|
active-text="自动实时"
|
|
|
|
|
|
|
|
inactive-text="手动"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
|
|
@click="compileNow"
|
|
|
|
|
|
|
|
>翻译为 CSS</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button @click="saveCurrentToHistory">保存到历史</el-button>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
|
|
@click="() => (sidebarOpen = true)"
|
|
|
|
|
|
|
|
>查看历史</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Content -->
|
|
|
|
|
|
|
|
<div class="flex-1 p-4 overflow-hidden flex gap-4">
|
|
|
|
|
|
|
|
<!-- Left: SCSS editor -->
|
|
|
|
|
|
|
|
<div class="w-1/2 h-full flex flex-col">
|
|
|
|
|
|
|
|
<div class="mb-2 flex items-center justify-between">
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="formatScss"
|
|
|
|
|
|
|
|
>格式化</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="clearEditor"
|
|
|
|
|
|
|
|
>清空</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="loadExample"
|
|
|
|
|
|
|
|
>示例</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="text-sm text-gray-500">字符: {{ scss.length }}</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
|
|
v-model="scss"
|
|
|
|
|
|
|
|
class="flex-1 w-full p-3 border rounded leading-relaxed font-mono text-sm resize-none"
|
|
|
|
|
|
|
|
placeholder="在此输入 SCSS 代码(支持常见变量与嵌套)"
|
|
|
|
|
|
|
|
></textarea>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Right: CSS output -->
|
|
|
|
|
|
|
|
<div class="w-1/2 h-full flex flex-col">
|
|
|
|
|
|
|
|
<div class="mb-2 flex items-center justify-between">
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="copyCss"
|
|
|
|
|
|
|
|
>复制 CSS</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="downloadCss"
|
|
|
|
|
|
|
|
>下载 CSS</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="text-sm text-gray-500">
|
|
|
|
|
|
|
|
编译状态: <strong>{{ compileStatus }}</strong>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<pre
|
|
|
|
|
|
|
|
class="flex-1 overflow-auto p-3 border rounded bg-gray-50 text-sm font-mono"
|
|
|
|
|
|
|
|
v-html="highlightedCss"
|
|
|
|
|
|
|
|
></pre>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
|
|
import { ref, reactive, computed, onMounted } from 'vue';
|
|
|
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
|
|
|
|
|
|
import { Edit } from '@element-plus/icons-vue';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---- State ----
|
|
|
|
|
|
|
|
const scss = ref<string>(`$primary: #3490dc;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.button {
|
|
|
|
|
|
|
|
background: $primary;
|
|
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
|
|
background: darken($primary, 10%);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.container {
|
|
|
|
|
|
|
|
.row { display:flex }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
const css = ref<string>('');
|
|
|
|
|
|
|
|
const compileStatus = ref('空闲');
|
|
|
|
|
|
|
|
const autoCompile = ref(true);
|
|
|
|
|
|
|
|
const sidebarOpen = ref(false);
|
|
|
|
|
|
|
|
const history = ref<Array<any>>(loadHistoryFromStorage());
|
|
|
|
|
|
|
|
const activeCollapse = ref([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// file input ref
|
|
|
|
|
|
|
|
const importFileInput = ref<HTMLInputElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Utility ----
|
|
|
|
|
|
|
|
function formatTime(ts: number) {
|
|
|
|
|
|
|
|
const d = new Date(ts);
|
|
|
|
|
|
|
|
return d.toLocaleString();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function saveHistory() {
|
|
|
|
|
|
|
|
localStorage.setItem('scss_history', JSON.stringify(history.value));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function loadHistoryFromStorage() {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const raw = localStorage.getItem('scss_history');
|
|
|
|
|
|
|
|
if (!raw) return [];
|
|
|
|
|
|
|
|
return JSON.parse(raw);
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function addHistoryRecord(record: any) {
|
|
|
|
|
|
|
|
history.value.unshift(record);
|
|
|
|
|
|
|
|
// keep max 200
|
|
|
|
|
|
|
|
if (history.value.length > 200) history.value.length = 200;
|
|
|
|
|
|
|
|
saveHistory();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function clearHistory() {
|
|
|
|
|
|
|
|
ElMessageBox.confirm('确定清空所有历史吗?此操作不可撤销。', '确认', { type: 'warning' })
|
|
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
|
|
history.value = [];
|
|
|
|
|
|
|
|
saveHistory();
|
|
|
|
|
|
|
|
ElMessage({ type: 'success', message: '已清空历史' });
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function deleteHistory(id: string) {
|
|
|
|
|
|
|
|
history.value = history.value.filter((h: any) => h.id !== id);
|
|
|
|
|
|
|
|
saveHistory();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function loadHistory(item: any) {
|
|
|
|
|
|
|
|
scss.value = item.scss;
|
|
|
|
|
|
|
|
sidebarOpen.value = false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function applyTranslation(item: any) {
|
|
|
|
|
|
|
|
css.value = item.css;
|
|
|
|
|
|
|
|
compileStatus.value = '已载入历史 CSS';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function saveCurrentToHistory() {
|
|
|
|
|
|
|
|
const id = Date.now().toString();
|
|
|
|
|
|
|
|
addHistoryRecord({ id, title: `翻译 ${new Date().toLocaleString()}`, scss: scss.value, css: css.value, ts: Date.now() });
|
|
|
|
|
|
|
|
ElMessage({ type: 'success', message: '已保存到历史' });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function formatCss(raw: string): string {
|
|
|
|
|
|
|
|
let indent = 0;
|
|
|
|
|
|
|
|
return raw
|
|
|
|
|
|
|
|
.replace(/\s+/g, ' ') // 先压掉多余空格
|
|
|
|
|
|
|
|
.replace(/\s*{\s*/g, ' {\n') // { 前后换行
|
|
|
|
|
|
|
|
.replace(/\s*}\s*/g, '\n}\n') // } 前后换行
|
|
|
|
|
|
|
|
.replace(/;\s*/g, ';\n') // ; 后换行
|
|
|
|
|
|
|
|
.split('\n')
|
|
|
|
|
|
|
|
.map((line) => {
|
|
|
|
|
|
|
|
if (line.includes('}')) indent -= 2;
|
|
|
|
|
|
|
|
const res = ' '.repeat(indent) + line.trim();
|
|
|
|
|
|
|
|
if (line.includes('{')) indent += 2;
|
|
|
|
|
|
|
|
return res;
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function formatScss() {
|
|
|
|
|
|
|
|
// simple formatting: trim trailing spaces
|
|
|
|
|
|
|
|
scss.value = formatCss(scss.value);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function clearEditor() {
|
|
|
|
|
|
|
|
scss.value = '';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function loadExample() {
|
|
|
|
|
|
|
|
scss.value = `$brand: #ff6b6b;\n.card {\n padding: 16px;\n background: lighten($brand, 30%);\n .title { font-weight: 700 }\n}\n`;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Compilation ----
|
|
|
|
|
|
|
|
let sassLib: any = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function ensureSass() {
|
|
|
|
|
|
|
|
if (sassLib) return sassLib;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
|
|
|
const script = document.createElement('script');
|
|
|
|
|
|
|
|
script.src = 'https://cdn.jsdelivr.net/npm/sass.js@0.11.1/dist/sass.sync.js';
|
|
|
|
|
|
|
|
script.onload = () => resolve();
|
|
|
|
|
|
|
|
script.onerror = (e) => reject(new Error('Sass.js failed to load'));
|
|
|
|
|
|
|
|
document.head.appendChild(script);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (window.Sass) {
|
|
|
|
|
|
|
|
sassLib = window.Sass;
|
|
|
|
|
|
|
|
return sassLib;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
console.error('Failed to load sass.js', e);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return null; // Fallback if Sass.js can't be loaded
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function compileScss(input: string) {
|
|
|
|
|
|
|
|
compileStatus.value = '编译中...';
|
|
|
|
|
|
|
|
const lib = await ensureSass();
|
|
|
|
|
|
|
|
if (!lib) {
|
|
|
|
|
|
|
|
// 简易回退:只做变量替换 (很有限),并移除嵌套(尝试扁平化)
|
|
|
|
|
|
|
|
const simple = simpleScssToCss(input);
|
|
|
|
|
|
|
|
css.value = simple;
|
|
|
|
|
|
|
|
compileStatus.value = '使用回退编译(有限支持)';
|
|
|
|
|
|
|
|
return simple;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return new Promise<string>((resolve) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
lib.compile(input, { style: 'expanded' }, (result: any) => {
|
|
|
|
|
|
|
|
if (result && result.text) {
|
|
|
|
|
|
|
|
css.value = formatCss(result.text);
|
|
|
|
|
|
|
|
compileStatus.value = '编译成功';
|
|
|
|
|
|
|
|
resolve(result.text);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
compileStatus.value = '编译失败(返回错误)';
|
|
|
|
|
|
|
|
css.value = `/* 编译失败: ${JSON.stringify(result)} */`;
|
|
|
|
|
|
|
|
resolve(css.value);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
compileStatus.value = '编译异常';
|
|
|
|
|
|
|
|
css.value = `/* 编译异常: ${e} */`;
|
|
|
|
|
|
|
|
resolve(css.value);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// a very naive fallback converter: handles $var: value; and simple nesting 1-level
|
|
|
|
|
|
|
|
function simpleScssToCss(src: string) {
|
|
|
|
|
|
|
|
// extract variables
|
|
|
|
|
|
|
|
const vars: Record<string, string> = {};
|
|
|
|
|
|
|
|
const lines = src.split(/\r?\n/);
|
|
|
|
|
|
|
|
const bodyLines: string[] = [];
|
|
|
|
|
|
|
|
for (const l of lines) {
|
|
|
|
|
|
|
|
const m = l.match(/^\s*\$([\w-]+)\s*:\s*(.+);?\s*$/);
|
|
|
|
|
|
|
|
if (m) {
|
|
|
|
|
|
|
|
vars[m[1]] = m[2].trim();
|
|
|
|
|
|
|
|
} else bodyLines.push(l);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// replace variables
|
|
|
|
|
|
|
|
let text = bodyLines.join('\n');
|
|
|
|
|
|
|
|
for (const k in vars) {
|
|
|
|
|
|
|
|
const re = new RegExp(`\\$${k}\\b`, 'g');
|
|
|
|
|
|
|
|
text = text.replace(re, vars[k]);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// naive nesting: convert .a { .b { ... } } => .a .b { ... }
|
|
|
|
|
|
|
|
// this is VERY limited and only supports two-levels
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const stack: string[] = [];
|
|
|
|
|
|
|
|
const outBlocks: string[] = [];
|
|
|
|
|
|
|
|
const outLines: string[] = [];
|
|
|
|
|
|
|
|
const indentRe = /^\s*/;
|
|
|
|
|
|
|
|
for (const l of text.split(/\r?\n/)) {
|
|
|
|
|
|
|
|
const trimmed = l.trim();
|
|
|
|
|
|
|
|
if (trimmed.endsWith('{')) {
|
|
|
|
|
|
|
|
const sel = trimmed.slice(0, -1).trim();
|
|
|
|
|
|
|
|
stack.push(sel);
|
|
|
|
|
|
|
|
} else if (trimmed === '}') {
|
|
|
|
|
|
|
|
stack.pop();
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
if (stack.length === 0) {
|
|
|
|
|
|
|
|
outLines.push(l);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
const selector = stack.join(' ').replace(/\s+/g, ' ');
|
|
|
|
|
|
|
|
outLines.push(`${selector} { ${trimmed} }`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return outLines.join('\n');
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
return text;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// compile now button or auto
|
|
|
|
|
|
|
|
let compileTimer: any = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function compileNow() {
|
|
|
|
|
|
|
|
await compileScss(scss.value);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// auto compile watch
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
|
|
// try initial compile
|
|
|
|
|
|
|
|
if (autoCompile.value) compileNow();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// watch scss changes (manual approach)
|
|
|
|
|
|
|
|
let last = scss.value;
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
|
|
|
if (!autoCompile.value) return;
|
|
|
|
|
|
|
|
if (scss.value !== last) {
|
|
|
|
|
|
|
|
last = scss.value;
|
|
|
|
|
|
|
|
compileNow();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, 600);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---- copy / download / export / import ----
|
|
|
|
|
|
|
|
function copyCss() {
|
|
|
|
|
|
|
|
navigator.clipboard
|
|
|
|
|
|
|
|
?.writeText(css.value || '')
|
|
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
|
|
ElMessage({ type: 'success', message: '已复制到剪贴板' });
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
|
|
ElMessage({ type: 'error', message: '复制失败' });
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function downloadCss() {
|
|
|
|
|
|
|
|
const blob = new Blob([css.value || ''], { type: 'text/css' });
|
|
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
|
|
a.download = 'style.css';
|
|
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function exportHistory() {
|
|
|
|
|
|
|
|
const blob = new Blob([JSON.stringify(history.value, null, 2)], { type: 'application/json' });
|
|
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
|
|
a.download = 'scss_history.json';
|
|
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function onImportFile(e: any) {
|
|
|
|
|
|
|
|
const f = e.target.files?.[0];
|
|
|
|
|
|
|
|
if (!f) return;
|
|
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
|
|
reader.onload = (ev) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const parsed = JSON.parse(String(ev.target?.result));
|
|
|
|
|
|
|
|
if (Array.isArray(parsed)) {
|
|
|
|
|
|
|
|
history.value = parsed.concat(history.value);
|
|
|
|
|
|
|
|
saveHistory();
|
|
|
|
|
|
|
|
ElMessage({ type: 'success', message: '导入成功' });
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
ElMessage({ type: 'error', message: 'JSON 格式应为数组' });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
ElMessage({ type: 'error', message: '读取失败' });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
reader.readAsText(f);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function importFromFileObject(file: File) {
|
|
|
|
|
|
|
|
const txt = await file.text();
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const parsed = JSON.parse(txt);
|
|
|
|
|
|
|
|
if (Array.isArray(parsed)) {
|
|
|
|
|
|
|
|
history.value = parsed.concat(history.value);
|
|
|
|
|
|
|
|
saveHistory();
|
|
|
|
|
|
|
|
ElMessage({ type: 'success', message: '导入成功' });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
ElMessage({ type: 'error', message: '导入失败' });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// allow user to provide file input programmatically
|
|
|
|
|
|
|
|
const importHandler = (e: any) => onImportFile(e);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// expose refs
|
|
|
|
|
|
|
|
const highlightedCss = computed(() => {
|
|
|
|
|
|
|
|
// simple HTML-escape
|
|
|
|
|
|
|
|
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
|
|
|
|
return esc(css.value || '');
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
|
|
/* 使用 Tailwind 做布局,局部用 SCSS 做样式 */
|
|
|
|
|
|
|
|
textarea {
|
|
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
pre {
|
|
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!--
|
|
|
|
|
|
|
|
说明:
|
|
|
|
|
|
|
|
1) 本组件为单文件 Vue 3 组件(setup 语法)。
|
|
|
|
|
|
|
|
2) 依赖:Element Plus、TailwindCSS、Vue3。请在你的项目中按常规引入。
|
|
|
|
|
|
|
|
3) SCSS 编译使用 sass.js (WASM 版或同步版)。组件会尝试按顺序:
|
|
|
|
|
|
|
|
a) 使用 window.Sass(如果你在 index.html 通过 CDN 事先引入)
|
|
|
|
|
|
|
|
b) 动态加载 https://cdn.jsdelivr.net/npm/sass.js@0.11.1/dist/sass.sync.js
|
|
|
|
|
|
|
|
c) 如果加载失败,组件会使用非常有限的回退逻辑(仅支持变量替换与简单嵌套扁平化)。
|
|
|
|
|
|
|
|
4) 历史会保存在 localStorage(键名:scss_history),并且可以导出为 JSON 文件或导入历史 JSON。
|
|
|
|
|
|
|
|
5) 如果你不想让组件加载远端脚本,请在 index.html 中手动引入 sass.js:
|
|
|
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/sass.js@0.11.1/dist/sass.sync.js"></script>
|
|
|
|
|
|
|
|
-->
|