update
This commit is contained in:
4
components.d.ts
vendored
4
components.d.ts
vendored
@@ -12,13 +12,11 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Challenge: typeof import('./src/components/Challenge.vue')['default']
|
Challenge: typeof import('./src/components/Challenge.vue')['default']
|
||||||
Corner: typeof import('./src/components/Corner.vue')['default']
|
|
||||||
Editor: typeof import('./src/components/Editor.vue')['default']
|
Editor: typeof import('./src/components/Editor.vue')['default']
|
||||||
Editors: typeof import('./src/components/Editors.vue')['default']
|
Editors: typeof import('./src/components/Editors.vue')['default']
|
||||||
Login: typeof import('./src/components/Login.vue')['default']
|
Login: typeof import('./src/components/Login.vue')['default']
|
||||||
MarkdownEditor: typeof import('./src/components/dashboard/MarkdownEditor.vue')['default']
|
MarkdownEditor: typeof import('./src/components/dashboard/MarkdownEditor.vue')['default']
|
||||||
NAlert: typeof import('naive-ui')['NAlert']
|
NAlert: typeof import('naive-ui')['NAlert']
|
||||||
NameWithFilter: typeof import('./src/components/submissions/NameWithFilter.vue')['default']
|
|
||||||
NButton: typeof import('naive-ui')['NButton']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
NCard: typeof import('naive-ui')['NCard']
|
NCard: typeof import('naive-ui')['NCard']
|
||||||
NCode: typeof import('naive-ui')['NCode']
|
NCode: typeof import('naive-ui')['NCode']
|
||||||
@@ -53,6 +51,8 @@ declare module 'vue' {
|
|||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Task: typeof import('./src/components/Task.vue')['default']
|
Task: typeof import('./src/components/Task.vue')['default']
|
||||||
TaskTitle: typeof import('./src/components/submissions/TaskTitle.vue')['default']
|
TaskTitle: typeof import('./src/components/submissions/TaskTitle.vue')['default']
|
||||||
|
Toolbar: typeof import('./src/components/Toolbar.vue')['default']
|
||||||
|
Tutorial: typeof import('./src/components/Tutorial.vue')['default']
|
||||||
UserActions: typeof import('./src/components/dashboard/UserActions.vue')['default']
|
UserActions: typeof import('./src/components/dashboard/UserActions.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/api.ts
33
src/api.ts
@@ -1,6 +1,6 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { router } from "./router"
|
import { router } from "./router"
|
||||||
import type { TutorialIn } from "./utils/type"
|
import type { TutorialIn, ChallengeIn } from "./utils/type"
|
||||||
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
@@ -100,6 +100,37 @@ export const Tutorial = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Challenge = {
|
||||||
|
async list() {
|
||||||
|
const res = await http.get("/challenge/list")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createOrUpdate(payload: ChallengeIn) {
|
||||||
|
const res = await http.post("/challenge/", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async togglePublic(display: number) {
|
||||||
|
const res = await http.put(`/challenge/public/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(display: number) {
|
||||||
|
await http.delete(`/challenge/${display}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(display: number) {
|
||||||
|
const res = await http.get(`/challenge/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listDisplay() {
|
||||||
|
const res = await http.get("/challenge/display")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const Submission = {
|
export const Submission = {
|
||||||
async create(
|
async create(
|
||||||
taskId: number,
|
taskId: number,
|
||||||
|
|||||||
@@ -1,14 +1,86 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
|
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
|
||||||
<n-empty>暂无挑战,敬请期待</n-empty>
|
<template v-if="currentChallenge">
|
||||||
|
<n-flex align="center" style="margin-bottom: 12px">
|
||||||
|
<n-button text @click="back">
|
||||||
|
<Icon :width="20" icon="pepicons-pencil:arrow-left"></Icon>
|
||||||
|
</n-button>
|
||||||
|
<span style="font-weight: bold">返回挑战列表</span>
|
||||||
|
</n-flex>
|
||||||
|
<div class="markdown-body" v-html="content" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<n-empty v-if="!challenges.length">暂无挑战,敬请期待</n-empty>
|
||||||
|
<n-grid v-else :cols="3" x-gap="12" y-gap="12">
|
||||||
|
<n-gi v-for="item in challenges" :key="item.display">
|
||||||
|
<n-card hoverable class="challenge-card" @click="select(item)">
|
||||||
|
<template #header>
|
||||||
|
{{ item.title }}
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-tag type="warning" size="small">{{ item.score }}分</n-tag>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { taskTab } from "../store/task"
|
import { ref, onMounted } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { marked } from "marked"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Challenge } from "../api"
|
||||||
|
import { taskTab, taskId, challengeDisplay } from "../store/task"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const challenges = ref<ChallengeSlim[]>([])
|
||||||
|
const currentChallenge = ref<ChallengeSlim | null>(null)
|
||||||
|
const content = ref("")
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
challenges.value = await Challenge.listDisplay()
|
||||||
|
// 从 URL 恢复选中状态
|
||||||
|
if (challengeDisplay.value) {
|
||||||
|
const item = challenges.value.find(
|
||||||
|
(c) => c.display === challengeDisplay.value,
|
||||||
|
)
|
||||||
|
if (item) await select(item, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function select(item: ChallengeSlim, updateUrl = true) {
|
||||||
|
currentChallenge.value = item
|
||||||
|
challengeDisplay.value = item.display
|
||||||
|
if (updateUrl) {
|
||||||
|
router.push({ name: "home-challenge", params: { display: item.display } })
|
||||||
|
}
|
||||||
|
const data = await Challenge.get(item.display)
|
||||||
|
taskId.value = data.task_ptr
|
||||||
|
const merged = `# #${data.display} ${data.title}\n${data.content}`
|
||||||
|
content.value = await marked.parse(merged, { async: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
currentChallenge.value = null
|
||||||
|
challengeDisplay.value = 0
|
||||||
|
taskId.value = 0
|
||||||
|
content.value = ""
|
||||||
|
router.push({ name: "home-challenge-list" })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadList)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-card {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,7 +67,11 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Corner @format="format" />
|
<Toolbar
|
||||||
|
:submit-loading="submitLoading"
|
||||||
|
@format="format"
|
||||||
|
@submit="formatAndSubmit"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,12 +83,16 @@ import * as cssParser from "prettier/parser-postcss"
|
|||||||
import * as babelParser from "prettier/parser-babel"
|
import * as babelParser from "prettier/parser-babel"
|
||||||
import * as estreeParser from "prettier/plugins/estree"
|
import * as estreeParser from "prettier/plugins/estree"
|
||||||
import Editor from "./Editor.vue"
|
import Editor from "./Editor.vue"
|
||||||
import Corner from "./Corner.vue"
|
import Toolbar from "./Toolbar.vue"
|
||||||
import { html, css, js, tab, size, reset } from "../store/editors"
|
import { html, css, js, tab, size, reset } from "../store/editors"
|
||||||
import { NCode, useDialog } from "naive-ui"
|
import { taskId } from "../store/task"
|
||||||
import { h } from "vue"
|
import { Submission } from "../api"
|
||||||
|
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||||
|
import { h, ref } from "vue"
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const message = useMessage()
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
|
||||||
function changeTab(name: string) {
|
function changeTab(name: string) {
|
||||||
tab.value = name
|
tab.value = name
|
||||||
@@ -94,30 +102,34 @@ function changeSize(num: number) {
|
|||||||
size.value = num
|
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() {
|
async function format() {
|
||||||
try {
|
try {
|
||||||
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
|
await formatCode()
|
||||||
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
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
dialog.error({
|
dialog.error({
|
||||||
title: "格式化失败",
|
title: "格式化失败",
|
||||||
@@ -126,6 +138,40 @@ async function format() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane {
|
.pane {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
:width="20"
|
:width="20"
|
||||||
></Icon>
|
></Icon>
|
||||||
<n-tabs
|
<n-tabs
|
||||||
style="width: 210px"
|
style="width: 150px"
|
||||||
type="segment"
|
type="segment"
|
||||||
animated
|
animated
|
||||||
:value="taskTab"
|
:value="taskTab"
|
||||||
@@ -19,22 +19,31 @@
|
|||||||
>
|
>
|
||||||
<n-tab name="tutorial" tab="教程"></n-tab>
|
<n-tab name="tutorial" tab="教程"></n-tab>
|
||||||
<n-tab name="challenge" tab="挑战"></n-tab>
|
<n-tab name="challenge" tab="挑战"></n-tab>
|
||||||
<n-tab
|
|
||||||
name="list"
|
|
||||||
tab="列表"
|
|
||||||
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
|
|
||||||
></n-tab>
|
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
<template v-if="!hideNav">
|
<template v-if="!hideNav">
|
||||||
<n-button text @click="prev" :disabled="prevDisabled">
|
<n-button
|
||||||
|
text
|
||||||
|
@click="tutorialRef?.prev()"
|
||||||
|
:disabled="tutorialRef?.prevDisabled()"
|
||||||
|
>
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
|
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button text @click="next" :disabled="nextDisabled">
|
<n-button
|
||||||
|
text
|
||||||
|
@click="tutorialRef?.next()"
|
||||||
|
:disabled="tutorialRef?.nextDisabled()"
|
||||||
|
>
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
|
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:list"></Icon>
|
||||||
|
</n-button>
|
||||||
<n-button text v-if="roleSuper" @click="edit">
|
<n-button text v-if="roleSuper" @click="edit">
|
||||||
<Icon :width="16" icon="lucide:edit"></Icon>
|
<Icon :width="16" icon="lucide:edit"></Icon>
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -43,187 +52,70 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div
|
<Tutorial v-if="taskTab === TASK_TYPE.Tutorial" ref="tutorialRef" />
|
||||||
v-if="taskTab === TASK_TYPE.Tutorial"
|
|
||||||
class="markdown-body"
|
|
||||||
v-html="content"
|
|
||||||
ref="$content"
|
|
||||||
/>
|
|
||||||
<Challenge v-else />
|
<Challenge v-else />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { computed, onMounted, ref, useTemplateRef, watch } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { marked } from "marked"
|
|
||||||
import copyFn from "copy-text-to-clipboard"
|
|
||||||
import { css, html, js, tab } from "../store/editors"
|
|
||||||
import { Tutorial } from "../api"
|
|
||||||
import { step } from "../store/tutorial"
|
import { step } from "../store/tutorial"
|
||||||
import { roleSuper } from "../store/user"
|
import { roleSuper } from "../store/user"
|
||||||
import { taskId, taskTab } from "../store/task"
|
import { taskTab, challengeDisplay } from "../store/task"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import Challenge from "./Challenge.vue"
|
import Challenge from "./Challenge.vue"
|
||||||
import { NButton } from "naive-ui"
|
import Tutorial from "./Tutorial.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tutorialIds = ref<number[]>([])
|
const tutorialRef = ref<InstanceType<typeof Tutorial>>()
|
||||||
const content = ref("")
|
|
||||||
const $content = useTemplateRef<any>("$content")
|
|
||||||
|
|
||||||
defineEmits(["hide"])
|
defineEmits(["hide"])
|
||||||
|
|
||||||
const hideNav = computed(
|
const hideNav = computed(
|
||||||
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
|
() =>
|
||||||
|
taskTab.value !== TASK_TYPE.Tutorial ||
|
||||||
|
(tutorialRef.value?.tutorialIds?.length ?? 0) <= 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
function changeTab(v: TASK_TYPE & "list") {
|
function changeTab(v: TASK_TYPE) {
|
||||||
// 排除 list
|
|
||||||
if (v === "list") return
|
|
||||||
taskTab.value = v
|
taskTab.value = v
|
||||||
const query = { task: v } as any
|
if (v === TASK_TYPE.Tutorial) {
|
||||||
if (v === TASK_TYPE.Tutorial) query.step = step.value
|
router.push(
|
||||||
router.push({ query })
|
step.value
|
||||||
}
|
? { name: "home-tutorial", params: { display: step.value } }
|
||||||
|
: { name: "home-tutorial-list" },
|
||||||
const prevDisabled = computed(() => {
|
)
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
} else if (v === TASK_TYPE.Challenge) {
|
||||||
return i <= 0
|
challengeDisplay.value = 0
|
||||||
})
|
router.push({ name: "home-challenge-list" })
|
||||||
|
}
|
||||||
const nextDisabled = computed(() => {
|
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
|
||||||
return i === tutorialIds.value.length - 1
|
|
||||||
})
|
|
||||||
|
|
||||||
function prev() {
|
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
|
||||||
step.value = tutorialIds.value[i - 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
function next() {
|
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
|
||||||
step.value = tutorialIds.value[i + 1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit() {
|
function edit() {
|
||||||
router.push({
|
const name =
|
||||||
name: taskTab.value,
|
taskTab.value === TASK_TYPE.Tutorial
|
||||||
params: taskTab.value === TASK_TYPE.Tutorial ? { display: step.value } : {},
|
? "tutorial-editor"
|
||||||
})
|
: "challenge-editor"
|
||||||
|
const display =
|
||||||
|
taskTab.value === TASK_TYPE.Tutorial ? step.value : challengeDisplay.value
|
||||||
|
router.push({ name, params: { display } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepare() {
|
function init() {
|
||||||
tutorialIds.value = await Tutorial.listDisplay()
|
const name = route.name as string
|
||||||
if (!tutorialIds.value.length) {
|
if (name.startsWith("home-tutorial")) {
|
||||||
content.value = "暂无教程"
|
taskTab.value = TASK_TYPE.Tutorial
|
||||||
|
if (route.params.display) step.value = Number(route.params.display)
|
||||||
|
} else if (name.startsWith("home-challenge")) {
|
||||||
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
|
if (route.params.display)
|
||||||
|
challengeDisplay.value = Number(route.params.display)
|
||||||
}
|
}
|
||||||
if (!tutorialIds.value.includes(step.value)) {
|
|
||||||
step.value = tutorialIds.value[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getContent() {
|
|
||||||
const data = await Tutorial.get(step.value)
|
|
||||||
taskId.value = data.task_ptr
|
|
||||||
const merged = `# #${data.display} ${data.title}\n${data.content}`
|
|
||||||
content.value = await marked.parse(merged, { async: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function addButton() {
|
|
||||||
const action = document.createElement("div")
|
|
||||||
action.className = "codeblock-action"
|
|
||||||
const pres = $content.value?.querySelectorAll("pre") ?? []
|
|
||||||
for (const pre of pres) {
|
|
||||||
let timer = 0
|
|
||||||
let copyTimer = 0
|
|
||||||
const actions = action.cloneNode() as HTMLDivElement
|
|
||||||
pre.insertBefore(actions, pre.children[0])
|
|
||||||
const $code = pre.childNodes[1] as HTMLPreElement
|
|
||||||
const match = $code.className.match(/-(.*)/)
|
|
||||||
let lang = "html"
|
|
||||||
if (match) lang = match[1].toLowerCase()
|
|
||||||
|
|
||||||
const langSpan = document.createElement("span")
|
|
||||||
langSpan.className = "lang"
|
|
||||||
langSpan.textContent = lang.toUpperCase()
|
|
||||||
|
|
||||||
const btnGroup = document.createElement("div")
|
|
||||||
btnGroup.className = "btn-group"
|
|
||||||
|
|
||||||
const copyBtn = document.createElement("button")
|
|
||||||
copyBtn.className = "action-btn"
|
|
||||||
copyBtn.textContent = "复制"
|
|
||||||
|
|
||||||
const replaceBtn = document.createElement("button")
|
|
||||||
replaceBtn.className = "action-btn"
|
|
||||||
replaceBtn.textContent = "替换"
|
|
||||||
|
|
||||||
btnGroup.appendChild(copyBtn)
|
|
||||||
btnGroup.appendChild(replaceBtn)
|
|
||||||
|
|
||||||
actions.appendChild(langSpan)
|
|
||||||
actions.appendChild(btnGroup)
|
|
||||||
|
|
||||||
copyBtn.onclick = () => {
|
|
||||||
const content = pre.children[1].textContent
|
|
||||||
copyFn(content ?? "")
|
|
||||||
copyBtn.textContent = "已复制"
|
|
||||||
clearTimeout(copyTimer)
|
|
||||||
copyTimer = setTimeout(() => {
|
|
||||||
copyBtn.textContent = "复制"
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceBtn.onclick = () => {
|
|
||||||
tab.value = lang
|
|
||||||
const content = pre.children[1].textContent
|
|
||||||
if (lang === "html") html.value = content
|
|
||||||
if (lang === "css") css.value = content
|
|
||||||
if (lang === "js") js.value = content
|
|
||||||
replaceBtn.textContent = "已替换"
|
|
||||||
clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
replaceBtn.textContent = "替换"
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function modifyLink() {
|
|
||||||
const links = $content.value?.querySelectorAll("a") ?? []
|
|
||||||
for (const link of links) {
|
|
||||||
link.target = "_blank"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render() {
|
|
||||||
await getContent()
|
|
||||||
addButton()
|
|
||||||
modifyLink()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
if (route.query.task) {
|
|
||||||
taskTab.value = route.query.task as TASK_TYPE
|
|
||||||
}
|
|
||||||
if (route.query.step) {
|
|
||||||
step.value = Number(route.query.step)
|
|
||||||
}
|
|
||||||
const query = { task: taskTab.value } as any
|
|
||||||
if (taskTab.value === TASK_TYPE.Tutorial) query.step = step.value
|
|
||||||
router.push({ query })
|
|
||||||
await prepare()
|
|
||||||
render()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
watch(step, (v) => {
|
|
||||||
router.push({ query: { ...route.query, step: v } })
|
|
||||||
render()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
@@ -239,65 +131,4 @@ watch(step, (v) => {
|
|||||||
border-bottom: 1px solid rgb(239, 239, 245);
|
border-bottom: 1px solid rgb(239, 239, 245);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body {
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<style>
|
|
||||||
.markdown-body pre {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body pre code {
|
|
||||||
padding: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: Monaco;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeblock-action {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-family:
|
|
||||||
v-sans,
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
"Segoe UI",
|
|
||||||
sans-serif,
|
|
||||||
"Apple Color Emoji",
|
|
||||||
"Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol";
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeblock-action .lang {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeblock-action .btn-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeblock-action .action-btn {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 14px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
background-color: #fff;
|
|
||||||
color: #333;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeblock-action .action-btn:hover {
|
|
||||||
border-color: #18a058;
|
|
||||||
color: #18a058;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
secondary
|
secondary
|
||||||
:disabled="submitDisabled"
|
:disabled="submitDisabled"
|
||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
@click="submit"
|
@click="emit('submit')"
|
||||||
>
|
>
|
||||||
提交
|
提交
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -29,23 +29,21 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, h, ref } from "vue"
|
import { computed, h } from "vue"
|
||||||
import { useMessage } from "naive-ui"
|
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { authed, roleNormal, roleSuper, user } from "../store/user"
|
import { authed, roleNormal, roleSuper, user } from "../store/user"
|
||||||
import { loginModal } from "../store/modal"
|
import { loginModal } from "../store/modal"
|
||||||
import { show, tutorialSize, step } from "../store/tutorial"
|
import { show, tutorialSize, step } from "../store/tutorial"
|
||||||
import { taskId, taskTab } from "../store/task"
|
import { taskId, taskTab } from "../store/task"
|
||||||
import { html, css, js } from "../store/editors"
|
import { Account } from "../api"
|
||||||
import { Account, Submission } from "../api"
|
|
||||||
import { Role } from "../utils/type"
|
import { Role } from "../utils/type"
|
||||||
import { router } from "../router"
|
import { router } from "../router"
|
||||||
import { ADMIN_URL, TASK_LABEL } from "../utils/const"
|
import { ADMIN_URL, TASK_LABEL } from "../utils/const"
|
||||||
|
|
||||||
const message = useMessage()
|
const props = defineProps<{
|
||||||
const emit = defineEmits(["format"])
|
submitLoading: boolean
|
||||||
|
}>()
|
||||||
const submitLoading = ref(false)
|
const emit = defineEmits(["format", "submit"])
|
||||||
|
|
||||||
const submitDisabled = computed(() => {
|
const submitDisabled = computed(() => {
|
||||||
return taskId.value === 0
|
return taskId.value === 0
|
||||||
@@ -96,7 +94,7 @@ function showTutorial() {
|
|||||||
function clickMenu(name: string) {
|
function clickMenu(name: string) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
router.push({ name: "tutorial", params: { display: step.value } })
|
router.push({ name: "tutorial-editor", params: { display: step.value } })
|
||||||
break
|
break
|
||||||
case "admin":
|
case "admin":
|
||||||
window.open(ADMIN_URL)
|
window.open(ADMIN_URL)
|
||||||
@@ -123,22 +121,6 @@ async function handleLogout() {
|
|||||||
user.username = ""
|
user.username = ""
|
||||||
user.role = Role.Normal
|
user.role = Role.Normal
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
try {
|
|
||||||
submitLoading.value = true
|
|
||||||
await Submission.create(taskId.value, {
|
|
||||||
html: html.value,
|
|
||||||
css: css.value,
|
|
||||||
js: js.value,
|
|
||||||
})
|
|
||||||
message.success("提交成功")
|
|
||||||
} catch (err) {
|
|
||||||
message.error("提交失败")
|
|
||||||
} finally {
|
|
||||||
submitLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.corner {
|
.corner {
|
||||||
203
src/components/Tutorial.vue
Normal file
203
src/components/Tutorial.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="markdown-body" v-html="content" ref="$content" />
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref, useTemplateRef, watch } from "vue"
|
||||||
|
import { marked } from "marked"
|
||||||
|
import copyFn from "copy-text-to-clipboard"
|
||||||
|
import { css, html, js, tab } from "../store/editors"
|
||||||
|
import { Tutorial } from "../api"
|
||||||
|
import { step } from "../store/tutorial"
|
||||||
|
import { taskId } from "../store/task"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const tutorialIds = ref<number[]>([])
|
||||||
|
const content = ref("")
|
||||||
|
const $content = useTemplateRef<any>("$content")
|
||||||
|
|
||||||
|
const prevDisabled = () => {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
return i <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDisabled = () => {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
return i === tutorialIds.value.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
step.value = tutorialIds.value[i - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
step.value = tutorialIds.value[i + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ tutorialIds, prevDisabled, nextDisabled, prev, next })
|
||||||
|
|
||||||
|
async function prepare() {
|
||||||
|
tutorialIds.value = await Tutorial.listDisplay()
|
||||||
|
if (!tutorialIds.value.length) {
|
||||||
|
content.value = "暂无教程"
|
||||||
|
}
|
||||||
|
if (!tutorialIds.value.includes(step.value)) {
|
||||||
|
step.value = tutorialIds.value[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContent() {
|
||||||
|
const data = await Tutorial.get(step.value)
|
||||||
|
taskId.value = data.task_ptr
|
||||||
|
const merged = `# #${data.display} ${data.title}\n${data.content}`
|
||||||
|
content.value = await marked.parse(merged, { async: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function addButton() {
|
||||||
|
const action = document.createElement("div")
|
||||||
|
action.className = "codeblock-action"
|
||||||
|
const pres = $content.value?.querySelectorAll("pre") ?? []
|
||||||
|
for (const pre of pres) {
|
||||||
|
let timer = 0
|
||||||
|
let copyTimer = 0
|
||||||
|
const actions = action.cloneNode() as HTMLDivElement
|
||||||
|
pre.insertBefore(actions, pre.children[0])
|
||||||
|
const $code = pre.childNodes[1] as HTMLPreElement
|
||||||
|
const match = $code.className.match(/-(.*)/)
|
||||||
|
let lang = "html"
|
||||||
|
if (match) lang = match[1].toLowerCase()
|
||||||
|
|
||||||
|
const langSpan = document.createElement("span")
|
||||||
|
langSpan.className = "lang"
|
||||||
|
langSpan.textContent = lang.toUpperCase()
|
||||||
|
|
||||||
|
const btnGroup = document.createElement("div")
|
||||||
|
btnGroup.className = "btn-group"
|
||||||
|
|
||||||
|
const copyBtn = document.createElement("button")
|
||||||
|
copyBtn.className = "action-btn"
|
||||||
|
copyBtn.textContent = "复制"
|
||||||
|
|
||||||
|
const replaceBtn = document.createElement("button")
|
||||||
|
replaceBtn.className = "action-btn"
|
||||||
|
replaceBtn.textContent = "替换"
|
||||||
|
|
||||||
|
btnGroup.appendChild(copyBtn)
|
||||||
|
btnGroup.appendChild(replaceBtn)
|
||||||
|
|
||||||
|
actions.appendChild(langSpan)
|
||||||
|
actions.appendChild(btnGroup)
|
||||||
|
|
||||||
|
copyBtn.onclick = () => {
|
||||||
|
const content = pre.children[1].textContent
|
||||||
|
copyFn(content ?? "")
|
||||||
|
copyBtn.textContent = "已复制"
|
||||||
|
clearTimeout(copyTimer)
|
||||||
|
copyTimer = setTimeout(() => {
|
||||||
|
copyBtn.textContent = "复制"
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceBtn.onclick = () => {
|
||||||
|
tab.value = lang
|
||||||
|
const content = pre.children[1].textContent
|
||||||
|
if (lang === "html") html.value = content
|
||||||
|
if (lang === "css") css.value = content
|
||||||
|
if (lang === "js") js.value = content
|
||||||
|
replaceBtn.textContent = "已替换"
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
replaceBtn.textContent = "替换"
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifyLink() {
|
||||||
|
const links = $content.value?.querySelectorAll("a") ?? []
|
||||||
|
for (const link of links) {
|
||||||
|
link.target = "_blank"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
await getContent()
|
||||||
|
addButton()
|
||||||
|
modifyLink()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await prepare()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
watch(step, (v) => {
|
||||||
|
router.push({ name: "home-tutorial", params: { display: v } })
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.markdown-body {
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
.markdown-body pre {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre code {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: Monaco;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-family:
|
||||||
|
v-sans,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif,
|
||||||
|
"Apple Color Emoji",
|
||||||
|
"Segoe UI Emoji",
|
||||||
|
"Segoe UI Symbol";
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .lang {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .action-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .action-btn:hover {
|
||||||
|
border-color: #18a058;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1 +0,0 @@
|
|||||||
<template></template>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<template>Dashboard Challenge</template>
|
|
||||||
182
src/pages/ChallengeEditor.vue
Normal file
182
src/pages/ChallengeEditor.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<n-grid x-gap="10" :cols="8">
|
||||||
|
<n-gi :span="2" class="col">
|
||||||
|
<n-flex vertical class="list">
|
||||||
|
<n-card
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.display"
|
||||||
|
@click="show(item.display)"
|
||||||
|
class="card"
|
||||||
|
:header-style="{
|
||||||
|
backgroundColor:
|
||||||
|
item.display === challenge.display
|
||||||
|
? 'rgba(24, 160, 80, 0.1)'
|
||||||
|
: '',
|
||||||
|
}"
|
||||||
|
:embedded="!item.is_public"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-button text @click.stop="togglePublic(item.display)">
|
||||||
|
<Icon
|
||||||
|
width="24"
|
||||||
|
:icon="
|
||||||
|
item.is_public
|
||||||
|
? 'twemoji:check-mark-button'
|
||||||
|
: 'twemoji:locked'
|
||||||
|
"
|
||||||
|
></Icon>
|
||||||
|
</n-button>
|
||||||
|
<span>【{{ item.display }}】{{ item.title }}</span>
|
||||||
|
<n-tag size="small" type="warning">{{ item.score }}分</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button text type="default" @click="remove(item.display)">
|
||||||
|
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
<n-button @click="createNew">新建</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<n-gi :span="6" class="col">
|
||||||
|
<n-flex vertical>
|
||||||
|
<n-form inline>
|
||||||
|
<n-form-item label="序号" label-placement="left">
|
||||||
|
<n-input-number v-model:value="challenge.display" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="标题" label-placement="left">
|
||||||
|
<n-input v-model:value="challenge.title" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="分数" label-placement="left">
|
||||||
|
<n-input-number v-model:value="challenge.score" :min="0" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="公开" label-placement="left">
|
||||||
|
<n-switch v-model:value="challenge.is_public" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label-placement="left">
|
||||||
|
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<MarkdownEditor
|
||||||
|
style="height: calc(100vh - 90px)"
|
||||||
|
v-model="challenge.content"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Challenge } from "../api"
|
||||||
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
|
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const confirm = useDialog()
|
||||||
|
|
||||||
|
const list = ref<ChallengeSlim[]>([])
|
||||||
|
const challenge = reactive({
|
||||||
|
display: 0,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
score: 0,
|
||||||
|
is_public: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => challenge.display && challenge.title && challenge.content,
|
||||||
|
)
|
||||||
|
async function getContent() {
|
||||||
|
list.value = await Challenge.list()
|
||||||
|
show(Number(route.params.display))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
challenge.display = list.value[list.value.length - 1]?.display ?? 0 + 1
|
||||||
|
challenge.title = ""
|
||||||
|
challenge.content = ""
|
||||||
|
challenge.score = 0
|
||||||
|
challenge.is_public = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
await Challenge.createOrUpdate(challenge)
|
||||||
|
message.success("提交成功")
|
||||||
|
challenge.display = 0
|
||||||
|
challenge.title = ""
|
||||||
|
challenge.content = ""
|
||||||
|
challenge.score = 0
|
||||||
|
challenge.is_public = false
|
||||||
|
await getContent()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response.data.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(display: number) {
|
||||||
|
confirm.warning({
|
||||||
|
title: "警告",
|
||||||
|
content: "你确定要删除吗?",
|
||||||
|
positiveText: "确定",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await Challenge.remove(display)
|
||||||
|
message.success("删除成功")
|
||||||
|
getContent()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(display: number) {
|
||||||
|
router.push({ name: "challenge-editor", params: { display } })
|
||||||
|
const item = await Challenge.get(display)
|
||||||
|
challenge.display = item.display
|
||||||
|
challenge.title = item.title
|
||||||
|
challenge.content = item.content
|
||||||
|
challenge.score = item.score
|
||||||
|
challenge.is_public = item.is_public
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublic(display: number) {
|
||||||
|
const data = await Challenge.togglePublic(display)
|
||||||
|
message.success(data.message)
|
||||||
|
list.value = list.value.map((item) => {
|
||||||
|
if (item.display === display) item.is_public = !item.is_public
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getContent)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.list {
|
||||||
|
overflow: auto;
|
||||||
|
height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,9 +26,12 @@ import { goHome } from "../utils/helper"
|
|||||||
const menu = [
|
const menu = [
|
||||||
{
|
{
|
||||||
label: "教程",
|
label: "教程",
|
||||||
route: { name: "tutorial", params: { display: step.value } },
|
route: { name: "tutorial-editor", params: { display: step.value } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "挑战",
|
||||||
|
route: { name: "challenge-editor", params: { display: 0 } },
|
||||||
},
|
},
|
||||||
{ label: "挑战", route: { name: "challenge" } },
|
|
||||||
{ label: "用户", route: { name: "user-manage", params: { page: 1 } } },
|
{ label: "用户", route: { name: "user-manage", params: { page: 1 } } },
|
||||||
{ label: "提交", route: { name: "submissions", params: { page: 1 } } },
|
{ label: "提交", route: { name: "submissions", params: { page: 1 } } },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid class="container" x-gap="10" :cols="2">
|
<n-grid class="container" x-gap="10" :cols="3">
|
||||||
<n-gi :span="1">
|
<n-gi :span="1">
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<n-flex justify="space-between">
|
<n-flex justify="space-between">
|
||||||
@@ -23,10 +23,16 @@
|
|||||||
</n-pagination>
|
</n-pagination>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-data-table striped :columns="columns" :data="data"></n-data-table>
|
<n-data-table
|
||||||
|
striped
|
||||||
|
:columns="columns"
|
||||||
|
:data="data"
|
||||||
|
:row-props="rowProps"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
></n-data-table>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi :span="1">
|
<n-gi :span="2">
|
||||||
<Preview
|
<Preview
|
||||||
v-if="submission.id"
|
v-if="submission.id"
|
||||||
:html="html"
|
:html="html"
|
||||||
@@ -59,7 +65,7 @@
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, type DataTableColumn } from "naive-ui"
|
import { type DataTableColumn } from "naive-ui"
|
||||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionOut } from "../utils/type"
|
import type { SubmissionOut } from "../utils/type"
|
||||||
@@ -125,22 +131,19 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
|||||||
else return "-"
|
else return "-"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "效果",
|
|
||||||
key: "code",
|
|
||||||
render: (row) =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
quaternary: submission.value.id !== row.id,
|
|
||||||
type: submission.value.id === row.id ? "primary" : "default",
|
|
||||||
onClick: () => getSubmissionByID(row.id),
|
|
||||||
},
|
|
||||||
() => "查看",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function rowProps(row: SubmissionOut) {
|
||||||
|
return {
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
onClick: () => getSubmissionByID(row.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClassName(row: SubmissionOut) {
|
||||||
|
return submission.value.id === row.id ? "row-active" : ""
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const res = await Submission.list(query)
|
const res = await Submission.list(query)
|
||||||
data.value = res.items
|
data.value = res.items
|
||||||
@@ -208,4 +211,8 @@ onUnmounted(() => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: calc(100% - 43px);
|
height: calc(100% - 43px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.row-active td) {
|
||||||
|
background-color: rgba(24, 160, 80, 0.1) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async function remove(display: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function show(display: number) {
|
async function show(display: number) {
|
||||||
router.push({ name: "tutorial", params: { display } })
|
router.push({ name: "tutorial-editor", params: { display } })
|
||||||
const item = await Tutorial.get(display)
|
const item = await Tutorial.get(display)
|
||||||
tutorial.display = item.display
|
tutorial.display = item.display
|
||||||
tutorial.title = item.title
|
tutorial.title = item.title
|
||||||
@@ -6,6 +6,10 @@ import { STORAGE_KEY } from "./utils/const"
|
|||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: Home },
|
{ path: "/", name: "home", component: Home },
|
||||||
|
{ path: "/tutorial", name: "home-tutorial-list", component: Home },
|
||||||
|
{ path: "/tutorial/:display", name: "home-tutorial", component: Home },
|
||||||
|
{ path: "/challenge", name: "home-challenge-list", component: Home },
|
||||||
|
{ path: "/challenge/:display", name: "home-challenge", component: Home },
|
||||||
{
|
{
|
||||||
path: "/submissions/:page",
|
path: "/submissions/:page",
|
||||||
name: "submissions",
|
name: "submissions",
|
||||||
@@ -25,13 +29,13 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "tutorial/:display",
|
path: "tutorial/:display",
|
||||||
name: "tutorial",
|
name: "tutorial-editor",
|
||||||
component: () => import("./pages/Tutorial.vue"),
|
component: () => import("./pages/TutorialEditor.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "challenge",
|
path: "challenge/:display",
|
||||||
name: "challenge",
|
name: "challenge-editor",
|
||||||
component: () => import("./pages/Challenge.vue"),
|
component: () => import("./pages/ChallengeEditor.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "user-manage/:page",
|
path: "user-manage/:page",
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ const currentTask = (urlParams.get("task") as TASK_TYPE) ?? TASK_TYPE.Tutorial
|
|||||||
|
|
||||||
export const taskTab = ref(currentTask)
|
export const taskTab = ref(currentTask)
|
||||||
export const taskId = ref(0)
|
export const taskId = ref(0)
|
||||||
|
export const challengeDisplay = ref(0)
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
|
|||||||
return time.value
|
return time.value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function goHome(router: any, type: TASK_TYPE, step: number) {
|
export function goHome(router: any, type: TASK_TYPE, display: number) {
|
||||||
const query = { type } as any
|
|
||||||
if (type === TASK_TYPE.Tutorial) {
|
if (type === TASK_TYPE.Tutorial) {
|
||||||
query.step = step
|
router.push({ name: "home-tutorial", params: { display } })
|
||||||
|
} else if (type === TASK_TYPE.Challenge) {
|
||||||
|
router.push({ name: "home-challenge", params: { display } })
|
||||||
|
} else {
|
||||||
|
router.push({ name: "home" })
|
||||||
}
|
}
|
||||||
router.push({ name: "home", query })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ export interface TutorialIn {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChallengeSlim {
|
||||||
|
display: number
|
||||||
|
title: string
|
||||||
|
score: number
|
||||||
|
is_public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeIn {
|
||||||
|
display: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
score: number
|
||||||
|
is_public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
Reference in New Issue
Block a user