update
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
xuyue committed 2026-09-27 19:36:28 +08:00
1 parent 23844ae301
commit d25b5dbe6d
268 files changed
+3684 -8861

No files matched your search

@@ -112,15 +112,11 @@ async function save() {
<div class="icon-preview">
<AchievementIcon :icon="form.icon" :size="28" />
</div>
<n-input
v-model:value="form.icon"
placeholder="iconify 图标名,例如 noto:owl"
/>
<n-input v-model:value="form.icon" placeholder="iconify 图标名,例如 noto:owl" />
</n-flex>
<n-text depth="3" style="font-size: 12px">
填 iconify 图标名(推荐 noto: 开头的彩色 emoji 图标),左侧是实时
预览;预览不出来说明名字写错了。图标名可在 icon-sets.iconify.design
搜索。
预览;预览不出来说明名字写错了。图标名可在 icon-sets.iconify.design 搜索。
</n-text>
</n-flex>
</n-form-item>
@@ -128,11 +124,7 @@ async function save() {
<n-select v-model:value="form.rarity" :options="rarityOptions" />
</n-form-item>
<n-form-item label="指标" required>
<n-select
v-model:value="form.metric"
:options="metricOptions"
filterable
/>
<n-select v-model:value="form.metric" :options="metricOptions" filterable />
</n-form-item>
<n-form-item v-if="metricHelp" label=" ">
<n-text depth="3">{{ metricHelp }}</n-text>
@@ -152,9 +144,7 @@ async function save() {
</n-form-item>
<n-form-item label="隐藏成就">
<n-switch v-model:value="form.hidden" />
<n-text depth="3" style="margin-left: 12px">
未解锁时学生只能看到 ???
</n-text>
<n-text depth="3" style="margin-left: 12px"> 未解锁时学生只能看到 ??? </n-text>
</n-form-item>
<n-form-item label="上架">
<n-switch v-model:value="form.visible" />
@@ -166,9 +156,7 @@ async function save() {
<template #footer>
<n-flex justify="end">
<n-button @click="emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">
保存
</n-button>
<n-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
</n-flex>
</template>
</n-modal>
+5 -22
View File
@@ -1,10 +1,6 @@
<script setup lang="ts">
import { NButton, NFlex } from "naive-ui"
import {
deleteAchievement,
getAdminAchievements,
type AdminAchievement,
} from "admin/api"
import { deleteAchievement, getAdminAchievements, type AdminAchievement } from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue"
import { RARITY_LABEL } from "utils/constants"
@@ -100,16 +96,8 @@ const columns: DataTableColumn<AdminAchievement>[] = [
width: 130,
render: (row) =>
h(NFlex, { size: 8 }, () => [
h(
NButton,
{ text: true, type: "primary", onClick: () => edit(row) },
() => "编辑",
),
h(
NButton,
{ text: true, type: "error", onClick: () => remove(row) },
() => "删除",
),
h(NButton, { text: true, type: "primary", onClick: () => edit(row) }, () => "编辑"),
h(NButton, { text: true, type: "error", onClick: () => remove(row) }, () => "删除"),
]),
},
]
@@ -124,8 +112,7 @@ onMounted(load)
</template>
<n-alert type="info" style="margin-bottom: 12px">
「已解锁人数」是唯一的仪表盘:配置一周后仍为
0,多半是阈值配错了而不是太难。
「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。
</n-alert>
<n-data-table
@@ -135,10 +122,6 @@ onMounted(load)
:row-key="(row: AdminAchievement) => row.id"
/>
<AchievementModal
v-model:show="showModal"
:editing="editing"
@saved="load"
/>
<AchievementModal v-model:show="showModal" :editing="editing" @saved="load" />
</n-card>
</template>
+4 -16
View File
@@ -30,11 +30,7 @@
</n-flex>
</n-alert>
<n-data-table striped :columns="columns" :data="reports" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
<n-modal
v-model:show="showModal"
@@ -68,12 +64,7 @@ import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import {
getAIReportList,
getAIReportDetail,
pinAIReport,
getPinnedAIReports,
} from "../api"
import { getAIReportList, getAIReportDetail, pinAIReport, getPinnedAIReports } from "../api"
import { NButton, NTag } from "naive-ui"
import type { AdminAiReport, AdminAiReportListItem } from "utils/types"
@@ -95,8 +86,7 @@ const columns: DataTableColumn<ReportItem>[] = [
title: "用户名",
key: "username",
width: 150,
render: (row) =>
h("span", { style: row.isPinned ? "font-weight:600" : "" }, row.username),
render: (row) => h("span", { style: row.isPinned ? "font-weight:600" : "" }, row.username),
},
{
title: "AI 分析内容",
@@ -114,9 +104,7 @@ const columns: DataTableColumn<ReportItem>[] = [
key: "is_pinned",
width: 100,
render: (row) =>
row.isPinned
? h(NTag, { type: "warning", size: "small" }, () => "已锁定")
: null,
row.isPinned ? h(NTag, { type: "warning", size: "small" }, () => "已锁定") : null,
},
{
title: "操作",
@@ -25,9 +25,7 @@ async function handleDelete() {
</script>
<template>
<n-flex>
<n-button size="small" type="success" secondary @click="goEdit">
编辑
</n-button>
<n-button size="small" type="success" secondary @click="goEdit"> 编辑 </n-button>
<n-popconfirm @positive-click="handleDelete">
<template #trigger>
<n-button size="small" type="error" secondary>删除</n-button>
+2 -11
View File
@@ -80,11 +80,7 @@ onMounted(init)
<n-input class="contestTitle" v-model:value="announcement.title" />
</n-form-item>
<n-form-item label="标签">
<n-select
class="select"
v-model:value="announcement.tag"
:options="tags"
/>
<n-select class="select" v-model:value="announcement.tag" :options="tags" />
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="announcement.visible" />
@@ -93,12 +89,7 @@ onMounted(init)
<n-switch v-model:value="announcement.top" />
</n-form-item>
</n-form>
<TextEditor
v-if="ready"
title="正文"
v-model:value="announcement.content"
:min-height="200"
/>
<TextEditor v-if="ready" title="正文" v-model:value="announcement.content" :min-height="200" />
<n-flex style="margin-bottom: 100px" justify="end">
<n-button type="primary" @click="submit">保存</n-button>
</n-flex>
+3 -11
View File
@@ -57,8 +57,7 @@ const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
title: "选项",
key: "actions",
width: 140,
render: (row) =>
h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
render: (row) => h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
},
]
@@ -96,19 +95,12 @@ watch(query, listAnnouncements, { deep: true })
<template>
<n-flex align="center" class="titleWrapper">
<h2 class="title">网站公告</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin announcement create' })"
>
<n-button type="primary" @click="$router.push({ name: 'admin announcement create' })">
新建
</n-button>
</n-flex>
<n-data-table striped :columns="columns" :data="announcements" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
<style scoped>
+34 -99
View File
@@ -63,9 +63,7 @@ export async function getProblemList(
contestID?: string,
tagId?: number,
) {
const endpoint = contestID
? `admin/contests/${contestID}/problems`
: "admin/problems"
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
const res = await api.get<AdminProblemList>(endpoint, {
params: { offset, limit, keyword, author, tagId },
})
@@ -133,24 +131,14 @@ export function batchTagProblems(
}
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank)
export function getAdminUserRank(
offset: number,
limit: number,
keyword: string,
) {
export function getAdminUserRank(offset: number, limit: number, keyword: string) {
return api.get<AdminUserRank>("admin/rankings/users", {
params: { offset, limit, keyword },
})
}
// 用户列表
export function getUserList(
offset = 0,
limit = 10,
type = "",
keyword: string,
orderBy = "",
) {
export function getUserList(offset = 0, limit = 10, type = "", keyword: string, orderBy = "") {
return api.get<AdminUserList>("admin/users", {
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
// "-online" 是新增的,原样透传
@@ -179,9 +167,7 @@ export function editUser(user: AdminUserForm) {
// 重置用户密码,返回新密码
export async function resetPassword(userID: number) {
const res = await api.post<{ password: string }>(
`admin/users/${userID}/reset-password`,
)
const res = await api.post<{ password: string }>(`admin/users/${userID}/reset-password`)
return res.password
}
@@ -242,14 +228,8 @@ export function getSQLTestcaseScripts(problemId: number) {
}
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
export function generateSQLTestcase(data: {
refSql: string
mode: "query" | "modify"
}) {
return api.post<GenerateSqlTestCaseResponse>(
"admin/sql-test-cases/generate",
data,
)
export function generateSQLTestcase(data: { refSql: string; mode: "query" | "modify" }) {
return api.post<GenerateSqlTestCaseResponse>("admin/sql-test-cases/generate", data)
}
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
@@ -328,15 +308,11 @@ export function getContest(id: string) {
return api.get<AdminContest>(`admin/contests/${id}`)
}
export function addProblemForContest(
contestID: string,
problemID: number,
displayID: string,
) {
return api.post<AdminProblem>(
`admin/contests/${contestID}/problems/from-public`,
{ problemId: problemID, displayId: displayID },
)
export function addProblemForContest(contestID: string, problemID: number, displayID: string) {
return api.post<AdminProblem>(`admin/contests/${contestID}/problems/from-public`, {
problemId: problemID,
displayId: displayID,
})
}
export function getWebsite() {
@@ -364,10 +340,9 @@ export function deleteJudgeServer(hostname: string) {
}
export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AdminAnnouncementListItem[]; total: number }>(
"admin/announcements",
{ params: { offset, limit } },
)
return api.get<{ results: AdminAnnouncementListItem[]; total: number }>("admin/announcements", {
params: { offset, limit },
})
}
export function getAnnouncement(id: number) {
@@ -464,9 +439,9 @@ export function makeProblemPublic(id: number, displayId: string) {
export function getACMHelperList(contestId: number) {
// acInfo 在契约里是 Record<string, unknown>(acm_contest_rank 的 JSONB 原文),
// 组件侧按 ContestSubmissionInfo 读,收窄放在这里
return api.get<
Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>
>(`admin/contests/${contestId}/acm-helper`)
return api.get<Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>>(
`admin/contests/${contestId}/acm-helper`,
)
}
export function updateACMHelperChecked(
@@ -525,10 +500,7 @@ export function createProblemSet(data: ProblemSetBody) {
}
export function editProblemSet(data: ProblemSetBody & { id: number }) {
return api.put<AdminProblemSet>(
`admin/problem-sets/${data.id}`,
toProblemSetBody(data),
)
return api.put<AdminProblemSet>(`admin/problem-sets/${data.id}`, toProblemSetBody(data))
}
export function deleteProblemSet(id: number) {
@@ -545,9 +517,7 @@ export function updateProblemSetStatus(id: number, status: string) {
// 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) {
return api.get<AdminProblemSetProblem[]>(
`admin/problem-sets/${problemSetId}/problems`,
)
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
}
export function addProblemToSet(
@@ -579,26 +549,16 @@ export function editProblemInSet(
hint?: string
},
) {
return api.put(
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
data,
)
return api.put(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, data)
}
export function removeProblemFromSet(
problemSetId: number,
problemSetProblemId: number,
) {
return api.delete(
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
)
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`)
}
// 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) {
return api.get<AdminProblemSetBadge[]>(
`admin/problem-sets/${problemSetId}/badges`,
)
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
}
interface BadgeBody {
@@ -626,11 +586,7 @@ export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
)
}
export function editProblemSetBadge(
problemSetId: number,
badgeId: number,
data: BadgeBody,
) {
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
return api.put<AdminProblemSetBadge>(
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
toBadgeBody(data),
@@ -644,9 +600,7 @@ export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
// 题单进度管理 API
// 注意:返回的是裸数组,不是分页信封 —— 和 oj 侧的 /user-progress 不同
export function getProblemSetProgress(problemSetId: number) {
return api.get<AdminProblemSetProgress[]>(
`admin/problem-sets/${problemSetId}/progress`,
)
return api.get<AdminProblemSetProgress[]>(`admin/problem-sets/${problemSetId}/progress`)
}
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
@@ -658,41 +612,28 @@ export function getStuckProblems() {
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
}
export function getLearnStudents(params: {
type: "python" | "c"
className?: string
}) {
export function getLearnStudents(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnStudentProgressList>("admin/learn-analytics/students", {
params,
})
}
export function getLearnTutorials(params: {
type: "python" | "c"
className?: string
}) {
export function getLearnTutorials(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnTutorialProgressList>("admin/learn-analytics/tutorials", {
params,
})
}
export function getLearnExercises(params: {
type: "python" | "c"
className?: string
}) {
export function getLearnExercises(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnExerciseProgressList>("admin/learn-analytics/exercises", {
params,
})
}
export function getLearnExerciseAttempts(
exerciseId: number,
params: { className?: string },
) {
return api.get<LearnExerciseAttempt[]>(
`admin/learn-analytics/exercises/${exerciseId}/attempts`,
{ params },
)
export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) {
return api.get<LearnExerciseAttempt[]>(`admin/learn-analytics/exercises/${exerciseId}/attempts`, {
params,
})
}
export function getTopACTrend(params: {
@@ -757,17 +698,11 @@ export function getMetricOptions() {
}
export function createAchievement(data: Partial<AdminAchievement>) {
return api.post<AdminAchievement>(
"admin/achievements",
toAchievementBody(data),
)
return api.post<AdminAchievement>("admin/achievements", toAchievementBody(data))
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return api.put<AdminAchievement>(
`admin/achievements/${data.id}`,
toAchievementBody(data),
)
return api.put<AdminAchievement>(`admin/achievements/${data.id}`, toAchievementBody(data))
}
export function deleteAchievement(id: number) {
@@ -45,15 +45,9 @@ async function clone() {
</script>
<template>
<n-flex>
<n-button size="small" type="primary" secondary @click="goEditProblems">
题目
</n-button>
<n-button size="small" type="warning" secondary @click="goACMHelper">
审核
</n-button>
<n-button size="small" type="info" secondary @click="goEdit">
编辑
</n-button>
<n-button size="small" type="primary" secondary @click="goEditProblems"> 题目 </n-button>
<n-button size="small" type="warning" secondary @click="goACMHelper"> 审核 </n-button>
<n-button size="small" type="info" secondary @click="goEdit"> 编辑 </n-button>
<n-button size="small" secondary @click="clone"> 复制 </n-button>
</n-flex>
</template>
+5 -26
View File
@@ -134,26 +134,14 @@ onMounted(getContestDetail)
<n-input style="width: 300px" v-model:value="contest.title" />
</n-form-item>
<n-form-item label="标签">
<n-select
style="width: 100px"
:options="tags"
v-model:value="contest.tag"
/>
<n-select style="width: 100px" :options="tags" v-model:value="contest.tag" />
</n-form-item>
<template v-if="props.contestID">
<n-form-item label="开始">
<n-date-picker
style="width: 200px"
v-model:value="startTime"
type="datetime"
/>
<n-date-picker style="width: 200px" v-model:value="startTime" type="datetime" />
</n-form-item>
<n-form-item label="结束">
<n-date-picker
style="width: 200px"
v-model:value="endTime"
type="datetime"
/>
<n-date-picker style="width: 200px" v-model:value="endTime" type="datetime" />
</n-form-item>
</template>
<template v-else>
@@ -161,11 +149,7 @@ onMounted(getContestDetail)
<n-input-number style="width: 120px" v-model:value="waitMins" />
</n-form-item>
<n-form-item label="比赛时长">
<n-input-number
style="width: 120px"
step="5"
v-model:value="durationMins"
/>
<n-input-number style="width: 120px" step="5" v-model:value="durationMins" />
</n-form-item>
</template>
<n-form-item label="密码">
@@ -175,12 +159,7 @@ onMounted(getContestDetail)
<n-switch v-model:value="contest.visible" />
</n-form-item>
</n-form>
<TextEditor
v-if="ready"
title="描述"
v-model:value="contest.description"
:min-height="200"
/>
<TextEditor v-if="ready" title="描述" v-model:value="contest.description" :min-height="200" />
<n-flex style="margin-bottom: 100px" justify="end">
<n-button type="primary" @click="submit">保存</n-button>
</n-flex>
+7 -30
View File
@@ -47,9 +47,7 @@ const currentSubmission = ref<any>(null)
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
function formatACTime(relativeSeconds: number) {
if (!contestStartTime.value) return "-"
const acTime = new Date(
contestStartTime.value.getTime() + relativeSeconds * 1000,
)
const acTime = new Date(contestStartTime.value.getTime() + relativeSeconds * 1000)
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
}
@@ -57,12 +55,7 @@ function formatACTime(relativeSeconds: number) {
async function toggleChecked(item: HelperItem) {
const newChecked = !item.checked
try {
await updateACMHelperChecked(
Number(props.contestID),
item.id,
item.problemId,
newChecked,
)
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, newChecked)
// 更新本地状态
item.checked = newChecked
item.acInfo.checked = newChecked
@@ -87,12 +80,7 @@ async function markAllAsChecked() {
const loadingMsg = message.loading("正在标记...", { duration: 0 })
try {
for (const item of unchecked) {
await updateACMHelperChecked(
Number(props.contestID),
item.id,
item.problemId,
true,
)
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, true)
item.checked = true
item.acInfo.checked = true
}
@@ -112,8 +100,7 @@ async function markAllAsChecked() {
const filteredSubmissions = computed(() => {
return submissions.value.filter((item) => {
if (query.username && !item.username.includes(query.username)) return false
if (query.problemId && !item.problemDisplayId.includes(query.problemId))
return false
if (query.problemId && !item.problemDisplayId.includes(query.problemId)) return false
if (query.checked === "checked" && !item.checked) return false
if (query.checked === "unchecked" && item.checked) return false
return true
@@ -250,15 +237,9 @@ onMounted(loadData)
<h2 style="margin: 0">比赛辅助检查</h2>
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
<n-tag type="warning" size="large">
未检查: {{ stats.unchecked }}
</n-tag>
<n-tag type="warning" size="large"> 未检查: {{ stats.unchecked }} </n-tag>
</n-flex>
<n-button
type="primary"
:disabled="stats.unchecked === 0"
@click="markAllAsChecked"
>
<n-button type="primary" :disabled="stats.unchecked === 0" @click="markAllAsChecked">
标记全部为已检查
</n-button>
</n-flex>
@@ -281,11 +262,7 @@ onMounted(loadData)
style="width: 150px"
clearable
/>
<n-select
v-model:value="query.checked"
:options="checkedOptions"
style="width: 120px"
/>
<n-select v-model:value="query.checked" :options="checkedOptions" style="width: 120px" />
</n-flex>
<n-data-table
+2 -9
View File
@@ -102,10 +102,7 @@ watchDebounced(() => query.keyword, listContests, {
<n-flex justify="space-between" class="titleWrapper">
<n-flex align="center">
<h2 class="title">比赛列表</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin contest create' })"
>
<n-button type="primary" @click="$router.push({ name: 'admin contest create' })">
新建
</n-button>
</n-flex>
@@ -114,11 +111,7 @@ watchDebounced(() => query.keyword, listContests, {
</div>
</n-flex>
<n-data-table :columns="columns" :data="contests" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
<style scoped>
@@ -24,11 +24,7 @@ const columns: DataTableColumn<LearnExerciseAttempt>[] = [
width: 130,
render: (row) =>
row.solved
? h(
NText,
{ type: "success" },
() => `做对了(第 ${row.attemptsToSolve} 次)`,
)
? h(NText, { type: "success" }, () => `做对了(第 ${row.attemptsToSolve} 次)`)
: h(NText, { type: "error" }, () => "还没做对"),
},
{ title: "提交次数", key: "attempts", width: 100, sorter: "default" },
+136 -186
View File
@@ -1,11 +1,7 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import { NProgress, NTag, NText } from "naive-ui"
import {
getLearnStudents,
getLearnTutorials,
getLearnExercises,
} from "admin/api"
import { getLearnStudents, getLearnTutorials, getLearnExercises } from "admin/api"
import { readableDuration, parseTime } from "utils/functions"
import type {
LearnStudentProgress,
@@ -92,16 +88,11 @@ const statusCounts = computed(() => {
return counts
})
const startedCount = computed(
() => students.value.length - statusCounts.value.idle,
)
const startedCount = computed(() => students.value.length - statusCounts.value.idle)
const avgRead = computed(() =>
students.value.length
? (
students.value.reduce((n, row) => n + row.readCount, 0) /
students.value.length
).toFixed(1)
? (students.value.reduce((n, row) => n + row.readCount, 0) / students.value.length).toFixed(1)
: "0",
)
@@ -147,11 +138,7 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
width: 110,
render: (row) => {
const meta = STATUS_META[statusOf(row)]
return h(
NTag,
{ size: "small", type: meta.type, bordered: false },
() => meta.label,
)
return h(NTag, { size: "small", type: meta.type, bordered: false }, () => meta.label)
},
},
{
@@ -193,11 +180,7 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
)
: null,
row.exerciseAttempts
? h(
NText,
{ depth: 3, style: "font-size: 12px" },
() => `共 ${row.exerciseAttempts} 次`,
)
? h(NText, { depth: 3, style: "font-size: 12px" }, () => `共 ${row.exerciseAttempts} 次`)
: null,
]),
},
@@ -217,150 +200,134 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
},
])
const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
() => [
{
title: "#",
key: "order",
width: 60,
render: (_, index) => index + 1,
},
{ title: "课程", key: "title", minWidth: 200 },
{
title: `读过的人(共 ${studentCount.value} 人)`,
key: "readers",
width: 200,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.readers} / ${studentCount.value}`),
h(NProgress, {
type: "line",
percentage: studentCount.value
? Math.round((row.readers / studentCount.value) * 100)
: 0,
showIndicator: false,
status: row.readers === 0 ? "error" : "success",
style: "width: 70px",
}),
]),
},
{
title: "人均时长",
key: "avgSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.avgSeconds),
},
{
title: "累计时长",
key: "totalSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.totalSeconds),
},
],
)
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
() => [
{
type: "expand",
renderExpand: (row) =>
h(ExerciseAttempts, {
exerciseId: row.exerciseId,
className: className.value.trim(),
const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(() => [
{
title: "#",
key: "order",
width: 60,
render: (_, index) => index + 1,
},
{ title: "课程", key: "title", minWidth: 200 },
{
title: `读过的人(共 ${studentCount.value} 人)`,
key: "readers",
width: 200,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.readers} / ${studentCount.value}`),
h(NProgress, {
type: "line",
percentage: studentCount.value ? Math.round((row.readers / studentCount.value) * 100) : 0,
showIndicator: false,
status: row.readers === 0 ? "error" : "success",
style: "width: 70px",
}),
]),
},
{
title: "人均时长",
key: "avgSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.avgSeconds),
},
{
title: "累计时长",
key: "totalSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.totalSeconds),
},
])
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(() => [
{
type: "expand",
renderExpand: (row) =>
h(ExerciseAttempts, {
exerciseId: row.exerciseId,
className: className.value.trim(),
}),
},
{
title: "课",
key: "tutorialOrder",
width: 160,
ellipsis: { tooltip: true },
render: (row) => `${row.tutorialOrder}. ${row.tutorialTitle}`,
},
{
title: "题型",
key: "type",
width: 90,
render: (row) => EXERCISE_TYPE_LABEL[row.type] ?? row.type,
},
{
title: "题干",
key: "question",
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.question || "(无题干)",
},
{
// 试的人不少、却没人一次做对,或者一半以上的人没做对 —— 多半是题有坑,
// 老师应该先去看展开里全班「最后一次错在」是不是同一个干扰项
title: "提示",
key: "flag",
width: 100,
render: (row) => {
if (row.triedUsers < 3) return null
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
return h(NTag, { size: "small", type: "warning", bordered: false }, () => "没人一次对")
}
if (row.solvedUsers / row.triedUsers < 0.5) {
return h(NTag, { size: "small", type: "error", bordered: false }, () => "多数人卡住")
}
return null
},
{
title: "课",
key: "tutorialOrder",
width: 160,
ellipsis: { tooltip: true },
render: (row) => `${row.tutorialOrder}. ${row.tutorialTitle}`,
},
{
title: "题型",
key: "type",
width: 90,
render: (row) => EXERCISE_TYPE_LABEL[row.type] ?? row.type,
},
{
title: "题干",
key: "question",
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.question || "(无题干)",
},
{
// 试的人不少、却没人一次做对,或者一半以上的人没做对 —— 多半是题有坑,
// 老师应该先去看展开里全班「最后一次错在」是不是同一个干扰项
title: "提示",
key: "flag",
width: 100,
render: (row) => {
if (row.triedUsers < 3) return null
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
return h(
NTag,
{ size: "small", type: "warning", bordered: false },
() => "没人一次对",
)
}
if (row.solvedUsers / row.triedUsers < 0.5) {
return h(
NTag,
{ size: "small", type: "error", bordered: false },
() => "多数人卡住",
)
}
return null
},
},
{
title: "做对 / 做过",
key: "solvedUsers",
width: 150,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.solvedUsers} / ${row.triedUsers}`),
h(NProgress, {
type: "line",
percentage: row.triedUsers
? Math.round((row.solvedUsers / row.triedUsers) * 100)
: 0,
showIndicator: false,
status: row.triedUsers === 0 ? "error" : "success",
style: "width: 60px",
}),
]),
},
{
title: "一次做对",
key: "firstTryUsers",
width: 110,
sorter: "default",
render: (row) => `${row.firstTryUsers} 人`,
},
{
// 做对的人平均试了几次。它和「一次做对」一起看才分得清难题和歧义题:
// 平均 3 次但没人一次对 → 题目本身有坑
title: "平均试几次",
key: "avgAttemptsToSolve",
width: 120,
sorter: "default",
defaultSortOrder: "descend",
render: (row) => (row.solvedUsers ? `${row.avgAttemptsToSolve} 次` : "-"),
},
{
title: "提交总次数",
key: "attempts",
width: 120,
sorter: "default",
},
],
)
},
{
title: "做对 / 做过",
key: "solvedUsers",
width: 150,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.solvedUsers} / ${row.triedUsers}`),
h(NProgress, {
type: "line",
percentage: row.triedUsers ? Math.round((row.solvedUsers / row.triedUsers) * 100) : 0,
showIndicator: false,
status: row.triedUsers === 0 ? "error" : "success",
style: "width: 60px",
}),
]),
},
{
title: "一次做对",
key: "firstTryUsers",
width: 110,
sorter: "default",
render: (row) => `${row.firstTryUsers} 人`,
},
{
// 做对的人平均试了几次。它和「一次做对」一起看才分得清难题和歧义题:
// 平均 3 次但没人一次对 → 题目本身有坑
title: "平均试几次",
key: "avgAttemptsToSolve",
width: 120,
sorter: "default",
defaultSortOrder: "descend",
render: (row) => (row.solvedUsers ? `${row.avgAttemptsToSolve} 次` : "-"),
},
{
title: "提交总次数",
key: "attempts",
width: 120,
sorter: "default",
},
])
async function load() {
loading.value = true
@@ -410,9 +377,7 @@ onMounted(load)
@clear="load"
/>
<n-button type="primary" secondary @click="load">查询</n-button>
<n-text depth="3">
{{ studentCount }} 名学生,{{ startedCount }} 人已经开始学
</n-text>
<n-text depth="3"> {{ studentCount }} 名学生,{{ startedCount }} 人已经开始学 </n-text>
<!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 -->
<n-text depth="3" style="font-size: 12px">
「已读」按累计停留满
@@ -420,13 +385,7 @@ onMounted(load)
</n-text>
</n-flex>
<n-grid
cols="2 s:3 m:5"
:x-gap="12"
:y-gap="12"
responsive="screen"
style="margin-bottom: 16px"
>
<n-grid cols="2 s:3 m:5" :x-gap="12" :y-gap="12" responsive="screen" style="margin-bottom: 16px">
<n-gi>
<n-card size="small" :bordered="true">
<n-statistic label="学生" :value="studentCount" />
@@ -448,10 +407,7 @@ onMounted(load)
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic
label="练一练做对率"
:value="solveRate === null ? '-' : `${solveRate}%`"
/>
<n-statistic label="练一练做对率" :value="solveRate === null ? '-' : `${solveRate}%`" />
</n-card>
</n-gi>
<n-gi>
@@ -472,16 +428,10 @@ onMounted(load)
clearable
style="width: 200px"
/>
<n-text v-if="keyword.trim()" depth="3">
找到 {{ filteredStudents.length }} 人
</n-text>
<n-text v-if="keyword.trim()" depth="3"> 找到 {{ filteredStudents.length }} 人 </n-text>
</n-flex>
<n-flex :size="8" style="margin-bottom: 12px">
<n-tag
checkable
:checked="statusFilter === 'all'"
@update:checked="statusFilter = 'all'"
>
<n-tag checkable :checked="statusFilter === 'all'" @update:checked="statusFilter = 'all'">
全部 {{ students.length }}
</n-tag>
<n-tag
+2 -13
View File
@@ -14,15 +14,7 @@ import {
import { getTopACTrend } from "admin/api"
import { zonedYear } from "utils/functions"
ChartJS.register(
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
)
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
type ProblemTrend = AcTrend
@@ -161,10 +153,7 @@ onMounted(fetchData)
<n-tag type="info" size="small">共 {{ data.length }} 题</n-tag>
</n-space>
<n-spin :show="loading">
<div
v-if="!loading && data.length === 0"
style="text-align: center; padding: 40px"
>
<div v-if="!loading && data.length === 0" style="text-align: center; padding: 40px">
暂无数据
</div>
<div v-else class="grid">
@@ -1,10 +1,6 @@
<script lang="ts" setup>
import { errorCode, errorMessage } from "utils/api"
import {
deleteContestProblem,
deleteProblem,
makeProblemPublic,
} from "admin/api"
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
import download from "utils/download"
interface Props {
@@ -18,9 +14,7 @@ const route = useRoute()
const router = useRouter()
const message = useMessage()
const isContestProblem = computed(
() => route.name === "admin contest problem list",
)
const isContestProblem = computed(() => route.name === "admin contest problem list")
const showMakePublicModal = ref(false)
const newDisplayID = ref("")
@@ -95,20 +89,11 @@ async function handleMakePublic() {
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary type="info" @click="goCheck">
查看
</n-button>
<n-button size="small" secondary type="primary" @click="goEdit"> 编辑 </n-button>
<n-button size="small" secondary type="info" @click="goCheck"> 查看 </n-button>
<n-tooltip v-if="isContestProblem">
<template #trigger>
<n-button
size="small"
secondary
type="warning"
@click="openMakePublicModal"
>
<n-button size="small" secondary type="warning" @click="openMakePublicModal">
公开
</n-button>
</template>
@@ -135,9 +120,7 @@ async function handleMakePublic() {
style="width: 500px"
>
<n-space vertical>
<p>
将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。
</p>
<p>将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。</p>
<n-form>
<n-form-item label="新的题目编号" required>
<n-input
@@ -148,8 +131,7 @@ async function handleMakePublic() {
/>
</n-form-item>
</n-form>
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
</n-alert>
<n-alert type="info" title="提示:请输入一个未被使用的题目编号"> </n-alert>
</n-space>
<template #footer>
<n-flex justify="end">
@@ -17,11 +17,7 @@ const displayID = ref(props.nextDisplayId || "")
async function addProblem() {
if (!displayID.value) return
try {
await addProblemForContest(
props.contestID,
props.problemID,
displayID.value,
)
await addProblemForContest(props.contestID, props.problemID, displayID.value)
emit("added")
} catch (err) {
if (errorCode(err) === "display-id-exists") {
@@ -73,18 +73,9 @@ watchDebounced(
title="从题库中添加"
@close="$emit('update:show', false)"
>
<n-input
class="search"
v-model:value="query.keyword"
clearable
placeholder="搜索标题或编号"
/>
<n-input class="search" v-model:value="query.keyword" clearable placeholder="搜索标题或编号" />
<n-data-table striped :columns="columns" :data="problems" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</n-modal>
</template>
<style scoped>
@@ -70,27 +70,22 @@ const ENGINE_OPTIONS: SelectOption[] = [
// 列表推导式、f-string 这些 C 根本没有的东西 —— 存得进去,判题时永远失败
// (或者反过来,「不能使用 f-string」永远通过),两头都不报错。
function nodeTargetOptions(lang: string): SelectOption[] {
return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
([value, entry]) => ({ label: entry.label, value }),
)
return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map(([value, entry]) => ({
label: entry.label,
value,
}))
}
// 逻辑名 and/or/not 在 C 里显示成 && / || / !,存进去的还是逻辑名
function operatorTargetOptions(lang: string): SelectOption[] {
return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
([value, label]) => ({
label: label === value ? value : `${label}(${value})`,
value,
}),
)
return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(([value, label]) => ({
label: label === value ? value : `${label}(${value})`,
value,
}))
}
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
const FUNCTION_ENGINES = [
"must_call_function",
"must_not_call_function",
"count_function_call",
]
const FUNCTION_ENGINES = ["must_call_function", "must_not_call_function", "count_function_call"]
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
const OPERATOR_ENGINES = ["must_use_operator"]
const COUNT_ENGINES = ["count_node", "count_function_call"]
@@ -171,13 +166,8 @@ function updateRules(lang: string, rules: AstRule[]) {
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
}
function getTargetLabel(
lang: string,
engine: string,
target: string,
): string | undefined {
if (isNodeEngine(engine))
return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
function getTargetLabel(lang: string, engine: string, target: string): string | undefined {
if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
// 运算符不写 label:判题结果的文案按语言翻译(astOperatorLabel),
// 存一个固定 label 反而会把 C 的 && 钉死成 and
return undefined
@@ -260,17 +250,8 @@ watch(supportedLanguages, (langs) => {
暂不支持代码规则检查,判题机只能检查
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
</n-alert>
<n-tabs
v-if="supportedLanguages.length"
type="segment"
v-model:value="activeTab"
>
<n-tab-pane
v-for="lang in supportedLanguages"
:key="lang"
:name="lang"
:tab="lang"
>
<n-tabs v-if="supportedLanguages.length" type="segment" v-model:value="activeTab">
<n-tab-pane v-for="lang in supportedLanguages" :key="lang" :name="lang" :tab="lang">
<n-flex vertical>
<div
v-for="(rule, index) in getRulesForLang(lang)"
@@ -281,9 +262,7 @@ watch(supportedLanguages, (langs) => {
<n-select
:options="ENGINE_OPTIONS"
:value="rule.engine"
@update:value="
(v: string) => updateRule(lang, index, 'engine', v)
"
@update:value="(v: string) => updateRule(lang, index, 'engine', v)"
style="width: 150px"
size="small"
/>
@@ -291,9 +270,7 @@ watch(supportedLanguages, (langs) => {
v-if="needsTargetDropdown(rule.engine)"
:options="nodeTargetOptions(lang)"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
style="width: 150px"
size="small"
filterable
@@ -301,9 +278,7 @@ watch(supportedLanguages, (langs) => {
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
placeholder="函数/方法名"
style="width: 150px"
size="small"
@@ -312,9 +287,7 @@ watch(supportedLanguages, (langs) => {
v-if="needsOperatorDropdown(rule.engine)"
:options="operatorTargetOptions(lang)"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
style="width: 150px"
size="small"
/>
@@ -322,18 +295,14 @@ watch(supportedLanguages, (langs) => {
<n-select
:options="COUNT_MODE_OPTIONS"
:value="getCountMode(rule)"
@update:value="
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
"
@update:value="(v: 'exact' | 'range') => updateCountMode(lang, index, v)"
style="width: 80px"
size="small"
/>
<n-input-number
v-if="getCountMode(rule) === 'exact'"
:value="rule.exact ?? null"
@update:value="
(v: number | null) => updateExactCount(lang, index, v)
"
@update:value="(v: number | null) => updateExactCount(lang, index, v)"
placeholder="次数"
style="width: 100px"
size="small"
@@ -343,9 +312,7 @@ watch(supportedLanguages, (langs) => {
<template v-else>
<n-input-number
:value="rule.min ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'min', v)
"
@update:value="(v: number | null) => updateRule(lang, index, 'min', v)"
placeholder="最少"
style="width: 100px"
size="small"
@@ -354,9 +321,7 @@ watch(supportedLanguages, (langs) => {
/>
<n-input-number
:value="rule.max ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'max', v)
"
@update:value="(v: number | null) => updateRule(lang, index, 'max', v)"
placeholder="最多"
style="width: 100px"
size="small"
@@ -367,29 +332,17 @@ watch(supportedLanguages, (langs) => {
</template>
<n-input
:value="rule.message"
@update:value="
(v: string) => updateRule(lang, index, 'message', v)
"
@update:value="(v: string) => updateRule(lang, index, 'message', v)"
placeholder="错误提示(选填)"
style="flex: 1"
size="small"
/>
<n-button
size="small"
tertiary
type="error"
@click="removeRule(lang, index)"
>
<n-button size="small" tertiary type="error" @click="removeRule(lang, index)">
删除
</n-button>
</n-flex>
</div>
<n-button
size="small"
tertiary
type="primary"
@click="addRule(lang)"
>
<n-button size="small" tertiary type="primary" @click="addRule(lang)">
添加规则
</n-button>
</n-flex>
@@ -397,9 +350,7 @@ watch(supportedLanguages, (langs) => {
</n-tabs>
<n-empty
v-else
:description="
languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'
"
:description="languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'"
/>
</n-collapse-item>
</n-collapse>
@@ -20,9 +20,7 @@ const tags = ref<AdminTag[]>([])
const selected = ref<string[]>([])
const newTags = ref<string[]>([])
const title = computed(() =>
props.action === "add" ? "批量添加标签" : "批量移除标签",
)
const title = computed(() => (props.action === "add" ? "批量添加标签" : "批量移除标签"))
const selectedSet = computed(() => new Set(selected.value))
@@ -53,11 +51,7 @@ async function submit() {
message.error("请先选择标签")
return
}
const res = await batchTagProblems(
props.problemIds,
names.value,
props.action,
)
const res = await batchTagProblems(props.problemIds, names.value, props.action)
const verb = props.action === "add" ? "添加" : "移除"
message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
close()
@@ -40,9 +40,7 @@ function blankEntry(): ScriptEntry {
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
const refSQL = computed(
() =>
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
"",
() => props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ?? "",
)
/** 已有脚本读取失败时的提示。空白编辑器和「本来就没测试点」长得一样,必须区分开 */
@@ -54,9 +52,7 @@ const isGenerating = ref(false)
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
const filledCount = computed(
() => scripts.value.filter((s) => s.sql.trim()).length,
)
const filledCount = computed(() => scripts.value.filter((s) => s.sql.trim()).length)
const canUpload = computed(() => {
const filled = scripts.value.filter((s) => s.sql.trim())
@@ -206,29 +202,15 @@ async function upload() {
<template>
<n-flex vertical>
<n-alert
v-if="!refSQL"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
<n-alert v-if="!refSQL" type="warning" :show-icon="false" style="margin-bottom: 8px">
还没有填写 SQL 标准答案,请先在上方"本题参考答案"中填写,再来编写测试点
</n-alert>
<n-alert
v-if="loadError"
type="error"
:show-icon="false"
style="margin-bottom: 8px"
>
<n-alert v-if="loadError" type="error" :show-icon="false" style="margin-bottom: 8px">
{{ loadError }}
</n-alert>
<n-flex align="center" wrap>
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
清空
</n-button>
<n-button :disabled="isPreviewing || isGenerating" @click="add">
+1
</n-button>
<n-button :disabled="isPreviewing || isGenerating" @click="reset"> 清空 </n-button>
<n-button :disabled="isPreviewing || isGenerating" @click="add"> +1 </n-button>
<n-tooltip :disabled="!!refSQL && hasBlankScript">
<template #trigger>
<span>
@@ -297,9 +279,7 @@ async function upload() {
placeholder="-- 本测试点的建表 + 插入数据脚本
CREATE TABLE ...;
INSERT INTO ...;"
:status="
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
"
:status="s.error ? 'error' : s.display && !s.stale ? 'success' : undefined"
/>
<n-alert v-if="s.error" type="error" :show-icon="false">
{{ s.error }}
@@ -32,21 +32,15 @@ const columns: DataTableColumn<AdminProblemRow>[] = [
key: "title",
minWidth: 200,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => goEdit(row) },
() => row.title,
),
h(NButton, { text: true, type: "primary", onClick: () => goEdit(row) }, () => row.title),
},
{
title: "可见",
key: "visible",
width: 80,
render: (row) =>
h(
NTag,
{ size: "small", type: row.visible ? "success" : "default" },
() => (row.visible ? "公开" : "隐藏"),
h(NTag, { size: "small", type: row.visible ? "success" : "default" }, () =>
row.visible ? "公开" : "隐藏",
),
},
{
@@ -54,25 +48,14 @@ const columns: DataTableColumn<AdminProblemRow>[] = [
key: "actions",
width: 110,
render: (row) =>
h(
NButton,
{ size: "small", type: "error", onClick: () => removeTag(row) },
() => "移除标签",
),
h(NButton, { size: "small", type: "error", onClick: () => removeTag(row) }, () => "移除标签"),
},
]
async function listProblems() {
if (page.value < 1) page.value = 1
const offset = (page.value - 1) * limit.value
const res = await getProblemList(
offset,
limit.value,
keyword.value,
"",
undefined,
props.tagId,
)
const res = await getProblemList(offset, limit.value, keyword.value, "", undefined, props.tagId)
problems.value = res.results
total.value = res.total
}
@@ -54,15 +54,11 @@ const availableLanguages = computed(() =>
props.answers.map((a) => ({ label: a.language, value: a.language })),
)
const hasAnyAnswerCode = computed(() =>
props.answers.some((a) => a.code.trim()),
)
const hasAnyAnswerCode = computed(() => props.answers.some((a) => a.code.trim()))
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
const hasAnswerCode = computed(() => {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
const answer = props.answers.find((a) => a.language === selectedLanguage.value)
return !!answer?.code.trim()
})
@@ -70,10 +66,7 @@ const hasAnswerCode = computed(() => {
watch(
availableLanguages,
(langs) => {
if (
langs.length &&
!langs.find((l) => l.value === selectedLanguage.value)
) {
if (langs.length && !langs.find((l) => l.value === selectedLanguage.value)) {
selectedLanguage.value = langs[0].value
}
},
@@ -136,9 +129,7 @@ function remove(index: number) {
}
async function run() {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
const answer = props.answers.find((a) => a.language === selectedLanguage.value)
if (!answer?.code.trim()) return
// 过滤没填过的行,去重(按输入内容)。全没填就留一行,当成无输入题跑一次 ——
@@ -210,12 +201,7 @@ async function upload() {
<template>
<n-flex vertical>
<n-alert
v-if="!hasAnyAnswerCode"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
<n-alert v-if="!hasAnyAnswerCode" type="warning" :show-icon="false" style="margin-bottom: 8px">
还没有填写答案代码,请先在上方"本题参考答案"中填写至少一种语言的答案,再来生成测试用例
</n-alert>
<n-flex align="center" wrap>
@@ -230,34 +216,19 @@ async function upload() {
<n-tooltip :disabled="hasAnswerCode">
<template #trigger>
<span>
<n-button
type="success"
:loading="isRunning"
:disabled="!hasAnswerCode"
@click="run"
>
<n-button type="success" :loading="isRunning" :disabled="!hasAnswerCode" @click="run">
先运行
</n-button>
</span>
</template>
请先在题目中填写答案代码
</n-tooltip>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload"
@click="upload"
>
<n-button type="primary" :loading="isUploading" :disabled="!canUpload" @click="upload">
上传
</n-button>
</n-flex>
<n-flex
v-for="(file, index) in files"
:key="file.id"
align="start"
style="gap: 8px"
>
<n-flex v-for="(file, index) in files" :key="file.id" align="start" style="gap: 8px">
<n-flex vertical style="flex: 1">
<span>{{ index + 1 }}.in</span>
<n-input type="textarea" v-model:value="file.in" :rows="3" />
+30 -124
View File
@@ -6,11 +6,7 @@ import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import {
CODE_TEMPLATES,
LANGUAGE_SHOW_VALUE,
STORAGE_KEY,
} from "utils/constants"
import { CODE_TEMPLATES, LANGUAGE_SHOW_VALUE, STORAGE_KEY } from "utils/constants"
import download from "utils/download"
import { unique } from "utils/functions"
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
@@ -23,13 +19,9 @@ import {
uploadTestcases,
} from "../api"
const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
const MermaidEditor = defineAsyncComponent(
() => import("shared/components/MermaidEditor.vue"),
)
const MermaidEditor = defineAsyncComponent(() => import("shared/components/MermaidEditor.vue"))
interface Props {
problemID?: string
@@ -349,8 +341,7 @@ async function validateProblem() {
// 题目
else if (
!problem.value.description ||
(!isSQLProblem.value &&
(!problem.value.inputDescription || !problem.value.outputDescription))
(!isSQLProblem.value && (!problem.value.inputDescription || !problem.value.outputDescription))
) {
message.error("题目或输入或输出没有填写")
hasErrors = true
@@ -363,9 +354,7 @@ async function validateProblem() {
// 样例是空的
else if (
!isSQLProblem.value &&
problem.value.samples.some(
(sample) => sample.output === "" || sample.input === "",
)
problem.value.samples.some((sample) => sample.output === "" || sample.input === "")
) {
message.error("空样例没有删干净")
hasErrors = true
@@ -384,9 +373,7 @@ async function validateProblem() {
hasErrors = true
} else if (
isSQLProblem.value &&
!problem.value.answers.find(
(ans) => ans.language === "SQL" && ans.code.trim() !== "",
)
!problem.value.answers.find((ans) => ans.language === "SQL" && ans.code.trim() !== "")
) {
message.error("SQL 题必须填写标准答案(判题时用它生成期望结果)")
hasErrors = true
@@ -430,9 +417,7 @@ function filterHint() {
}
function filterAnswers() {
problem.value.answers = problem.value.answers.filter(
(ans) => ans.code.trim() !== "",
)
problem.value.answers = problem.value.answers.filter((ans) => ans.code.trim() !== "")
}
function filterSamplesForSQL() {
@@ -465,16 +450,10 @@ async function submit() {
problem.value = null
selectedTags.value = []
newTags.value = []
if (
route.name === "admin problem create" ||
route.name === "admin contest problem create"
) {
if (route.name === "admin problem create" || route.name === "admin contest problem create") {
message.success("恭喜你 💐 出题成功")
}
if (
route.name === "admin problem create" ||
route.name === "admin problem edit"
) {
if (route.name === "admin problem create" || route.name === "admin problem edit") {
router.push({ name: "admin problem list" })
} else {
router.push({
@@ -492,9 +471,7 @@ async function submit() {
}
const showClear = computed(
() =>
route.name === "admin problem create" ||
route.name === "admin contest problem create",
() => route.name === "admin problem create" || route.name === "admin contest problem create",
)
function clear() {
@@ -523,10 +500,7 @@ async function generateMermaid() {
const showGeneratorModal = ref(false)
function handleTestcasesGenerated(
testCaseId: string,
testCaseScore: Testcase[],
) {
function handleTestcasesGenerated(testCaseId: string, testCaseScore: Testcase[]) {
problem.value.testCaseId = testCaseId
problem.value.testCaseScore = testCaseScore
showGeneratorModal.value = false
@@ -545,9 +519,7 @@ watch(
() => problem.value.languages,
(langs) => {
const answers = langs.map((lang) => {
const existing = problem.value.answers.find(
(ans) => ans.language === lang,
)
const existing = problem.value.answers.find((ans) => ans.language === lang)
return existing || { language: lang, code: "" }
})
problem.value.answers = answers
@@ -569,11 +541,7 @@ watch(
<n-input class="problemTitleInput" v-model:value="problem.title" />
</n-form-item>
<n-form-item label="难度">
<n-select
class="w-100"
:options="difficultyOptions"
v-model:value="problem.difficulty"
/>
<n-select class="w-100" :options="difficultyOptions" v-model:value="problem.difficulty" />
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="problem.visible" />
@@ -621,12 +589,7 @@ watch(
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong>
<n-button
tertiary
type="warning"
size="small"
@click="removeSample(index)"
>
<n-button tertiary type="warning" size="small" @click="removeSample(index)">
删除 {{ index + 1 }}
</n-button>
</n-flex>
@@ -658,10 +621,7 @@ watch(
/>
</n-form-item>
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
<n-input
v-model:value="problem.prompt"
placeholder="比如考察选择、循环、算法等知识点"
/>
<n-input v-model:value="problem.prompt" placeholder="比如考察选择、循环、算法等知识点" />
</n-form-item>
</n-form>
@@ -683,10 +643,7 @@ watch(
</n-checkbox-group>
</n-form-item>
<n-form-item v-if="!isSQLProblem">
<n-checkbox
v-model:checked="needTemplate"
label="预制代码(显示在编辑器中,帮助快速上手)"
/>
<n-checkbox v-model:checked="needTemplate" label="预制代码(显示在编辑器中,帮助快速上手)" />
</n-form-item>
<n-form-item>
<n-button
@@ -701,17 +658,11 @@ watch(
</n-form-item>
</n-form>
<n-form
v-if="isSQLProblem && problem.sqlConfig"
inline
label-placement="left"
>
<n-form v-if="isSQLProblem && problem.sqlConfig" inline label-placement="left">
<n-form-item label="SQL 题型">
<n-radio-group v-model:value="problem.sqlConfig.mode">
<n-radio-button value="query">查询题(比对查询结果)</n-radio-button>
<n-radio-button value="modify">
增删改题(比对执行后的表数据)
</n-radio-button>
<n-radio-button value="modify"> 增删改题(比对执行后的表数据) </n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="严格比对行顺序">
@@ -732,11 +683,7 @@ watch(
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
"
>
<n-tabs
type="segment"
default-value="Python"
v-model:value="currentActiveAnswer"
>
<n-tabs type="segment" default-value="Python" v-model:value="currentActiveAnswer">
<n-tab-pane
v-for="(answer, index) in problem.answers"
:key="index"
@@ -756,16 +703,8 @@ watch(
<n-gi>
<n-form v-if="needTemplate">
<n-form-item label="编写预制代码">
<n-tabs
type="segment"
default-value="Python"
v-model:value="currentActiveTemplate"
>
<n-tab-pane
v-for="(lang, index) in problem.languages"
:key="index"
:name="lang"
>
<n-tabs type="segment" default-value="Python" v-model:value="currentActiveTemplate">
<n-tab-pane v-for="(lang, index) in problem.languages" :key="index" :name="lang">
<CodeEditor
v-model:value="template[lang]"
:language="lang"
@@ -781,10 +720,7 @@ watch(
<n-grid v-if="!isSQLProblem" :cols="2">
<n-gi :span="1">
<AstRulesEditor
v-model="problem.astRules!"
:languages="problem.languages"
/>
<AstRulesEditor v-model="problem.astRules!" :languages="problem.languages" />
</n-gi>
</n-grid>
@@ -794,16 +730,10 @@ watch(
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
<div>
<n-button type="success" @click="showGeneratorModal = true">
(新)直接生成
</n-button>
<n-button type="success" @click="showGeneratorModal = true"> (新)直接生成 </n-button>
</div>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-upload :show-file-list="false" accept=".zip" :custom-request="handleUploadTestcases">
<n-button type="info">(老)手动上传</n-button>
</n-upload>
</div>
@@ -823,12 +753,7 @@ watch(
@uploaded="handleTestcasesGenerated"
/>
<n-alert
class="box"
v-if="problem.testCaseScore.length"
:show-icon="false"
type="info"
>
<n-alert class="box" v-if="problem.testCaseScore.length" :show-icon="false" type="info">
<template #header>
<n-flex align="center">
<div>
@@ -836,13 +761,7 @@ watch(
{{ problem.testCaseScore.length }}
条测试用例
</div>
<n-button
v-if="problem.id"
tertiary
type="info"
size="small"
@click="downloadTestcases"
>
<n-button v-if="problem.id" tertiary type="info" size="small" @click="downloadTestcases">
下载
</n-button>
</n-flex>
@@ -875,10 +794,7 @@ watch(
<n-button
type="primary"
size="small"
:disabled="
!problem.answers.filter((a) => a.language === 'Python')[0]?.code
.length
"
:disabled="!problem.answers.filter((a) => a.language === 'Python')[0]?.code.length"
:loading="isAIGenerating"
@click="generateMermaid"
>
@@ -890,15 +806,8 @@ watch(
</n-form-item>
<n-form-item label="显示标准流程图">
<n-flex align="center">
<n-switch
v-model:value="problem.showFlowchart"
:disabled="problem.allowFlowchart"
/>
<n-text
v-if="problem.allowFlowchart"
depth="3"
style="font-size: 12px"
>
<n-switch v-model:value="problem.showFlowchart" :disabled="problem.allowFlowchart" />
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
让学生自己画图时,标准流程图不会下发给学生,这个开关没有意义
</n-text>
</n-flex>
@@ -907,10 +816,7 @@ watch(
<n-form>
<n-form-item>
<MermaidEditor
v-model="problem.mermaidCode"
@render-state="onMermaidRenderState"
/>
<MermaidEditor v-model="problem.mermaidCode" @render-state="onMermaidRenderState" />
</n-form-item>
<n-form-item label="流程图提示信息(选填)">
<n-input
+8 -30
View File
@@ -29,9 +29,7 @@ const title = computed(
"admin contest problem list": "比赛题目列表",
})[route.name as string],
)
const isContestProblemList = computed(
() => route.name === "admin contest problem list",
)
const isContestProblemList = computed(() => route.name === "admin contest problem list")
const [show, toggleShow] = useToggle()
const { count, inc } = useCounter(0)
@@ -42,9 +40,7 @@ const selectedRowKeys = ref<DataTableRowKey[]>([])
const batchTagAction = ref<"add" | "remove">("add")
const [showBatchTag, toggleBatchTag] = useToggle(false)
const selectedProblemIds = computed(() =>
selectedRowKeys.value.map((key) => Number(key)),
)
const selectedProblemIds = computed(() => selectedRowKeys.value.map((key) => Number(key)))
const rowKey = (row: AdminProblemRow) => row.id
@@ -103,9 +99,7 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
key: "tags",
minWidth: 120,
render: (row) =>
h(NFlex, { size: 4 }, () =>
row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t)),
),
h(NFlex, { size: 4 }, () => row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t))),
},
{
title: "功能",
@@ -254,16 +248,10 @@ watch(() => [query.page, query.limit, query.author], listProblems)
>
卡点分析
</n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin top ac trend' })"
>
<n-button v-if="!isContestProblemList" @click="$router.push({ name: 'admin top ac trend' })">
年度趋势
</n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin tag list' })"
>
<n-button v-if="!isContestProblemList" @click="$router.push({ name: 'admin tag list' })">
标签管理
</n-button>
</n-flex>
@@ -274,14 +262,8 @@ watch(() => [query.page, query.limit, query.author], listProblems)
</n-button>
<n-button @click="openBatchTag('remove')">移除标签</n-button>
</template>
<n-button v-if="isContestProblemList" @click="createContestProblem">
新建比赛题目
</n-button>
<n-button
v-if="isContestProblemList"
type="primary"
@click="selectProblems"
>
<n-button v-if="isContestProblemList" @click="createContestProblem"> 新建比赛题目 </n-button>
<n-button v-if="isContestProblemList" type="primary" @click="selectProblems">
从题目中选择
</n-button>
<n-flex align="center" v-if="!props.contestID">
@@ -305,11 +287,7 @@ watch(() => [query.page, query.limit, query.author], listProblems)
:row-key="rowKey"
@update:checked-row-keys="chooseProblems"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
<AddFromLibraryModal
v-model:show="show"
:count="count"
+5 -18
View File
@@ -54,10 +54,8 @@ const columns: DataTableColumn<AdminTag>[] = [
key: "problem_count",
width: 100,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
() => String(row.problemCount),
h(NButton, { text: true, type: "primary", onClick: () => openTagProblems(row) }, () =>
String(row.problemCount),
),
},
{
@@ -76,11 +74,7 @@ const columns: DataTableColumn<AdminTag>[] = [
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
]
: [
h(
NButton,
{ size: "small", onClick: () => startEdit(row) },
() => "重命名",
),
h(NButton, { size: "small", onClick: () => startEdit(row) }, () => "重命名"),
h(
NButton,
{
@@ -153,16 +147,9 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">标签管理</h2>
<n-button @click="$router.push({ name: 'admin problem list' })">
返回题目列表
</n-button>
<n-button @click="$router.push({ name: 'admin problem list' })"> 返回题目列表 </n-button>
</n-flex>
<n-input
v-model:value="keyword"
style="width: 200px"
placeholder="搜索标签"
clearable
/>
<n-input v-model:value="keyword" style="width: 200px" placeholder="搜索标签" clearable />
</n-flex>
<n-data-table striped :columns="columns" :data="tags" />
<TagProblemsModal
@@ -62,15 +62,9 @@ async function handleUpdateStatus() {
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary type="info" @click="goDetail">
详情
</n-button>
<n-button size="small" secondary type="warning" @click="openStatusModal">
状态
</n-button>
<n-button size="small" secondary type="primary" @click="goEdit"> 编辑 </n-button>
<n-button size="small" secondary type="info" @click="goDetail"> 详情 </n-button>
<n-button size="small" secondary type="warning" @click="openStatusModal"> 状态 </n-button>
<n-popconfirm @positive-click="handleDeleteProblemSet">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
@@ -79,20 +73,11 @@ async function handleUpdateStatus() {
</n-popconfirm>
</n-flex>
<n-modal
v-model:show="showStatusModal"
preset="card"
title="更新题单状态"
style="width: 400px"
>
<n-modal v-model:show="showStatusModal" preset="card" title="更新题单状态" style="width: 400px">
<n-space vertical>
<n-form>
<n-form-item label="状态" required>
<n-select
v-model:value="newStatus"
:options="statusOptions"
placeholder="选择状态"
/>
<n-select v-model:value="newStatus" :options="statusOptions" placeholder="选择状态" />
</n-form-item>
</n-form>
</n-space>
@@ -55,9 +55,7 @@ defineEmits<Emits>()
title: '条件值',
key: 'condition_value',
render: (row) => {
return row.conditionType === 'all_problems'
? '-'
: row.conditionValue
return row.conditionType === 'all_problems' ? '-' : row.conditionValue
},
},
{ title: '描述', key: 'description' },
@@ -28,9 +28,7 @@ const emit = defineEmits<Emits>()
const badgeName = ref("")
const badgeDescription = ref("")
const badgeIcon = ref("")
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems")
const badgeConditionValue = ref(1)
// 预设奖章图标选项
@@ -99,12 +97,7 @@ watch(
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="badgeDescription"
type="textarea"
placeholder="奖章描述"
required
/>
<n-input v-model:value="badgeDescription" type="textarea" placeholder="奖章描述" required />
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
@@ -115,17 +108,13 @@ watch(
:style="{
width: '60px',
height: '60px',
border:
badgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
border: badgeIcon === option.value ? '2px solid #1890ff' : '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
badgeIcon === option.value ? '#f0f8ff' : 'transparent',
backgroundColor: badgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
@@ -147,10 +136,7 @@ watch(
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="badgeConditionType !== 'all_problems'"
>
<n-form-item label="条件值" v-if="badgeConditionType !== 'all_problems'">
<n-input-number
style="width: 120px"
v-model:value="badgeConditionValue"
@@ -21,9 +21,7 @@ defineEmits<Emits>()
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>题目列表</h3>
<n-button type="primary" @click="$emit('add-problem')">
添加题目
</n-button>
<n-button type="primary" @click="$emit('add-problem')"> 添加题目 </n-button>
</n-flex>
<n-data-table
:columns="[
@@ -75,16 +75,10 @@ watch(
<n-input :value="problem.title" disabled />
</n-form-item>
<n-form-item v-else label="题目ID" required>
<n-input
v-model:value="problemId"
placeholder="请输入题目的显示ID(如:1001)"
/>
<n-input v-model:value="problemId" placeholder="请输入题目的显示ID(如:1001)" />
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="problemOrder"
placeholder="题目在题单中的顺序"
/>
<n-input-number v-model:value="problemOrder" placeholder="题目在题单中的顺序" />
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="problemRequired" />
@@ -93,11 +87,7 @@ watch(
<n-input-number v-model:value="problemScore" placeholder="题目分数" />
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="problemHint"
type="textarea"
placeholder="题目提示"
/>
<n-input v-model:value="problemHint" type="textarea" placeholder="题目提示" />
</n-form-item>
</n-form>
<template #footer>
@@ -22,8 +22,7 @@ const progressColumns = [
title: "加入时间",
key: "joinTime",
width: 180,
render: (row: AdminProblemSetProgress) =>
parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
render: (row: AdminProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
},
{ title: "已完成", key: "completedProblemsCount", width: 100 },
{ title: "总题目", key: "totalProblemsCount", width: 100 },
@@ -31,8 +30,7 @@ const progressColumns = [
title: "进度",
key: "progressPercentage",
width: 100,
render: (row: AdminProblemSetProgress) =>
`${row.progressPercentage.toFixed(0)}%`,
render: (row: AdminProblemSetProgress) => `${row.progressPercentage.toFixed(0)}%`,
},
{
title: "是否完成",
+1 -4
View File
@@ -237,10 +237,7 @@ onMounted(() => {
</n-tab-pane>
<n-tab-pane name="progress" tab="进度管理">
<ProgressManagement
:progress="progress"
@remove-user="handleRemoveUser"
/>
<ProgressManagement :progress="progress" @remove-user="handleRemoveUser" />
</n-tab-pane>
</n-tabs>
+2 -9
View File
@@ -23,10 +23,7 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
// n-date-picker 按浏览器本地渲染,所以要平移一次再绑定(见 utils/functions.ts
// 的 toPickerValue)。`formData.endTime` 里始终存**真实时刻**,只有喂给选择器那一步换。
const endTimeTimestamp = computed({
get: () =>
formData.value.endTime
? toPickerValue(formData.value.endTime.getTime())
: null,
get: () => (formData.value.endTime ? toPickerValue(formData.value.endTime.getTime()) : null),
set: (val: number | null) => {
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
},
@@ -89,11 +86,7 @@ async function handleSubmit() {
}
router.push({ name: "admin problemset list" })
} catch (err) {
message.error(
(isEdit.value ? "更新" : "创建") +
"题单失败:" +
errorMessage(err, "未知错误"),
)
message.error((isEdit.value ? "更新" : "创建") + "题单失败:" + errorMessage(err, "未知错误"))
} finally {
loading.value = false
}
+5 -23
View File
@@ -58,11 +58,7 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
Hard: { type: "error" as const, text: "困难" },
}
const config = difficultyMap[row.difficulty]
return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
},
},
{
@@ -76,11 +72,7 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
draft: { type: "info" as const, text: "草稿" },
}
const config = statusMap[row.status]
return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
},
},
{
@@ -146,20 +138,14 @@ watchDebounced(() => query.keyword, listProblemSets, {
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
watch(() => [query.page, query.limit, query.difficulty, query.status], listProblemSets)
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">题单管理</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin problemset create' })"
>
<n-button type="primary" @click="$router.push({ name: 'admin problemset create' })">
新建题单
</n-button>
</n-flex>
@@ -194,11 +180,7 @@ watch(
</n-flex>
</n-flex>
<n-data-table striped :columns="columns" :data="problemSets" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
<style scoped>
+10 -41
View File
@@ -1,11 +1,7 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants"
import { parseTime } from "utils/functions"
import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types"
import { useConfigStore } from "shared/store/config"
@@ -41,11 +37,7 @@ const testcaseColumns: DataTableColumn<OrphanTestCase>[] = [
title: "选项",
key: "delete",
render: (row) =>
h(
NButton,
{ size: "small", onClick: () => deleteTestcase(row.id) },
() => "删除",
),
h(NButton, { size: "small", onClick: () => deleteTestcase(row.id) }, () => "删除"),
},
]
@@ -111,9 +103,7 @@ const serverColumns: DataTableColumn<Server>[] = [
const testcases = ref<OrphanTestCase[]>([])
const token = ref("")
const servers = ref<Server[]>([])
const abnormalServers = computed(() =>
servers.value.filter((item) => item.status === "abnormal"),
)
const abnormalServers = computed(() => servers.value.filter((item) => item.status === "abnormal"))
const websiteConfig = reactive<WebsiteConfig>({
websiteBaseUrl: "",
@@ -152,9 +142,7 @@ function renderClassTag(tag: string | { label: string }, index: number) {
opacity: dragFrom.value === index ? 0.4 : 1,
},
onClose: () => {
websiteConfig.classList = websiteConfig.classList.filter(
(_, i) => i !== index,
)
websiteConfig.classList = websiteConfig.classList.filter((_, i) => i !== index)
},
onDragstart: (e: DragEvent) => {
dragFrom.value = index
@@ -248,9 +236,7 @@ async function delJudgeServer(hostname: string) {
}
async function deleteAbnormalServers() {
const dels = abnormalServers.value.map((item) =>
deleteJudgeServer(item.hostname),
)
const dels = abnormalServers.value.map((item) => deleteJudgeServer(item.hostname))
await Promise.all(dels)
message.success("删除成功")
getJudgeServerData()
@@ -268,9 +254,7 @@ onMounted(() => {
<template #header>
<n-flex align="center">
网站设置
<n-button type="primary" size="small" @click="saveWebsiteConfig">
保存
</n-button>
<n-button type="primary" size="small" @click="saveWebsiteConfig"> 保存 </n-button>
</n-flex>
</template>
<n-form inline label-placement="left">
@@ -287,10 +271,7 @@ onMounted(() => {
<n-form label-placement="left">
<n-form-item label="班级列表">
<n-flex vertical size="small">
<n-dynamic-tags
v-model:value="websiteConfig.classList"
:render-tag="renderClassTag"
/>
<n-dynamic-tags v-model:value="websiteConfig.classList" :render-tag="renderClassTag" />
<n-text depth="3" style="font-size: 12px">
填 {{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字,如 251、2510,要和用户名里 ks 后面那段一致;
@@ -331,28 +312,16 @@ onMounted(() => {
<div class="box">
接口凭证 <n-tag size="small">{{ token }}</n-tag>
</div>
<n-data-table
:single-line="false"
striped
:columns="serverColumns"
:data="servers"
/>
<n-data-table :single-line="false" striped :columns="serverColumns" :data="servers" />
</n-card>
<n-card class="box" v-if="testcases.length">
<template #header>
<n-flex align="center">
无效的测试用例
<n-button size="small" type="warning" @click="() => deleteTestcase()">
全部删除
</n-button>
<n-button size="small" type="warning" @click="() => deleteTestcase()"> 全部删除 </n-button>
</n-flex>
</template>
<n-data-table
striped
class="table"
:columns="testcaseColumns"
:data="testcases"
/>
<n-data-table striped class="table" :columns="testcaseColumns" :data="testcases" />
</n-card>
</template>
+3 -12
View File
@@ -160,14 +160,10 @@ watch(showModal, (v) => {
<n-gradient-text type="info"> 总用户数:{{ userCount }} </n-gradient-text>
</h2>
<h2>
<n-gradient-text type="error">
今日提交:{{ submissionCount }}
</n-gradient-text>
<n-gradient-text type="error"> 今日提交:{{ submissionCount }} </n-gradient-text>
</h2>
<h2>
<n-gradient-text type="warning">
近期比赛:{{ contestCount }}
</n-gradient-text>
<n-gradient-text type="warning"> 近期比赛:{{ contestCount }} </n-gradient-text>
</h2>
<h2>
<!-- 判题机数量后端一直在下发,这里以前没显示 —— 判题机全掉线的时候,
@@ -199,12 +195,7 @@ watch(showModal, (v) => {
/>
</n-flex>
<n-data-table v-if="data.length" striped :data="data" :columns="columns" />
<n-modal
preset="card"
title="猜猜看幸运儿是谁?"
v-model:show="showModal"
style="width: 400px"
>
<n-modal preset="card" title="猜猜看幸运儿是谁?" v-model:show="showModal" style="width: 400px">
<n-flex vertical justify="center" align="center">
<n-h1 :key="pulseKey" class="lucky pulse">{{ luckyGuy }}</n-h1>
<n-button block :disabled="isRolling" @click="getRandom">
+1 -3
View File
@@ -1,9 +1,7 @@
import type { AdminProblemRow, AdminProblemListItem } from "utils/types"
// 把后端的列表项塑形成管理端列表行,与请求逻辑解耦。
export function toAdminProblemRow(
result: AdminProblemListItem,
): AdminProblemRow {
export function toAdminProblemRow(result: AdminProblemListItem): AdminProblemRow {
return {
id: result.id,
_id: result._id,
@@ -30,9 +30,7 @@ async function handleDelete() {
</script>
<template>
<n-flex>
<n-button size="small" type="success" secondary @click="goEdit">
编辑
</n-button>
<n-button size="small" type="success" secondary @click="goEdit"> 编辑 </n-button>
<n-popconfirm @positive-click="handleDelete">
<template #trigger>
<n-button size="small" type="error" secondary>删除</n-button>
@@ -11,12 +11,7 @@ import type {
ExerciseDebugData,
ExerciseGroupData,
} from "utils/types"
import {
getAdminExercises,
createExercise,
updateExercise,
deleteExercise,
} from "admin/api"
import { getAdminExercises, createExercise, updateExercise, deleteExercise } from "admin/api"
const props = defineProps<{ tutorialId: number }>()
const message = useMessage()
@@ -55,9 +50,7 @@ const groupQuestion = ref("")
const groupBuckets = ref("")
const groupItems = ref("")
const debugLines = computed(() =>
debugCode.value === "" ? [] : debugCode.value.split("\n"),
)
const debugLines = computed(() => (debugCode.value === "" ? [] : debugCode.value.split("\n")))
async function load() {
exercises.value = await getAdminExercises(props.tutorialId)
@@ -135,9 +128,7 @@ function openEdit(ex: Exercise) {
const d = ex.data as ExerciseGroupData
groupQuestion.value = d.question
groupBuckets.value = d.buckets.join("\n")
groupItems.value = d.items
.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`)
.join("\n")
groupItems.value = d.items.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`).join("\n")
}
showForm.value = true
}
@@ -241,9 +232,7 @@ function buildData(): Record<string, unknown> | null {
}
if (formType.value === "debug") {
const lines = debugCode.value.split("\n")
const answer = debugAnswer.value
.filter((i) => i < lines.length)
.sort((a, b) => a - b)
const answer = debugAnswer.value.filter((i) => i < lines.length).sort((a, b) => a - b)
if (lines.length === 0 || answer.length === 0) {
message.error("请填写代码并勾选至少一行错误")
return null
@@ -377,9 +366,7 @@ function typeTagType(type: ExerciseType) {
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<n-text>共 {{ exercises.length }} 道练习题</n-text>
<n-button type="primary" size="small" @click="openCreate"
>+ 添加练习题</n-button
>
<n-button type="primary" size="small" @click="openCreate">+ 添加练习题</n-button>
</n-flex>
<n-empty v-if="exercises.length === 0" description="暂无练习题" />
@@ -398,16 +385,12 @@ function typeTagType(type: ExerciseType) {
<n-space :size="8">
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" @click="copyPlaceholder(ex.id)">
复制占位符
</n-button>
<n-button size="small" @click="copyPlaceholder(ex.id)"> 复制占位符 </n-button>
</template>
将 [[exercise:{{ ex.id }}]] 粘贴到 Markdown 内容中
</n-tooltip>
<n-button size="small" @click="openEdit(ex)">编辑</n-button>
<n-button size="small" type="error" @click="confirmDelete(ex.id)">
删除
</n-button>
<n-button size="small" type="error" @click="confirmDelete(ex.id)"> 删除 </n-button>
</n-space>
</n-flex>
</n-list-item>
@@ -433,11 +416,7 @@ function typeTagType(type: ExerciseType) {
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="formOrder"
:min="0"
style="width: 100px"
/>
<n-input-number v-model:value="formOrder" :min="0" style="width: 100px" />
</n-form-item>
<template v-if="formType === 'mcq'">
@@ -451,16 +430,8 @@ function typeTagType(type: ExerciseType) {
</n-form-item>
<n-form-item label="选项(勾选所有正确答案)">
<n-space vertical style="width: 100%">
<n-flex
v-for="(_opt, i) in mcqOptions"
:key="i"
align="center"
:size="8"
>
<n-checkbox
:checked="mcqAnswer.includes(i)"
@update:checked="toggleAnswer(i)"
/>
<n-flex v-for="(_opt, i) in mcqOptions" :key="i" align="center" :size="8">
<n-checkbox :checked="mcqAnswer.includes(i)" @update:checked="toggleAnswer(i)" />
<n-input
v-model:value="mcqOptions[i]"
:placeholder="`选项 ${String.fromCharCode(65 + i)}`"
@@ -472,18 +443,14 @@ function typeTagType(type: ExerciseType) {
@click="
() => {
mcqOptions.splice(i, 1)
mcqAnswer = mcqAnswer
.filter((a) => a !== i)
.map((a) => (a > i ? a - 1 : a))
mcqAnswer = mcqAnswer.filter((a) => a !== i).map((a) => (a > i ? a - 1 : a))
}
"
>
✕
</n-button>
</n-flex>
<n-button size="small" @click="mcqOptions.push('')">
+ 添加选项
</n-button>
<n-button size="small" @click="mcqOptions.push('')"> + 添加选项 </n-button>
</n-space>
</n-form-item>
</template>
@@ -573,9 +540,7 @@ function typeTagType(type: ExerciseType) {
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
<n-form-item
label="正确输出(多个可接受答案之间用单独一行 === 分隔)"
>
<n-form-item label="正确输出(多个可接受答案之间用单独一行 === 分隔)">
<n-input
v-model:value="predictAnswer"
type="textarea"
@@ -606,21 +571,9 @@ function typeTagType(type: ExerciseType) {
</n-form-item>
<n-form-item label="勾选错误行">
<n-space vertical style="width: 100%">
<n-empty
v-if="debugLines.length === 0"
description="先填写代码"
size="small"
/>
<n-flex
v-for="(line, i) in debugLines"
:key="i"
align="center"
:size="8"
>
<n-checkbox
:checked="debugAnswer.includes(i)"
@update:checked="toggleDebug(i)"
/>
<n-empty v-if="debugLines.length === 0" description="先填写代码" size="small" />
<n-flex v-for="(line, i) in debugLines" :key="i" align="center" :size="8">
<n-checkbox :checked="debugAnswer.includes(i)" @update:checked="toggleDebug(i)" />
<n-text style="font-family: Monaco; white-space: pre">
{{ i + 1 }}. {{ line }}
</n-text>
+2 -10
View File
@@ -80,18 +80,10 @@ onMounted(init)
<n-input class="contestTitle" v-model:value="tutorial.title" />
</n-form-item>
<n-form-item label="语言">
<n-select
v-model:value="tutorial.type"
:options="typeOptions"
class="select"
/>
<n-select v-model:value="tutorial.type" :options="typeOptions" class="select" />
</n-form-item>
<n-form-item label="顺序">
<n-input-number
style="width: 100px"
v-model:value="tutorial.order"
:min="0"
/>
<n-input-number style="width: 100px" v-model:value="tutorial.order" :min="0" />
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="tutorial.isPublic" />
+2 -6
View File
@@ -54,8 +54,7 @@ const columns: DataTableColumn<TutorialListItem>[] = [
title: "操作",
key: "actions",
width: 140,
render: (row) =>
h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
render: (row) => h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
},
]
@@ -80,10 +79,7 @@ onMounted(listTutorials)
<template>
<n-flex align="center" class="titleWrapper">
<h2 class="title">教程列表</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin tutorial create' })"
>
<n-button type="primary" @click="$router.push({ name: 'admin tutorial create' })">
新建
</n-button>
</n-flex>
+2 -12
View File
@@ -21,20 +21,10 @@ async function banUser() {
</script>
<template>
<n-flex>
<n-button
size="small"
type="error"
secondary
@click="$emit('resetPassword', props.user)"
>
<n-button size="small" type="error" secondary @click="$emit('resetPassword', props.user)">
重置密码
</n-button>
<n-button
size="small"
type="primary"
secondary
@click="$emit('openEditModal', props.user)"
>
<n-button size="small" type="primary" secondary @click="$emit('openEditModal', props.user)">
编辑
</n-button>
<n-button
@@ -22,8 +22,7 @@ defineEmits<{
* TextCopy(渲染成空)。
*/
const maskable = computed(
() =>
props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
() => props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
)
</script>
<template>
@@ -8,20 +8,12 @@ interface Props {
user: AdminUserForm
}
const props = defineProps<Props>()
const hasRoleTag = computed(
() => props.user.adminType !== USER_TYPE.REGULAR_USER,
)
const hasRoleTag = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USER)
</script>
<template>
<n-flex align="center">
<n-tag v-if="props.user.isDisabled" type="error" size="small">
封号中
</n-tag>
<n-tag
v-if="hasRoleTag"
:type="getUserRole(props.user.adminType).type"
size="small"
>
<n-tag v-if="props.user.isDisabled" type="error" size="small"> 封号中 </n-tag>
<n-tag v-if="hasRoleTag" :type="getUserRole(props.user.adminType).type" size="small">
{{ getUserRole(props.user.adminType).label }}
</n-tag>
<n-tag
@@ -31,11 +23,7 @@ const hasRoleTag = computed(
props.user.adminType === USER_TYPE.TEACHER_ADMIN
"
>
{{
props.user.problemPermission === PROBLEM_PERMISSION.ALL
? "全部"
: "仅自己"
}}
{{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? "全部" : "仅自己" }}
</n-tag>
<TextCopy>{{ props.user.username }}</TextCopy>
</n-flex>
+2 -6
View File
@@ -58,9 +58,7 @@ async function uploadUsers() {
message.success("用户已上传成功")
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、
// 真名本来就是老师粘进来的那一列,都不用回传
const csv = users.value
.map(([username, password]) => `${username},${password}`)
.join("\n")
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n")
const hiddenElement = document.createElement("a")
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
hiddenElement.target = "_blank"
@@ -102,9 +100,7 @@ async function submit() {
placeholder="每行一个用户名"
v-model:value="rawInput"
/>
<n-button type="warning" :loading="loading" @click="submit">
确定导入
</n-button>
<n-button type="warning" :loading="loading" @click="submit"> 确定导入 </n-button>
</n-flex>
</n-space>
</template>
+11 -46
View File
@@ -5,13 +5,7 @@ import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import type { AdminUserForm } from "utils/types"
import {
deleteUsers,
editUser,
getUserList,
importUsers,
resetPassword,
} from "../api"
import { deleteUsers, editUser, getUserList, importUsers, resetPassword } from "../api"
import Actions from "./components/Actions.vue"
import UserNameCell from "./components/UserNameCell.vue"
import PasswordCell from "./components/PasswordCell.vue"
@@ -78,8 +72,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
user: row,
revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => {
if (!revealedPasswords.value.delete(id))
revealedPasswords.value.add(id)
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
},
}),
},
@@ -87,8 +80,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
title: "创建时间",
key: "create_time",
width: 200,
render: (row) =>
row.createTime ? parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss") : "",
render: (row) => (row.createTime ? parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss") : ""),
},
{
title: "上次登录",
@@ -98,12 +90,8 @@ const columns: DataTableColumn<AdminUserForm>[] = [
// 会话能留 7 天。上次登录时间照旧显示,在线的人前面多一个标记
render: (row) =>
h(NFlex, { align: "center", size: "small" }, () => [
row.isOnline
? h(NTag, { type: "success", size: "small" }, () => "在线")
: null,
row.lastLogin
? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss")
: "从未登录",
row.isOnline ? h(NTag, { type: "success", size: "small" }, () => "在线") : null,
row.lastLogin ? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") : "从未登录",
]),
},
{
@@ -144,13 +132,7 @@ const problemPermissionOptions: SelectOption[] = [
async function listUsers() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getUserList(
offset,
query.limit,
query.type,
query.keyword,
query.orderBy,
)
const res = await getUserList(offset, query.limit, query.type, query.keyword, query.orderBy)
total.value = res.total
users.value = res.results
revealedPasswords.value.clear()
@@ -274,15 +256,10 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<n-flex>
<h2 class="title">用户列表</h2>
<n-button type="primary" @click="createNewUser">新建</n-button>
<n-button @click="$router.push({ name: 'admin user generate' })">
导入
</n-button>
<n-button @click="$router.push({ name: 'admin user generate' })"> 导入 </n-button>
</n-flex>
<n-flex>
<n-popconfirm
v-if="userIDs.length"
@positive-click="onDeleteUsers(userIDs)"
>
<n-popconfirm v-if="userIDs.length" @positive-click="onDeleteUsers(userIDs)">
<template #trigger>
<n-button type="warning">删除</n-button>
</template>
@@ -319,11 +296,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
:row-key="rowKey"
@update:checked-row-keys="chooseUsers"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
<n-modal
:mask-closable="false"
:show="!!userEditing"
@@ -343,11 +316,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<!-- 班级是后端从用户名的 ks 数字前缀推出来的(classNameOf),
editUser 也不发这个字段,所以这里只读 —— 以前是个能改、改了没用的输入框 -->
<n-form-item-gi v-if="!create" :span="1" label="班级">
<n-input
:value="userEditing.className"
disabled
placeholder="由用户名自动推导"
/>
<n-input :value="userEditing.className" disabled placeholder="由用户名自动推导" />
</n-form-item-gi>
<n-form-item-gi :span="1" label="邮箱">
<n-input v-model:value="userEditing.email" />
@@ -355,11 +324,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<n-form-item-gi v-if="!create" :span="1" label="类型">
<n-select v-model:value="userEditing.adminType" :options="options" />
</n-form-item-gi>
<n-form-item-gi
:span="1"
label="密码"
label-style="color: red; font-weight: bold"
>
<n-form-item-gi :span="1" label="密码" label-style="color: red; font-weight: bold">
<n-input v-model:value="password" />
</n-form-item-gi>
<n-form-item-gi