feat: 预览里的学生代码可以用 localStorage
Deploy / deploy (build, debian, 22) (push) Canceled after 0s

预览 iframe 没有 allow-same-origin,访问 localStorage 会抛异常。现在在学生代码之前
注入一个替身(支持常用方法、属性读写、Object.keys 等):

- 自己的编辑器(教程、挑战、自由创作):写入通过 postMessage 交给主页面,按“用户 + 任务”
  存到本站 localStorage 的 preview-storage:* 下,下次渲染注入回去,刷新后数据还在
- 看别人的作品:只存在内存里
- 单个任务最多 10 万字符,总共 50 万字符,超出时先删最久没用的,不挤占登录 token 的配额
- 下载的 index.html 不带替身

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 21:55:31 +08:00
1 parent dc91b9b5db
commit 40f3063b91
8 files changed
+230 -6

No files matched your search

+23 -4
View File
@@ -43,6 +43,7 @@
</n-flex>
<div class="iframe-wrapper" :style="iframeWrapperStyle">
<iframe
ref="frame"
class="iframe"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="previewContent"
@@ -51,7 +52,7 @@
</template>
<script lang="ts" setup>
import { watchDebounced } from "@vueuse/core"
import { computed, onMounted, ref } from "vue"
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import { submission } from "../../store/submission"
@@ -62,6 +63,12 @@ import {
buildPreviewDocument,
PREVIEW_SANDBOX,
} from "../../utils/previewDocument"
import {
listenPreviewStorage,
loadPreviewStorage,
} from "../../utils/previewStorage"
import { taskId } from "../../store/task"
import { user } from "../../store/user"
interface Props {
html: string
@@ -71,6 +78,8 @@ interface Props {
submissionId?: string
showCodeButton?: boolean
clearable?: boolean
// 自己的编辑器:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
persistStorage?: boolean
}
const props = defineProps<Props>()
@@ -118,12 +127,18 @@ const router = useRouter()
const showDL = computed(() => props.html || props.css || props.js)
const previewContent = ref("")
function getContent() {
const frame = useTemplateRef<HTMLIFrameElement>("frame")
const storageScope = () =>
props.persistStorage && taskId.value ? `${user.username}:${taskId.value}` : ""
function getContent(forDownload = false) {
const scope = storageScope()
return buildPreviewDocument({
html: props.html,
css: props.css,
js: props.js,
assetBaseUrl: props.assetBaseUrl,
storage: forDownload ? false : scope ? loadPreviewStorage(scope) : undefined,
})
}
@@ -132,7 +147,7 @@ function preview() {
}
function download() {
const content = getContent()
const content = getContent(true)
const blob = new Blob([content], { type: "text/html" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
@@ -159,7 +174,10 @@ function open() {
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
)
doc.close()
doc.querySelector("iframe")!.srcdoc = getContent()
const tabFrame = doc.querySelector("iframe")!
// 全屏页里的写入也存到同一个地方
listenPreviewStorage(newTab, () => tabFrame, storageScope)
tabFrame.srcdoc = getContent()
}
}
@@ -192,6 +210,7 @@ watchDebounced(
},
)
onMounted(preview)
onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
</script>
<style scoped>
.title {
@@ -241,6 +241,7 @@ import { computed, ref, watch } from "vue"
import { NPopconfirm, NButton, useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { renderMarkdown } from "../../utils/markdown"
import { storageShim } from "../../utils/previewStorage"
import { Prompt, Submission } from "../../api"
import type { PromptRound } from "../../utils/type"
import { user, roleSuper } from "../../store/user"
@@ -298,7 +299,7 @@ const selectedPageHtml = computed(() => {
if (!round?.html) return null
const style = round.css ? `<style>${round.css}</style>` : ""
const script = round.js ? `<script>${round.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}${style}</head><body>${round.html}${script}</body></html>`
})
function isPromptLong(text: string): boolean {