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 {
+1
View File
@@ -90,6 +90,7 @@
:css="css"
:js="js"
:asset-base-url="assetBaseUrl"
persist-storage
show-code-button
clearable
@showCode="showCode = true"
+1
View File
@@ -80,6 +80,7 @@
:html="html"
:css="css"
:js="js"
persist-storage
show-code-button
clearable
@showCode="showCode = true"
+2 -1
View File
@@ -78,6 +78,7 @@
</template>
<script setup lang="ts">
import { storageShim } from "../utils/previewStorage"
import { onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
@@ -91,7 +92,7 @@ const awards = ref<AwardSection[]>([])
function buildSrcdoc(item: ShowcaseItem): string {
const css = item.css ? `<style>${item.css}</style>` : ""
const js = item.js ? `<script>${item.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}<meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
}
function openDetail(item: ShowcaseItem) {
+1
View File
@@ -14,6 +14,7 @@
:css="css"
:js="js"
:asset-base-url="assetBaseUrl"
persist-storage
/>
</template>
</n-split>
+7
View File
@@ -3,11 +3,16 @@
export const PREVIEW_SANDBOX =
"allow-scripts allow-modals allow-forms allow-popups allow-pointer-lock"
import { storageShim, type PreviewStorage } from "./previewStorage"
interface PreviewDocumentOptions {
html: string
css: string
js: string
assetBaseUrl?: string
// 不传:localStorage 替身只存在内存里;传入:带上已存的数据并写回主页面;
// false:不注入替身(下载成文件时用,浏览器里直接打开有真正的 localStorage)
storage?: PreviewStorage | false
}
export function buildPreviewDocument({
@@ -15,11 +20,13 @@ export function buildPreviewDocument({
css,
js,
assetBaseUrl,
storage,
}: PreviewDocumentOptions) {
return `<!DOCTYPE html>
<html lang="zh-Hans-CN">
<head>
<meta charset="UTF-8" />
${storage === false ? "" : storageShim(storage)}
<title>预览</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
+193
View File
@@ -0,0 +1,193 @@
// 预览 iframe 没有 allow-same-origin,学生代码里访问 localStorage 会抛异常。
// 这里在学生代码之前注入一个替身:数据先放在 iframe 的内存里;
// 自己编辑器里的预览再通过 postMessage 交给主页面,存到本站 localStorage 的独立 key 下,
// 下次渲染时注入回去,所以刷新、改代码后数据还在。看别人的作品时只存在内存里。
const KEY_PREFIX = "preview-storage:"
// 本站的登录 token 也在 localStorage 里(配额约 5MB),预览数据不能把它挤满
const SCOPE_MAX_CHARS = 100_000
const TOTAL_MAX_CHARS = 500_000
const MESSAGE_TYPE = "preview-storage"
export interface PreviewStorage {
scope: string
data: Record<string, string>
}
// 在 iframe 里运行,必须自成一体(不能引用外面的变量)
const SHIM = `(function (config, maxChars, type) {
function create(initial, persist) {
var data = new Map(Object.entries(initial || {}))
function post(msg) {
if (!persist) return
msg.type = type
msg.scope = config.scope
parent.postMessage(msg, "*")
}
function size() {
var n = 0
data.forEach(function (v, k) { n += k.length + v.length })
return n
}
var api = {
getItem: function (k) {
k = String(k)
return data.has(k) ? data.get(k) : null
},
setItem: function (k, v) {
k = String(k)
v = String(v)
var old = data.has(k) ? k.length + data.get(k).length : 0
if (size() - old + k.length + v.length > maxChars) {
throw new DOMException("预览里的 localStorage 最多存 " + maxChars + " 个字符", "QuotaExceededError")
}
data.set(k, v)
post({ op: "set", key: k, value: v })
},
removeItem: function (k) {
k = String(k)
if (data.delete(k)) post({ op: "remove", key: k })
},
clear: function () {
data.clear()
post({ op: "clear" })
},
key: function (i) {
var keys = Array.from(data.keys())
return i >= 0 && i < keys.length ? keys[i] : null
},
}
var isApi = function (p) { return p === "length" || Object.prototype.hasOwnProperty.call(api, p) }
// 支持 localStorage.foo = 1、localStorage["foo"]、Object.keys(localStorage) 这些写法
return new Proxy({}, {
get: function (_, p) {
if (p === "length") return data.size
if (isApi(p)) return api[p]
if (typeof p === "string" && data.has(p)) return data.get(p)
return undefined
},
set: function (_, p, v) {
if (typeof p === "string" && !isApi(p)) api.setItem(p, v)
return true
},
deleteProperty: function (_, p) {
if (typeof p === "string") api.removeItem(p)
return true
},
has: function (_, p) {
return isApi(p) || (typeof p === "string" && data.has(p))
},
ownKeys: function () {
return Array.from(data.keys())
},
getOwnPropertyDescriptor: function (_, p) {
if (typeof p !== "string" || !data.has(p)) return undefined
return { value: data.get(p), writable: true, enumerable: true, configurable: true }
},
})
}
function define(name, value) {
try {
Object.defineProperty(window, name, { value: value, configurable: true })
} catch (e) {}
}
define("localStorage", create(config && config.data, !!config))
define("sessionStorage", create(null, false))
})`
// storage 不传时只存在内存里
export function storageShim(storage?: PreviewStorage) {
// 防止数据里的 </script> 提前结束脚本
const config = JSON.stringify(storage ?? null).replace(/</g, "\\u003c")
return `<script>${SHIM}(${config}, ${SCOPE_MAX_CHARS}, "${MESSAGE_TYPE}")<\/script>`
}
interface Saved {
// 最后写入时间,总量超了先删最久没用的
t: number
d: Record<string, string>
}
function read(key: string): Saved | null {
try {
const saved = JSON.parse(localStorage.getItem(key) ?? "null")
return saved && typeof saved.d === "object" ? saved : null
} catch {
return null
}
}
export function loadPreviewStorage(scope: string): PreviewStorage {
return { scope, data: read(KEY_PREFIX + scope)?.d ?? {} }
}
function previewKeys() {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k?.startsWith(KEY_PREFIX)) keys.push(k)
}
return keys
}
function evictOthers(keep: string, all: boolean) {
const others = previewKeys()
.filter((k) => k !== keep)
.map((k) => ({ k, t: read(k)?.t ?? 0, n: localStorage.getItem(k)!.length }))
.sort((a, b) => a.t - b.t)
let total = others.reduce((n, o) => n + o.n, 0) + (localStorage.getItem(keep)?.length ?? 0)
for (const o of others) {
if (!all && total <= TOTAL_MAX_CHARS) break
localStorage.removeItem(o.k)
total -= o.n
}
}
function save(scope: string, data: Record<string, string>) {
const key = KEY_PREFIX + scope
const value = JSON.stringify({ t: Date.now(), d: data } satisfies Saved)
try {
localStorage.setItem(key, value)
evictOthers(key, false)
} catch {
// 配额满了:清掉别的预览数据再试一次,还不行就不存了
try {
evictOthers(key, true)
localStorage.setItem(key, value)
} catch {}
}
}
function apply(scope: string, msg: any) {
const data = { ...loadPreviewStorage(scope).data }
if (msg.op === "set") {
if (typeof msg.key !== "string" || typeof msg.value !== "string") return
data[msg.key] = msg.value
} else if (msg.op === "remove") {
if (typeof msg.key !== "string") return
delete data[msg.key]
} else if (msg.op === "clear") {
for (const k of Object.keys(data)) delete data[k]
} else {
return
}
const size = Object.entries(data).reduce((n, [k, v]) => n + k.length + v.length, 0)
if (size > SCOPE_MAX_CHARS) return
save(scope, data)
}
// 在 win 上接收 iframe 发来的写入。只认这个 iframe、这个 scope 的消息;返回取消监听的函数
export function listenPreviewStorage(
win: Window,
frame: () => HTMLIFrameElement | null | undefined,
scope: () => string,
) {
const handler = (e: MessageEvent) => {
const s = scope()
if (!s || !e.data || e.data.type !== MESSAGE_TYPE || e.data.scope !== s) return
if (!frame() || e.source !== frame()!.contentWindow) return
apply(s, e.data)
}
win.addEventListener("message", handler)
return () => win.removeEventListener("message", handler)
}