Compare commits
7
Commits
9b79cddfa6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0164e10513 | ||
|
|
aa928dd3f5 | ||
|
|
2afc81e9ab | ||
|
|
fb071e71a1 | ||
|
|
4031bbe82e | ||
|
|
aaf8719943 | ||
|
|
22f7c34f76 |
@@ -9,9 +9,9 @@ jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
@@ -214,13 +214,6 @@ function onReady(payload: {
|
||||
// 更新高亮的函数
|
||||
function updateHighlight() {
|
||||
if (editorView.value) {
|
||||
console.log(
|
||||
"Updating highlight - currentLine:",
|
||||
props.currentLine,
|
||||
"nextLine:",
|
||||
props.nextLine,
|
||||
)
|
||||
|
||||
// 如果当前行和下一步相同,只高亮当前行,不显示下一步
|
||||
const nextLine =
|
||||
props.currentLine === props.nextLine ? undefined : props.nextLine
|
||||
|
||||
@@ -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>
|
||||
+117
-70
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
// Vue 核心
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { ref, computed, onBeforeUnmount, watch } from "vue"
|
||||
|
||||
// 第三方库
|
||||
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 { isMobile } from "../composables/breakpoints"
|
||||
import { Status } from "../types"
|
||||
|
||||
// ==================== Props 和 Emits ====================
|
||||
@@ -81,7 +82,6 @@ const currentLine = computed(() => {
|
||||
debugData.value.trace[currentStep.value]
|
||||
) {
|
||||
const line = debugData.value.trace[currentStep.value].line
|
||||
console.log(`Step ${currentStep.value}: currentLine = ${line}`)
|
||||
return line && line > 0 ? line : undefined
|
||||
}
|
||||
return undefined
|
||||
@@ -94,10 +94,8 @@ const nextLine = computed(() => {
|
||||
debugData.value.trace[currentStep.value + 1]
|
||||
) {
|
||||
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
|
||||
}
|
||||
console.log(`Step ${currentStep.value}: nextLine = undefined (no next step)`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -106,48 +104,8 @@ const currentTraceEntry = computed(() => {
|
||||
return debugData.value?.trace?.[currentStep.value] ?? null
|
||||
})
|
||||
|
||||
// 调试信息相关:优先显示栈顶(高亮)帧的局部变量,没有则用全局
|
||||
const currentVariables = computed(() => {
|
||||
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> = {
|
||||
// pg_encoder 的类型标签 -> 展示用的类型名
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
LIST: "list",
|
||||
TUPLE: "tuple",
|
||||
SET: "set",
|
||||
@@ -156,12 +114,87 @@ const formattedVariables = computed(() => {
|
||||
INSTANCE: "object",
|
||||
INSTANCE_PPRINT: "object",
|
||||
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
|
||||
})
|
||||
|
||||
// 下面的 watch 会把当前调试步的输出/状态写进全局 output、status,
|
||||
// 面板关闭后必须还原,否则主页面的输出区会停在某一步的内容、
|
||||
// 状态标签也可能被改成「运行错误」。setup 期取值即进入面板时的快照,
|
||||
// 放在 onBeforeUnmount 还原可以覆盖所有关闭方式(关闭按钮、Esc、遮罩)。
|
||||
const outputBeforeDebug = output.value
|
||||
const statusBeforeDebug = status.value
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
output.value = outputBeforeDebug
|
||||
status.value = statusBeforeDebug
|
||||
})
|
||||
|
||||
// 把外部状态的同步放到 watch 里(computed 不能有副作用)
|
||||
watch(
|
||||
[currentOutput, () => debugData.value?.trace],
|
||||
@@ -346,16 +391,6 @@ watch(
|
||||
if (newData.trace && newData.trace.length > 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 },
|
||||
@@ -429,9 +464,10 @@ function autoRun() {
|
||||
</script>
|
||||
|
||||
<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
|
||||
v-model="code.value"
|
||||
:font-size="size"
|
||||
@@ -503,22 +539,32 @@ function autoRun() {
|
||||
</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-text strong style="margin-bottom: 8px">变量</n-text>
|
||||
<n-scrollbar style="max-height: 260px">
|
||||
<n-scrollbar style="max-height: 300px">
|
||||
<n-flex
|
||||
v-if="formattedVariables.length === 0"
|
||||
v-if="variableGroups.length === 0"
|
||||
vertical
|
||||
style="padding: 20px; text-align: center"
|
||||
>
|
||||
<n-text type="info">暂无变量</n-text>
|
||||
</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
|
||||
v-for="variable in formattedVariables"
|
||||
:key="variable.name"
|
||||
v-for="variable in group.variables"
|
||||
:key="`${group.key}-${variable.name}`"
|
||||
size="small"
|
||||
:bordered="true"
|
||||
>
|
||||
@@ -535,6 +581,7 @@ function autoRun() {
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-scrollbar>
|
||||
</n-flex>
|
||||
|
||||
|
||||
+78
-18
@@ -1,15 +1,22 @@
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import copyTextToClipboard from "copy-text-to-clipboard"
|
||||
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 { sources } from "../templates"
|
||||
import { Cache, Code, LANGUAGE, Status } from "../types"
|
||||
import { atou, utoa } from "../utils"
|
||||
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 languages: LANGUAGE[] = ["python", "c", "cpp", "turtle", "sql"]
|
||||
|
||||
const cache: Cache = {
|
||||
language: useStorage<LANGUAGE>("code_language", defaultLanguage),
|
||||
@@ -62,6 +69,58 @@ watch(input, (value: string) => {
|
||||
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() {
|
||||
code.language = cache.language.value
|
||||
code.value = cache.code[code.language].value
|
||||
@@ -72,24 +131,24 @@ export async function init() {
|
||||
const parsed = qs.parse(location.search)
|
||||
const base64 = parsed.share as string
|
||||
if (base64) {
|
||||
let shared: Shared
|
||||
try {
|
||||
const data = JSON.parse(atou(base64))
|
||||
const lang = ["python", "c", "cpp", "turtle", "sql"].includes(data.lang)
|
||||
? (data.lang as LANGUAGE)
|
||||
: defaultLanguage
|
||||
const sharedCode = data.code ?? sources[lang]
|
||||
cache.code[lang].value = sharedCode
|
||||
code.language = lang
|
||||
code.value = sharedCode
|
||||
input.value = typeof data.input === "string" ? data.input : ""
|
||||
} catch (err) {}
|
||||
shared = parseShared(base64)
|
||||
} catch (err) {
|
||||
notice.error("分享链接已损坏,可能在传输中被截断")
|
||||
return
|
||||
}
|
||||
await applyShared(shared)
|
||||
return
|
||||
}
|
||||
const preset = parsed.query as string
|
||||
if (preset) {
|
||||
try {
|
||||
const result = await getCodeByQuery(preset)
|
||||
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]
|
||||
output.value = ""
|
||||
status.value = Status.NotStarted
|
||||
const url = qs.exclude(location.href, ["query"])
|
||||
const url = qs.exclude(location.href, ["query", "share"])
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
@@ -131,15 +190,16 @@ export async function run() {
|
||||
}
|
||||
|
||||
export function share() {
|
||||
const data = {
|
||||
const data: Shared = {
|
||||
lang: code.language,
|
||||
code: code.value,
|
||||
input: input.value,
|
||||
}
|
||||
if (code.language === "sql") data.table = selectedTableId.value
|
||||
const base64 = utoa(JSON.stringify(data))
|
||||
copyTextToClipboard(
|
||||
qs.stringifyUrl({ url: location.href, query: { share: base64 } }),
|
||||
)
|
||||
// 基址要去掉 query(预设代码会在 init 里覆盖分享内容)和上一次的 share
|
||||
const url = qs.exclude(location.href, ["query", "share"])
|
||||
return copyTextToClipboard(qs.stringifyUrl({ url, query: { share: base64 } }))
|
||||
}
|
||||
|
||||
export async function format() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
+35
-17
@@ -1,10 +1,5 @@
|
||||
import { ref } from "vue"
|
||||
import {
|
||||
buildSetupSql,
|
||||
defaultSqlTableId,
|
||||
sqlTables,
|
||||
type SqlColumn,
|
||||
} from "../data/sqlTables"
|
||||
import { buildSetupSql, defaultSqlTableId, sqlTables } from "../data/sqlTables"
|
||||
|
||||
export const selectedTableId = ref(defaultSqlTableId)
|
||||
|
||||
@@ -12,32 +7,55 @@ export function resetSqlTableSelection() {
|
||||
selectedTableId.value = defaultSqlTableId
|
||||
}
|
||||
|
||||
// 分享链接里的表 id 不可信,认不出来就退回默认表
|
||||
export function selectSqlTable(id: unknown) {
|
||||
selectedTableId.value = sqlTables.some((item) => item.id === id)
|
||||
? (id as string)
|
||||
: defaultSqlTableId
|
||||
}
|
||||
|
||||
// SELECT / WITH 属于查询,直接展示查询结果的列;其余(增删改)回显整张表
|
||||
function isQuery(sql: string): boolean {
|
||||
return /^\s*(SELECT|WITH)\b/i.test(sql)
|
||||
}
|
||||
|
||||
export function buildSqlScript(studentSql: string) {
|
||||
const table =
|
||||
sqlTables.find((item) => item.id === selectedTableId.value) ??
|
||||
sqlTables[0]
|
||||
sqlTables.find((item) => item.id === selectedTableId.value) ?? sqlTables[0]
|
||||
const normalizedSql = studentSql.trim().replace(/;?\s*$/, ";")
|
||||
if (isQuery(studentSql.trim())) {
|
||||
return [buildSetupSql(table), ".headers on", normalizedSql].join("\n\n")
|
||||
}
|
||||
return [
|
||||
buildSetupSql(table),
|
||||
".output /dev/null",
|
||||
normalizedSql,
|
||||
".output stdout",
|
||||
".headers on",
|
||||
`SELECT * FROM ${table.tableName};`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
export function parseResultRows(
|
||||
output: string,
|
||||
columns: SqlColumn[],
|
||||
): Record<string, string | number>[] {
|
||||
return output
|
||||
export interface SqlResult {
|
||||
columns: string[]
|
||||
rows: Record<string, string | number>[]
|
||||
}
|
||||
|
||||
// 输出为 sqlite CLI 的 list 模式(| 分隔),开启 .headers on 后首行是列名
|
||||
export function parseResult(output: string): SqlResult {
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
if (lines.length === 0) return { columns: [], rows: [] }
|
||||
const columns = lines[0].split("|")
|
||||
const rows = lines.slice(1).map((line, index) => {
|
||||
const cells = line.split("|")
|
||||
return Object.fromEntries(
|
||||
columns.map((column, index) => [column.name, cells[index] ?? ""]),
|
||||
)
|
||||
const record: Record<string, string | number> = { __key: index }
|
||||
columns.forEach((column, i) => {
|
||||
record[column] = cells[i] ?? ""
|
||||
})
|
||||
return record
|
||||
})
|
||||
return { columns, rows }
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue"
|
||||
import copyTextToClipboard from "copy-text-to-clipboard"
|
||||
import { useMessage } from "naive-ui"
|
||||
import CodeEditor from "../components/CodeEditor.vue"
|
||||
import DebugPanel from "../components/DebugPanel.vue"
|
||||
import { code, format, input, reset, size } from "../composables/code"
|
||||
import { debug } from "../api"
|
||||
import DebugModal from "../components/DebugModal.vue"
|
||||
import { code, format, reset, size } from "../composables/code"
|
||||
import { debugLoading, startDebug } from "../composables/debug"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const showDebugModal = ref(false)
|
||||
const debugData = ref<any>(null)
|
||||
|
||||
function closeDebug() {
|
||||
showDebugModal.value = false
|
||||
debugData.value = null
|
||||
}
|
||||
|
||||
function copy() {
|
||||
copyTextToClipboard(code.value)
|
||||
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() {
|
||||
const inputs = input.value
|
||||
? input.value.split("\n").filter((line) => line.trim() !== "")
|
||||
: []
|
||||
|
||||
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
|
||||
const result = await startDebug()
|
||||
if (!result.ok) message[result.level](result.message)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -84,7 +46,8 @@ async function handleDebug() {
|
||||
v-if="code.language === 'python'"
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="!code.value"
|
||||
:loading="debugLoading"
|
||||
:disabled="!code.value || debugLoading"
|
||||
@click="handleDebug"
|
||||
>
|
||||
调试
|
||||
@@ -92,16 +55,5 @@ async function handleDebug() {
|
||||
</template>
|
||||
</CodeEditor>
|
||||
|
||||
<n-modal
|
||||
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>
|
||||
<DebugModal />
|
||||
</template>
|
||||
|
||||
@@ -9,8 +9,11 @@ import { code, loading, run, share, size } from "../composables/code"
|
||||
const message = useMessage()
|
||||
|
||||
function handleShare() {
|
||||
share()
|
||||
if (share()) {
|
||||
message.success("分享链接已复制")
|
||||
} else {
|
||||
message.error("复制失败,请检查浏览器剪贴板权限")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DataTableColumns } from "naive-ui"
|
||||
import { computed, watch } from "vue"
|
||||
import { computed, h, watch } from "vue"
|
||||
import { output, status } from "../composables/code"
|
||||
import { parseResultRows, selectedTableId } from "../composables/sqlTable"
|
||||
import { parseResult, selectedTableId } from "../composables/sqlTable"
|
||||
import { sqlTables } from "../data/sqlTables"
|
||||
import { Status } from "../types"
|
||||
import OutputSection from "./OutputSection.vue"
|
||||
@@ -13,9 +13,37 @@ const selectedTable = computed(
|
||||
sqlTables[0],
|
||||
)
|
||||
|
||||
// 列头展示:列名 + 暗色小字的数据类型(仅取基础类型,去掉约束如 NOT NULL)
|
||||
function baseType(type: string): string {
|
||||
return type.trim().split(/\s+/)[0]
|
||||
}
|
||||
|
||||
function renderColumnTitle(name: string, type?: string) {
|
||||
return () =>
|
||||
h("span", null, [
|
||||
name,
|
||||
type
|
||||
? h(
|
||||
"span",
|
||||
{
|
||||
style:
|
||||
"font-size:12px;opacity:0.55;margin-left:4px;font-weight:normal;",
|
||||
},
|
||||
baseType(type),
|
||||
)
|
||||
: null,
|
||||
])
|
||||
}
|
||||
|
||||
const columnTypeMap = computed<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
selectedTable.value.columns.map((column) => [column.name, column.type]),
|
||||
),
|
||||
)
|
||||
|
||||
const tableColumns = computed<DataTableColumns>(() =>
|
||||
selectedTable.value.columns.map((column) => ({
|
||||
title: column.name,
|
||||
title: renderColumnTitle(column.name, column.type),
|
||||
key: column.name,
|
||||
})),
|
||||
)
|
||||
@@ -31,10 +59,17 @@ const initialRows = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const resultRows = computed(() =>
|
||||
parseResultRows(output.value, selectedTable.value.columns),
|
||||
const result = computed(() => parseResult(output.value))
|
||||
|
||||
const resultColumns = computed<DataTableColumns>(() =>
|
||||
result.value.columns.map((name) => ({
|
||||
title: renderColumnTitle(name, columnTypeMap.value[name]),
|
||||
key: name,
|
||||
})),
|
||||
)
|
||||
|
||||
const resultRows = computed(() => result.value.rows)
|
||||
|
||||
watch(selectedTableId, () => {
|
||||
output.value = ""
|
||||
status.value = Status.NotStarted
|
||||
@@ -50,7 +85,9 @@ watch(selectedTableId, () => {
|
||||
>
|
||||
<template #1>
|
||||
<div class="table-panel">
|
||||
<div class="panel-title">原始数据({{ selectedTable.label }})</div>
|
||||
<div class="panel-title">
|
||||
原始数据({{ selectedTable.label }} · {{ selectedTable.tableName }})
|
||||
</div>
|
||||
<n-data-table
|
||||
size="small"
|
||||
:bordered="false"
|
||||
@@ -62,13 +99,13 @@ watch(selectedTableId, () => {
|
||||
</template>
|
||||
<template #2>
|
||||
<div class="table-panel" v-if="status === Status.Accepted">
|
||||
<div class="panel-title">运行后数据</div>
|
||||
<div class="panel-title">运行结果</div>
|
||||
<n-data-table
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:columns="tableColumns"
|
||||
:columns="resultColumns"
|
||||
:data="resultRows"
|
||||
:row-key="(row: any) => row.id"
|
||||
:row-key="(row: any) => row.__key"
|
||||
/>
|
||||
</div>
|
||||
<OutputSection v-else />
|
||||
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
} from "@codemirror/autocomplete"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { LANGUAGE } from "../types"
|
||||
import { selectedTableId } from "../composables/sqlTable"
|
||||
import { sqlTables } from "../data/sqlTables"
|
||||
import { cpp } from "./cpp"
|
||||
import { c } from "./c"
|
||||
import { python } from "./python"
|
||||
import { turtle } from "./turtle"
|
||||
import { sql } from "./sql"
|
||||
|
||||
type ChineseCompletion = Pick<
|
||||
Completion,
|
||||
@@ -22,6 +25,39 @@ const chineseAnnotations: Record<string, ChineseCompletion[]> = {
|
||||
turtle,
|
||||
c,
|
||||
cpp,
|
||||
sql,
|
||||
}
|
||||
|
||||
// 数据类型只取基础部分(TEXT NOT NULL -> TEXT)
|
||||
function baseType(type: string): string {
|
||||
return type.trim().split(/\s+/)[0]
|
||||
}
|
||||
|
||||
// 根据当前选中的数据表,动态生成表名与列名补全
|
||||
function buildSqlDynamicCompletions(): ChineseCompletion[] {
|
||||
const tableCompletions: ChineseCompletion[] = sqlTables.map((table) => ({
|
||||
label: table.tableName,
|
||||
detail: table.label,
|
||||
type: "type",
|
||||
info: `数据表 ${table.label},包含列:${table.columns
|
||||
.map((column) => column.name)
|
||||
.join("、")}`,
|
||||
boost: table.id === selectedTableId.value ? 95 : 40,
|
||||
apply: `${table.tableName} `,
|
||||
}))
|
||||
const activeTable =
|
||||
sqlTables.find((table) => table.id === selectedTableId.value) ??
|
||||
sqlTables[0]
|
||||
const columnCompletions: ChineseCompletion[] = activeTable.columns.map(
|
||||
(column) => ({
|
||||
label: column.name,
|
||||
detail: baseType(column.type),
|
||||
type: "variable",
|
||||
info: `${activeTable.label} 的列,类型 ${baseType(column.type)}`,
|
||||
boost: 93,
|
||||
}),
|
||||
)
|
||||
return [...tableCompletions, ...columnCompletions]
|
||||
}
|
||||
|
||||
export function enhanceCompletion(language: LANGUAGE): CompletionSource {
|
||||
@@ -31,7 +67,12 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
|
||||
const word = context.matchBefore(/\w+/)
|
||||
if (!word) return null
|
||||
|
||||
const completions: Completion[] = (chineseAnnotations[language] || []).map(
|
||||
const source: ChineseCompletion[] = [
|
||||
...(language === "sql" ? buildSqlDynamicCompletions() : []),
|
||||
...(chineseAnnotations[language] || []),
|
||||
]
|
||||
|
||||
const completions: Completion[] = source.map(
|
||||
(completion) => {
|
||||
const insertText =
|
||||
typeof completion.apply === "string"
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
export const sql = [
|
||||
{
|
||||
label: "SELECT",
|
||||
detail: "查询数据",
|
||||
type: "keyword",
|
||||
info: "从表中查询数据,后面接要查询的列名,用 * 表示所有列。",
|
||||
boost: 100,
|
||||
apply: "SELECT ",
|
||||
},
|
||||
{
|
||||
label: "FROM",
|
||||
detail: "指定表",
|
||||
type: "keyword",
|
||||
info: "指定要查询的表,和 SELECT 搭配使用,如 SELECT * FROM 表名。",
|
||||
boost: 98,
|
||||
apply: "FROM ",
|
||||
},
|
||||
{
|
||||
label: "WHERE",
|
||||
detail: "筛选条件",
|
||||
type: "keyword",
|
||||
info: "按条件筛选行,只保留满足条件的数据,如 WHERE age > 18。",
|
||||
boost: 96,
|
||||
apply: "WHERE ",
|
||||
},
|
||||
{
|
||||
label: "ORDER BY",
|
||||
detail: "排序",
|
||||
type: "keyword",
|
||||
info: "按指定列排序,默认从小到大(ASC),加 DESC 表示从大到小。",
|
||||
boost: 90,
|
||||
apply: "ORDER BY ",
|
||||
},
|
||||
{
|
||||
label: "GROUP BY",
|
||||
detail: "分组",
|
||||
type: "keyword",
|
||||
info: "按指定列分组,常配合 COUNT、SUM 等聚合函数统计每组数据。",
|
||||
boost: 88,
|
||||
apply: "GROUP BY ",
|
||||
},
|
||||
{
|
||||
label: "HAVING",
|
||||
detail: "分组后筛选",
|
||||
type: "keyword",
|
||||
info: "对分组后的结果再筛选,WHERE 筛选行,HAVING 筛选组。",
|
||||
boost: 86,
|
||||
apply: "HAVING ",
|
||||
},
|
||||
{
|
||||
label: "LIMIT",
|
||||
detail: "限制条数",
|
||||
type: "keyword",
|
||||
info: "限制返回的行数,如 LIMIT 5 只取前 5 条,常和 ORDER BY 搭配。",
|
||||
boost: 84,
|
||||
apply: "LIMIT ",
|
||||
},
|
||||
{
|
||||
label: "DISTINCT",
|
||||
detail: "去重",
|
||||
type: "keyword",
|
||||
info: "去掉查询结果中的重复值,如 SELECT DISTINCT city FROM users。",
|
||||
boost: 82,
|
||||
apply: "DISTINCT ",
|
||||
},
|
||||
{
|
||||
label: "AS",
|
||||
detail: "起别名",
|
||||
type: "keyword",
|
||||
info: "给列或表起别名,让结果更易读,如 SELECT name AS 姓名。",
|
||||
boost: 80,
|
||||
apply: "AS ",
|
||||
},
|
||||
{
|
||||
label: "JOIN",
|
||||
detail: "连接表",
|
||||
type: "keyword",
|
||||
info: "把两张表按条件连接起来查询,需要用 ON 指定连接条件。",
|
||||
boost: 78,
|
||||
apply: "JOIN ",
|
||||
},
|
||||
{
|
||||
label: "LEFT JOIN",
|
||||
detail: "左连接",
|
||||
type: "keyword",
|
||||
info: "以左表为主连接右表,左表的行都保留,右表没匹配的补 NULL。",
|
||||
boost: 76,
|
||||
apply: "LEFT JOIN ",
|
||||
},
|
||||
{
|
||||
label: "ON",
|
||||
detail: "连接条件",
|
||||
type: "keyword",
|
||||
info: "指定两张表的连接条件,如 ON a.id = b.user_id,和 JOIN 搭配。",
|
||||
boost: 74,
|
||||
apply: "ON ",
|
||||
},
|
||||
{
|
||||
label: "INSERT INTO",
|
||||
detail: "插入数据",
|
||||
type: "keyword",
|
||||
info: "向表中插入新行,如 INSERT INTO 表名 (列1, 列2) VALUES (值1, 值2)。",
|
||||
boost: 72,
|
||||
apply: "INSERT INTO ",
|
||||
},
|
||||
{
|
||||
label: "VALUES",
|
||||
detail: "插入的值",
|
||||
type: "keyword",
|
||||
info: "和 INSERT INTO 搭配,写具体要插入的值,顺序要和列名对应。",
|
||||
boost: 70,
|
||||
apply: "VALUES ",
|
||||
},
|
||||
{
|
||||
label: "UPDATE",
|
||||
detail: "更新数据",
|
||||
type: "keyword",
|
||||
info: "修改表中已有的数据,配合 SET 设置新值,别忘了加 WHERE 限定范围。",
|
||||
boost: 68,
|
||||
apply: "UPDATE ",
|
||||
},
|
||||
{
|
||||
label: "SET",
|
||||
detail: "设置新值",
|
||||
type: "keyword",
|
||||
info: "和 UPDATE 搭配,指定要修改的列和新值,如 SET score = 90。",
|
||||
boost: 66,
|
||||
apply: "SET ",
|
||||
},
|
||||
{
|
||||
label: "DELETE FROM",
|
||||
detail: "删除数据",
|
||||
type: "keyword",
|
||||
info: "删除表中的行,一定要配合 WHERE 使用,否则会删掉整张表的数据。",
|
||||
boost: 64,
|
||||
apply: "DELETE FROM ",
|
||||
},
|
||||
{
|
||||
label: "CREATE TABLE",
|
||||
detail: "创建表",
|
||||
type: "keyword",
|
||||
info: "新建一张表,需要定义列名和类型,如 CREATE TABLE users (id INTEGER, name TEXT)。",
|
||||
boost: 62,
|
||||
apply: "CREATE TABLE ",
|
||||
},
|
||||
{
|
||||
label: "DROP TABLE",
|
||||
detail: "删除表",
|
||||
type: "keyword",
|
||||
info: "删除整张表(包括结构和数据),操作不可恢复,要谨慎使用。",
|
||||
boost: 60,
|
||||
apply: "DROP TABLE ",
|
||||
},
|
||||
{
|
||||
label: "ALTER TABLE",
|
||||
detail: "修改表结构",
|
||||
type: "keyword",
|
||||
info: "修改已有表的结构,比如添加列:ALTER TABLE 表名 ADD COLUMN 列名 类型。",
|
||||
boost: 58,
|
||||
apply: "ALTER TABLE ",
|
||||
},
|
||||
{
|
||||
label: "AND",
|
||||
detail: "并且",
|
||||
type: "keyword",
|
||||
info: "连接多个条件,全部成立才算满足,如 WHERE age > 18 AND city = '上海'。",
|
||||
boost: 56,
|
||||
apply: "AND ",
|
||||
},
|
||||
{
|
||||
label: "OR",
|
||||
detail: "或者",
|
||||
type: "keyword",
|
||||
info: "连接多个条件,任意一个成立就算满足。",
|
||||
boost: 54,
|
||||
apply: "OR ",
|
||||
},
|
||||
{
|
||||
label: "NOT",
|
||||
detail: "取反",
|
||||
type: "keyword",
|
||||
info: "对条件取反,如 NOT IN、NOT LIKE、IS NOT NULL。",
|
||||
boost: 52,
|
||||
apply: "NOT ",
|
||||
},
|
||||
{
|
||||
label: "IN",
|
||||
detail: "在列表中",
|
||||
type: "keyword",
|
||||
info: "判断值是否在给定列表中,如 WHERE city IN ('北京', '上海')。",
|
||||
boost: 50,
|
||||
apply: "IN ",
|
||||
},
|
||||
{
|
||||
label: "BETWEEN",
|
||||
detail: "在区间内",
|
||||
type: "keyword",
|
||||
info: "判断值是否在某个范围内(包含两端),如 BETWEEN 60 AND 100。",
|
||||
boost: 48,
|
||||
apply: "BETWEEN ",
|
||||
},
|
||||
{
|
||||
label: "LIKE",
|
||||
detail: "模糊匹配",
|
||||
type: "keyword",
|
||||
info: "模糊查询,% 匹配任意多个字符,_ 匹配单个字符,如 LIKE '张%'。",
|
||||
boost: 46,
|
||||
apply: "LIKE ",
|
||||
},
|
||||
{
|
||||
label: "IS NULL",
|
||||
detail: "是否为空",
|
||||
type: "keyword",
|
||||
info: "判断值是否为 NULL(空值),不能写 = NULL,要用 IS NULL。",
|
||||
boost: 44,
|
||||
},
|
||||
{
|
||||
label: "IS NOT NULL",
|
||||
detail: "是否非空",
|
||||
type: "keyword",
|
||||
info: "判断值不为 NULL,常用于过滤掉缺失数据的行。",
|
||||
boost: 42,
|
||||
},
|
||||
{
|
||||
label: "ASC",
|
||||
detail: "升序",
|
||||
type: "keyword",
|
||||
info: "排序时从小到大排列,是 ORDER BY 的默认方式,可以省略。",
|
||||
boost: 40,
|
||||
},
|
||||
{
|
||||
label: "DESC",
|
||||
detail: "降序",
|
||||
type: "keyword",
|
||||
info: "排序时从大到小排列,如 ORDER BY score DESC 按分数从高到低。",
|
||||
boost: 41,
|
||||
},
|
||||
{
|
||||
label: "UNION",
|
||||
detail: "合并结果",
|
||||
type: "keyword",
|
||||
info: "合并两个查询的结果并去重,两个查询的列数和类型要一致。",
|
||||
boost: 38,
|
||||
apply: "UNION ",
|
||||
},
|
||||
{
|
||||
label: "CASE",
|
||||
detail: "条件表达式",
|
||||
type: "keyword",
|
||||
info: "类似 if/else 的条件判断,语法 CASE WHEN 条件 THEN 值 ELSE 值 END。",
|
||||
boost: 36,
|
||||
apply: "CASE ",
|
||||
},
|
||||
{
|
||||
label: "WHEN",
|
||||
detail: "当条件成立",
|
||||
type: "keyword",
|
||||
info: "和 CASE 搭配,写判断条件,成立时返回 THEN 后面的值。",
|
||||
boost: 34,
|
||||
apply: "WHEN ",
|
||||
},
|
||||
{
|
||||
label: "THEN",
|
||||
detail: "返回值",
|
||||
type: "keyword",
|
||||
info: "和 WHEN 搭配,条件成立时返回的结果。",
|
||||
boost: 32,
|
||||
apply: "THEN ",
|
||||
},
|
||||
{
|
||||
label: "ELSE",
|
||||
detail: "否则",
|
||||
type: "keyword",
|
||||
info: "CASE 中所有 WHEN 都不成立时返回的默认值。",
|
||||
boost: 30,
|
||||
apply: "ELSE ",
|
||||
},
|
||||
{
|
||||
label: "END",
|
||||
detail: "结束 CASE",
|
||||
type: "keyword",
|
||||
info: "标记 CASE 表达式的结束,写 CASE 时不要漏掉。",
|
||||
boost: 28,
|
||||
},
|
||||
{
|
||||
label: "NULL",
|
||||
detail: "空值",
|
||||
type: "keyword",
|
||||
info: "表示没有值(缺失),判断时要用 IS NULL / IS NOT NULL。",
|
||||
boost: 26,
|
||||
},
|
||||
{
|
||||
label: "COUNT",
|
||||
detail: "统计行数",
|
||||
type: "function",
|
||||
info: "统计行数,COUNT(*) 统计所有行,COUNT(列名) 不统计 NULL。",
|
||||
boost: 92,
|
||||
apply: "COUNT()",
|
||||
},
|
||||
{
|
||||
label: "SUM",
|
||||
detail: "求和",
|
||||
type: "function",
|
||||
info: "对某一列求和,只能用于数字列,如 SUM(score)。",
|
||||
boost: 87,
|
||||
apply: "SUM()",
|
||||
},
|
||||
{
|
||||
label: "AVG",
|
||||
detail: "平均值",
|
||||
type: "function",
|
||||
info: "计算某一列的平均值,会自动忽略 NULL,如 AVG(score)。",
|
||||
boost: 85,
|
||||
apply: "AVG()",
|
||||
},
|
||||
{
|
||||
label: "MAX",
|
||||
detail: "最大值",
|
||||
type: "function",
|
||||
info: "求某一列的最大值,如 MAX(score) 找最高分。",
|
||||
boost: 83,
|
||||
apply: "MAX()",
|
||||
},
|
||||
{
|
||||
label: "MIN",
|
||||
detail: "最小值",
|
||||
type: "function",
|
||||
info: "求某一列的最小值,如 MIN(score) 找最低分。",
|
||||
boost: 81,
|
||||
apply: "MIN()",
|
||||
},
|
||||
{
|
||||
label: "LENGTH",
|
||||
detail: "字符串长度",
|
||||
type: "function",
|
||||
info: "返回字符串的字符个数,如 LENGTH(name)。",
|
||||
boost: 79,
|
||||
apply: "LENGTH()",
|
||||
},
|
||||
{
|
||||
label: "UPPER",
|
||||
detail: "转大写",
|
||||
type: "function",
|
||||
info: "把字符串中的字母转成大写,如 UPPER('abc') 得到 'ABC'。",
|
||||
boost: 77,
|
||||
apply: "UPPER()",
|
||||
},
|
||||
{
|
||||
label: "LOWER",
|
||||
detail: "转小写",
|
||||
type: "function",
|
||||
info: "把字符串中的字母转成小写,如 LOWER('ABC') 得到 'abc'。",
|
||||
boost: 75,
|
||||
apply: "LOWER()",
|
||||
},
|
||||
{
|
||||
label: "SUBSTR",
|
||||
detail: "截取子串",
|
||||
type: "function",
|
||||
info: "截取字符串的一部分,语法 SUBSTR(字符串, 起始位置, 长度),位置从 1 开始。",
|
||||
boost: 73,
|
||||
apply: "SUBSTR()",
|
||||
},
|
||||
{
|
||||
label: "REPLACE",
|
||||
detail: "替换字符串",
|
||||
type: "function",
|
||||
info: "把字符串中的内容替换成新内容,语法 REPLACE(字符串, 旧内容, 新内容)。",
|
||||
boost: 71,
|
||||
apply: "REPLACE()",
|
||||
},
|
||||
{
|
||||
label: "ROUND",
|
||||
detail: "四舍五入",
|
||||
type: "function",
|
||||
info: "按指定小数位四舍五入,如 ROUND(3.456, 2) 得到 3.46。",
|
||||
boost: 69,
|
||||
apply: "ROUND()",
|
||||
},
|
||||
{
|
||||
label: "ABS",
|
||||
detail: "绝对值",
|
||||
type: "function",
|
||||
info: "返回数字的绝对值,把负数变成正数,如 ABS(-5) 得到 5。",
|
||||
boost: 67,
|
||||
apply: "ABS()",
|
||||
},
|
||||
{
|
||||
label: "IFNULL",
|
||||
detail: "空值替代",
|
||||
type: "function",
|
||||
info: "如果第一个值是 NULL 就返回第二个值,如 IFNULL(score, 0) 把空分数当 0。",
|
||||
boost: 65,
|
||||
apply: "IFNULL()",
|
||||
},
|
||||
{
|
||||
label: "TRIM",
|
||||
detail: "去首尾空格",
|
||||
type: "function",
|
||||
info: "去掉字符串首尾的空格,常用于清理输入数据。",
|
||||
boost: 63,
|
||||
apply: "TRIM()",
|
||||
},
|
||||
]
|
||||
+25
-3
@@ -2,7 +2,9 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import copyTextToClipboard from "copy-text-to-clipboard"
|
||||
import { useMessage, type DropdownOption } from "naive-ui"
|
||||
import { computed } from "vue"
|
||||
import { code, loading, reset, run, share } from "../composables/code"
|
||||
import { debugLoading, startDebug } from "../composables/debug"
|
||||
import { tab } from "../composables/tab"
|
||||
|
||||
const message = useMessage()
|
||||
@@ -17,15 +19,35 @@ function copy() {
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
share()
|
||||
if (share()) {
|
||||
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: "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>
|
||||
<template>
|
||||
<n-layout-header class="container" bordered>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import DebugModal from "../components/DebugModal.vue"
|
||||
import Content from "./Content.vue"
|
||||
import Header from "./Header.vue"
|
||||
</script>
|
||||
<template>
|
||||
<Header />
|
||||
<Content />
|
||||
<DebugModal />
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ for i in range(4):
|
||||
|
||||
turtle.done()`
|
||||
|
||||
const sqlSource = "-- 在这里编写你的 SQL 语句\n"
|
||||
const sqlSource = ""
|
||||
|
||||
export const languageToId: { [key in string]: number } = {
|
||||
c: 50,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Reference in New Issue
Block a user