Compare commits

..

4 Commits

Author SHA1 Message Date
0164e10513 fix: 修复调试面板右侧信息被挤到下方的问题
Some checks failed
Deploy / build-and-deploy (push) Has been cancelled
外层 flex 未禁用换行且左列没有 min-width: 0,编辑器的最小内容宽度
会把「调试信息」卡片挤到下一行。现在桌面端强制左右分栏,移动端保持
上下堆叠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JypUVVTKYKhYdBAVGVPpCC
2026-09-07 05:37:05 -06:00
aa928dd3f5 feat: 调试面板支持移动端,变量按作用域分组
Some checks failed
Deploy / build-and-deploy (push) Has been cancelled
- 调试逻辑下沉到 composables/debug.ts,桌面和移动端共用;模态框抽成
  DebugModal.vue(桌面 80vw / 移动 96vw),用 update:show 收口,
  关闭按钮、Esc、遮罩三条路径都会清理状态
- 移动端在"操作"菜单里补上调试入口(仅 Python)
- 变量面板改为分组展示:栈顶帧的局部变量在前、全局变量在后,
  顺序用 pg_encoder 的 ordered_globals / ordered_varnames,
  __return__ 显示成"返回值"。原来两者是 merge 成一份的,
  在函数里分不清哪个是局部、哪个是外面的全局
- 面板关闭时还原全局 output / status,否则主页输出区会停在某一调试步
- 输入切分不再过滤空行,只丢掉末尾换行产生的空串:程序可能正需要读一个
  空行,过滤掉会让输入提前耗尽、误报"需要更多输入"
- 后端超时/超限的 detail 透传到提示里,调试按钮加 loading 态
- 清掉 computed 和高亮更新里遗留的 console.log(自动播放时 500ms 刷一次)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QGZaLUWzGPEMTk6A5RShC
2026-09-07 04:46:01 -06:00
2afc81e9ab update
Some checks failed
Deploy / build-and-deploy (push) Has been cancelled
2026-09-07 04:09:09 -06:00
fb071e71a1 fix: 修复分享链接的多个问题
- reset() 清空时一并剥离 share 参数,否则重载后分享代码会被写回
- share() 以去掉 query/share 的地址为基址,避免预设代码在 init 里覆盖分享内容
- 打开分享链接前比对本地已存代码,会覆盖用户改动时先弹窗确认
- SQL 分享带上选中的数据表 id,接收方运行结果与分享者一致
- 分享负载的 code/table 字段补类型校验
- 链接损坏或预设加载失败时给出提示,不再静默吞掉
- 剪贴板写入失败时不再提示"已复制"

组件外的 init 阶段拿不到 useMessage(),新增 composables/notice.ts
用 createDiscreteApi 提供提示与确认框,主题跟随 useDark()。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CWT2cy7mkZevZR4WVVfAVu
2026-09-07 04:08:54 -06:00
12 changed files with 373 additions and 176 deletions

View File

@@ -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

View File

@@ -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

View 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>

View File

@@ -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,48 +104,8 @@ 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
if (!entry) return {}
const stack = entry.stack_to_render ?? []
const topFrame = stack.find((f: any) => f.is_highlighted) ?? stack[stack.length - 1]
if (topFrame && topFrame.encoded_locals) {
return { ...(entry.globals ?? {}), ...topFrame.encoded_locals }
}
return entry.globals ?? {}
})
// 格式化变量显示
const formattedVariables = computed(() => {
const variables = currentVariables.value
if (!variables || Object.keys(variables).length === 0) {
return []
}
const heap: Record<string, any> =
debugData.value?.trace?.[currentStep.value]?.heap ?? {}
return Object.entries(variables)
.filter(([, value]) => {
// 隐藏导入的模块/函数占位符
if (
Array.isArray(value) &&
value[0] === "IMPORTED_FAUX_PRIMITIVE"
)
return false
if (Array.isArray(value) && value[0] === "FUNCTION") return false
return true
})
.map(([key, value]) => {
const displayValue = decodeValue(value, heap)
// resolve REF before checking tag
const resolved =
Array.isArray(value) && value[0] === "REF"
? heap[String(value[1])]
: value
const tag = Array.isArray(resolved) ? resolved[0] : null
const typeMap: Record<string, string> = {
LIST: "list", LIST: "list",
TUPLE: "tuple", TUPLE: "tuple",
SET: "set", SET: "set",
@@ -157,11 +115,86 @@ const formattedVariables = computed(() => {
INSTANCE_PPRINT: "object", INSTANCE_PPRINT: "object",
CLASS: "class", CLASS: "class",
} }
const displayType =
tag && typeMap[tag] ? typeMap[tag] : typeof value
return { name: key, value: displayValue, type: displayType } /**
* 把一组编码变量转成可展示的列表。
* orderedNames 用 pg_encoder 给的定义顺序ordered_globals / ordered_varnames
* 比 Object.keys 更贴近代码里出现的先后。
*/
function formatVariables(
encoded: Record<string, any> | undefined,
orderedNames: string[] | undefined,
heap: Record<string, any>,
) {
if (!encoded) return []
const names = orderedNames?.length
? orderedNames.filter((name) => name in encoded)
: Object.keys(encoded)
return names
.filter((name) => {
const value = encoded[name]
// 隐藏导入的模块/函数占位符
if (Array.isArray(value) && value[0] === "IMPORTED_FAUX_PRIMITIVE")
return false
if (Array.isArray(value) && value[0] === "FUNCTION") return false
return true
}) })
.map((name) => {
const value = encoded[name]
const displayValue = decodeValue(value, heap)
// resolve REF before checking tag
const resolved =
Array.isArray(value) && value[0] === "REF"
? heap[String(value[1])]
: value
const tag = Array.isArray(resolved) ? resolved[0] : null
const displayType =
tag && TYPE_LABELS[tag] ? TYPE_LABELS[tag] : typeof value
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,22 +539,32 @@ 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-flex v-for="group in variableGroups" :key="group.key" vertical>
<n-text strong depth="2">{{ group.title }}</n-text>
<n-card <n-card
v-for="variable in formattedVariables" v-for="variable in group.variables"
:key="variable.name" :key="`${group.key}-${variable.name}`"
size="small" size="small"
:bordered="true" :bordered="true"
> >
@@ -535,6 +581,7 @@ function autoRun() {
</n-flex> </n-flex>
</n-card> </n-card>
</n-flex> </n-flex>
</n-flex>
</n-scrollbar> </n-scrollbar>
</n-flex> </n-flex>

View File

@@ -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
View 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=Truetrace 中至多只有 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
View 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

View File

@@ -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")

View File

@@ -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=Truetrace 中至多只有 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>

View File

@@ -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>

View File

@@ -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() {
const result = await startDebug()
if (!result.ok) message[result.level](result.message)
}
const menu = computed<DropdownOption[]>(() => {
const options: DropdownOption[] = [
{ label: "复制", key: "copy", props: { onClick: copy } }, { label: "复制", key: "copy", props: { onClick: copy } },
{ label: "清空", key: "reset", props: { onClick: reset } }, { label: "清空", key: "reset", props: { onClick: reset } },
{ label: "分享", key: "share", props: { onClick: handleShare } }, { 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>

View File

@@ -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>