feat: 实现一个scss转css的实时预览工具,用于scss学习

master
LCJ-MinYa 5 months ago
parent 021eddb5ea
commit e173a00bb5

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 PlusTailwindCSSVue3请在你的项目中按常规引入
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>
-->
Loading…
Cancel
Save