refactor: move editor components to components/editor/

This commit is contained in:
2026-04-01 03:50:09 -06:00
parent 40f361cf91
commit e4359e8093
7 changed files with 24 additions and 21 deletions

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import { css } from "@codemirror/lang-css"
import { javascript } from "@codemirror/lang-javascript"
import { html } from "@codemirror/lang-html"
import { computed } from "vue"
const styleTheme = EditorView.baseTheme({
"& .cm-scroller": {
"font-family": "Monaco",
},
"&.cm-editor.cm-focused": {
outline: "none",
},
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
"font-family": "Monaco",
},
})
interface Props {
language?: "html" | "css" | "js"
fontSize?: number
}
const props = withDefaults(defineProps<Props>(), {
language: "html",
fontSize: 20,
})
const code = defineModel<string>("value")
const lang = computed(() => {
if (props.language === "html") return html()
if (props.language === "css") return css()
return javascript()
})
</script>
<template>
<Codemirror
v-model="code"
indentWithTab
:extensions="[styleTheme, lang]"
:tabSize="4"
:style="{ height: '100%', fontSize: props.fontSize + 'px' }"
/>
</template>

View File

@@ -0,0 +1,188 @@
<template>
<n-tabs
:value="tab"
pane-class="pane"
style="height: 100%"
type="card"
@update:value="changeTab"
>
<n-tab-pane name="html" tab="HTML">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:html"></Icon>
<span>HTML</span>
</n-flex>
</template>
<Editor v-model:value="html" :font-size="size" language="html" />
</n-tab-pane>
<n-tab-pane name="css" tab="CSS">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:css"></Icon>
<span>CSS</span>
</n-flex>
</template>
<Editor v-model:value="css" :font-size="size" language="css" />
</n-tab-pane>
<n-tab-pane name="js" tab="JS">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:javascript"></Icon>
<span>JS</span>
</n-flex>
</template>
<Editor v-model:value="js" :font-size="size" language="js" />
</n-tab-pane>
<n-tab-pane name="actions" tab="选项">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
<span>选项</span>
</n-flex>
</template>
<n-flex class="wrapper" vertical>
<n-flex align="center">
<span class="label">重置</span>
<n-button @click="reset('html')">HTML</n-button>
<n-button @click="reset('css')">CSS</n-button>
<n-button @click="reset('js')">JS</n-button>
</n-flex>
<n-flex align="center">
<span class="label">字号</span>
<n-flex align="center">
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
调小
</n-button>
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
调大
</n-button>
</n-flex>
</n-flex>
<n-flex align="center">
<span class="label">预加载</span>
<n-tag type="success">Normalize.css</n-tag>
</n-flex>
</n-flex>
</n-tab-pane>
<template #suffix>
<Toolbar
:submit-loading="submitLoading"
@format="format"
@submit="formatAndSubmit"
/>
</template>
</n-tabs>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import prettier from "prettier/standalone"
import * as htmlParser from "prettier/parser-html"
import * as cssParser from "prettier/parser-postcss"
import * as babelParser from "prettier/parser-babel"
import * as estreeParser from "prettier/plugins/estree"
import Editor from "./Editor.vue"
import Toolbar from "./Toolbar.vue"
import { html, css, js, tab, size, reset } from "../../store/editors"
import { taskId } from "../../store/task"
import { Submission } from "../../api"
import { NCode, useDialog, useMessage } from "naive-ui"
import { h, ref } from "vue"
const dialog = useDialog()
const message = useMessage()
const submitLoading = ref(false)
function changeTab(name: string) {
tab.value = name
}
function changeSize(num: number) {
size.value = num
}
async function formatCode() {
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
prettier.format(html.value, {
parser: "html",
//@ts-ignore
plugins: [htmlParser, babelParser, estreeParser, cssParser],
tabWidth: 4,
}),
prettier.format(css.value, {
parser: "css",
plugins: [cssParser],
tabWidth: 4,
}),
prettier.format(js.value, {
parser: "babel",
//@ts-ignore
plugins: [babelParser, estreeParser],
tabWidth: 2,
}),
])
html.value = htmlFormatted
css.value = cssFormatted
js.value = jsFormatted
}
async function format() {
try {
await formatCode()
} catch (err: any) {
dialog.error({
title: "格式化失败",
content: () => h(NCode, { code: err.message }),
style: { width: "auto" },
})
}
}
async function doSubmit() {
try {
await Submission.create(taskId.value, {
html: html.value,
css: css.value,
js: js.value,
})
message.success("提交成功")
} catch (err) {
message.error("提交失败")
}
}
async function formatAndSubmit() {
submitLoading.value = true
try {
await formatCode()
await doSubmit()
} catch (err: any) {
dialog.warning({
title: "代码整理失败",
content: () => h(NCode, { code: err.message }),
positiveText: "忽略并提交",
negativeText: "取消",
style: { width: "auto" },
onPositiveClick: async () => {
await doSubmit()
},
})
} finally {
submitLoading.value = false
}
}
</script>
<style scoped>
.pane {
height: 100%;
overflow: auto;
}
.wrapper {
padding-left: 16px;
}
.label {
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<n-flex align="center" justify="space-between" class="title">
<div>
<n-text class="titleText">预览</n-text>
<n-text v-if="!!submission.id" depth="3"
>({{ submission.view_count || 0 }})</n-text
>
</div>
<n-flex>
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
<n-button quaternary @click="open">全屏</n-button>
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
<n-button
quaternary
v-if="props.showCodeButton"
@click="emits('showCode')"
>代码</n-button
>
<n-button quaternary v-if="props.submissionId" @click="copyLink">
链接
</n-button>
<n-flex v-if="!!submission.id" align="center">
<n-button quaternary @click="emits('showCode')">代码</n-button>
<n-popover v-if="submission.my_score === 0">
<template #trigger>
<n-button secondary type="primary">打分</n-button>
</template>
<n-rate :size="30" @update:value="updateScore" />
</n-popover>
</n-flex>
</n-flex>
</n-flex>
<iframe class="iframe" ref="iframe"></iframe>
</template>
<script lang="ts" setup>
import { watchDebounced } from "@vueuse/core"
import { computed, onMounted, useTemplateRef } from "vue"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import { submission } from "../../store/submission"
import { useMessage } from "naive-ui"
import copy from "copy-text-to-clipboard"
interface Props {
html: string
css: string
js: string
submissionId?: string
showCodeButton?: boolean
clearable?: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(["afterScore", "showCode", "clear"])
const message = useMessage()
const router = useRouter()
const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
const showDL = computed(() => props.html || props.css || props.js)
function getContent() {
return `<!DOCTYPE html>
<html lang="zh-Hans-CN">
<head>
<meta charset="UTF-8" />
<title>预览</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>${props.css}</style>
<link rel="stylesheet" href="/normalize.min.css" />
</head>
<body>
${props.html}
<script>${props.js}<\/script>
</body>
</html>`
}
function preview() {
if (!iframe.value) return
const doc = iframe.value.contentDocument
if (doc) {
doc.open()
doc.write(getContent())
doc.close()
}
}
function download() {
const content = getContent()
const blob = new Blob([content], { type: "text/html" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "index.html"
a.click()
URL.revokeObjectURL(url)
}
function open() {
if (props.submissionId) {
const data = router.resolve({
name: "submission",
params: { id: props.submissionId },
})
window.open(data.href, "_blank")
} else {
const newTab = window.open("/usercontent.html")
if (!newTab) return
newTab.document.open()
newTab.document.write(getContent())
newTab.document.close()
}
}
function clear() {
emits("clear")
}
function copyLink() {
copy(`${document.location.origin}/submission/${props.submissionId}`)
message.success("该提交的链接已复制")
}
async function updateScore(score: number) {
try {
const res = await Submission.updateScore(submission.value.id, score)
message.success(res.message)
submission.value.my_score = score
emits("afterScore")
} catch (err: any) {
message.error(err.response.data.detail)
}
}
watchDebounced(() => [props.html, props.css, props.js], preview, {
debounce: 500,
maxWait: 1000,
})
onMounted(preview)
</script>
<style scoped>
.title {
height: 43px;
padding: 0 20px;
border-bottom: 1px solid rgb(239, 239, 245);
box-sizing: border-box;
}
.titleText {
font-size: 16px;
}
.iframe {
width: 100%;
height: 100%;
border: none;
outline: none;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<n-flex align="center" class="corner">
<n-button quaternary v-if="!show" @click="showTutorial"> 教程 </n-button>
<template v-if="user.loaded && authed">
<n-button quaternary @click="emit('format')">整理</n-button>
<n-button
type="primary"
secondary
:disabled="submitDisabled"
:loading="submitLoading"
@click="emit('submit')"
>
提交
</n-button>
<n-dropdown :options="menu" @select="clickMenu">
<n-button>{{ user.username }}</n-button>
</n-dropdown>
</template>
<n-button
v-if="user.loaded && !authed"
@click="handleLogin"
secondary
type="primary"
>
登录
</n-button>
</n-flex>
</template>
<script lang="ts" setup>
import { computed, h } from "vue"
import { Icon } from "@iconify/vue"
import { authed, roleNormal, roleSuper, user } from "../../store/user"
import { loginModal } from "../../store/modal"
import { show, panelSize } from "../../store/panel"
import { step } from "../../store/tutorial"
import { taskId } from "../../store/task"
import { Account } from "../../api"
import { Role } from "../../utils/type"
import { router } from "../../router"
import { ADMIN_URL } from "../../utils/const"
const props = defineProps<{
submitLoading: boolean
}>()
const emit = defineEmits(["format", "submit"])
const submitDisabled = computed(() => {
return taskId.value === 0
})
const menu = computed(() => [
{
label: "后台管理",
key: "dashboard",
show: !roleNormal.value,
icon: () =>
h(Icon, {
icon: "streamline-emojis:robot-face-1",
width: 20,
}),
},
{
label: "数据管理",
key: "admin",
show: roleSuper.value,
icon: () =>
h(Icon, {
icon: "skill-icons:django",
width: 20,
}),
},
{
label: "我的提交",
key: "submissions",
icon: () =>
h(Icon, {
icon: "streamline-emojis:bar-chart",
width: 20,
}),
},
{
label: "退出账号",
key: "logout",
icon: () =>
h(Icon, {
icon: "streamline-emojis:hot-beverage-2",
width: 20,
}),
},
])
function showTutorial() {
show.value = true
panelSize.value = 2 / 5
}
function clickMenu(name: string) {
switch (name) {
case "dashboard":
router.push({ name: "tutorial-editor", params: { display: step.value } })
break
case "admin":
window.open(ADMIN_URL)
break
case "submissions":
router.push({
name: "submissions",
params: { page: 1 },
query: { username: user.username },
})
break
case "logout":
handleLogout()
break
}
}
function handleLogin() {
loginModal.value = true
}
async function handleLogout() {
await Account.logout()
user.username = ""
user.role = Role.Normal
}
</script>
<style scoped>
.corner {
margin-right: 20px;
}
</style>