feat: “我的提交”和“全部提交”拆成两个页面
- 学生的“我的提交”(MySubmissions):左边按教程 / 挑战列出交过的任务,右边看每一次交的版本, 标出计入成绩的那一版(分数最高的一次);可以看提示词、放回编辑器、删除这一版 - 超管的“全部提交”:按学生、类型、分区筛选,按时间或得分排序;点一行在右边打开批改面板, 批改面板加了超管专用的“删除这一版” - 删掉只给旧提交页用的展开子表、看代码弹框、任务标题组件和全局 submission store, 编辑器预览里随之不用的打分、看代码、复制链接一起去掉 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
726906e3f6
commit
6ad9be2b30
10 files changed
+673
-591
No files matched your search
@@ -2,9 +2,6 @@
|
|||||||
<n-flex align="center" justify="space-between" class="title">
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
<div>
|
<div>
|
||||||
<slot name="title"><n-text class="titleText">预览</n-text></slot>
|
<slot name="title"><n-text class="titleText">预览</n-text></slot>
|
||||||
<n-text v-if="!!submission.id" depth="3">
|
|
||||||
({{ submission.view_count || 0 }})
|
|
||||||
</n-text>
|
|
||||||
</div>
|
</div>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
<n-tooltip>
|
<n-tooltip>
|
||||||
@@ -19,26 +16,6 @@
|
|||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||||
<n-button quaternary @click="open">全屏</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>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||||
@@ -54,12 +31,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
|
||||||
import { Submission } from "../../api"
|
|
||||||
import { submission } from "../../store/submission"
|
|
||||||
import { useMessage } from "naive-ui"
|
|
||||||
import { Icon } from "../../icons"
|
import { Icon } from "../../icons"
|
||||||
import copy from "copy-text-to-clipboard"
|
|
||||||
import {
|
import {
|
||||||
buildPreviewDocument,
|
buildPreviewDocument,
|
||||||
PREVIEW_SANDBOX,
|
PREVIEW_SANDBOX,
|
||||||
@@ -75,15 +47,11 @@ interface Props {
|
|||||||
css: string
|
css: string
|
||||||
js: string
|
js: string
|
||||||
assetBaseUrl?: string
|
assetBaseUrl?: string
|
||||||
submissionId?: string
|
|
||||||
showCodeButton?: boolean
|
|
||||||
clearable?: boolean
|
|
||||||
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
||||||
storageTaskId?: number
|
storageTaskId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
|
||||||
|
|
||||||
type Layout = "desktop" | "mobile" | "tablet"
|
type Layout = "desktop" | "mobile" | "tablet"
|
||||||
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
||||||
@@ -121,9 +89,6 @@ function cycleLayout() {
|
|||||||
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const showDL = computed(() => props.html || props.css || props.js)
|
const showDL = computed(() => props.html || props.css || props.js)
|
||||||
const previewContent = ref("")
|
const previewContent = ref("")
|
||||||
|
|
||||||
@@ -162,13 +127,6 @@ function download() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
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("about:blank", "_blank")
|
const newTab = window.open("about:blank", "_blank")
|
||||||
if (!newTab) return
|
if (!newTab) return
|
||||||
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
||||||
@@ -183,27 +141,7 @@ function open() {
|
|||||||
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
||||||
tabFrame.srcdoc = getContent()
|
tabFrame.srcdoc = getContent()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
watchDebounced(
|
||||||
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||||
|
|||||||
@@ -121,11 +121,7 @@ const userMenu = [
|
|||||||
|
|
||||||
async function clickUserMenu(key: string) {
|
async function clickUserMenu(key: string) {
|
||||||
if (key === "submissions") {
|
if (key === "submissions") {
|
||||||
router.push({
|
router.push({ name: "submissions" })
|
||||||
name: "submissions",
|
|
||||||
params: { page: 1 },
|
|
||||||
query: { username: user.username },
|
|
||||||
})
|
|
||||||
} else if (key === "logout") {
|
} else if (key === "logout") {
|
||||||
await logout()
|
await logout()
|
||||||
// 留在需要登录或特定角色的页面上没有意义
|
// 留在需要登录或特定角色的页面上没有意义
|
||||||
|
|||||||
@@ -111,6 +111,12 @@
|
|||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div class="muted hint">P 键:加入 / 取消课上展示</div>
|
<div class="muted hint">P 键:加入 / 取消课上展示</div>
|
||||||
|
<n-popconfirm v-if="canDelete" @positive-click="remove">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary type="error" class="delete">删除这一版</n-button>
|
||||||
|
</template>
|
||||||
|
删除后找不回来,评分和作品展里的这一版也会一起删掉;它对应的那轮 AI 对话也会删掉。
|
||||||
|
</n-popconfirm>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -155,6 +161,8 @@ const props = defineProps<{
|
|||||||
canWiden?: boolean
|
canWiden?: boolean
|
||||||
// 作品墙里用:能不能切到上一个 / 下一个学生
|
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||||
nav?: { prev: boolean; next: boolean }
|
nav?: { prev: boolean; next: boolean }
|
||||||
|
// 全部提交页(超管):能删除这一版
|
||||||
|
canDelete?: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
prev: []
|
prev: []
|
||||||
@@ -163,6 +171,7 @@ const emit = defineEmits<{
|
|||||||
"toggle-wide": []
|
"toggle-wide": []
|
||||||
// 让作品墙同步更新这张卡片
|
// 让作品墙同步更新这张卡片
|
||||||
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||||
|
deleted: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -255,6 +264,19 @@ async function addToAward(awardId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!sub.value) return
|
||||||
|
const id = sub.value.id
|
||||||
|
try {
|
||||||
|
await Submission.delete(id)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success("已删除")
|
||||||
|
emit("deleted", id)
|
||||||
|
}
|
||||||
|
|
||||||
function openFull() {
|
function openFull() {
|
||||||
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
|
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
|
||||||
}
|
}
|
||||||
@@ -397,6 +419,10 @@ h4 {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.rates {
|
.rates {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-modal
|
|
||||||
preset="card"
|
|
||||||
:show="show"
|
|
||||||
style="max-width: 60%"
|
|
||||||
@update:show="$emit('update:show', $event)"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<n-flex align="center">
|
|
||||||
<span>前端代码</span>
|
|
||||||
<n-button tertiary @click="$emit('copy-to-editor')"
|
|
||||||
>复制到编辑框</n-button
|
|
||||||
>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<n-tabs animated type="segment">
|
|
||||||
<n-tab-pane name="html" tab="html">
|
|
||||||
<n-code :code="html" language="html" word-wrap />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="css" tab="css">
|
|
||||||
<n-code :code="css" language="css" word-wrap />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane v-if="!!js" name="js" tab="js">
|
|
||||||
<n-code :code="js" language="js" word-wrap />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
show: boolean
|
|
||||||
html: string
|
|
||||||
css: string
|
|
||||||
js: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
"update:show": [value: boolean]
|
|
||||||
"copy-to-editor": []
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-spin v-if="loading" size="small" style="padding: 12px" />
|
|
||||||
<n-data-table
|
|
||||||
v-else-if="items"
|
|
||||||
:columns="subColumns"
|
|
||||||
:data="items"
|
|
||||||
size="small"
|
|
||||||
striped
|
|
||||||
:row-key="(r: SubmissionOut) => r.id"
|
|
||||||
:row-props="rowProps"
|
|
||||||
:row-class-name="rowClassName"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, h } from "vue"
|
|
||||||
import {
|
|
||||||
NButton,
|
|
||||||
NDataTable,
|
|
||||||
NPopconfirm,
|
|
||||||
NSpin,
|
|
||||||
type DataTableColumn,
|
|
||||||
} from "naive-ui"
|
|
||||||
import type { SubmissionOut } from "../../utils/type"
|
|
||||||
import { TASK_TYPE } from "../../utils/const"
|
|
||||||
import { parseTime } from "../../utils/helper"
|
|
||||||
import { user, roleSuper } from "../../store/user"
|
|
||||||
import { submission } from "../../store/submission"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
row: SubmissionOut
|
|
||||||
items: SubmissionOut[] | undefined
|
|
||||||
loading: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [id: string]
|
|
||||||
delete: [row: SubmissionOut, parentId: string]
|
|
||||||
"show-chain": [submissionId: string, username: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
|
|
||||||
|
|
||||||
function rowProps(r: SubmissionOut) {
|
|
||||||
return {
|
|
||||||
style: { cursor: "pointer" },
|
|
||||||
onClick: () => emit("select", r.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowClassName(r: SubmissionOut) {
|
|
||||||
return submission.value.id === r.id ? "row-active" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
|
|
||||||
{
|
|
||||||
title: "时间",
|
|
||||||
key: "created",
|
|
||||||
width: 160,
|
|
||||||
render: (r) => parseTime(r.created, "YYYY-MM-DD HH:mm:ss"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "得分",
|
|
||||||
key: "score",
|
|
||||||
width: 80,
|
|
||||||
render: (r) => {
|
|
||||||
const myScore = r.my_score > 0 ? String(r.my_score) : "-"
|
|
||||||
const avgScore = r.score > 0 ? r.score.toFixed(2) : "-"
|
|
||||||
return h(
|
|
||||||
"div",
|
|
||||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
|
||||||
[
|
|
||||||
h("span", avgScore),
|
|
||||||
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
key: "actions",
|
|
||||||
width: isChallenge.value ? 110 : 60,
|
|
||||||
render: (r: SubmissionOut) =>
|
|
||||||
h("div", { style: { display: "flex", gap: "8px" } }, [
|
|
||||||
...(isChallenge.value
|
|
||||||
? [
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
text: true,
|
|
||||||
type: "primary",
|
|
||||||
onClick: (e: Event) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
emit("show-chain", r.id, r.username)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
() => "查看",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(r.username === user.username || roleSuper.value
|
|
||||||
? [
|
|
||||||
h(
|
|
||||||
NPopconfirm,
|
|
||||||
{ onPositiveClick: () => emit("delete", r, props.row.id) },
|
|
||||||
{
|
|
||||||
trigger: () =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
text: true,
|
|
||||||
type: "error",
|
|
||||||
size: "small",
|
|
||||||
onClick: (e: Event) => e.stopPropagation(),
|
|
||||||
},
|
|
||||||
() => "删除",
|
|
||||||
),
|
|
||||||
default: () => "确定删除这次提交?",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]),
|
|
||||||
} as DataTableColumn<SubmissionOut>,
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-flex>
|
|
||||||
<n-tag
|
|
||||||
size="small"
|
|
||||||
:bordered="false"
|
|
||||||
type="primary"
|
|
||||||
v-if="props.submission.task_type === 'tutorial'"
|
|
||||||
>
|
|
||||||
教程
|
|
||||||
</n-tag>
|
|
||||||
<n-tag
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
type="error"
|
|
||||||
v-if="props.submission.task_type === 'challenge'"
|
|
||||||
>
|
|
||||||
挑战
|
|
||||||
</n-tag>
|
|
||||||
<n-button text @click="open">{{ props.submission.task_title }}</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useRouter } from "vue-router"
|
|
||||||
import type { SubmissionOut } from "../../utils/type"
|
|
||||||
import { goHome } from "../../utils/helper"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
submission: SubmissionOut
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
function open() {
|
|
||||||
goHome(router, props.submission.task_type, props.submission.task_display)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mine">
|
||||||
|
<aside class="tasks">
|
||||||
|
<div class="tasks-head">
|
||||||
|
<b>我的提交</b>
|
||||||
|
<span class="muted">{{ tasks.length }} 个任务</span>
|
||||||
|
</div>
|
||||||
|
<n-spin :show="loadingList" class="task-list">
|
||||||
|
<template v-for="g in groups" :key="g.label">
|
||||||
|
<div class="group-label">{{ g.label }}</div>
|
||||||
|
<button
|
||||||
|
v-for="t in g.items"
|
||||||
|
:key="t.task_id"
|
||||||
|
:class="['task', { on: t.task_id === taskId }]"
|
||||||
|
@click="taskId = t.task_id"
|
||||||
|
>
|
||||||
|
<span class="task-title">{{ t.task_display }}. {{ t.task_title }}</span>
|
||||||
|
<span class="task-meta">
|
||||||
|
{{ t.submit_count }} 次 · {{ parseTime(t.created, "MM-DD") }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<n-empty
|
||||||
|
v-if="!loadingList && !tasks.length"
|
||||||
|
description="还没有交过作品"
|
||||||
|
style="margin-top: 60px"
|
||||||
|
/>
|
||||||
|
</n-spin>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="detail">
|
||||||
|
<template v-if="task">
|
||||||
|
<div class="detail-head">
|
||||||
|
<div class="title">
|
||||||
|
<span class="kind">{{ task.task_type === TASK_TYPE.Tutorial ? "教程" : "挑战" }}</span>
|
||||||
|
<b>{{ task.task_display }}. {{ task.task_title }}</b>
|
||||||
|
</div>
|
||||||
|
<n-button size="small" quaternary icon-placement="right" @click="openTask">
|
||||||
|
打开这道题
|
||||||
|
<template #icon><Icon icon="lucide:arrow-right" /></template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-spin :show="loadingVersions" class="stage">
|
||||||
|
<div v-if="versions.length" class="versions">
|
||||||
|
<button
|
||||||
|
v-for="(v, i) in versions"
|
||||||
|
:key="v.id"
|
||||||
|
:class="['ver', { on: v.id === currentId }]"
|
||||||
|
@click="currentId = v.id"
|
||||||
|
>
|
||||||
|
第 {{ versions.length - i }} 次
|
||||||
|
<span class="ver-score">{{ v.score ? v.score.toFixed(1) : "未评" }}</span>
|
||||||
|
<span v-if="v.id === bestId && versions.length > 1" class="best">计入成绩</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="frame">
|
||||||
|
<iframe
|
||||||
|
v-if="current"
|
||||||
|
:key="current.id"
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="doc"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-if="current" class="meta">
|
||||||
|
<span class="muted">
|
||||||
|
{{ parseTime(current.created, "YYYY-MM-DD HH:mm") }} 提交 ·
|
||||||
|
{{ current.score ? `得分 ${current.score.toFixed(2)}` : "还没有人评分" }}
|
||||||
|
<template v-if="versions.length > 1"> · 成绩按分数最高的一次算</template>
|
||||||
|
</span>
|
||||||
|
<n-flex :size="4" :wrap="false">
|
||||||
|
<n-button
|
||||||
|
v-if="task.task_type === TASK_TYPE.Challenge"
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
@click="showChain = true"
|
||||||
|
>
|
||||||
|
提示词
|
||||||
|
</n-button>
|
||||||
|
<n-popconfirm @positive-click="copyToEditor">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary>放回编辑器</n-button>
|
||||||
|
</template>
|
||||||
|
把这一版的代码放进这道题的编辑器,会替换编辑器里现在的代码。
|
||||||
|
</n-popconfirm>
|
||||||
|
<n-popconfirm @positive-click="remove">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary type="error">删除这一版</n-button>
|
||||||
|
</template>
|
||||||
|
删除后找不回来;它对应的那轮 AI 对话也会一起删掉。<br />
|
||||||
|
已经有人评过分的不能删。
|
||||||
|
</n-popconfirm>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</template>
|
||||||
|
<n-empty v-else-if="tasks.length" description="选一个任务" style="margin-top: 120px" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChainModal
|
||||||
|
v-if="current"
|
||||||
|
v-model:show="showChain"
|
||||||
|
:submission-id="current.id"
|
||||||
|
:username="current.username"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import { Submission } from "../api"
|
||||||
|
import ChainModal from "../components/submissions/ChainModal.vue"
|
||||||
|
import { saveTaskCode } from "../composables/useTaskCode"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import { goHome, parseTime } from "../utils/helper"
|
||||||
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||||
|
import type { SubmissionOut } from "../utils/type"
|
||||||
|
|
||||||
|
// 学生看自己交过的作品:左边按任务列出,右边看每一次交的版本
|
||||||
|
type Version = SubmissionOut & { html: string | null; css: string | null; js: string | null }
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const tasks = ref<SubmissionOut[]>([])
|
||||||
|
const loadingList = ref(false)
|
||||||
|
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
|
||||||
|
const versions = ref<Version[]>([])
|
||||||
|
const loadingVersions = ref(false)
|
||||||
|
const currentId = ref<string | null>(null)
|
||||||
|
const showChain = ref(false)
|
||||||
|
|
||||||
|
const task = computed(() => tasks.value.find((t) => t.task_id === taskId.value) ?? null)
|
||||||
|
const current = computed(() => versions.value.find((v) => v.id === currentId.value) ?? null)
|
||||||
|
|
||||||
|
const groups = computed(() =>
|
||||||
|
[
|
||||||
|
{ label: "教程", type: TASK_TYPE.Tutorial },
|
||||||
|
{ label: "挑战", type: TASK_TYPE.Challenge },
|
||||||
|
]
|
||||||
|
.map((g) => ({
|
||||||
|
label: g.label,
|
||||||
|
items: tasks.value
|
||||||
|
.filter((t) => t.task_type === g.type)
|
||||||
|
.sort((a, b) => a.task_display - b.task_display),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.items.length),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 和成绩册一样:分数最高的一次计入成绩,同分取最新
|
||||||
|
const bestId = computed(() => {
|
||||||
|
let best: Version | null = null
|
||||||
|
for (const v of versions.value) if (v.score > 0 && (!best || v.score > best.score)) best = v
|
||||||
|
return best?.id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const doc = computed(() =>
|
||||||
|
current.value
|
||||||
|
? buildPreviewDocument({
|
||||||
|
html: current.value.html ?? "",
|
||||||
|
css: current.value.css ?? "",
|
||||||
|
js: current.value.js ?? "",
|
||||||
|
assetBaseUrl: `/media/tasks/${current.value.task_type}/${current.value.task_display}/`,
|
||||||
|
})
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
loadingList.value = true
|
||||||
|
try {
|
||||||
|
const all: SubmissionOut[] = []
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const res = await Submission.list({ page, page_size: 100, grouped: true })
|
||||||
|
all.push(...res.items)
|
||||||
|
if (all.length >= res.count || !res.items.length) break
|
||||||
|
}
|
||||||
|
tasks.value = all
|
||||||
|
if (!task.value) taskId.value = groups.value[0]?.items[0]?.task_id ?? null
|
||||||
|
} finally {
|
||||||
|
loadingList.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
async function loadVersions() {
|
||||||
|
const s = ++seq
|
||||||
|
const t = task.value
|
||||||
|
if (!t) {
|
||||||
|
versions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingVersions.value = true
|
||||||
|
try {
|
||||||
|
const list = (await Submission.listByUserTask(t.userid, t.task_id)) as Version[]
|
||||||
|
if (s !== seq) return
|
||||||
|
versions.value = list
|
||||||
|
if (!list.some((v) => v.id === currentId.value)) currentId.value = list[0]?.id ?? null
|
||||||
|
} finally {
|
||||||
|
if (s === seq) loadingVersions.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(taskId, (id) => {
|
||||||
|
router.replace({ query: id ? { task: String(id) } : {} })
|
||||||
|
loadVersions()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openTask() {
|
||||||
|
if (task.value) goHome(router, task.value.task_type, task.value.task_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToEditor() {
|
||||||
|
const v = current.value
|
||||||
|
if (!v) return
|
||||||
|
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
|
||||||
|
saveTaskCode(v.task_id, { html: v.html ?? "", css: v.css ?? "", js: v.js ?? "" })
|
||||||
|
goHome(router, v.task_type, v.task_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
const v = current.value
|
||||||
|
if (!v) return
|
||||||
|
try {
|
||||||
|
await Submission.delete(v.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "删除失败,请重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success("已删除")
|
||||||
|
if (versions.value.length > 1) {
|
||||||
|
await loadVersions()
|
||||||
|
const t = tasks.value.find((x) => x.task_id === v.task_id)
|
||||||
|
if (t) t.submit_count = versions.value.length
|
||||||
|
} else {
|
||||||
|
// 这道题一次都不剩了
|
||||||
|
tasks.value = tasks.value.filter((x) => x.task_id !== v.task_id)
|
||||||
|
taskId.value = groups.value[0]?.items[0]?.task_id ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadTasks()
|
||||||
|
loadVersions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mine {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks {
|
||||||
|
width: 260px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
padding: 10px 6px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task.on {
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage :deep(.n-spin-content) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver:hover {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver.on {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver-score {
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.best {
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver.on .best {
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+201
-294
@@ -1,335 +1,217 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-split
|
<div class="subs">
|
||||||
class="container"
|
<main class="list">
|
||||||
direction="horizontal"
|
<div class="toolbar">
|
||||||
:default-size="0.5"
|
<n-input
|
||||||
:min="0.2"
|
v-model:value="query.username"
|
||||||
:max="0.8"
|
size="small"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索学生"
|
||||||
|
style="width: 150px"
|
||||||
>
|
>
|
||||||
<template #1>
|
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
|
||||||
<n-flex
|
</n-input>
|
||||||
vertical
|
<n-radio-group v-model:value="query.task_type" size="small">
|
||||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
<n-radio-button value="">全部</n-radio-button>
|
||||||
>
|
<n-radio-button value="tutorial">教程</n-radio-button>
|
||||||
<n-flex align="center" justify="end">
|
<n-radio-button value="challenge">挑战</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="query.zone"
|
v-model:value="query.zone"
|
||||||
style="width: 110px"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
placeholder="得分分区"
|
placeholder="得分分区"
|
||||||
:options="zoneOptions"
|
:options="zoneOptions"
|
||||||
|
style="width: 110px"
|
||||||
/>
|
/>
|
||||||
<n-input
|
<n-select
|
||||||
v-if="isAdmin"
|
v-model:value="query.ordering"
|
||||||
style="width: 120px"
|
size="small"
|
||||||
v-model:value="query.username"
|
:options="orderOptions"
|
||||||
placeholder="搜索学生"
|
style="width: 130px"
|
||||||
clearable
|
|
||||||
/>
|
/>
|
||||||
<n-pagination
|
<n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
|
||||||
v-model:page="query.page"
|
<span class="spacer"></span>
|
||||||
:page-size="10"
|
<span class="muted">共 {{ count }} 条</span>
|
||||||
:item-count="count"
|
<n-tooltip>
|
||||||
simple
|
<template #trigger>
|
||||||
/>
|
<n-button size="small" quaternary :loading="loading" @click="load">
|
||||||
<n-button
|
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||||
secondary
|
|
||||||
style="padding: 0 10px"
|
|
||||||
title="刷新"
|
|
||||||
@click="init"
|
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:refresh-cw" />
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</template>
|
||||||
|
刷新
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-data-table
|
<n-data-table
|
||||||
|
class="table"
|
||||||
|
size="small"
|
||||||
flex-height
|
flex-height
|
||||||
striped
|
:loading="loading"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="data"
|
:data="data"
|
||||||
:row-key="(row: SubmissionOut) => row.id"
|
:row-key="(row: SubmissionOut) => row.id"
|
||||||
:expanded-row-keys="expandedKeys"
|
|
||||||
@update:expanded-row-keys="handleExpand"
|
|
||||||
:row-props="rowProps"
|
:row-props="rowProps"
|
||||||
:row-class-name="rowClassName"
|
:row-class-name="(row: SubmissionOut) => (row.id === selectedId ? 'row-active' : '')"
|
||||||
style="flex: 1; min-height: 0"
|
/>
|
||||||
|
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-pagination
|
||||||
|
v-model:page="query.page"
|
||||||
|
:page-size="PAGE_SIZE"
|
||||||
|
:item-count="count"
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</main>
|
||||||
<template #2>
|
|
||||||
<div style="height: 100%; padding-left: 10px">
|
<aside v-if="selectedId" class="detail">
|
||||||
<Preview
|
<ReviewPanel
|
||||||
v-if="submission.id"
|
:submission-id="selectedId"
|
||||||
:html="html"
|
can-delete
|
||||||
:css="css"
|
@close="selectedId = null"
|
||||||
:js="js"
|
@changed="applyPatch"
|
||||||
:submission-id="submission.id"
|
@deleted="onDeleted"
|
||||||
@after-score="afterScore"
|
|
||||||
@show-code="codeModal = true"
|
|
||||||
/>
|
/>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-split>
|
|
||||||
|
|
||||||
<CodeModal
|
|
||||||
v-model:show="codeModal"
|
|
||||||
:html="html"
|
|
||||||
:css="css"
|
|
||||||
:js="js"
|
|
||||||
@copy-to-editor="copyToEditor"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChainModal
|
|
||||||
v-model:show="chainModal"
|
|
||||||
:submission-id="chainSubmissionId"
|
|
||||||
:username="chainUsername"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { watchDebounced } from "@vueuse/core"
|
||||||
|
import type { DataTableColumn } from "naive-ui"
|
||||||
import { Icon } from "../icons"
|
import { Icon } from "../icons"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionOut } from "../utils/type"
|
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||||
import { parseTime } from "../utils/helper"
|
|
||||||
import { goHome } from "../utils/helper"
|
|
||||||
import { saveTaskCode } from "../composables/useTaskCode"
|
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { parseTime } from "../utils/helper"
|
||||||
import { useRouter, useRoute } from "vue-router"
|
import type { FlagType, SubmissionOut } from "../utils/type"
|
||||||
|
|
||||||
import Preview from "../components/editor/Preview.vue"
|
// 全站提交列表,留给超管查某个学生、处理有问题的提交;平时批改用“批改”页
|
||||||
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
const PAGE_SIZE = 20
|
||||||
import CodeModal from "../components/submissions/CodeModal.vue"
|
|
||||||
import ChainModal from "../components/submissions/ChainModal.vue"
|
|
||||||
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
|
||||||
|
|
||||||
import { submission } from "../store/submission"
|
|
||||||
import { roleAdmin, roleSuper } from "../store/user"
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
// 列表数据
|
|
||||||
const data = ref<SubmissionOut[]>([])
|
const data = ref<SubmissionOut[]>([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: Number(route.params.page),
|
page: Number(route.params.page) || 1,
|
||||||
username: (Array.isArray(route.query.username)
|
username: typeof route.query.username === "string" ? route.query.username : "",
|
||||||
? ""
|
task_type: "" as "" | "tutorial" | "challenge",
|
||||||
: (route.query.username ?? "")) as string,
|
|
||||||
zone: null as string | null,
|
zone: null as string | null,
|
||||||
|
ordering: "-created",
|
||||||
|
grouped: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当前选中提交的代码
|
|
||||||
const html = computed(() => submission.value.html)
|
|
||||||
const css = computed(() => submission.value.css)
|
|
||||||
const js = computed(() => submission.value.js)
|
|
||||||
|
|
||||||
// Modal 状态
|
|
||||||
const codeModal = ref(false)
|
|
||||||
const chainModal = ref(false)
|
|
||||||
const chainSubmissionId = ref<string>("")
|
|
||||||
const chainUsername = ref<string>("")
|
|
||||||
|
|
||||||
// 展开行
|
|
||||||
const expandedKeys = ref<string[]>([])
|
|
||||||
const expandedData = reactive(new Map<string, SubmissionOut[]>())
|
|
||||||
const expandedLoading = reactive(new Set<string>())
|
|
||||||
|
|
||||||
// 管理员判断
|
|
||||||
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
|
|
||||||
|
|
||||||
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
|
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
|
||||||
const zoneOptions = [
|
const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
|
||||||
{ label: "优秀", value: "featured" },
|
const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
|
||||||
{ label: "待定", value: "pending" },
|
const orderOptions = [
|
||||||
{ label: "待提高", value: "low" },
|
{ label: "最新的在前", value: "-created" },
|
||||||
|
{ label: "得分从高到低", value: "-score" },
|
||||||
|
{ label: "得分从低到高", value: "score" },
|
||||||
]
|
]
|
||||||
|
|
||||||
function showChain(submissionId: string, username: string) {
|
|
||||||
chainSubmissionId.value = submissionId
|
|
||||||
chainUsername.value = username
|
|
||||||
chainModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格列定义
|
|
||||||
const columns: DataTableColumn<SubmissionOut>[] = [
|
const columns: DataTableColumn<SubmissionOut>[] = [
|
||||||
{
|
|
||||||
type: "expand",
|
|
||||||
expandable: () => true,
|
|
||||||
renderExpand: (row) =>
|
|
||||||
h(ExpandedSubTable, {
|
|
||||||
row,
|
|
||||||
items: expandedData.get(row.id),
|
|
||||||
loading: expandedLoading.has(row.id),
|
|
||||||
onSelect: (id) => getSubmissionByID(id),
|
|
||||||
onDelete: (r, parentId) => handleDelete(r, parentId),
|
|
||||||
"onShow-chain": (submissionId, username) =>
|
|
||||||
showChain(submissionId, username),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "分区",
|
|
||||||
key: "zone",
|
|
||||||
width: 64,
|
|
||||||
render: (row) => {
|
|
||||||
const map: Record<
|
|
||||||
string,
|
|
||||||
{ label: string; type: "success" | "default" | "warning" }
|
|
||||||
> = {
|
|
||||||
featured: { label: "优秀", type: "success" },
|
|
||||||
pending: { label: "待定", type: "default" },
|
|
||||||
low: { label: "待提高", type: "warning" },
|
|
||||||
}
|
|
||||||
if (!row.zone || !map[row.zone]) return null
|
|
||||||
const { label, type } = map[row.zone]
|
|
||||||
return h(NTag, { size: "small", round: true, type }, () => label)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "时间",
|
title: "时间",
|
||||||
key: "created",
|
key: "created",
|
||||||
width: 110,
|
width: 100,
|
||||||
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "提交者",
|
|
||||||
key: "user",
|
|
||||||
width: 80,
|
|
||||||
render: (row) => row.username,
|
|
||||||
},
|
},
|
||||||
|
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
|
||||||
{
|
{
|
||||||
title: "任务",
|
title: "任务",
|
||||||
key: "task_title",
|
key: "task_title",
|
||||||
render: (row) => h(TaskTitle, { submission: row }),
|
ellipsis: { tooltip: true },
|
||||||
|
render: (row) => [
|
||||||
|
h("span", { class: "muted" }, row.task_type === TASK_TYPE.Tutorial ? "教程 " : "挑战 "),
|
||||||
|
`${row.task_display}. ${row.task_title}`,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "得分",
|
title: "得分",
|
||||||
key: "score",
|
key: "score",
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (row) => {
|
align: "right",
|
||||||
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
|
render: (row) =>
|
||||||
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
|
row.score > 0
|
||||||
return h(
|
? h("span", { class: "mono" }, row.score.toFixed(2))
|
||||||
"div",
|
: h("span", { class: "muted" }, "未评"),
|
||||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
|
||||||
[
|
|
||||||
h("span", avgScore),
|
|
||||||
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "次数",
|
title: "分区",
|
||||||
|
key: "zone",
|
||||||
|
width: 70,
|
||||||
|
render: (row) => (row.zone ? h("span", { class: `zone z-${row.zone}` }, ZONES[row.zone]) : null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "共交",
|
||||||
key: "submit_count",
|
key: "submit_count",
|
||||||
width: 60,
|
width: 60,
|
||||||
render: (row) => row.submit_count || "-",
|
align: "right",
|
||||||
},
|
render: (row) => h("span", { class: "mono muted" }, `${row.submit_count} 次`),
|
||||||
{
|
|
||||||
title: "浏览",
|
|
||||||
key: "view_count",
|
|
||||||
width: 60,
|
|
||||||
render: (row) => row.view_count || "-",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function handleExpand(keys: (string | number)[]) {
|
|
||||||
const strKeys = keys.map(String)
|
|
||||||
const newKey = strKeys.find((k) => !expandedKeys.value.includes(k))
|
|
||||||
expandedKeys.value = strKeys
|
|
||||||
if (newKey) {
|
|
||||||
const row = data.value.find((d) => d.id === newKey)
|
|
||||||
if (row && !expandedData.has(newKey)) {
|
|
||||||
expandedLoading.add(newKey)
|
|
||||||
try {
|
|
||||||
const items = await Submission.listByUserTask(row.userid, row.task_id)
|
|
||||||
expandedData.set(newKey, items)
|
|
||||||
} finally {
|
|
||||||
expandedLoading.delete(newKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(row: SubmissionOut, parentId: string) {
|
|
||||||
try {
|
|
||||||
await Submission.delete(row.id)
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.response?.data?.detail ?? "删除失败,请重试")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const items = expandedData.get(parentId)
|
|
||||||
if (items)
|
|
||||||
expandedData.set(
|
|
||||||
parentId,
|
|
||||||
items.filter((d) => d.id !== row.id),
|
|
||||||
)
|
|
||||||
if (submission.value.id === row.id) submission.value.id = ""
|
|
||||||
const res = await Submission.list(query)
|
|
||||||
data.value = res.items
|
|
||||||
count.value = res.count
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowProps(row: SubmissionOut) {
|
function rowProps(row: SubmissionOut) {
|
||||||
return {
|
return {
|
||||||
style: { cursor: "pointer" },
|
style: { cursor: "pointer" },
|
||||||
onClick: () => {
|
onClick: () => (selectedId.value = row.id),
|
||||||
getSubmissionByID(row.id)
|
|
||||||
handleExpand(
|
|
||||||
expandedKeys.value.includes(row.id)
|
|
||||||
? expandedKeys.value.filter((k) => k !== row.id)
|
|
||||||
: [...expandedKeys.value, row.id],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowClassName(row: SubmissionOut) {
|
|
||||||
return submission.value.id === row.id ? "row-active" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// 快速翻页时只用最后一次请求的结果
|
// 快速翻页时只用最后一次请求的结果
|
||||||
let initSeq = 0
|
let seq = 0
|
||||||
|
|
||||||
async function init() {
|
async function load() {
|
||||||
const seq = ++initSeq
|
const s = ++seq
|
||||||
expandedKeys.value = []
|
loading.value = true
|
||||||
expandedData.clear()
|
try {
|
||||||
const res = await Submission.list(query)
|
const res = await Submission.list({
|
||||||
if (seq !== initSeq) return
|
page: query.page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
username: query.username || undefined,
|
||||||
|
task_type: query.task_type || undefined,
|
||||||
|
zone: query.zone,
|
||||||
|
ordering: query.ordering,
|
||||||
|
grouped: query.grouped,
|
||||||
|
})
|
||||||
|
if (s !== seq) return
|
||||||
data.value = res.items
|
data.value = res.items
|
||||||
count.value = res.count
|
count.value = res.count
|
||||||
|
} finally {
|
||||||
|
if (s === seq) loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let detailSeq = 0
|
function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
|
||||||
|
const row = data.value.find((d) => d.id === patch.id)
|
||||||
async function getSubmissionByID(id: string) {
|
if (row) Object.assign(row, patch)
|
||||||
const seq = ++detailSeq
|
|
||||||
const res = await Submission.get(id)
|
|
||||||
if (seq === detailSeq) submission.value = res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function afterScore() {
|
function onDeleted() {
|
||||||
data.value = data.value.map((d) => {
|
selectedId.value = null
|
||||||
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
load()
|
||||||
return d
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToEditor() {
|
// 换了筛选条件就回到第一页
|
||||||
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
|
function reset() {
|
||||||
saveTaskCode(submission.value.task_id, { html: html.value, css: css.value, js: js.value })
|
if (query.page !== 1) query.page = 1
|
||||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
else load()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => query.page,
|
() => query.page,
|
||||||
(v) => {
|
(page) => {
|
||||||
init()
|
load()
|
||||||
router.push({ params: { page: v } })
|
router.push({ params: { page } })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||||
@@ -340,53 +222,78 @@ watch(
|
|||||||
if (page !== query.page) query.page = page
|
if (page !== query.page) query.page = page
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watchDebounced(
|
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
|
||||||
() => query.username,
|
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
|
||||||
() => {
|
|
||||||
if (query.page !== 1) query.page = 1
|
|
||||||
else init()
|
|
||||||
},
|
|
||||||
{ debounce: 500, maxWait: 1000 },
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => query.zone,
|
|
||||||
() => {
|
|
||||||
if (query.page !== 1) query.page = 1
|
|
||||||
else init()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(load)
|
||||||
onUnmounted(() => {
|
|
||||||
submission.value = {
|
|
||||||
id: "",
|
|
||||||
userid: 0,
|
|
||||||
username: "",
|
|
||||||
task_id: 0,
|
|
||||||
task_display: 0,
|
|
||||||
task_title: "",
|
|
||||||
task_type: TASK_TYPE.Tutorial,
|
|
||||||
score: 0,
|
|
||||||
my_score: 0,
|
|
||||||
html: "",
|
|
||||||
css: "",
|
|
||||||
js: "",
|
|
||||||
submit_count: 0,
|
|
||||||
view_count: 0,
|
|
||||||
created: new Date(),
|
|
||||||
modified: new Date(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.subs {
|
||||||
padding: 10px;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
display: flex;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.row-active td) {
|
.list {
|
||||||
background-color: var(--wp-brand-soft) !important;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.row-active td) {
|
||||||
|
background: var(--wp-brand-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.mono) {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted,
|
||||||
|
.table :deep(.muted) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.zone) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.z-featured) {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.z-low) {
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.z-pending) {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
width: 440px;
|
||||||
|
flex: none;
|
||||||
|
border-left: 1px solid var(--wp-line);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -44,9 +44,10 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/submissions/:page",
|
// 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
|
||||||
|
path: "/submissions/:page?",
|
||||||
name: "submissions",
|
name: "submissions",
|
||||||
component: () => import("./pages/Submissions.vue"),
|
component: () => import("./pages/MySubmissions.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,7 +117,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("./pages/Review.vue"),
|
component: () => import("./pages/Review.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
|
// 全站提交列表(超管),和学生的“我的提交”是两个页面
|
||||||
path: "submissions/:page",
|
path: "submissions/:page",
|
||||||
name: "teach-submissions",
|
name: "teach-submissions",
|
||||||
component: () => import("./pages/Submissions.vue"),
|
component: () => import("./pages/Submissions.vue"),
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { ref } from "vue"
|
|
||||||
import type { SubmissionAll } from "../utils/type"
|
|
||||||
import { TASK_TYPE } from "../utils/const"
|
|
||||||
|
|
||||||
export const submission = ref<SubmissionAll>({
|
|
||||||
id: "",
|
|
||||||
userid: 0,
|
|
||||||
username: "",
|
|
||||||
task_id: 0,
|
|
||||||
task_display: 0, // 这是序号
|
|
||||||
task_title: "",
|
|
||||||
task_type: TASK_TYPE.Tutorial,
|
|
||||||
score: 0.0,
|
|
||||||
my_score: 0,
|
|
||||||
submit_count: 0,
|
|
||||||
view_count: 0,
|
|
||||||
html: "",
|
|
||||||
css: "",
|
|
||||||
js: "",
|
|
||||||
created: new Date(),
|
|
||||||
modified: new Date(),
|
|
||||||
})
|
|
||||||
Reference in new issue
Block a user