Compare commits
4 Commits
4031bbe82e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0164e10513 | |||
| aa928dd3f5 | |||
| 2afc81e9ab | |||
| fb071e71a1 |
4
.github/workflows/deploy.yaml
vendored
4
.github/workflows/deploy.yaml
vendored
@@ -9,9 +9,9 @@ jobs:
|
|||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -214,13 +214,6 @@ function onReady(payload: {
|
|||||||
// 更新高亮的函数
|
// 更新高亮的函数
|
||||||
function updateHighlight() {
|
function updateHighlight() {
|
||||||
if (editorView.value) {
|
if (editorView.value) {
|
||||||
console.log(
|
|
||||||
"Updating highlight - currentLine:",
|
|
||||||
props.currentLine,
|
|
||||||
"nextLine:",
|
|
||||||
props.nextLine,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 如果当前行和下一步相同,只高亮当前行,不显示下一步
|
// 如果当前行和下一步相同,只高亮当前行,不显示下一步
|
||||||
const nextLine =
|
const nextLine =
|
||||||
props.currentLine === props.nextLine ? undefined : props.nextLine
|
props.currentLine === props.nextLine ? undefined : props.nextLine
|
||||||
|
|||||||
27
src/components/DebugModal.vue
Normal file
27
src/components/DebugModal.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import DebugPanel from "./DebugPanel.vue"
|
||||||
|
import { isMobile } from "../composables/breakpoints"
|
||||||
|
import { closeDebug, debugData, showDebugModal } from "../composables/debug"
|
||||||
|
|
||||||
|
// 用 update:show 而不是 @close:关闭按钮、Esc、遮罩点击都会走到这里
|
||||||
|
function onUpdateShow(value: boolean) {
|
||||||
|
if (!value) closeDebug()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="showDebugModal"
|
||||||
|
preset="card"
|
||||||
|
title="调试"
|
||||||
|
size="large"
|
||||||
|
:mask-closable="false"
|
||||||
|
:auto-focus="false"
|
||||||
|
:style="
|
||||||
|
isMobile ? { width: '96vw' } : { width: '80vw', maxWidth: '1000px' }
|
||||||
|
"
|
||||||
|
@update:show="onUpdateShow"
|
||||||
|
>
|
||||||
|
<DebugPanel :initial-debug-data="debugData" />
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// Vue 核心
|
// Vue 核心
|
||||||
import { ref, computed, watch } from "vue"
|
import { ref, computed, onBeforeUnmount, watch } from "vue"
|
||||||
|
|
||||||
// 第三方库
|
// 第三方库
|
||||||
import copyTextToClipboard from "copy-text-to-clipboard"
|
import copyTextToClipboard from "copy-text-to-clipboard"
|
||||||
@@ -13,6 +13,7 @@ import DebugEditor from "./DebugEditor.vue"
|
|||||||
|
|
||||||
// 组合式函数和类型
|
// 组合式函数和类型
|
||||||
import { code, size, output, status } from "../composables/code"
|
import { code, size, output, status } from "../composables/code"
|
||||||
|
import { isMobile } from "../composables/breakpoints"
|
||||||
import { Status } from "../types"
|
import { Status } from "../types"
|
||||||
|
|
||||||
// ==================== Props 和 Emits ====================
|
// ==================== Props 和 Emits ====================
|
||||||
@@ -81,7 +82,6 @@ const currentLine = computed(() => {
|
|||||||
debugData.value.trace[currentStep.value]
|
debugData.value.trace[currentStep.value]
|
||||||
) {
|
) {
|
||||||
const line = debugData.value.trace[currentStep.value].line
|
const line = debugData.value.trace[currentStep.value].line
|
||||||
console.log(`Step ${currentStep.value}: currentLine = ${line}`)
|
|
||||||
return line && line > 0 ? line : undefined
|
return line && line > 0 ? line : undefined
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
@@ -94,10 +94,8 @@ const nextLine = computed(() => {
|
|||||||
debugData.value.trace[currentStep.value + 1]
|
debugData.value.trace[currentStep.value + 1]
|
||||||
) {
|
) {
|
||||||
const line = debugData.value.trace[currentStep.value + 1].line
|
const line = debugData.value.trace[currentStep.value + 1].line
|
||||||
console.log(`Step ${currentStep.value}: nextLine = ${line}`)
|
|
||||||
return line && line > 0 && line !== currentLine.value ? line : undefined
|
return line && line > 0 && line !== currentLine.value ? line : undefined
|
||||||
}
|
}
|
||||||
console.log(`Step ${currentStep.value}: nextLine = undefined (no next step)`)
|
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,40 +104,45 @@ const currentTraceEntry = computed(() => {
|
|||||||
return debugData.value?.trace?.[currentStep.value] ?? null
|
return debugData.value?.trace?.[currentStep.value] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
// 调试信息相关:优先显示栈顶(高亮)帧的局部变量,没有则用全局
|
// pg_encoder 的类型标签 -> 展示用的类型名
|
||||||
const currentVariables = computed(() => {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
const entry = currentTraceEntry.value
|
LIST: "list",
|
||||||
if (!entry) return {}
|
TUPLE: "tuple",
|
||||||
const stack = entry.stack_to_render ?? []
|
SET: "set",
|
||||||
const topFrame = stack.find((f: any) => f.is_highlighted) ?? stack[stack.length - 1]
|
DICT: "dict",
|
||||||
if (topFrame && topFrame.encoded_locals) {
|
FUNCTION: "function",
|
||||||
return { ...(entry.globals ?? {}), ...topFrame.encoded_locals }
|
INSTANCE: "object",
|
||||||
}
|
INSTANCE_PPRINT: "object",
|
||||||
return entry.globals ?? {}
|
CLASS: "class",
|
||||||
})
|
}
|
||||||
|
|
||||||
// 格式化变量显示
|
/**
|
||||||
const formattedVariables = computed(() => {
|
* 把一组编码变量转成可展示的列表。
|
||||||
const variables = currentVariables.value
|
* orderedNames 用 pg_encoder 给的定义顺序(ordered_globals / ordered_varnames),
|
||||||
if (!variables || Object.keys(variables).length === 0) {
|
* 比 Object.keys 更贴近代码里出现的先后。
|
||||||
return []
|
*/
|
||||||
}
|
function formatVariables(
|
||||||
|
encoded: Record<string, any> | undefined,
|
||||||
|
orderedNames: string[] | undefined,
|
||||||
|
heap: Record<string, any>,
|
||||||
|
) {
|
||||||
|
if (!encoded) return []
|
||||||
|
|
||||||
const heap: Record<string, any> =
|
const names = orderedNames?.length
|
||||||
debugData.value?.trace?.[currentStep.value]?.heap ?? {}
|
? orderedNames.filter((name) => name in encoded)
|
||||||
|
: Object.keys(encoded)
|
||||||
|
|
||||||
return Object.entries(variables)
|
return names
|
||||||
.filter(([, value]) => {
|
.filter((name) => {
|
||||||
|
const value = encoded[name]
|
||||||
// 隐藏导入的模块/函数占位符
|
// 隐藏导入的模块/函数占位符
|
||||||
if (
|
if (Array.isArray(value) && value[0] === "IMPORTED_FAUX_PRIMITIVE")
|
||||||
Array.isArray(value) &&
|
|
||||||
value[0] === "IMPORTED_FAUX_PRIMITIVE"
|
|
||||||
)
|
|
||||||
return false
|
return false
|
||||||
if (Array.isArray(value) && value[0] === "FUNCTION") return false
|
if (Array.isArray(value) && value[0] === "FUNCTION") return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.map(([key, value]) => {
|
.map((name) => {
|
||||||
|
const value = encoded[name]
|
||||||
const displayValue = decodeValue(value, heap)
|
const displayValue = decodeValue(value, heap)
|
||||||
// resolve REF before checking tag
|
// resolve REF before checking tag
|
||||||
const resolved =
|
const resolved =
|
||||||
@@ -147,21 +150,51 @@ const formattedVariables = computed(() => {
|
|||||||
? heap[String(value[1])]
|
? heap[String(value[1])]
|
||||||
: value
|
: value
|
||||||
const tag = Array.isArray(resolved) ? resolved[0] : null
|
const tag = Array.isArray(resolved) ? resolved[0] : null
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
LIST: "list",
|
|
||||||
TUPLE: "tuple",
|
|
||||||
SET: "set",
|
|
||||||
DICT: "dict",
|
|
||||||
FUNCTION: "function",
|
|
||||||
INSTANCE: "object",
|
|
||||||
INSTANCE_PPRINT: "object",
|
|
||||||
CLASS: "class",
|
|
||||||
}
|
|
||||||
const displayType =
|
const displayType =
|
||||||
tag && typeMap[tag] ? typeMap[tag] : typeof value
|
tag && TYPE_LABELS[tag] ? TYPE_LABELS[tag] : typeof value
|
||||||
|
|
||||||
return { name: key, value: displayValue, type: displayType }
|
return {
|
||||||
|
// __return__ 是 pg_logger 给返回值起的内部名字,直接显示不友好
|
||||||
|
name: name === "__return__" ? "返回值" : name,
|
||||||
|
value: displayValue,
|
||||||
|
type: displayType,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变量按作用域分组:栈顶(高亮)帧的局部变量在前,全局变量在后。
|
||||||
|
* 之前是把两者 merge 成一份展示的,函数里看不出哪个是局部、哪个是外面的全局。
|
||||||
|
*/
|
||||||
|
const variableGroups = computed(() => {
|
||||||
|
const entry = currentTraceEntry.value
|
||||||
|
if (!entry) return []
|
||||||
|
|
||||||
|
const heap: Record<string, any> = entry.heap ?? {}
|
||||||
|
const stack = entry.stack_to_render ?? []
|
||||||
|
const topFrame =
|
||||||
|
stack.find((f: any) => f.is_highlighted) ?? stack[stack.length - 1]
|
||||||
|
|
||||||
|
const groups = []
|
||||||
|
if (topFrame?.encoded_locals) {
|
||||||
|
groups.push({
|
||||||
|
key: "locals",
|
||||||
|
title: `局部变量 · ${topFrame.func_name}()`,
|
||||||
|
variables: formatVariables(
|
||||||
|
topFrame.encoded_locals,
|
||||||
|
topFrame.ordered_varnames,
|
||||||
|
heap,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
groups.push({
|
||||||
|
// 不在函数里时没有对照组,标题就用朴素的"变量"
|
||||||
|
key: "globals",
|
||||||
|
title: topFrame ? "全局变量" : "变量",
|
||||||
|
variables: formatVariables(entry.globals, entry.ordered_globals, heap),
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups.filter((group) => group.variables.length > 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算输出行数
|
// 计算输出行数
|
||||||
@@ -310,6 +343,18 @@ const currentOutput = computed(() => {
|
|||||||
return outputText
|
return outputText
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 下面的 watch 会把当前调试步的输出/状态写进全局 output、status,
|
||||||
|
// 面板关闭后必须还原,否则主页面的输出区会停在某一步的内容、
|
||||||
|
// 状态标签也可能被改成「运行错误」。setup 期取值即进入面板时的快照,
|
||||||
|
// 放在 onBeforeUnmount 还原可以覆盖所有关闭方式(关闭按钮、Esc、遮罩)。
|
||||||
|
const outputBeforeDebug = output.value
|
||||||
|
const statusBeforeDebug = status.value
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
output.value = outputBeforeDebug
|
||||||
|
status.value = statusBeforeDebug
|
||||||
|
})
|
||||||
|
|
||||||
// 把外部状态的同步放到 watch 里(computed 不能有副作用)
|
// 把外部状态的同步放到 watch 里(computed 不能有副作用)
|
||||||
watch(
|
watch(
|
||||||
[currentOutput, () => debugData.value?.trace],
|
[currentOutput, () => debugData.value?.trace],
|
||||||
@@ -346,16 +391,6 @@ watch(
|
|||||||
if (newData.trace && newData.trace.length > 5000) {
|
if (newData.trace && newData.trace.length > 5000) {
|
||||||
message.warning(`超过 5000 步,请优化代码或减少循环次数`)
|
message.warning(`超过 5000 步,请优化代码或减少循环次数`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示前几个 trace 条目的行号
|
|
||||||
if (newData.trace) {
|
|
||||||
console.log("First few trace entries:")
|
|
||||||
newData.trace.slice(0, 5).forEach((entry: any, index: number) => {
|
|
||||||
console.log(
|
|
||||||
` Step ${index}: line ${entry.line}, event: ${entry.event}`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -429,9 +464,10 @@ function autoRun() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex>
|
<!-- 移动端上下堆叠,桌面端左右分栏(不换行,否则窄屏时右侧面板会被挤到下面) -->
|
||||||
|
<n-flex :vertical="isMobile" :wrap="false" align="stretch">
|
||||||
<!-- 左侧:分为上中下三层 -->
|
<!-- 左侧:分为上中下三层 -->
|
||||||
<n-flex vertical style="flex: 1">
|
<n-flex vertical style="flex: 1; min-width: 0">
|
||||||
<DebugEditor
|
<DebugEditor
|
||||||
v-model="code.value"
|
v-model="code.value"
|
||||||
:font-size="size"
|
:font-size="size"
|
||||||
@@ -503,37 +539,48 @@ function autoRun() {
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
<!-- 右侧:调试信息面板 -->
|
<!-- 右侧:调试信息面板 -->
|
||||||
<n-card :bordered="true" title="调试信息" size="small" style="width: 350px">
|
<n-card
|
||||||
<!-- 变量部分 -->
|
:bordered="true"
|
||||||
|
title="调试信息"
|
||||||
|
size="small"
|
||||||
|
:style="
|
||||||
|
isMobile
|
||||||
|
? { width: '100%' }
|
||||||
|
: { width: '350px', flex: '0 0 350px' }
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- 变量部分:局部 / 全局分组 -->
|
||||||
<n-flex vertical style="margin-bottom: 16px">
|
<n-flex vertical style="margin-bottom: 16px">
|
||||||
<n-text strong style="margin-bottom: 8px">变量</n-text>
|
<n-scrollbar style="max-height: 300px">
|
||||||
<n-scrollbar style="max-height: 260px">
|
|
||||||
<n-flex
|
<n-flex
|
||||||
v-if="formattedVariables.length === 0"
|
v-if="variableGroups.length === 0"
|
||||||
vertical
|
vertical
|
||||||
style="padding: 20px; text-align: center"
|
style="padding: 20px; text-align: center"
|
||||||
>
|
>
|
||||||
<n-text type="info">暂无变量</n-text>
|
<n-text type="info">暂无变量</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex v-else vertical>
|
<n-flex v-else vertical size="large">
|
||||||
<n-card
|
<n-flex v-for="group in variableGroups" :key="group.key" vertical>
|
||||||
v-for="variable in formattedVariables"
|
<n-text strong depth="2">{{ group.title }}</n-text>
|
||||||
:key="variable.name"
|
<n-card
|
||||||
size="small"
|
v-for="variable in group.variables"
|
||||||
:bordered="true"
|
:key="`${group.key}-${variable.name}`"
|
||||||
>
|
size="small"
|
||||||
<n-flex vertical>
|
:bordered="true"
|
||||||
<n-flex justify="space-between" align="center">
|
>
|
||||||
<n-text class="debug-text-title" strong type="primary">{{
|
<n-flex vertical>
|
||||||
variable.name
|
<n-flex justify="space-between" align="center">
|
||||||
}}</n-text>
|
<n-text class="debug-text-title" strong type="primary">{{
|
||||||
<n-tag size="small" type="info">{{ variable.type }}</n-tag>
|
variable.name
|
||||||
|
}}</n-text>
|
||||||
|
<n-tag size="small" type="info">{{ variable.type }}</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
<n-text code class="debug-text">
|
||||||
|
{{ variable.value }}
|
||||||
|
</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-text code class="debug-text">
|
</n-card>
|
||||||
{{ variable.value }}
|
</n-flex>
|
||||||
</n-text>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { useStorage } from "@vueuse/core"
|
import { useStorage } from "@vueuse/core"
|
||||||
import copyTextToClipboard from "copy-text-to-clipboard"
|
import copyTextToClipboard from "copy-text-to-clipboard"
|
||||||
import qs from "query-string"
|
import qs from "query-string"
|
||||||
import { reactive, ref, watch } from "vue"
|
import { nextTick, reactive, ref, watch } from "vue"
|
||||||
import { formatCode, getCodeByQuery, submit } from "../api"
|
import { formatCode, getCodeByQuery, submit } from "../api"
|
||||||
import { sources } from "../templates"
|
import { sources } from "../templates"
|
||||||
import { Cache, Code, LANGUAGE, Status } from "../types"
|
import { Cache, Code, LANGUAGE, Status } from "../types"
|
||||||
import { atou, utoa } from "../utils"
|
import { atou, utoa } from "../utils"
|
||||||
import { isMobile } from "./breakpoints"
|
import { isMobile } from "./breakpoints"
|
||||||
import { buildSqlScript, resetSqlTableSelection } from "./sqlTable"
|
import { dialog, notice } from "./notice"
|
||||||
|
import {
|
||||||
|
buildSqlScript,
|
||||||
|
resetSqlTableSelection,
|
||||||
|
selectSqlTable,
|
||||||
|
selectedTableId,
|
||||||
|
} from "./sqlTable"
|
||||||
|
|
||||||
const defaultLanguage = "python"
|
const defaultLanguage = "python"
|
||||||
|
const languages: LANGUAGE[] = ["python", "c", "cpp", "turtle", "sql"]
|
||||||
|
|
||||||
const cache: Cache = {
|
const cache: Cache = {
|
||||||
language: useStorage<LANGUAGE>("code_language", defaultLanguage),
|
language: useStorage<LANGUAGE>("code_language", defaultLanguage),
|
||||||
@@ -62,6 +69,58 @@ watch(input, (value: string) => {
|
|||||||
cache.input.value = value
|
cache.input.value = value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface Shared {
|
||||||
|
lang: LANGUAGE
|
||||||
|
code: string
|
||||||
|
input: string
|
||||||
|
table?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链接内容完全来自 URL,字段都要校验后才能落到编辑器里
|
||||||
|
function parseShared(base64: string): Shared {
|
||||||
|
const data = JSON.parse(atou(base64))
|
||||||
|
const lang = languages.includes(data.lang)
|
||||||
|
? (data.lang as LANGUAGE)
|
||||||
|
: defaultLanguage
|
||||||
|
return {
|
||||||
|
lang,
|
||||||
|
code: typeof data.code === "string" ? data.code : sources[lang],
|
||||||
|
input: typeof data.input === "string" ? data.input : "",
|
||||||
|
table: typeof data.table === "string" ? data.table : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmOverwrite() {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
dialog.warning({
|
||||||
|
title: "打开分享的代码",
|
||||||
|
content: "这会覆盖你当前保存的代码,是否继续?",
|
||||||
|
positiveText: "打开分享",
|
||||||
|
negativeText: "保留我的代码",
|
||||||
|
onPositiveClick: () => resolve(true),
|
||||||
|
onNegativeClick: () => resolve(false),
|
||||||
|
onClose: () => resolve(false),
|
||||||
|
onMaskClick: () => resolve(false),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyShared(shared: Shared) {
|
||||||
|
const saved = cache.code[shared.lang].value
|
||||||
|
const safe = saved === sources[shared.lang] || saved === shared.code
|
||||||
|
if (!safe && !(await confirmOverwrite())) return
|
||||||
|
|
||||||
|
cache.code[shared.lang].value = shared.code
|
||||||
|
code.language = shared.lang
|
||||||
|
code.value = shared.code
|
||||||
|
input.value = shared.input
|
||||||
|
if (shared.lang === "sql") {
|
||||||
|
// 切换语言的 watch 会重置选中的表,要等它跑完再应用分享里的表
|
||||||
|
await nextTick()
|
||||||
|
selectSqlTable(shared.table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
code.language = cache.language.value
|
code.language = cache.language.value
|
||||||
code.value = cache.code[code.language].value
|
code.value = cache.code[code.language].value
|
||||||
@@ -72,24 +131,24 @@ export async function init() {
|
|||||||
const parsed = qs.parse(location.search)
|
const parsed = qs.parse(location.search)
|
||||||
const base64 = parsed.share as string
|
const base64 = parsed.share as string
|
||||||
if (base64) {
|
if (base64) {
|
||||||
|
let shared: Shared
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(atou(base64))
|
shared = parseShared(base64)
|
||||||
const lang = ["python", "c", "cpp", "turtle", "sql"].includes(data.lang)
|
} catch (err) {
|
||||||
? (data.lang as LANGUAGE)
|
notice.error("分享链接已损坏,可能在传输中被截断")
|
||||||
: defaultLanguage
|
return
|
||||||
const sharedCode = data.code ?? sources[lang]
|
}
|
||||||
cache.code[lang].value = sharedCode
|
await applyShared(shared)
|
||||||
code.language = lang
|
return
|
||||||
code.value = sharedCode
|
|
||||||
input.value = typeof data.input === "string" ? data.input : ""
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
}
|
||||||
const preset = parsed.query as string
|
const preset = parsed.query as string
|
||||||
if (preset) {
|
if (preset) {
|
||||||
try {
|
try {
|
||||||
const result = await getCodeByQuery(preset)
|
const result = await getCodeByQuery(preset)
|
||||||
code.value = result.data.code
|
code.value = result.data.code
|
||||||
} catch (err) {}
|
} catch (err) {
|
||||||
|
notice.error("预设代码加载失败")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +161,7 @@ export function reset() {
|
|||||||
cache.code[code.language].value = sources[code.language]
|
cache.code[code.language].value = sources[code.language]
|
||||||
output.value = ""
|
output.value = ""
|
||||||
status.value = Status.NotStarted
|
status.value = Status.NotStarted
|
||||||
const url = qs.exclude(location.href, ["query"])
|
const url = qs.exclude(location.href, ["query", "share"])
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,15 +190,16 @@ export async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function share() {
|
export function share() {
|
||||||
const data = {
|
const data: Shared = {
|
||||||
lang: code.language,
|
lang: code.language,
|
||||||
code: code.value,
|
code: code.value,
|
||||||
input: input.value,
|
input: input.value,
|
||||||
}
|
}
|
||||||
|
if (code.language === "sql") data.table = selectedTableId.value
|
||||||
const base64 = utoa(JSON.stringify(data))
|
const base64 = utoa(JSON.stringify(data))
|
||||||
copyTextToClipboard(
|
// 基址要去掉 query(预设代码会在 init 里覆盖分享内容)和上一次的 share
|
||||||
qs.stringifyUrl({ url: location.href, query: { share: base64 } }),
|
const url = qs.exclude(location.href, ["query", "share"])
|
||||||
)
|
return copyTextToClipboard(qs.stringifyUrl({ url, query: { share: base64 } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function format() {
|
export async function format() {
|
||||||
|
|||||||
74
src/composables/debug.ts
Normal file
74
src/composables/debug.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
import { debug as fetchDebugTrace } from "../api"
|
||||||
|
import { code, input } from "./code"
|
||||||
|
|
||||||
|
export const showDebugModal = ref(false)
|
||||||
|
export const debugData = ref<any>(null)
|
||||||
|
export const debugLoading = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把输入框内容切成 stdin 行。
|
||||||
|
* 只丢掉末尾换行产生的那个空串,中间的空行必须保留 ——
|
||||||
|
* 程序可能正需要读一个空行(input() 返回 ""),过滤掉会导致输入提前耗尽。
|
||||||
|
*/
|
||||||
|
function splitInputLines(text: string): string[] {
|
||||||
|
if (!text) return []
|
||||||
|
const lines = text.split("\n")
|
||||||
|
if (lines[lines.length - 1] === "") lines.pop()
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* trace 末尾停在 raw_input 即说明输入不足
|
||||||
|
* (pg_logger 在缺输入时会立刻 done=True,trace 中至多只有 1 个 raw_input 事件,
|
||||||
|
* 所以不能用计数对比,只能看末尾)
|
||||||
|
*/
|
||||||
|
function endsAtRawInput(data: any): boolean {
|
||||||
|
const trace = data?.trace
|
||||||
|
if (!trace?.length) return false
|
||||||
|
return trace[trace.length - 1].event === "raw_input"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DebugResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; level: "error" | "warning"; message: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求调试数据并打开面板。
|
||||||
|
* 提示文案交给调用方展示:useMessage() 只能在组件的 setup 里取。
|
||||||
|
*/
|
||||||
|
export async function startDebug(): Promise<DebugResult> {
|
||||||
|
if (debugLoading.value) return { ok: true }
|
||||||
|
|
||||||
|
debugLoading.value = true
|
||||||
|
let res
|
||||||
|
try {
|
||||||
|
res = await fetchDebugTrace(code.value, splitInputLines(input.value))
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
level: "error",
|
||||||
|
message: `调试失败: ${err?.response?.data?.detail ?? err?.message ?? "未知错误"}`,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
debugLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
debugData.value = res.data
|
||||||
|
|
||||||
|
if (endsAtRawInput(res.data)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
level: "warning",
|
||||||
|
message: "程序需要更多输入,请在输入框补全后重新点击调试",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDebugModal.value = true
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDebug() {
|
||||||
|
showDebugModal.value = false
|
||||||
|
debugData.value = null
|
||||||
|
}
|
||||||
15
src/composables/notice.ts
Normal file
15
src/composables/notice.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useDark } from "@vueuse/core"
|
||||||
|
import { createDiscreteApi, darkTheme } from "naive-ui"
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const isDark = useDark()
|
||||||
|
|
||||||
|
// 组件外(如 init 阶段)需要提示时用这套脱离上下文的 API,主题跟随 App.vue
|
||||||
|
const api = createDiscreteApi(["message", "dialog"], {
|
||||||
|
configProviderProps: computed(() => ({
|
||||||
|
theme: isDark.value ? darkTheme : null,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const notice = api.message
|
||||||
|
export const dialog = api.dialog
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import {
|
import { buildSetupSql, defaultSqlTableId, sqlTables } from "../data/sqlTables"
|
||||||
buildSetupSql,
|
|
||||||
defaultSqlTableId,
|
|
||||||
sqlTables,
|
|
||||||
} from "../data/sqlTables"
|
|
||||||
|
|
||||||
export const selectedTableId = ref(defaultSqlTableId)
|
export const selectedTableId = ref(defaultSqlTableId)
|
||||||
|
|
||||||
@@ -11,6 +7,13 @@ export function resetSqlTableSelection() {
|
|||||||
selectedTableId.value = defaultSqlTableId
|
selectedTableId.value = defaultSqlTableId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分享链接里的表 id 不可信,认不出来就退回默认表
|
||||||
|
export function selectSqlTable(id: unknown) {
|
||||||
|
selectedTableId.value = sqlTables.some((item) => item.id === id)
|
||||||
|
? (id as string)
|
||||||
|
: defaultSqlTableId
|
||||||
|
}
|
||||||
|
|
||||||
// SELECT / WITH 属于查询,直接展示查询结果的列;其余(增删改)回显整张表
|
// SELECT / WITH 属于查询,直接展示查询结果的列;其余(增删改)回显整张表
|
||||||
function isQuery(sql: string): boolean {
|
function isQuery(sql: string): boolean {
|
||||||
return /^\s*(SELECT|WITH)\b/i.test(sql)
|
return /^\s*(SELECT|WITH)\b/i.test(sql)
|
||||||
@@ -18,8 +21,7 @@ function isQuery(sql: string): boolean {
|
|||||||
|
|
||||||
export function buildSqlScript(studentSql: string) {
|
export function buildSqlScript(studentSql: string) {
|
||||||
const table =
|
const table =
|
||||||
sqlTables.find((item) => item.id === selectedTableId.value) ??
|
sqlTables.find((item) => item.id === selectedTableId.value) ?? sqlTables[0]
|
||||||
sqlTables[0]
|
|
||||||
const normalizedSql = studentSql.trim().replace(/;?\s*$/, ";")
|
const normalizedSql = studentSql.trim().replace(/;?\s*$/, ";")
|
||||||
if (isQuery(studentSql.trim())) {
|
if (isQuery(studentSql.trim())) {
|
||||||
return [buildSetupSql(table), ".headers on", normalizedSql].join("\n\n")
|
return [buildSetupSql(table), ".headers on", normalizedSql].join("\n\n")
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue"
|
|
||||||
import copyTextToClipboard from "copy-text-to-clipboard"
|
import copyTextToClipboard from "copy-text-to-clipboard"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import CodeEditor from "../components/CodeEditor.vue"
|
import CodeEditor from "../components/CodeEditor.vue"
|
||||||
import DebugPanel from "../components/DebugPanel.vue"
|
import DebugModal from "../components/DebugModal.vue"
|
||||||
import { code, format, input, reset, size } from "../composables/code"
|
import { code, format, reset, size } from "../composables/code"
|
||||||
import { debug } from "../api"
|
import { debugLoading, startDebug } from "../composables/debug"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const showDebugModal = ref(false)
|
|
||||||
const debugData = ref<any>(null)
|
|
||||||
|
|
||||||
function closeDebug() {
|
|
||||||
showDebugModal.value = false
|
|
||||||
debugData.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function copy() {
|
function copy() {
|
||||||
copyTextToClipboard(code.value)
|
copyTextToClipboard(code.value)
|
||||||
message.success("已经复制好了")
|
message.success("已经复制好了")
|
||||||
@@ -33,38 +24,9 @@ async function handleFormat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* trace 末尾停在 raw_input 即说明输入不足
|
|
||||||
* (pg_logger 在缺输入时会立刻 done=True,trace 中至多只有 1 个 raw_input 事件,
|
|
||||||
* 所以不能用计数对比,只能看末尾)
|
|
||||||
*/
|
|
||||||
function endsAtRawInput(debugData: any): boolean {
|
|
||||||
const trace = debugData?.trace
|
|
||||||
if (!trace?.length) return false
|
|
||||||
return trace[trace.length - 1].event === "raw_input"
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDebug() {
|
async function handleDebug() {
|
||||||
const inputs = input.value
|
const result = await startDebug()
|
||||||
? input.value.split("\n").filter((line) => line.trim() !== "")
|
if (!result.ok) message[result.level](result.message)
|
||||||
: []
|
|
||||||
|
|
||||||
let res
|
|
||||||
try {
|
|
||||||
res = await debug(code.value, inputs)
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(`调试请求失败: ${err?.message ?? err}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
debugData.value = res.data
|
|
||||||
|
|
||||||
if (endsAtRawInput(res.data)) {
|
|
||||||
message.warning("程序需要更多输入,请在输入框补全后重新点击调试")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showDebugModal.value = true
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,7 +46,8 @@ async function handleDebug() {
|
|||||||
v-if="code.language === 'python'"
|
v-if="code.language === 'python'"
|
||||||
quaternary
|
quaternary
|
||||||
type="error"
|
type="error"
|
||||||
:disabled="!code.value"
|
:loading="debugLoading"
|
||||||
|
:disabled="!code.value || debugLoading"
|
||||||
@click="handleDebug"
|
@click="handleDebug"
|
||||||
>
|
>
|
||||||
调试
|
调试
|
||||||
@@ -92,16 +55,5 @@ async function handleDebug() {
|
|||||||
</template>
|
</template>
|
||||||
</CodeEditor>
|
</CodeEditor>
|
||||||
|
|
||||||
<n-modal
|
<DebugModal />
|
||||||
v-model:show="showDebugModal"
|
|
||||||
preset="card"
|
|
||||||
title="调试"
|
|
||||||
size="large"
|
|
||||||
:mask-closable="false"
|
|
||||||
:auto-focus="false"
|
|
||||||
@close="closeDebug"
|
|
||||||
style="width: 80vw; max-width: 1000px"
|
|
||||||
>
|
|
||||||
<DebugPanel :initial-debug-data="debugData" @close="closeDebug" />
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import { code, loading, run, share, size } from "../composables/code"
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
function handleShare() {
|
function handleShare() {
|
||||||
share()
|
if (share()) {
|
||||||
message.success("分享链接已复制")
|
message.success("分享链接已复制")
|
||||||
|
} else {
|
||||||
|
message.error("复制失败,请检查浏览器剪贴板权限")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import copyTextToClipboard from "copy-text-to-clipboard"
|
import copyTextToClipboard from "copy-text-to-clipboard"
|
||||||
import { useMessage, type DropdownOption } from "naive-ui"
|
import { useMessage, type DropdownOption } from "naive-ui"
|
||||||
|
import { computed } from "vue"
|
||||||
import { code, loading, reset, run, share } from "../composables/code"
|
import { code, loading, reset, run, share } from "../composables/code"
|
||||||
|
import { debugLoading, startDebug } from "../composables/debug"
|
||||||
import { tab } from "../composables/tab"
|
import { tab } from "../composables/tab"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -17,15 +19,35 @@ function copy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleShare() {
|
function handleShare() {
|
||||||
share()
|
if (share()) {
|
||||||
message.success("分享链接已复制")
|
message.success("分享链接已复制")
|
||||||
|
} else {
|
||||||
|
message.error("复制失败,请检查浏览器剪贴板权限")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const menu: DropdownOption[] = [
|
async function handleDebug() {
|
||||||
{ label: "复制", key: "copy", props: { onClick: copy } },
|
const result = await startDebug()
|
||||||
{ label: "清空", key: "reset", props: { onClick: reset } },
|
if (!result.ok) message[result.level](result.message)
|
||||||
{ label: "分享", key: "share", props: { onClick: handleShare } },
|
}
|
||||||
]
|
|
||||||
|
const menu = computed<DropdownOption[]>(() => {
|
||||||
|
const options: DropdownOption[] = [
|
||||||
|
{ label: "复制", key: "copy", props: { onClick: copy } },
|
||||||
|
{ label: "清空", key: "reset", props: { onClick: reset } },
|
||||||
|
{ label: "分享", key: "share", props: { onClick: handleShare } },
|
||||||
|
]
|
||||||
|
// 调试只支持 Python,跟桌面端的按钮保持一致
|
||||||
|
if (code.language === "python") {
|
||||||
|
options.push({
|
||||||
|
label: debugLoading.value ? "调试中…" : "调试",
|
||||||
|
key: "debug",
|
||||||
|
disabled: debugLoading.value || !code.value,
|
||||||
|
props: { onClick: handleDebug },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-layout-header class="container" bordered>
|
<n-layout-header class="container" bordered>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import DebugModal from "../components/DebugModal.vue"
|
||||||
import Content from "./Content.vue"
|
import Content from "./Content.vue"
|
||||||
import Header from "./Header.vue"
|
import Header from "./Header.vue"
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Header />
|
<Header />
|
||||||
<Content />
|
<Content />
|
||||||
|
<DebugModal />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user