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">
|
||||
<div>
|
||||
<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>
|
||||
<n-flex>
|
||||
<n-tooltip>
|
||||
@@ -19,26 +16,6 @@
|
||||
</n-tooltip>
|
||||
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||
<n-button quaternary @click="open">全屏</n-button>
|
||||
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
|
||||
<n-button
|
||||
quaternary
|
||||
v-if="props.showCodeButton"
|
||||
@click="emits('showCode')"
|
||||
>
|
||||
代码
|
||||
</n-button>
|
||||
<n-button quaternary v-if="props.submissionId" @click="copyLink">
|
||||
链接
|
||||
</n-button>
|
||||
<n-flex v-if="!!submission.id" align="center">
|
||||
<n-button quaternary @click="emits('showCode')">代码</n-button>
|
||||
<n-popover v-if="submission.my_score === 0">
|
||||
<template #trigger>
|
||||
<n-button secondary type="primary">打分</n-button>
|
||||
</template>
|
||||
<n-rate :size="30" @update:value="updateScore" />
|
||||
</n-popover>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||
@@ -54,12 +31,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
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 copy from "copy-text-to-clipboard"
|
||||
import {
|
||||
buildPreviewDocument,
|
||||
PREVIEW_SANDBOX,
|
||||
@@ -75,15 +47,11 @@ interface Props {
|
||||
css: string
|
||||
js: string
|
||||
assetBaseUrl?: string
|
||||
submissionId?: string
|
||||
showCodeButton?: boolean
|
||||
clearable?: boolean
|
||||
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
||||
storageTaskId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
||||
|
||||
type Layout = "desktop" | "mobile" | "tablet"
|
||||
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
||||
@@ -121,9 +89,6 @@ function cycleLayout() {
|
||||
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const showDL = computed(() => props.html || props.css || props.js)
|
||||
const previewContent = ref("")
|
||||
|
||||
@@ -162,13 +127,6 @@ function download() {
|
||||
}
|
||||
|
||||
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")
|
||||
if (!newTab) return
|
||||
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
||||
@@ -183,27 +141,7 @@ function open() {
|
||||
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
||||
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(
|
||||
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||
|
||||
@@ -121,11 +121,7 @@ const userMenu = [
|
||||
|
||||
async function clickUserMenu(key: string) {
|
||||
if (key === "submissions") {
|
||||
router.push({
|
||||
name: "submissions",
|
||||
params: { page: 1 },
|
||||
query: { username: user.username },
|
||||
})
|
||||
router.push({ name: "submissions" })
|
||||
} else if (key === "logout") {
|
||||
await logout()
|
||||
// 留在需要登录或特定角色的页面上没有意义
|
||||
|
||||
@@ -111,6 +111,12 @@
|
||||
</n-dropdown>
|
||||
</n-flex>
|
||||
<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>
|
||||
@@ -155,6 +161,8 @@ const props = defineProps<{
|
||||
canWiden?: boolean
|
||||
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||
nav?: { prev: boolean; next: boolean }
|
||||
// 全部提交页(超管):能删除这一版
|
||||
canDelete?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
prev: []
|
||||
@@ -163,6 +171,7 @@ const emit = defineEmits<{
|
||||
"toggle-wide": []
|
||||
// 让作品墙同步更新这张卡片
|
||||
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||
deleted: [id: string]
|
||||
}>()
|
||||
|
||||
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() {
|
||||
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
|
||||
}
|
||||
@@ -397,6 +419,10 @@ h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.delete {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.rates {
|
||||
display: flex;
|
||||
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>
|
||||
<n-split
|
||||
class="container"
|
||||
direction="horizontal"
|
||||
:default-size="0.5"
|
||||
:min="0.2"
|
||||
:max="0.8"
|
||||
<div class="subs">
|
||||
<main class="list">
|
||||
<div class="toolbar">
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索学生"
|
||||
style="width: 150px"
|
||||
>
|
||||
<template #1>
|
||||
<n-flex
|
||||
vertical
|
||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
||||
>
|
||||
<n-flex align="center" justify="end">
|
||||
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
|
||||
</n-input>
|
||||
<n-radio-group v-model:value="query.task_type" size="small">
|
||||
<n-radio-button value="">全部</n-radio-button>
|
||||
<n-radio-button value="tutorial">教程</n-radio-button>
|
||||
<n-radio-button value="challenge">挑战</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-select
|
||||
v-model:value="query.zone"
|
||||
style="width: 110px"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="得分分区"
|
||||
:options="zoneOptions"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<n-input
|
||||
v-if="isAdmin"
|
||||
style="width: 120px"
|
||||
v-model:value="query.username"
|
||||
placeholder="搜索学生"
|
||||
clearable
|
||||
<n-select
|
||||
v-model:value="query.ordering"
|
||||
size="small"
|
||||
:options="orderOptions"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<n-pagination
|
||||
v-model:page="query.page"
|
||||
:page-size="10"
|
||||
:item-count="count"
|
||||
simple
|
||||
/>
|
||||
<n-button
|
||||
secondary
|
||||
style="padding: 0 10px"
|
||||
title="刷新"
|
||||
@click="init"
|
||||
>
|
||||
<Icon :width="16" icon="lucide:refresh-cw" />
|
||||
<n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">共 {{ count }} 条</span>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" quaternary :loading="loading" @click="load">
|
||||
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
刷新
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<n-data-table
|
||||
class="table"
|
||||
size="small"
|
||||
flex-height
|
||||
striped
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: SubmissionOut) => row.id"
|
||||
:expanded-row-keys="expandedKeys"
|
||||
@update:expanded-row-keys="handleExpand"
|
||||
:row-props="rowProps"
|
||||
:row-class-name="rowClassName"
|
||||
style="flex: 1; min-height: 0"
|
||||
:row-class-name="(row: SubmissionOut) => (row.id === selectedId ? 'row-active' : '')"
|
||||
/>
|
||||
|
||||
<n-flex justify="end">
|
||||
<n-pagination
|
||||
v-model:page="query.page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:item-count="count"
|
||||
size="small"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #2>
|
||||
<div style="height: 100%; padding-left: 10px">
|
||||
<Preview
|
||||
v-if="submission.id"
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
:submission-id="submission.id"
|
||||
@after-score="afterScore"
|
||||
@show-code="codeModal = true"
|
||||
</main>
|
||||
|
||||
<aside v-if="selectedId" class="detail">
|
||||
<ReviewPanel
|
||||
:submission-id="selectedId"
|
||||
can-delete
|
||||
@close="selectedId = null"
|
||||
@changed="applyPatch"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</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">
|
||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
||||
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import type { DataTableColumn } from "naive-ui"
|
||||
import { Icon } from "../icons"
|
||||
import { Submission } from "../api"
|
||||
import type { SubmissionOut } from "../utils/type"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import { goHome } from "../utils/helper"
|
||||
import { saveTaskCode } from "../composables/useTaskCode"
|
||||
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import type { FlagType, SubmissionOut } from "../utils/type"
|
||||
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
||||
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 PAGE_SIZE = 20
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
// 列表数据
|
||||
const data = ref<SubmissionOut[]>([])
|
||||
const count = ref(0)
|
||||
const loading = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
const query = reactive({
|
||||
page: Number(route.params.page),
|
||||
username: (Array.isArray(route.query.username)
|
||||
? ""
|
||||
: (route.query.username ?? "")) as string,
|
||||
page: Number(route.params.page) || 1,
|
||||
username: typeof route.query.username === "string" ? route.query.username : "",
|
||||
task_type: "" as "" | "tutorial" | "challenge",
|
||||
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)
|
||||
const zoneOptions = [
|
||||
{ label: "优秀", value: "featured" },
|
||||
{ label: "待定", value: "pending" },
|
||||
{ label: "待提高", value: "low" },
|
||||
const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
|
||||
const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
|
||||
const orderOptions = [
|
||||
{ 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>[] = [
|
||||
{
|
||||
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: "时间",
|
||||
key: "created",
|
||||
width: 110,
|
||||
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "提交者",
|
||||
key: "user",
|
||||
width: 80,
|
||||
render: (row) => row.username,
|
||||
width: 100,
|
||||
render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
|
||||
},
|
||||
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
|
||||
{
|
||||
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: "得分",
|
||||
key: "score",
|
||||
width: 70,
|
||||
render: (row) => {
|
||||
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
|
||||
const avgScore = row.score > 0 ? row.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),
|
||||
],
|
||||
)
|
||||
},
|
||||
align: "right",
|
||||
render: (row) =>
|
||||
row.score > 0
|
||||
? h("span", { class: "mono" }, row.score.toFixed(2))
|
||||
: h("span", { class: "muted" }, "未评"),
|
||||
},
|
||||
{
|
||||
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",
|
||||
width: 60,
|
||||
render: (row) => row.submit_count || "-",
|
||||
},
|
||||
{
|
||||
title: "浏览",
|
||||
key: "view_count",
|
||||
width: 60,
|
||||
render: (row) => row.view_count || "-",
|
||||
align: "right",
|
||||
render: (row) => h("span", { class: "mono muted" }, `${row.submit_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) {
|
||||
return {
|
||||
style: { cursor: "pointer" },
|
||||
onClick: () => {
|
||||
getSubmissionByID(row.id)
|
||||
handleExpand(
|
||||
expandedKeys.value.includes(row.id)
|
||||
? expandedKeys.value.filter((k) => k !== row.id)
|
||||
: [...expandedKeys.value, row.id],
|
||||
)
|
||||
},
|
||||
onClick: () => (selectedId.value = row.id),
|
||||
}
|
||||
}
|
||||
|
||||
function rowClassName(row: SubmissionOut) {
|
||||
return submission.value.id === row.id ? "row-active" : ""
|
||||
}
|
||||
|
||||
// 快速翻页时只用最后一次请求的结果
|
||||
let initSeq = 0
|
||||
let seq = 0
|
||||
|
||||
async function init() {
|
||||
const seq = ++initSeq
|
||||
expandedKeys.value = []
|
||||
expandedData.clear()
|
||||
const res = await Submission.list(query)
|
||||
if (seq !== initSeq) return
|
||||
async function load() {
|
||||
const s = ++seq
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await Submission.list({
|
||||
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
|
||||
count.value = res.count
|
||||
} finally {
|
||||
if (s === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let detailSeq = 0
|
||||
|
||||
async function getSubmissionByID(id: string) {
|
||||
const seq = ++detailSeq
|
||||
const res = await Submission.get(id)
|
||||
if (seq === detailSeq) submission.value = res
|
||||
function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
|
||||
const row = data.value.find((d) => d.id === patch.id)
|
||||
if (row) Object.assign(row, patch)
|
||||
}
|
||||
|
||||
function afterScore() {
|
||||
data.value = data.value.map((d) => {
|
||||
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
||||
return d
|
||||
})
|
||||
function onDeleted() {
|
||||
selectedId.value = null
|
||||
load()
|
||||
}
|
||||
|
||||
function copyToEditor() {
|
||||
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
|
||||
saveTaskCode(submission.value.task_id, { html: html.value, css: css.value, js: js.value })
|
||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
||||
// 换了筛选条件就回到第一页
|
||||
function reset() {
|
||||
if (query.page !== 1) query.page = 1
|
||||
else load()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => query.page,
|
||||
(v) => {
|
||||
init()
|
||||
router.push({ params: { page: v } })
|
||||
(page) => {
|
||||
load()
|
||||
router.push({ params: { page } })
|
||||
},
|
||||
)
|
||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||
@@ -340,53 +222,78 @@ watch(
|
||||
if (page !== query.page) query.page = page
|
||||
},
|
||||
)
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => {
|
||||
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()
|
||||
},
|
||||
)
|
||||
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
|
||||
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
onMounted(init)
|
||||
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(),
|
||||
}
|
||||
})
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
.subs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.row-active td) {
|
||||
background-color: var(--wp-brand-soft) !important;
|
||||
.list {
|
||||
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>
|
||||
@@ -44,9 +44,10 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/submissions/:page",
|
||||
// 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
|
||||
path: "/submissions/:page?",
|
||||
name: "submissions",
|
||||
component: () => import("./pages/Submissions.vue"),
|
||||
component: () => import("./pages/MySubmissions.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
@@ -116,7 +117,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("./pages/Review.vue"),
|
||||
},
|
||||
{
|
||||
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
|
||||
// 全站提交列表(超管),和学生的“我的提交”是两个页面
|
||||
path: "submissions/:page",
|
||||
name: "teach-submissions",
|
||||
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