app #1 simpleasm: 从 oci 迁过来,asm.famzheng.me 已上线
deploy cube / build-and-deploy (push) Successful in 1m18s
deploy simpleasm / build-and-deploy (push) Successful in 1m45s

- 后端 FastAPI 重写为 axum + rusqlite (musl static, 2.8MB)
- 前端原样搬运 (Vue3 + Vite + Pinia + vue-router + vite-plugin-yaml)
- k8s: cube-simpleasm ns + 1Gi PVC (k3s local-path) + Recreate strategy
- CI: 复刻 deploy-cube.yml,按 apps/simpleasm/** 触发
- cube 门户里 simpleasm 状态从 pending 改成 live
- 数据冷启 (Fam 拍板不带历史进度)
This commit is contained in:
Fam Zheng
2026-05-04 15:12:22 +01:00
parent 5b2e53c040
commit 388b505e0b
40 changed files with 3985 additions and 3 deletions
@@ -0,0 +1,106 @@
<template>
<div class="code-editor" :class="{ running: readOnly }">
<div class="editor-header">
<span>Code</span>
<span class="line-count mono">{{ instrCount }} {{ instrCount === 1 ? 'instruction' : 'instructions' }}</span>
</div>
<div class="editor-body">
<div class="line-numbers" ref="lineNumsEl">
<div v-for="n in totalLines" :key="n" class="ln"
:class="{ current: n-1 === currentLine, error: n-1 === errorLine }">{{ n }}</div>
</div>
<textarea
v-if="!readOnly"
ref="ta"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
@scroll="syncScroll"
@keydown.tab.prevent="insertTab"
spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
></textarea>
<pre v-else class="code-display" ref="codeDisplay" @scroll="syncScroll"><code><template
v-for="(line, i) in displayLines" :key="i"
><span class="cl" :class="{ current: i === currentLine, error: i === errorLine }"
v-html="hl(line)"></span>
</template></code></pre>
</div>
</div>
</template>
<script setup>
import { computed, ref, nextTick, watch } from 'vue'
const props = defineProps({
modelValue: String,
currentLine: { type: Number, default: -1 },
errorLine: { type: Number, default: -1 },
readOnly: Boolean,
})
defineEmits(['update:modelValue'])
const ta = ref(null), lineNumsEl = ref(null), codeDisplay = ref(null)
const totalLines = computed(() => (props.modelValue||'').split('\n').length)
const displayLines = computed(() => (props.modelValue||'').split('\n'))
const instrCount = computed(() => {
let c = 0
for (const line of (props.modelValue||'').split('\n')) {
let l = line; const ci = l.indexOf(';'); if (ci >= 0) l = l.slice(0, ci); l = l.trim()
if (!l) continue
const lm = l.match(/^[a-zA-Z_]\w*\s*:(.*)$/); if (lm) { l = lm[1].trim(); if (!l) continue }
c++
}
return c
})
function hl(line) {
let h = line.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
h = h.replace(/(;.*)$/, '<span class="sc">$1</span>')
h = h.replace(/^(\s*\w+\s*:)/, '<span class="sl">$1</span>')
h = h.replace(/\b(MOV|ADD|SUB|MUL|XDIV|XMOD|AND|ORR|EOR|MVN|LSL|LSR|CMP|BEQ|BNE|BGT|BLT|BGE|BLE|LDR|STR|PUSH|POP|XOUT|XHLT|NOP|B)\b/gi,
'<span class="sk">$1</span>')
h = h.replace(/\b(R[0-7])\b/gi, '<span class="sr">$1</span>')
h = h.replace(/(\[[^\]]+\])/g, '<span class="sm">$1</span>')
h = h.replace(/(#(?:0x[0-9a-fA-F]+|0b[01]+|\d+))/g, '<span class="sn">$1</span>')
// Bare numbers not already highlighted
h = h.replace(/(?<![#\w])\b(\d+)\b(?![0-9a-fA-F])/g, '<span class="sn">$1</span>')
return h || ' '
}
function syncScroll(e) { if (lineNumsEl.value) lineNumsEl.value.scrollTop = e.target.scrollTop }
function insertTab() {
const el = ta.value; if (!el) return
const s = el.selectionStart, e2 = el.selectionEnd, v = props.modelValue
el.value = v.substring(0,s) + ' ' + v.substring(e2)
el.selectionStart = el.selectionEnd = s + 2
el.dispatchEvent(new Event('input'))
}
watch(() => props.currentLine, async (line) => {
if (line < 0) return; await nextTick()
const c = codeDisplay.value; if (!c) return
const el = c.querySelectorAll('.cl')[line]; if (el) el.scrollIntoView({ block: 'nearest' })
})
</script>
<style scoped>
.code-editor { display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; min-height: 0; }
.editor-header { display: flex; justify-content: space-between; padding: 6px 14px; background: var(--bg-surface); font-size: 12px; color: var(--text-muted); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.editor-body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
.line-numbers { flex-shrink: 0; padding: 10px 0; background: var(--bg-dark); user-select: none; min-width: 36px; overflow: hidden; border-right: 1px solid var(--border); }
.ln { padding: 0 8px 0 10px; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-muted); text-align: right; }
.ln.current { color: var(--accent-yellow); background: rgba(245,158,11,0.1); }
.ln.error { color: var(--accent-red); background: rgba(239,68,68,0.1); }
textarea { flex: 1; padding: 10px 14px; background: var(--bg-dark); border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; line-height: 1.6; resize: none; outline: none; tab-size: 2; white-space: pre; overflow: auto; }
.code-display { flex: 1; padding: 10px 14px; background: var(--bg-dark); margin: 0; overflow: auto; min-height: 0; }
.code-display code { display: block; }
.cl { display: block; font-size: 13px; line-height: 1.6; padding: 0 4px; border-radius: 2px; white-space: pre; min-height: 1.6em; }
.cl.current { background: rgba(245,158,11,0.15); border-left: 2px solid var(--accent-yellow); padding-left: 2px; }
.cl.error { background: rgba(239,68,68,0.15); border-left: 2px solid var(--accent-red); padding-left: 2px; }
:deep(.sk) { color: var(--accent-blue); font-weight: 600; }
:deep(.sr) { color: var(--accent-cyan); }
:deep(.sn) { color: var(--accent-yellow); }
:deep(.sc) { color: var(--text-muted); font-style: italic; }
:deep(.sm) { color: var(--accent-purple); }
:deep(.sl) { color: var(--accent-pink); }
</style>
@@ -0,0 +1,106 @@
<template>
<Teleport to="body">
<div class="overlay" @click.self="$emit('close')">
<div class="confetti" ref="confettiEl"></div>
<div class="card">
<div class="badge">&#127881;</div>
<h2>Level Complete!</h2>
<p class="lname">{{ level.title }}</p>
<div class="stars-row">
<span v-for="s in 3" :key="s"
class="star" :class="{ earned: s <= stars }"
:style="{ animationDelay: s * 0.3 + 's' }">&#9733;</span>
</div>
<div class="stats">
<div class="stat"><span class="sl">Instructions</span><span class="sv">{{ instructionCount }}</span></div>
<div class="stat"><span class="sl">Stars Earned</span><span class="sv">{{ stars }} / 3</span></div>
</div>
<div class="actions">
<button class="btn-retry" @click="$emit('retry')">Try Again</button>
<button v-if="level.id < 10" class="btn-next" @click="$emit('next')">Next Level &rarr;</button>
<button v-else class="btn-next" @click="$emit('close')">Back to Levels</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({ level: Object, stars: Number, instructionCount: Number })
defineEmits(['next', 'retry', 'close'])
const confettiEl = ref(null)
onMounted(() => {
if (!confettiEl.value) return
const colors = ['#3b82f6','#06b6d4','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899']
for (let i = 0; i < 60; i++) {
const p = document.createElement('div')
p.className = 'cp'
p.style.left = `${Math.random() * 100}%`
p.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]
p.style.animationDuration = `${2 + Math.random() * 3}s`
p.style.animationDelay = `${Math.random() * 0.8}s`
p.style.width = `${6 + Math.random() * 8}px`
p.style.height = `${6 + Math.random() * 8}px`
confettiEl.value.appendChild(p)
}
})
</script>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
:deep(.cp) {
position: absolute; top: -20px; border-radius: 2px;
animation: cf linear forwards;
}
@keyframes cf {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 40px;
text-align: center; max-width: 400px; width: 90%;
position: relative; z-index: 1;
animation: cardPop 0.4s cubic-bezier(0.175,0.885,0.32,1.275);
}
@keyframes cardPop { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.badge { font-size: 56px; margin-bottom: 4px; }
h2 { font-size: 24px; margin-bottom: 4px; }
.lname { color: var(--text-secondary); margin-bottom: 24px; }
.stars-row { display: flex; justify-content: center; gap: 12px; margin-bottom: 24px; }
.star {
font-size: 48px; color: var(--border);
animation: sp 0.5s cubic-bezier(0.175,0.885,0.32,1.275) both;
}
.star.earned { color: var(--accent-yellow); text-shadow: 0 0 20px rgba(245,158,11,0.5); }
@keyframes sp { 0% { transform: scale(0) rotate(-180deg); opacity: 0; } 100% { transform: scale(1) rotate(0); opacity: 1; } }
.stats { display: flex; justify-content: center; gap: 32px; margin-bottom: 28px; }
.stat { text-align: center; }
.sl { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.sv { font-family: var(--font-mono); font-size: 20px; font-weight: 600; }
.actions { display: flex; gap: 12px; justify-content: center; }
.btn-retry { padding: 10px 24px; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border); }
.btn-retry:hover { background: var(--bg-hover); }
.btn-next { padding: 10px 24px; background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)); color: white; font-weight: 600; }
.btn-next:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
</style>
@@ -0,0 +1,139 @@
<template>
<div class="machine-state">
<h3>&#9881; Machine State</h3>
<div class="section">
<h4>Registers</h4>
<div class="registers">
<div v-for="(reg, idx) in regs" :key="reg"
class="register" :class="{ changed: changedRegs.has(reg) }"
:style="{ borderLeftColor: regColors[idx] }">
<span class="reg-label" :style="{ color: regColors[idx] }">{{ reg }}</span>
<span class="reg-dec">{{ registers[reg] }}</span>
<span class="reg-hex">{{ hex(registers[reg]) }}</span>
<span class="reg-bin">{{ bin(registers[reg]) }}</span>
</div>
</div>
</div>
<div class="section">
<h4>Flags</h4>
<div class="flags-row">
<div class="flag" :class="{ on: flags.zero }"><span class="led"></span><span>Z</span></div>
<div class="flag" :class="{ on: flags.carry }"><span class="led"></span><span>C</span></div>
<div class="flag" :class="{ on: flags.negative }"><span class="led"></span><span>N</span></div>
</div>
</div>
<div v-if="stack && stack.length" class="section">
<h4>Stack ({{ stack.length }})</h4>
<div class="stack-list">
<div v-for="(v, i) in stackRev" :key="i" class="stack-item">{{ v }}</div>
</div>
</div>
<div v-if="showMemory" class="section">
<h4>Memory</h4>
<div class="memory-grid">
<div class="mg-header">
<span class="mg-corner"></span>
<span v-for="c in 16" :key="c" class="mg-ch">{{ (c-1).toString(16).toUpperCase() }}</span>
</div>
<div v-for="row in memRows" :key="row" class="mg-row">
<span class="mg-addr">{{ (row*16).toString(16).toUpperCase().padStart(2,'0') }}</span>
<span v-for="col in 16" :key="col"
class="mg-cell"
:class="{ nz: memory[row*16+col-1] !== 0, cg: changedMem.has(row*16+col-1) }">
{{ memory[row*16+col-1].toString(16).toUpperCase().padStart(2,'0') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
registers: Object, flags: Object, memory: Array, stack: Array,
showMemory: Boolean, memoryRange: { type: Array, default: () => [0,31] }, changes: Array,
})
const regs = ['R0','R1','R2','R3','R4','R5','R6','R7']
const regColors = ['#06b6d4','#8b5cf6','#f59e0b','#ec4899','#10b981','#3b82f6','#f97316','#ef4444']
const changedRegs = ref(new Set())
const changedMem = ref(new Set())
const stackRev = computed(() => props.stack ? [...props.stack].reverse() : [])
const memRows = computed(() => {
if (!props.showMemory) return []
const s = Math.floor((props.memoryRange?.[0]??0)/16)
const e = Math.floor((props.memoryRange?.[1]??31)/16)
return Array.from({ length: e-s+1 }, (_,i) => s+i)
})
function hex(v) { return '0x' + (v&0xFFFF).toString(16).toUpperCase().padStart(4,'0') }
function bin(v) {
const b = (v&0xFFFF).toString(2).padStart(16,'0')
return b.slice(0,4)+' '+b.slice(4,8)+' '+b.slice(8,12)+' '+b.slice(12)
}
watch(() => props.changes, (ch) => {
changedRegs.value = new Set(); changedMem.value = new Set()
if (!ch) return
for (const c of ch) {
if (c.type === 'reg') changedRegs.value.add(c.name)
if (c.type === 'mem') changedMem.value.add(c.addr)
}
setTimeout(() => { changedRegs.value = new Set(); changedMem.value = new Set() }, 600)
}, { deep: true })
</script>
<style scoped>
.machine-state {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px; overflow-y: auto;
}
.machine-state > h3 { font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 14px; }
.section { margin-bottom: 16px; }
.section h4 { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
.registers { display: flex; flex-direction: column; gap: 3px; }
.register {
display: grid; grid-template-columns: 28px 44px 58px 1fr;
align-items: center; gap: 4px;
padding: 4px 6px; background: var(--bg-surface); border-radius: 4px;
border-left: 3px solid var(--border); transition: background 0.3s;
font-family: var(--font-mono); font-size: 12px;
}
.register.changed { animation: regfl 0.5s ease; }
@keyframes regfl { 0%,100% { background: var(--bg-surface); } 50% { background: rgba(59,130,246,0.2); } }
.reg-label { font-weight: 700; font-size: 11px; }
.reg-dec { font-weight: 600; text-align: right; font-size: 13px; }
.reg-hex { color: var(--text-muted); font-size: 10px; }
.reg-bin { color: var(--text-muted); font-size: 9px; letter-spacing: 0.5px; }
.flags-row { display: flex; gap: 8px; }
.flag {
display: flex; align-items: center; gap: 4px;
padding: 4px 10px; background: var(--bg-surface); border-radius: var(--radius);
font-family: var(--font-mono); font-weight: 600; font-size: 13px;
}
.led { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); transition: all 0.3s; }
.flag.on .led { background: var(--accent-green); box-shadow: 0 0 8px var(--accent-green); }
.stack-list { display: flex; flex-direction: column; gap: 2px; }
.stack-item { font-family: var(--font-mono); padding: 3px 12px; background: var(--bg-surface); border-radius: 4px; font-size: 12px; text-align: center; }
.memory-grid { font-family: var(--font-mono); font-size: 10px; overflow-x: auto; }
.mg-header, .mg-row { display: flex; gap: 1px; }
.mg-header { margin-bottom: 1px; }
.mg-corner { width: 22px; flex-shrink: 0; }
.mg-ch { width: 20px; text-align: center; color: var(--text-muted); }
.mg-addr { width: 22px; flex-shrink: 0; color: var(--text-muted); text-align: right; padding-right: 3px; }
.mg-cell { width: 20px; text-align: center; padding: 2px 0; background: var(--bg-dark); border-radius: 1px; color: var(--text-muted); transition: all 0.3s; }
.mg-cell.nz { color: var(--accent-cyan); background: rgba(6,182,212,0.08); }
.mg-cell.cg { animation: mfl 0.5s ease; }
@keyframes mfl { 0%,100% { background: rgba(6,182,212,0.08); } 50% { background: rgba(6,182,212,0.4); } }
</style>
@@ -0,0 +1,72 @@
<template>
<div class="output-console">
<div class="console-header">
<span>Output</span>
<button v-if="messages.length" @click="$emit('clear')" class="clear-btn">Clear</button>
</div>
<div class="console-body" ref="bodyEl">
<div v-for="(m, i) in messages" :key="i" class="console-line" :class="m.type">
<span class="pfx">{{ m.pfx }}</span>
<span>{{ m.text }}</span>
</div>
<div v-if="!messages.length" class="empty">Waiting to run...</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
const props = defineProps({ output: Array, error: String, status: String })
defineEmits(['clear'])
const bodyEl = ref(null)
const messages = computed(() => {
const msgs = []
if (props.status) msgs.push({ type: 'info', pfx: '>', text: props.status })
for (const v of (props.output || [])) msgs.push({ type: 'output', pfx: 'OUT', text: String(v) })
if (props.error) msgs.push({ type: 'error', pfx: '!', text: props.error })
return msgs
})
watch(messages, async () => {
await nextTick()
if (bodyEl.value) bodyEl.value.scrollTop = bodyEl.value.scrollHeight
}, { deep: true })
</script>
<style scoped>
.output-console {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
flex-shrink: 0;
}
.console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 14px;
background: var(--bg-surface);
font-size: 12px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.clear-btn { padding: 2px 8px; font-size: 11px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
.console-body {
padding: 8px 14px;
min-height: 40px;
max-height: 100px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 13px;
background: var(--bg-dark);
}
.console-line { padding: 1px 0; display: flex; gap: 8px; }
.pfx { color: var(--text-muted); flex-shrink: 0; min-width: 28px; }
.console-line.output { color: var(--accent-green); }
.console-line.error { color: var(--accent-red); }
.console-line.info { color: var(--accent-cyan); }
.empty { color: var(--text-muted); font-style: italic; font-size: 12px; }
</style>
@@ -0,0 +1,99 @@
<template>
<div class="tutorial-panel" :class="{ collapsed: !visible }">
<button class="toggle-btn" @click="$emit('toggle')" :title="visible ? 'Collapse' : 'Expand tutorial'">
{{ visible ? '◀' : '▶' }}
</button>
<div v-show="visible" class="tutorial-content">
<div class="goal-box">
<h4>&#127919; Goal</h4>
<p v-html="fmt(level.goal)"></p>
</div>
<div v-for="(sec, i) in level.tutorial" :key="i" class="tut-section">
<h4>{{ sec.title }}</h4>
<p v-html="fmt(sec.text)"></p>
<pre v-if="sec.code" class="tut-code"><code>{{ sec.code }}</code></pre>
</div>
</div>
</div>
</template>
<script setup>
defineProps({ level: Object, visible: Boolean })
defineEmits(['toggle'])
function fmt(t) {
if (!t) return ''
return t
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
</script>
<style scoped>
.tutorial-panel {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: min-width 0.3s;
display: flex;
flex-direction: column;
}
.tutorial-panel.collapsed { min-width: 32px !important; max-width: 32px !important; }
.toggle-btn {
position: absolute;
top: 8px;
right: 4px;
z-index: 2;
width: 24px;
height: 24px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
background: var(--bg-surface);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 4px;
}
.tutorial-content {
padding: 16px;
padding-right: 12px;
overflow-y: auto;
flex: 1;
}
.goal-box {
background: rgba(59,130,246,0.08);
border: 1px solid rgba(59,130,246,0.2);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 20px;
}
.goal-box h4 { margin-bottom: 6px; font-size: 14px; }
.goal-box p { font-size: 14px; line-height: 1.5; color: var(--text-secondary); }
.goal-box :deep(strong) { color: var(--accent-yellow); }
.tut-section { margin-bottom: 18px; }
.tut-section h4 { font-size: 14px; margin-bottom: 6px; color: var(--accent-cyan); }
.tut-section p { font-size: 13px; line-height: 1.6; color: var(--text-secondary); }
.tut-section :deep(code) {
font-family: var(--font-mono);
background: var(--bg-surface);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
color: var(--accent-yellow);
}
.tut-section :deep(strong) { color: var(--text-primary); font-weight: 600; }
.tut-code {
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
margin-top: 8px;
overflow-x: auto;
}
.tut-code code { font-size: 12px; line-height: 1.5; color: var(--accent-green); }
</style>