Files
webpreview/src/pages/Submissions.vue
yuetsh 375a78b852
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
fix chain modal
2026-04-30 09:42:38 -06:00

443 lines
11 KiB
Vue

<template>
<n-split
class="container"
direction="horizontal"
:default-size="0.5"
:min="0.2"
:max="0.8"
>
<template #1>
<n-flex
vertical
style="height: 100%; padding-right: 10px; overflow: hidden"
>
<n-flex justify="space-between" style="flex-shrink: 0">
<n-button
secondary
@click="
() =>
goHome(
$router,
taskTab,
taskTab === TASK_TYPE.Challenge ? challengeDisplay : step,
)
"
>
首页
</n-button>
<n-flex align="center">
<n-select
:value="query.flag"
style="width: 100px"
clearable
placeholder="标记"
:options="flagFilterOptions"
@update:value="handleFlagSelect"
/>
<n-select
v-model:value="query.zone"
style="width: 100px"
clearable
placeholder="从夯到拉"
:options="[
{ label: '夯爆了', value: 'featured' },
{ label: 'NPC', value: 'pending' },
{ label: '拉完了', value: 'low' },
]"
/>
<n-input
style="width: 120px"
v-model:value="query.username"
clearable
/>
<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-button>
</n-flex>
</n-flex>
<n-data-table
flex-height
striped
: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"
/>
</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"
/>
</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, type DataTableColumn } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Submission } from "../api"
import type { SubmissionOut, FlagType } from "../utils/type"
import { parseTime } from "../utils/helper"
import { goHome } from "../utils/helper"
import { TASK_TYPE } from "../utils/const"
import { watchDebounced } from "@vueuse/core"
import { useRouter, useRoute } from "vue-router"
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 FlagCell from "../components/submissions/FlagCell.vue"
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
import { submission } from "../store/submission"
import { taskTab, challengeDisplay } from "../store/task"
import { step } from "../store/tutorial"
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
import { roleAdmin, roleSuper, user } from "../store/user"
const route = useRoute()
const router = useRouter()
// 列表数据
const data = ref<SubmissionOut[]>([])
const count = ref(0)
const query = reactive({
page: Number(route.params.page),
username: (Array.isArray(route.query.username)
? ""
: (route.query.username ?? "")) as string,
flag: null as string | null,
zone: null as string | null,
})
// 当前选中提交的代码
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)
// Flag 过滤选项
const flagFilterOptions = computed(() => {
const opts = [
{ label: "红旗", value: "red" },
{ label: "蓝旗", value: "blue" },
{ label: "绿旗", value: "green" },
{ label: "黄旗", value: "yellow" },
{ label: "全部", value: "any" },
]
if (isAdmin.value) opts.push({ label: "清除", value: "_clear_all" })
return opts
})
function handleFlagSelect(value: string | null) {
if (value === "_clear_all") {
clearAllFlags()
return
}
query.flag = value
}
async function updateFlag(row: SubmissionOut, flag: FlagType) {
await Submission.updateFlag(row.id, flag)
row.flag = flag
}
async function clearAllFlags() {
await Submission.clearAllFlags()
data.value = data.value.map((d) => ({ ...d, flag: null }))
query.flag = null
}
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: "flag",
width: 50,
render: (row) =>
h(FlagCell, {
flag: row.flag ?? null,
isAdmin: isAdmin.value,
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
}),
},
{
title: "",
key: "zone",
width: 42,
render: (row) => {
const map: Record<
string,
{ label: string; type: "success" | "default" | "warning" }
> = {
featured: { label: "", type: "success" },
pending: { label: "N", 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,
},
{
title: "任务",
key: "task_title",
render: (row) => h(TaskTitle, { submission: row }),
},
{
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: "#999" } }, myScore),
],
)
},
},
{
title: "次数",
key: "submit_count",
width: 60,
render: (row) => 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) {
await Submission.delete(row.id)
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],
)
},
}
}
function rowClassName(row: SubmissionOut) {
return submission.value.id === row.id ? "row-active" : ""
}
async function init() {
expandedKeys.value = []
expandedData.clear()
const res = await Submission.list(query)
data.value = res.items
count.value = res.count
}
async function getSubmissionByID(id: string) {
submission.value = await Submission.get(id)
}
function afterScore() {
data.value = data.value.map((d) => {
if (d.id === submission.value.id) d.my_score = submission.value.my_score
return d
})
}
function copyToEditor() {
eHtml.value = html.value
eCss.value = css.value
eJs.value = js.value
goHome(router, submission.value.task_type, submission.value.task_display)
}
watch(
() => query.page,
(v) => {
init()
router.push({ params: { page: v } })
},
)
watchDebounced(
() => query.username,
() => {
query.page = 1
init()
},
{ debounce: 500, maxWait: 1000 },
)
watch(
() => query.flag,
() => {
query.page = 1
init()
},
)
watch(
() => query.zone,
() => {
query.page = 1
init()
},
)
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(),
}
})
</script>
<style scoped>
.container {
padding: 10px;
box-sizing: border-box;
width: 100%;
}
:deep(.row-active td) {
background-color: rgba(24, 160, 80, 0.1) !important;
}
</style>