This commit is contained in:
2026-01-14 22:02:57 +08:00
parent d2cf646974
commit d4bac566e4
9 changed files with 170 additions and 95 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>中文Blockly Python代码生成平台</title>
<title>草履虫</title>
</head>
<body>
<div id="app"></div>

32
package-lock.json generated
View File

@@ -8,8 +8,8 @@
"name": "blockly",
"version": "0.0.0",
"dependencies": {
"@blockly/theme-modern": "^7.0.4",
"blockly": "^12.3.1",
"skulpt": "^1.2.0",
"vue": "^3.5.26"
},
"devDependencies": {
@@ -80,18 +80,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@blockly/theme-modern": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@blockly/theme-modern/-/theme-modern-7.0.4.tgz",
"integrity": "sha512-34Q/oTA/hpqoPJ/7aUNgWdsjhCeo4530zuzF3NPOagPp9AbtEUwdZIdc82iIN8mhDkZjeeT69wC7OjoSoG3TbA==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.17.0"
},
"peerDependencies": {
"blockly": "^12.0.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -913,6 +901,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/jsbi": {
"version": "3.2.5",
"resolved": "https://registry.npmmirror.com/jsbi/-/jsbi-3.2.5.tgz",
"integrity": "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ==",
"license": "Apache-2.0"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
@@ -1409,6 +1403,18 @@
"node": ">=v12.22.7"
}
},
"node_modules/skulpt": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/skulpt/-/skulpt-1.2.0.tgz",
"integrity": "sha512-T0cv0sdSOXLlIJTuyXSeYJ3TFdWYSZfX2PcBLpRKrKZ3dTbVQXRMiYudSuko2xzcyMif47DS2ShatCwjCbsSsA==",
"license": "MIT",
"dependencies": {
"jsbi": "^3.1.4"
},
"engines": {
"node": ">=10.4"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -9,8 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@blockly/theme-modern": "^7.0.4",
"blockly": "^12.3.1",
"skulpt": "^1.2.0",
"vue": "^3.5.26"
},
"devDependencies": {

View File

@@ -2,15 +2,20 @@
import { onBeforeUnmount, onMounted, ref } from "vue"
import * as Blockly from "blockly/core"
import "blockly/blocks"
import Theme from "@blockly/theme-modern"
import { setBlocklyLocale } from "./blockly/locale"
import { defineBlocks } from "./blockly/blocks"
import { toolbox } from "./blockly/toolbox"
import { initPythonGenerator } from "./blockly/pythonGenerator"
import { runPython } from "./blockly/skulptRunner"
const workspaceHost = ref<HTMLDivElement | null>(null)
const codePreview = ref("")
const statusText = ref("准备就绪")
const idleStatus = statusText.value
const runOutput = ref("")
const storageKey = "blockly-workspace"
let saveTimer: number | null = null
let suppressSave = false
let workspace: Blockly.WorkspaceSvg | null = null
const generator = initPythonGenerator()
@@ -21,53 +26,79 @@ const updateCode = () => {
codePreview.value = code.trim() || "# 在左侧拖入方块生成Python代码"
}
// const platformTheme = Blockly.Theme.defineTheme('cnPlatform', {
// base: Blockly.Themes.Zelos,
// componentStyles: {
// workspaceBackgroundColour: '#f7f5ef',
// toolboxBackgroundColour: '#f2ebe2',
// toolboxForegroundColour: '#28343b',
// flyoutBackgroundColour: '#fffaf2',
// flyoutOpacity: 0.96,
// scrollbarColour: '#c3b7aa',
// scrollbarOpacity: 0.7,
// },
// fontStyle: {
// family: '"Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
// size: 13,
// weight: 500,
// },
// blockStyles: {
// logic_blocks: { colourPrimary: '#4f7a8f', colourSecondary: '#406574', colourTertiary: '#2f4b57' },
// loop_blocks: { colourPrimary: '#4f8f6d', colourSecondary: '#3f7458', colourTertiary: '#2e5944' },
// math_blocks: { colourPrimary: '#c6844a', colourSecondary: '#a96f3c', colourTertiary: '#8a592f' },
// text_blocks: { colourPrimary: '#b86a80', colourSecondary: '#9c5668', colourTertiary: '#7d4552' },
// list_blocks: { colourPrimary: '#6d6fb8', colourSecondary: '#595a99', colourTertiary: '#444575' },
// variable_blocks: { colourPrimary: '#aa5d7b', colourSecondary: '#8c4c64', colourTertiary: '#6f3b4e' },
// procedure_blocks: { colourPrimary: '#8b6d52', colourSecondary: '#705640', colourTertiary: '#54402f' },
// },
// categoryStyles: {
// logic_category: { colour: '#4f7a8f' },
// loop_category: { colour: '#4f8f6d' },
// math_category: { colour: '#c6844a' },
// text_category: { colour: '#b86a80' },
// list_category: { colour: '#6d6fb8' },
// variable_category: { colour: '#aa5d7b' },
// procedure_category: { colour: '#8b6d52' },
// },
// })
const handleResize = () => {
if (workspace) {
Blockly.svgResize(workspace)
}
}
const saveWorkspace = () => {
if (!workspace || suppressSave) return
if (saveTimer) {
window.clearTimeout(saveTimer)
}
saveTimer = window.setTimeout(() => {
const state = Blockly.serialization.workspaces.save(workspace!)
localStorage.setItem(storageKey, JSON.stringify(state))
}, 250)
}
const restoreWorkspace = () => {
if (!workspace) return
const raw = localStorage.getItem(storageKey)
if (!raw) return
try {
const state = JSON.parse(raw)
suppressSave = true
Blockly.serialization.workspaces.load(state, workspace)
} catch {
localStorage.removeItem(storageKey)
} finally {
suppressSave = false
}
}
const handleClearWorkspace = () => {
if (!workspace) return
suppressSave = true
workspace.clear()
suppressSave = false
localStorage.removeItem(storageKey)
updateCode()
}
const handleRun = () => {
statusText.value = "执行环境尚未接入可先复制代码到Python环境运行"
window.setTimeout(() => {
statusText.value = "准备就绪"
}, 2200)
if (!codePreview.value.trim()) {
statusText.value = "No code to run."
window.setTimeout(() => {
statusText.value = idleStatus
}, 1600)
return
}
runOutput.value = ""
statusText.value = "Running..."
runPython(codePreview.value, (text) => {
runOutput.value += text
})
.then(() => {
if (!runOutput.value) {
runOutput.value = ""
}
statusText.value = "运行成功"
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
runOutput.value =
(runOutput.value ? `${runOutput.value}\n` : "") + `Error: ${message}`
statusText.value = "运行失败"
})
.finally(() => {
window.setTimeout(() => {
statusText.value = idleStatus
}, 2000)
})
}
const handleCopy = async () => {
@@ -88,10 +119,10 @@ const handleDownload = () => {
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = "blockly_program.py"
link.download = "demo.py"
link.click()
URL.revokeObjectURL(url)
statusText.value = "已导出为 .py 文件"
statusText.value = "已导出代码文件"
window.setTimeout(() => {
statusText.value = "准备就绪"
}, 1800)
@@ -112,12 +143,13 @@ onMounted(() => {
minScale: 0.5,
},
trashcan: true,
renderer: "geras",
theme: Theme,
media: "/blockly-media/",
renderer: "zelos",
theme: Blockly.Themes.Zelos,
})
workspace.addChangeListener(updateCode)
workspace.addChangeListener(saveWorkspace)
updateCode()
restoreWorkspace()
window.addEventListener("resize", handleResize)
})
@@ -127,6 +159,9 @@ onBeforeUnmount(() => {
workspace.dispose()
workspace = null
}
if (saveTimer) {
window.clearTimeout(saveTimer)
}
})
</script>
@@ -134,18 +169,15 @@ onBeforeUnmount(() => {
<div class="app-shell">
<header class="app-header">
<div class="brand">
<span class="brand-badge">Blockly</span>
<span class="brand-badge">草履虫</span>
<div>
<h1>中文Python可视化编程平台</h1>
<p>拖拽方块实时生成可执行的Python代码</p>
<h1>拖拽式代码生成网页</h1>
<p>拖拽方块实时生成可执行的 Python 代码</p>
</div>
</div>
<div class="header-actions">
<button class="btn ghost" type="button" @click="handleCopy">
复制代码
</button>
<button class="btn ghost" type="button" @click="handleDownload">
导出 .py
<button class="btn ghost" type="button" @click="handleClearWorkspace">
清除
</button>
<button class="btn primary" type="button" @click="handleRun">
运行
@@ -160,9 +192,6 @@ onBeforeUnmount(() => {
<h2>工作区</h2>
<p>通过分类工具箱选择方块构建你的程序</p>
</div>
<div class="panel-tip">
<span>实时反馈</span>
</div>
</div>
<div class="workspace-stage" ref="workspaceHost"></div>
</section>
@@ -170,10 +199,9 @@ onBeforeUnmount(() => {
<aside class="side-panel">
<div class="panel-head">
<div>
<h2>Python代码</h2>
<p>符合PEP8风格支持直接导出</p>
<h2>Python 代码</h2>
<p>由左侧的积木块自动生成</p>
</div>
<div class="panel-tip">预览</div>
</div>
<pre class="code-preview"><code>{{ codePreview }}</code></pre>
<div class="status-bar">
@@ -187,6 +215,8 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<div class="output-label">运行结果</div>
<pre class="code-preview output-preview"><code>{{ runOutput }}</code></pre>
</aside>
</main>

View File

@@ -2,5 +2,5 @@ import * as Blockly from 'blockly'
import * as zhHans from 'blockly/msg/zh-hans'
export const setBlocklyLocale = () => {
Blockly.setLocale(zhHans)
Blockly.setLocale(zhHans as unknown as Record<string, string>)
}

View File

@@ -1,16 +1,16 @@
import { pythonGenerator } from 'blockly/python'
import { pythonGenerator, Order } from 'blockly/python'
import type { Block } from 'blockly/core'
export const initPythonGenerator = () => {
pythonGenerator.forBlock['cn_print'] = (block: Block, generator) => {
const text = generator.valueToCode(block, 'TEXT', generator.ORDER_NONE) || "''"
const text = generator.valueToCode(block, 'TEXT', Order.NONE) || "''"
return `print(${text})\n`
}
pythonGenerator.forBlock['cn_input'] = (block: Block, generator) => {
const prompt = generator.valueToCode(block, 'PROMPT', generator.ORDER_NONE)
const prompt = generator.valueToCode(block, 'PROMPT', Order.NONE)
const code = prompt ? `input(${prompt})` : 'input()'
return [code, generator.ORDER_FUNCTION_CALL]
return [code, Order.FUNCTION_CALL]
}
pythonGenerator.addReservedWords('input,print')

View File

@@ -0,0 +1,34 @@
//@ts-ignore
import Sk from "skulpt"
type OutputHandler = (text: string) => void
const readBuiltinFile = (path: string) => {
if (!Sk?.builtinFiles?.files?.[path]) {
throw new Error(`Skulpt file not found: ${path}`)
}
return Sk.builtinFiles.files[path]
}
export const runPython = async (code: string, onOutput: OutputHandler) => {
if (!Sk) {
throw new Error("Skulpt failed to load.")
}
Sk.configure({
output: (text: string) => onOutput(text),
read: readBuiltinFile,
inputfun: (prompt: string) => {
const response = window.prompt(prompt || "请输入")
if (response === null) {
throw new Error("用户取消输入")
}
return response
},
inputfunTakesPrompt: true,
})
await Sk.misceval.asyncToPromise(() =>
Sk.importMainWithBody("<stdin>", false, code, true)
)
}

View File

@@ -78,11 +78,5 @@ export const toolbox = {
colour: '#A65D7B',
custom: 'VARIABLE',
},
{
kind: 'category',
name: '函数',
colour: '#8B6D52',
custom: 'PROCEDURE',
},
],
}

View File

@@ -115,15 +115,6 @@ body {
font-size: 13px;
}
.panel-tip {
background: rgba(31, 122, 140, 0.12);
color: var(--accent-strong);
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.workspace-stage {
flex: 1;
min-height: 520px;
@@ -157,6 +148,21 @@ body {
gap: 8px;
}
.output-label {
padding: 10px 18px;
font-size: 12px;
color: var(--muted);
border-top: 1px solid var(--border);
background: rgba(31, 122, 140, 0.06);
}
.output-preview {
flex: 0 0 160px;
background: #0a1519;
color: #d7f1f6;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.btn {
border: 0;
border-radius: 999px;
@@ -206,6 +212,11 @@ body {
font-weight: 600;
}
.blocklyToolboxCategory {
height: 40px;
line-height: 30px;
}
@media (max-width: 980px) {
.app-shell {
padding: 20px;