feat(题单): 题单页重做——布置中的放最上面,详情顶上一条进度和奖章阶梯,没加入看得见题点不进;老师看全班、后台一屏编辑
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
- 列表:布置中的大卡片(做对几道、布置到哪天、再做对几道拿哪个奖章),其余小卡片,加入过的在前 - 详情:进度 + 奖章阶梯(下一个金圈「再做对 n 道」),题目两列写几点做对 / 交过几次; 以前做对过的标「旧代码先藏着」;做完整条变绿,烟花只在做完那一刻放 - 老师多一个「全班情况」页签;题目页从链接直接进来没加入时,条上给「加入题单」 - 后台一屏编辑:布置一周 / 两周 / 四周 / 选日期,公开开关,粘贴多个题号一次加,拖动排序,奖章只剩两种条件 - 旧代码藏着的地方都说清楚是哪个题单、哪天之后能看 - 修掉列表卡片把选做题数说成「新加了 N 题」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c53e28dcb
commit
c17f88cdbb
32 files changed
+2630
-2181
No files matched your search
+32
-98
@@ -2,7 +2,6 @@ import api from "utils/api"
|
||||
import { toAdminProblemRow } from "admin/transforms"
|
||||
import type {
|
||||
AcTrend,
|
||||
AdminProblemSetProgress,
|
||||
BatchProblemTagResponse,
|
||||
GenerateSqlTestCaseResponse,
|
||||
RenameTagResponse,
|
||||
@@ -40,6 +39,7 @@ import type {
|
||||
AdminProblemSetList,
|
||||
AdminProblemSet,
|
||||
AdminProblemSetProblem,
|
||||
AddProblemsToSetResult,
|
||||
TutorialListItem,
|
||||
TagCategory,
|
||||
} from "utils/types"
|
||||
@@ -495,15 +495,9 @@ export function updateACMHelperChecked(
|
||||
}
|
||||
|
||||
// 题单管理 API
|
||||
export function getProblemSetList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword = "",
|
||||
difficulty = "",
|
||||
status = "",
|
||||
) {
|
||||
export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
|
||||
return api.get<AdminProblemSetList>("admin/problem-sets", {
|
||||
params: { offset, limit, keyword, difficulty, status },
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -511,33 +505,20 @@ export function getProblemSetDetail(id: number) {
|
||||
return api.get<AdminProblemSet>(`admin/problem-sets/${id}`)
|
||||
}
|
||||
|
||||
interface ProblemSetBody {
|
||||
title?: string
|
||||
description?: string
|
||||
difficulty?: AdminProblemSet["difficulty"]
|
||||
status?: AdminProblemSet["status"]
|
||||
// 表单里是 Date,出站要 ISO 串
|
||||
endTime?: Date | null
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
function toProblemSetBody(data: ProblemSetBody) {
|
||||
return {
|
||||
title: data.title,
|
||||
description: data.description ?? "",
|
||||
difficulty: data.difficulty ?? "Easy",
|
||||
status: data.status ?? "active",
|
||||
endTime: data.endTime ? new Date(data.endTime).toISOString() : null,
|
||||
visible: data.visible ?? true,
|
||||
}
|
||||
export interface ProblemSetBody {
|
||||
title: string
|
||||
description: string
|
||||
/** 布置到哪天(东八区当天结束的 ISO 时刻);null = 不布置 */
|
||||
assignedUntil: string | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export function createProblemSet(data: ProblemSetBody) {
|
||||
return api.post<AdminProblemSet>("admin/problem-sets", toProblemSetBody(data))
|
||||
return api.post<AdminProblemSet>("admin/problem-sets", data)
|
||||
}
|
||||
|
||||
export function editProblemSet(data: ProblemSetBody & { id: number }) {
|
||||
return api.put<AdminProblemSet>(`admin/problem-sets/${data.id}`, toProblemSetBody(data))
|
||||
export function editProblemSet(id: number, data: ProblemSetBody) {
|
||||
return api.put<AdminProblemSet>(`admin/problem-sets/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteProblemSet(id: number) {
|
||||
@@ -548,49 +529,28 @@ export function toggleProblemSetVisible(id: number) {
|
||||
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
|
||||
}
|
||||
|
||||
export function updateProblemSetStatus(id: number, status: string) {
|
||||
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/status`, { status })
|
||||
}
|
||||
|
||||
// 题单题目管理 API
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
|
||||
}
|
||||
|
||||
export function addProblemToSet(
|
||||
problemSetId: number,
|
||||
data: {
|
||||
problemId: string
|
||||
order?: number
|
||||
isRequired?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return api.post(`admin/problem-sets/${problemSetId}/problems`, {
|
||||
problemId: data.problemId,
|
||||
order: data.order ?? 0,
|
||||
isRequired: data.isRequired ?? true,
|
||||
score: data.score ?? 0,
|
||||
hint: data.hint ?? "",
|
||||
/** 一次加好几道(题号),按给的顺序接在最后;找不到的、已经在里面的分开报回来 */
|
||||
export function addProblemsToSet(problemSetId: number, problemIds: string[]) {
|
||||
return api.post<AddProblemsToSetResult>(`admin/problem-sets/${problemSetId}/problems`, {
|
||||
problemIds,
|
||||
})
|
||||
}
|
||||
|
||||
export function editProblemInSet(
|
||||
problemSetId: number,
|
||||
problemSetProblemId: number,
|
||||
data: {
|
||||
order?: number
|
||||
isRequired?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return api.put(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, data)
|
||||
export function setProblemRequired(problemSetId: number, itemId: number, isRequired: boolean) {
|
||||
return api.put(`admin/problem-sets/${problemSetId}/problems/${itemId}`, { isRequired })
|
||||
}
|
||||
|
||||
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) {
|
||||
return api.delete(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`)
|
||||
export function reorderProblemSetProblems(problemSetId: number, ids: number[]) {
|
||||
return api.put(`admin/problem-sets/${problemSetId}/problems/order`, { ids })
|
||||
}
|
||||
|
||||
export function removeProblemFromSet(problemSetId: number, itemId: number) {
|
||||
return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
|
||||
}
|
||||
|
||||
// 题单奖章管理 API
|
||||
@@ -598,52 +558,26 @@ export function getProblemSetBadges(problemSetId: number) {
|
||||
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
|
||||
}
|
||||
|
||||
interface BadgeBody {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
conditionType?: AdminProblemSetBadge["conditionType"]
|
||||
conditionValue?: number
|
||||
}
|
||||
|
||||
function toBadgeBody(data: BadgeBody) {
|
||||
return {
|
||||
name: data.name,
|
||||
description: data.description ?? "",
|
||||
icon: data.icon ?? "",
|
||||
conditionType: data.conditionType,
|
||||
conditionValue: data.conditionValue ?? 0,
|
||||
}
|
||||
export interface BadgeBody {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
conditionType: AdminProblemSetBadge["conditionType"]
|
||||
conditionValue: number
|
||||
}
|
||||
|
||||
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
|
||||
return api.post<AdminProblemSetBadge>(
|
||||
`admin/problem-sets/${problemSetId}/badges`,
|
||||
toBadgeBody(data),
|
||||
)
|
||||
return api.post<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges`, data)
|
||||
}
|
||||
|
||||
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
|
||||
return api.put<AdminProblemSetBadge>(
|
||||
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
|
||||
toBadgeBody(data),
|
||||
)
|
||||
return api.put<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges/${badgeId}`, data)
|
||||
}
|
||||
|
||||
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
|
||||
}
|
||||
|
||||
// 题单进度管理 API
|
||||
// 注意:返回的是裸数组,不是分页信封 —— 和 oj 侧的 /user-progress 不同
|
||||
export function getProblemSetProgress(problemSetId: number) {
|
||||
return api.get<AdminProblemSetProgress[]>(`admin/problem-sets/${problemSetId}/progress`)
|
||||
}
|
||||
|
||||
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||
return api.delete(`admin/problem-sets/${problemSetId}/progress/${userId}`)
|
||||
}
|
||||
|
||||
// 学生卡点分析
|
||||
export function getStuckProblems() {
|
||||
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorMessage } from "utils/api"
|
||||
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemSetId: number
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showStatusModal = ref(false)
|
||||
const newStatus = ref<"active" | "archived" | "draft">("active")
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
async function handleDeleteProblemSet() {
|
||||
try {
|
||||
await deleteProblemSet(props.problemSetId)
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err) {
|
||||
message.error("删除失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin problemset edit",
|
||||
params: { problemSetId: props.problemSetId },
|
||||
})
|
||||
}
|
||||
|
||||
function goDetail() {
|
||||
router.push({
|
||||
name: "admin problemset detail",
|
||||
params: { problemSetId: props.problemSetId },
|
||||
})
|
||||
}
|
||||
|
||||
function openStatusModal() {
|
||||
showStatusModal.value = true
|
||||
}
|
||||
|
||||
async function handleUpdateStatus() {
|
||||
try {
|
||||
await updateProblemSetStatus(props.problemSetId, newStatus.value)
|
||||
message.success("状态更新成功")
|
||||
showStatusModal.value = false
|
||||
emit("updated")
|
||||
} catch (err) {
|
||||
message.error("状态更新失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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-popconfirm @positive-click="handleDeleteProblemSet">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
</template>
|
||||
确定删除这个题单吗?删除后题单将不可见。
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
|
||||
<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-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showStatusModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import type { AdminProblemSetBadge } from "utils/types"
|
||||
import { NButton, NImage } from "naive-ui"
|
||||
|
||||
interface Props {
|
||||
badges: AdminProblemSetBadge[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "add-badge"): void
|
||||
(e: "edit-badge", badge: AdminProblemSetBadge): void
|
||||
(e: "delete-badge", badgeId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>奖章列表</h3>
|
||||
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{
|
||||
title: '图标',
|
||||
key: 'icon',
|
||||
render: (row) =>
|
||||
h(NImage, {
|
||||
src: row.icon,
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: 'cover',
|
||||
previewDisabled: true,
|
||||
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
|
||||
}),
|
||||
},
|
||||
{ title: '名称', key: 'name' },
|
||||
{
|
||||
title: '条件类型',
|
||||
key: 'condition_type',
|
||||
render: (row) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
all_problems: '完成所有题目',
|
||||
problem_count: '完成指定数量题目',
|
||||
score: '达到指定分数',
|
||||
}
|
||||
return typeMap[row.conditionType] || row.conditionType
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '条件值',
|
||||
key: 'condition_value',
|
||||
render: (row) => {
|
||||
return row.conditionType === 'all_problems' ? '-' : row.conditionValue
|
||||
},
|
||||
},
|
||||
{ title: '描述', key: 'description' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
secondary: true,
|
||||
onClick: () => $emit('edit-badge', row),
|
||||
},
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => $emit('delete-badge', row.id),
|
||||
},
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
]),
|
||||
},
|
||||
]"
|
||||
:data="badges"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,155 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { BadgeBody } from "admin/api"
|
||||
import type { AdminProblemSetBadge } from "utils/types"
|
||||
|
||||
// 添加和编辑共用一个弹窗:传了 badge 就是编辑,null 就是添加。
|
||||
// 原来是两份八成相同的组件,改一处表单项另一处总要漏
|
||||
interface Props {
|
||||
show: boolean
|
||||
badge: AdminProblemSetBadge | null
|
||||
}
|
||||
/**
|
||||
* 加奖章 / 改奖章共用一个弹窗:传了 badge 就是改,null 就是加。
|
||||
* 条件只有两种:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了)。
|
||||
* 「做对 N 道」数的是做对的全部题(含选做),和后端 eligibleForBadge 同一个口径。
|
||||
*/
|
||||
const props = defineProps<{ badge: AdminProblemSetBadge | null; problemsCount: number }>()
|
||||
const show = defineModel<boolean>("show", { required: true })
|
||||
const emit = defineEmits<{ save: [data: BadgeBody] }>()
|
||||
const theme = useThemeVars()
|
||||
|
||||
type BadgeFormData = {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
conditionType: "all_problems" | "problem_count" | "score"
|
||||
conditionValue?: number
|
||||
}
|
||||
const ICONS = [1, 2, 3, 4, 5, 6].map((i) => `/badge-${i}.png`)
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(e: "create", data: BadgeFormData): void
|
||||
(e: "update", data: BadgeFormData): void
|
||||
}
|
||||
const form = reactive<BadgeBody>({
|
||||
name: "",
|
||||
description: "",
|
||||
icon: ICONS[0]!,
|
||||
conditionType: "problem_count",
|
||||
conditionValue: 1,
|
||||
})
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
// 每次打开都按当前模式重新填表:改就填这枚奖章的值,加就清空
|
||||
watch(show, (open) => {
|
||||
if (!open) return
|
||||
Object.assign(form, {
|
||||
name: props.badge?.name ?? "",
|
||||
description: props.badge?.description ?? "",
|
||||
icon: props.badge?.icon || ICONS[0]!,
|
||||
conditionType: props.badge?.conditionType ?? "problem_count",
|
||||
conditionValue: props.badge?.conditionValue || 1,
|
||||
})
|
||||
})
|
||||
|
||||
const badgeName = ref("")
|
||||
const badgeDescription = ref("")
|
||||
const badgeIcon = ref("")
|
||||
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems")
|
||||
const badgeConditionValue = ref(1)
|
||||
const valid = computed(
|
||||
() =>
|
||||
form.name.trim().length > 0 &&
|
||||
(form.conditionType === "all_problems" || form.conditionValue >= 1),
|
||||
)
|
||||
|
||||
// 预设奖章图标选项
|
||||
const BADGE_LEN = 6
|
||||
const badgeIconOptions = []
|
||||
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||
badgeIconOptions.push({
|
||||
label: `奖章${i}`,
|
||||
value: `/badge-${i}.png`,
|
||||
icon: `/badge-${i}.png`,
|
||||
function save() {
|
||||
if (!valid.value) return
|
||||
emit("save", {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
conditionValue: form.conditionType === "all_problems" ? 0 : form.conditionValue,
|
||||
})
|
||||
}
|
||||
|
||||
const conditionTypeOptions = [
|
||||
{ label: "完成所有题目", value: "all_problems" },
|
||||
{ label: "完成指定数量题目", value: "problem_count" },
|
||||
{ label: "达到指定分数", value: "score" },
|
||||
]
|
||||
|
||||
function handleConfirm() {
|
||||
const data: BadgeFormData = {
|
||||
name: badgeName.value,
|
||||
description: badgeDescription.value,
|
||||
icon: badgeIcon.value,
|
||||
conditionType: badgeConditionType.value,
|
||||
// 只有非"完成所有题目"时才带条件值
|
||||
...(badgeConditionType.value === "all_problems"
|
||||
? {}
|
||||
: { conditionValue: badgeConditionValue.value }),
|
||||
}
|
||||
if (props.badge) emit("update", data)
|
||||
else emit("create", data)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 每次打开都按当前模式重新填表:编辑就填这枚奖章的值,添加就清空。
|
||||
// 编辑取消后再打开同一枚,看到的是库里的值,而不是上次没保存的改动
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
const badge = props.badge
|
||||
badgeName.value = badge?.name ?? ""
|
||||
badgeDescription.value = badge?.description ?? ""
|
||||
badgeIcon.value = badge?.icon ?? ""
|
||||
badgeConditionType.value = badge?.conditionType ?? "all_problems"
|
||||
badgeConditionValue.value = badge?.conditionValue ?? 1
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="badge ? '编辑奖章' : '添加奖章'"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
:title="badge ? '改奖章' : '加奖章'"
|
||||
style="width: 460px"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="奖章名称" required>
|
||||
<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-form-item>
|
||||
<n-form-item label="图标" required>
|
||||
<n-flex align="center" gap="small">
|
||||
<div
|
||||
v-for="option in badgeIconOptions"
|
||||
:key="option.value"
|
||||
@click="badgeIcon = option.value"
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
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',
|
||||
}"
|
||||
<div class="form">
|
||||
<div class="field">
|
||||
<span class="label">图标</span>
|
||||
<div class="icons">
|
||||
<button
|
||||
v-for="icon in ICONS"
|
||||
:key="icon"
|
||||
class="icon"
|
||||
:class="{ on: form.icon === icon }"
|
||||
:aria-label="`选这个图标`"
|
||||
@click="form.icon = icon"
|
||||
>
|
||||
<n-image
|
||||
:src="option.icon"
|
||||
width="50"
|
||||
height="50"
|
||||
object-fit="cover"
|
||||
preview-disabled
|
||||
style="border-radius: 2px"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-flex align="center">
|
||||
<n-form-item label="获得条件">
|
||||
<n-select
|
||||
style="width: 200px"
|
||||
v-model:value="badgeConditionType"
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="条件值" v-if="badgeConditionType !== 'all_problems'">
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="badgeConditionValue"
|
||||
placeholder="条件值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
<img :src="icon" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">名字</span>
|
||||
<n-input v-model:value="form.name" placeholder="比如 人上人" maxlength="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">说明</span>
|
||||
<n-input v-model:value="form.description" placeholder="选填,学生拿到时看得到" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">条件</span>
|
||||
<n-radio-group v-model:value="form.conditionType">
|
||||
<n-radio-button value="problem_count">做对几道</n-radio-button>
|
||||
<n-radio-button value="all_problems">全部做完</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-input-number
|
||||
v-if="form.conditionType === 'problem_count'"
|
||||
v-model:value="form.conditionValue"
|
||||
:min="1"
|
||||
:max="Math.max(1, problemsCount)"
|
||||
style="width: 110px"
|
||||
>
|
||||
<template #suffix>道</template>
|
||||
</n-input-number>
|
||||
</div>
|
||||
<span class="hint">
|
||||
{{
|
||||
form.conditionType === "all_problems"
|
||||
? "必做题全部做对就发。"
|
||||
: "做对的题(选做也算)够这个数就发。"
|
||||
}}改了条件会按大家现在的进度重新发、收。
|
||||
</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button type="primary" :disabled="!valid" @click="save">保存</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon.on {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.icon img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||
import type { AdminProblemSetProblem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
problems: AdminProblemSetProblem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "add-problem"): void
|
||||
(e: "edit-problem", problem: AdminProblemSetProblem): void
|
||||
(e: "remove-problem", problemSetProblemId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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-flex>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{ title: '题目ID', key: 'problemDisplayId', width: 80 },
|
||||
{ title: '题目标题', key: 'title', minWidth: 200 },
|
||||
{ title: '顺序', key: 'order', width: 80 },
|
||||
{
|
||||
title: '必做',
|
||||
key: 'isRequired',
|
||||
width: 80,
|
||||
render: (row) => (row.isRequired ? '是' : '否'),
|
||||
},
|
||||
{ title: '分数', key: 'score', width: 80 },
|
||||
{ title: '提示', key: 'hint', minWidth: 200 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
secondary: true,
|
||||
onClick: () => $emit('edit-problem', row),
|
||||
},
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => $emit('remove-problem', row.id),
|
||||
},
|
||||
{ default: () => '移除' },
|
||||
),
|
||||
]),
|
||||
},
|
||||
]"
|
||||
:data="problems"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminProblemSetProblem } from "utils/types"
|
||||
|
||||
// 添加和编辑共用一个弹窗:传了 problem 就是编辑,null 就是添加。
|
||||
// 两种模式只差第一栏 —— 添加时填题目 ID,编辑时题目已定、只显示标题
|
||||
interface Props {
|
||||
show: boolean
|
||||
problem: AdminProblemSetProblem | null
|
||||
}
|
||||
|
||||
type ProblemFormData = {
|
||||
order: number
|
||||
isRequired: boolean
|
||||
score: number
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(e: "create", data: ProblemFormData & { problemId: string }): void
|
||||
(e: "update", data: ProblemFormData): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const problemId = ref("")
|
||||
const problemOrder = ref(0)
|
||||
const problemRequired = ref(true)
|
||||
const problemScore = ref(0)
|
||||
const problemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
const data: ProblemFormData = {
|
||||
order: problemOrder.value,
|
||||
isRequired: problemRequired.value,
|
||||
score: problemScore.value,
|
||||
hint: problemHint.value,
|
||||
}
|
||||
if (props.problem) emit("update", data)
|
||||
else emit("create", { problemId: problemId.value, ...data })
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 每次打开都按当前模式重新填表:编辑就填这道题的值,添加就清空。
|
||||
// 编辑取消后再打开同一道,看到的是库里的值,而不是上次没保存的改动
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
const problem = props.problem
|
||||
problemId.value = ""
|
||||
problemOrder.value = problem?.order ?? 0
|
||||
problemRequired.value = problem?.isRequired ?? true
|
||||
problemScore.value = problem?.score ?? 0
|
||||
problemHint.value = problem?.hint || ""
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="problem ? '编辑题目' : '添加题目'"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item v-if="problem" label="题目标题">
|
||||
<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-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number v-model:value="problemOrder" placeholder="题目在题单中的顺序" />
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="problemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<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-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminProblemSet } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
problemSet: AdminProblemSet
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card title="题单信息" style="margin-bottom: 16px">
|
||||
<n-descriptions :column="4" bordered>
|
||||
<n-descriptions-item label="描述">
|
||||
{{ problemSet.description }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建者">
|
||||
{{ problemSet.createdBy.username }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="难度">
|
||||
<n-tag
|
||||
:type="
|
||||
problemSet.difficulty === 'Easy'
|
||||
? 'success'
|
||||
: problemSet.difficulty === 'Medium'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
>
|
||||
{{
|
||||
problemSet.difficulty === "Easy"
|
||||
? "简单"
|
||||
: problemSet.difficulty === "Medium"
|
||||
? "中等"
|
||||
: "困难"
|
||||
}}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag
|
||||
:type="
|
||||
problemSet.status === 'active'
|
||||
? 'success'
|
||||
: problemSet.status === 'archived'
|
||||
? 'default'
|
||||
: 'info'
|
||||
"
|
||||
>
|
||||
{{
|
||||
problemSet.status === "active"
|
||||
? "活跃"
|
||||
: problemSet.status === "archived"
|
||||
? "已归档"
|
||||
: "草稿"
|
||||
}}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="可见">
|
||||
{{ problemSet.visible ? "是" : "否" }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="题目数量">
|
||||
{{ problemSet.problemsCount }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ parseTime(problemSet.createTime, "YYYY-MM-DD HH:mm:ss") }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminProblemSetProgress } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
progress: AdminProblemSetProgress[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "remove-user", userId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 定义表格列
|
||||
const progressColumns = [
|
||||
{ title: "用户", key: "username", width: 120 },
|
||||
{
|
||||
title: "加入时间",
|
||||
key: "joinTime",
|
||||
width: 180,
|
||||
render: (row: AdminProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{ title: "已完成", key: "completedProblemsCount", width: 100 },
|
||||
{ title: "总题目", key: "totalProblemsCount", width: 100 },
|
||||
{
|
||||
title: "进度",
|
||||
key: "progressPercentage",
|
||||
width: 100,
|
||||
render: (row: AdminProblemSetProgress) => `${row.progressPercentage.toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
title: "是否完成",
|
||||
key: "isCompleted",
|
||||
width: 100,
|
||||
render: (row: AdminProblemSetProgress) => (row.isCompleted ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
render: (row: AdminProblemSetProgress) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "error",
|
||||
secondary: true,
|
||||
onClick: () => emit("remove-user", row.userId),
|
||||
},
|
||||
{ default: () => "移除" },
|
||||
),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>用户进度</h3>
|
||||
</n-flex>
|
||||
<n-data-table :columns="progressColumns" :data="progress" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,261 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { NTabPane, NTabs, NButton } from "naive-ui"
|
||||
import type {
|
||||
AdminProblemSet,
|
||||
AdminProblemSetProblem,
|
||||
AddProblemToSetRequest,
|
||||
UpdateProblemInSetRequest,
|
||||
CreateProblemSetBadgeRequest,
|
||||
AdminProblemSetBadge,
|
||||
AdminProblemSetProgress,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getProblemSetDetail,
|
||||
getProblemSetProblems,
|
||||
getProblemSetBadges,
|
||||
getProblemSetProgress,
|
||||
addProblemToSet,
|
||||
editProblemInSet,
|
||||
removeProblemFromSet,
|
||||
createProblemSetBadge,
|
||||
editProblemSetBadge,
|
||||
deleteProblemSetBadge,
|
||||
removeUserFromProblemSet,
|
||||
} from "../api"
|
||||
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
|
||||
import ProblemManagement from "./components/ProblemManagement.vue"
|
||||
import BadgeManagement from "./components/BadgeManagement.vue"
|
||||
import ProgressManagement from "./components/ProgressManagement.vue"
|
||||
import ProblemModal from "./components/ProblemModal.vue"
|
||||
import BadgeModal from "./components/BadgeModal.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
/** 奖章弹窗的表单值:条件类型是「完成所有题目」时不带 conditionValue,后端补 0 */
|
||||
type BadgeFormData = Omit<CreateProblemSetBadgeRequest, "conditionValue"> & {
|
||||
conditionValue?: number
|
||||
}
|
||||
|
||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||
|
||||
const problemSet = ref<AdminProblemSet | null>(null)
|
||||
const problems = ref<AdminProblemSetProblem[]>([])
|
||||
const badges = ref<AdminProblemSetBadge[]>([])
|
||||
const progress = ref<AdminProblemSetProgress[]>([])
|
||||
|
||||
// 模态框状态。添加和编辑共用一个弹窗,editing* 为 null 就是添加
|
||||
const showProblemModal = ref(false)
|
||||
const showBadgeModal = ref(false)
|
||||
|
||||
// 编辑数据
|
||||
const editingProblem = ref<AdminProblemSetProblem | null>(null)
|
||||
const editingBadge = ref<AdminProblemSetBadge | null>(null)
|
||||
|
||||
async function loadProblemSetDetail() {
|
||||
try {
|
||||
const res = await getProblemSetDetail(problemSetId.value)
|
||||
problemSet.value = res
|
||||
} catch (err) {
|
||||
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProblems() {
|
||||
try {
|
||||
const res = await getProblemSetProblems(problemSetId.value)
|
||||
problems.value = res
|
||||
} catch (err) {
|
||||
message.error("加载题目列表失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBadges() {
|
||||
try {
|
||||
const res = await getProblemSetBadges(problemSetId.value)
|
||||
badges.value = res
|
||||
} catch (err) {
|
||||
message.error("加载奖章列表失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
try {
|
||||
const res = await getProblemSetProgress(problemSetId.value)
|
||||
progress.value = res
|
||||
} catch (err) {
|
||||
message.error("加载进度列表失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddProblem(data: AddProblemToSetRequest) {
|
||||
try {
|
||||
await addProblemToSet(problemSetId.value, data)
|
||||
message.success("题目添加成功")
|
||||
showProblemModal.value = false
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err) {
|
||||
message.error("添加题目失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveProblem(problemSetProblemId: number) {
|
||||
try {
|
||||
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
|
||||
message.success("题目移除成功")
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err) {
|
||||
message.error("移除题目失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditProblem(data: UpdateProblemInSetRequest) {
|
||||
if (!editingProblem.value) return
|
||||
|
||||
try {
|
||||
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
|
||||
message.success("题目编辑成功")
|
||||
showProblemModal.value = false
|
||||
loadProblems()
|
||||
} catch (err) {
|
||||
message.error("编辑题目失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddBadge(data: BadgeFormData) {
|
||||
try {
|
||||
await createProblemSetBadge(problemSetId.value, data)
|
||||
message.success("奖章创建成功")
|
||||
showBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err) {
|
||||
message.error("创建奖章失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBadge(badgeId: number) {
|
||||
try {
|
||||
await deleteProblemSetBadge(problemSetId.value, badgeId)
|
||||
message.success("奖章删除成功")
|
||||
loadBadges()
|
||||
} catch (err) {
|
||||
message.error("删除奖章失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditBadge(data: BadgeFormData) {
|
||||
if (!editingBadge.value) return
|
||||
|
||||
try {
|
||||
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
|
||||
message.success("奖章编辑成功")
|
||||
showBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err) {
|
||||
message.error("编辑奖章失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveUser(userId: number) {
|
||||
try {
|
||||
await removeUserFromProblemSet(problemSetId.value, userId)
|
||||
message.success("用户移除成功")
|
||||
loadProgress()
|
||||
} catch (err) {
|
||||
message.error("移除用户失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
function openAddProblemModal() {
|
||||
editingProblem.value = null
|
||||
showProblemModal.value = true
|
||||
}
|
||||
|
||||
function openAddBadgeModal() {
|
||||
editingBadge.value = null
|
||||
showBadgeModal.value = true
|
||||
}
|
||||
|
||||
function openEditProblemModal(problem: AdminProblemSetProblem) {
|
||||
editingProblem.value = problem
|
||||
showProblemModal.value = true
|
||||
}
|
||||
|
||||
function openEditBadgeModal(badge: AdminProblemSetBadge) {
|
||||
editingBadge.value = badge
|
||||
showBadgeModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProblemSetDetail()
|
||||
loadProblems()
|
||||
loadBadges()
|
||||
loadProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="problemSet">
|
||||
<PageHeader :title="problemSet.title" :back="{ name: 'admin problemset list' }">
|
||||
<template #actions>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'admin problemset edit',
|
||||
params: { problemSetId },
|
||||
})
|
||||
"
|
||||
>
|
||||
编辑题单
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<ProblemSetInfo :problem-set="problemSet" />
|
||||
|
||||
<n-tabs type="line">
|
||||
<n-tab-pane name="problems" tab="题目管理">
|
||||
<ProblemManagement
|
||||
:problems="problems"
|
||||
@add-problem="openAddProblemModal"
|
||||
@edit-problem="openEditProblemModal"
|
||||
@remove-problem="handleRemoveProblem"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="badges" tab="奖章管理">
|
||||
<BadgeManagement
|
||||
:badges="badges"
|
||||
@add-badge="openAddBadgeModal"
|
||||
@edit-badge="openEditBadgeModal"
|
||||
@delete-badge="handleDeleteBadge"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="progress" tab="进度管理">
|
||||
<ProgressManagement :progress="progress" @remove-user="handleRemoveUser" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<ProblemModal
|
||||
v-model:show="showProblemModal"
|
||||
:problem="editingProblem"
|
||||
@create="handleAddProblem"
|
||||
@update="handleEditProblem"
|
||||
/>
|
||||
|
||||
<BadgeModal
|
||||
v-model:show="showBadgeModal"
|
||||
:badge="editingBadge"
|
||||
@create="handleAddBadge"
|
||||
@update="handleEditBadge"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,168 +1,820 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
||||
import { fromPickerValue, toPickerValue } from "utils/functions"
|
||||
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
||||
import { fromPickerValue, parseTime, toPickerValue, zonedParts } from "utils/functions"
|
||||
import type { AdminProblemRow, AdminProblemSet, AdminProblemSetBadge } from "utils/types"
|
||||
import {
|
||||
addProblemsToSet,
|
||||
createProblemSet,
|
||||
createProblemSetBadge,
|
||||
deleteProblemSet,
|
||||
deleteProblemSetBadge,
|
||||
editProblemSet,
|
||||
editProblemSetBadge,
|
||||
getLibraryProblemList,
|
||||
getProblemSetBadges,
|
||||
getProblemSetDetail,
|
||||
getProblemSetProblems,
|
||||
removeProblemFromSet,
|
||||
reorderProblemSetProblems,
|
||||
setProblemRequired,
|
||||
type BadgeBody,
|
||||
} from "../api"
|
||||
import BadgeModal from "./components/BadgeModal.vue"
|
||||
|
||||
/**
|
||||
* 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
|
||||
* 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。
|
||||
*
|
||||
* 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。
|
||||
* 新建时题目先攒在页面上,保存时建好题单再一次加进去;编辑时加、删、排、切选做当场生效。
|
||||
*/
|
||||
const props = defineProps<{ problemSetId?: string }>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||
const isEdit = computed(() => !!problemSetId.value)
|
||||
const editing = computed(() => !!props.problemSetId)
|
||||
const id = computed(() => Number(props.problemSetId))
|
||||
const ready = ref(false)
|
||||
const saving = ref(false)
|
||||
const current = ref<AdminProblemSet | null>(null)
|
||||
|
||||
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
|
||||
title: "",
|
||||
description: "",
|
||||
difficulty: "Easy",
|
||||
status: "draft",
|
||||
visible: false,
|
||||
endTime: null,
|
||||
const form = reactive({ title: "", description: "", visible: true })
|
||||
|
||||
// ---------------------------------------------------------------- 布置期
|
||||
|
||||
const DAY = 86_400_000
|
||||
const OFFSET = 8 * 3_600_000
|
||||
const CHOICES = [
|
||||
{ days: 7, label: "一周" },
|
||||
{ days: 14, label: "两周" },
|
||||
{ days: 28, label: "四周" },
|
||||
]
|
||||
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
|
||||
|
||||
/** 东八区某一天结束的时刻(23:59:59.999) */
|
||||
function endOfDay(ms: number) {
|
||||
const parts = zonedParts(new Date(ms))!
|
||||
return Date.UTC(parts.year, parts.month - 1, parts.day + 1) - OFFSET - 1
|
||||
}
|
||||
|
||||
/** "none" 不布置 / 天数 / "pick" 自己选日期 */
|
||||
const choice = ref<"none" | "pick" | number>(14)
|
||||
const picked = ref(toPickerValue(Date.now() + 14 * DAY))
|
||||
|
||||
const untilMs = computed(() => {
|
||||
if (choice.value === "none") return null
|
||||
if (choice.value === "pick") return endOfDay(fromPickerValue(picked.value))
|
||||
return endOfDay(Date.now() + choice.value * DAY)
|
||||
})
|
||||
|
||||
// n-date-picker 按浏览器本地渲染,所以要平移一次再绑定(见 utils/functions.ts
|
||||
// 的 toPickerValue)。`formData.endTime` 里始终存**真实时刻**,只有喂给选择器那一步换。
|
||||
const endTimeTimestamp = computed({
|
||||
get: () => (formData.value.endTime ? toPickerValue(formData.value.endTime.getTime()) : null),
|
||||
set: (val: number | null) => {
|
||||
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
|
||||
function dayText(ms: number) {
|
||||
const iso = new Date(ms).toISOString()
|
||||
return `${parseTime(iso, "M月D日")} 周${WEEKDAYS[new Date(ms + OFFSET).getUTCDay()]}`
|
||||
}
|
||||
|
||||
const running = computed(() => !!current.value?.assigning)
|
||||
const expired = computed(
|
||||
() => !!current.value && !current.value.assigning && !!current.value.assignedUntil,
|
||||
)
|
||||
|
||||
const assignHint = computed(() => {
|
||||
if (untilMs.value === null) {
|
||||
return running.value
|
||||
? "保存后马上结束布置,以前的代码都放出来。"
|
||||
: "不布置:学生照样能加入、做题,只是不藏以前的代码。"
|
||||
}
|
||||
if (untilMs.value <= Date.now()) return "这一天已经过了,选个以后的日子。"
|
||||
const head = `布置到 ${dayText(untilMs.value)}。`
|
||||
return running.value
|
||||
? `${head}这一轮从 ${parseTime(current.value!.assignedAt!, "M月D日")} 开始,改日期只是延长或提前结束。`
|
||||
: `${head}${expired.value ? "上一轮已经结束,保存就是再布置一次。" : ""}`
|
||||
})
|
||||
|
||||
const disabledDate = (ts: number) => fromPickerValue(ts) + DAY <= Date.now()
|
||||
|
||||
// ---------------------------------------------------------------- 题目
|
||||
|
||||
type Item = { id: number; displayId: string; title: string; isRequired: boolean }
|
||||
const items = ref<Item[]>([])
|
||||
|
||||
async function loadProblems() {
|
||||
const rows = await getProblemSetProblems(id.value)
|
||||
items.value = rows.map((row) => ({
|
||||
id: row.id,
|
||||
displayId: row.problemDisplayId,
|
||||
title: row.title,
|
||||
isRequired: row.isRequired,
|
||||
}))
|
||||
}
|
||||
|
||||
const keyword = ref("")
|
||||
const results = ref<AdminProblemRow[]>([])
|
||||
const searching = ref(false)
|
||||
|
||||
/** 输入框里粘了好几个题号(空格、逗号、顿号隔开) */
|
||||
const pasted = computed(() => {
|
||||
const parts = keyword.value
|
||||
.split(/[\s,,、;;]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
return parts.length > 1 ? parts : []
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
keyword,
|
||||
async (value) => {
|
||||
if (!value.trim() || pasted.value.length) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
results.value = (await getLibraryProblemList(0, 8, value.trim())).results
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
{ debounce: 300 },
|
||||
)
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "简单", value: "Easy" },
|
||||
{ label: "中等", value: "Medium" },
|
||||
{ label: "困难", value: "Hard" },
|
||||
]
|
||||
const added = (displayId: string) =>
|
||||
items.value.some((item) => item.displayId.toLowerCase() === displayId.toLowerCase())
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadProblemSetDetail() {
|
||||
if (!isEdit.value) return
|
||||
|
||||
try {
|
||||
const res = await getProblemSetDetail(problemSetId.value)
|
||||
const data = res
|
||||
formData.value = {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
difficulty: data.difficulty,
|
||||
status: data.status,
|
||||
visible: data.visible,
|
||||
endTime: data.endTime ? new Date(data.endTime) : null,
|
||||
async function addMany(displayIds: string[], titles = new Map<string, string>()) {
|
||||
if (!editing.value) {
|
||||
// 新建时先攒着;题目名要查一次,粘贴进来的题号只有号
|
||||
for (const typed of displayIds) {
|
||||
if (added(typed)) continue
|
||||
let displayId = typed
|
||||
let title = titles.get(typed)
|
||||
if (!title) {
|
||||
const found = (await getLibraryProblemList(0, 5, typed)).results.find(
|
||||
(row) => row._id.toLowerCase() === typed.toLowerCase(),
|
||||
)
|
||||
if (!found) {
|
||||
message.warning(`题号 ${typed} 找不到(或者没公开)`)
|
||||
continue
|
||||
}
|
||||
title = found.title
|
||||
displayId = found._id
|
||||
}
|
||||
items.value.push({ id: 0, displayId, title, isRequired: true })
|
||||
}
|
||||
keyword.value = ""
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await addProblemsToSet(id.value, displayIds)
|
||||
const notes = []
|
||||
if (res.missing.length) notes.push(`找不到:${res.missing.join("、")}`)
|
||||
if (res.duplicate.length) notes.push(`已经在里面:${res.duplicate.join("、")}`)
|
||||
if (notes.length) message.warning(notes.join(";"))
|
||||
else message.success(`加了 ${res.added.length} 道`)
|
||||
keyword.value = ""
|
||||
await Promise.all([loadProblems(), reloadSet()])
|
||||
} catch (err) {
|
||||
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.title?.trim()) {
|
||||
message.error("请输入题单标题")
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.description?.trim()) {
|
||||
message.error("请输入题单描述")
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
function addRow(row: AdminProblemRow) {
|
||||
addMany([row._id], new Map([[row._id, row.title]]))
|
||||
}
|
||||
|
||||
async function toggleRequired(item: Item) {
|
||||
item.isRequired = !item.isRequired
|
||||
if (!editing.value) return
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await editProblemSet(formData.value as EditProblemSetData)
|
||||
message.success("题单更新成功")
|
||||
} else {
|
||||
await createProblemSet(formData.value as CreateProblemSetData)
|
||||
message.success("题单创建成功")
|
||||
}
|
||||
router.push({ name: "admin problemset list" })
|
||||
await setProblemRequired(id.value, item.id, item.isRequired)
|
||||
} catch (err) {
|
||||
message.error((isEdit.value ? "更新" : "创建") + "题单失败:" + errorMessage(err, "未知错误"))
|
||||
item.isRequired = !item.isRequired
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
function remove(item: Item) {
|
||||
if (!editing.value) {
|
||||
items.value = items.value.filter((it) => it !== item)
|
||||
return
|
||||
}
|
||||
dialog.warning({
|
||||
title: "从题单里拿掉这道题?",
|
||||
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
|
||||
positiveText: "拿掉",
|
||||
negativeText: "不拿",
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await removeProblemFromSet(id.value, item.id)
|
||||
await Promise.all([loadProblems(), reloadSet()])
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 拖动排序:原生 drag & drop,和比赛出卷那页同一套
|
||||
const dragging = ref<number | null>(null)
|
||||
function onDragStart(index: number) {
|
||||
dragging.value = index
|
||||
}
|
||||
function onDragEnter(index: number) {
|
||||
const from = dragging.value
|
||||
if (from === null || from === index) return
|
||||
const list = [...items.value]
|
||||
const [moved] = list.splice(from, 1)
|
||||
list.splice(index, 0, moved!)
|
||||
items.value = list
|
||||
dragging.value = index
|
||||
}
|
||||
async function onDragEnd() {
|
||||
if (dragging.value === null) return
|
||||
dragging.value = null
|
||||
if (!editing.value) return
|
||||
try {
|
||||
await reorderProblemSetProblems(
|
||||
id.value,
|
||||
items.value.map((item) => item.id),
|
||||
)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
await loadProblems()
|
||||
}
|
||||
}
|
||||
|
||||
const optionalCount = computed(() => items.value.filter((item) => !item.isRequired).length)
|
||||
|
||||
// ---------------------------------------------------------------- 奖章
|
||||
|
||||
const badges = ref<AdminProblemSetBadge[]>([])
|
||||
const badgeModal = ref(false)
|
||||
const editingBadge = ref<AdminProblemSetBadge | null>(null)
|
||||
|
||||
function badgeCondition(badge: AdminProblemSetBadge) {
|
||||
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
|
||||
}
|
||||
|
||||
function openBadge(badge: AdminProblemSetBadge | null) {
|
||||
editingBadge.value = badge
|
||||
badgeModal.value = true
|
||||
}
|
||||
|
||||
async function saveBadge(data: BadgeBody) {
|
||||
try {
|
||||
if (editingBadge.value) await editProblemSetBadge(id.value, editingBadge.value.id, data)
|
||||
else await createProblemSetBadge(id.value, data)
|
||||
badgeModal.value = false
|
||||
badges.value = await getProblemSetBadges(id.value)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
function removeBadge(badge: AdminProblemSetBadge) {
|
||||
dialog.warning({
|
||||
title: `删掉奖章「${badge.name}」?`,
|
||||
content: badge.earnedCount
|
||||
? `已经拿到它的 ${badge.earnedCount} 个人也会一起被收回。`
|
||||
: "还没有人拿到它。",
|
||||
positiveText: "删掉",
|
||||
negativeText: "不删",
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteProblemSetBadge(id.value, badge.id)
|
||||
badges.value = await getProblemSetBadges(id.value)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 载入 / 保存
|
||||
|
||||
async function reloadSet() {
|
||||
current.value = await getProblemSetDetail(id.value)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
keyword.value = ""
|
||||
if (!editing.value) {
|
||||
ready.value = true
|
||||
return
|
||||
}
|
||||
await reloadSet()
|
||||
const set = current.value!
|
||||
Object.assign(form, { title: set.title, description: set.description, visible: set.visible })
|
||||
if (set.assigning) {
|
||||
choice.value = "pick"
|
||||
picked.value = toPickerValue(Date.parse(set.assignedUntil!))
|
||||
} else {
|
||||
choice.value = "none"
|
||||
}
|
||||
await Promise.all([loadProblems(), getProblemSetBadges(id.value).then((b) => (badges.value = b))])
|
||||
ready.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.title.trim()) {
|
||||
message.error("题单还没起名字")
|
||||
return
|
||||
}
|
||||
if (untilMs.value !== null && untilMs.value <= Date.now()) {
|
||||
message.error("布置到的日期已经过了")
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
visible: form.visible,
|
||||
assignedUntil: untilMs.value === null ? null : new Date(untilMs.value).toISOString(),
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
current.value = await editProblemSet(id.value, body)
|
||||
message.success("改好了")
|
||||
return
|
||||
}
|
||||
const created = await createProblemSet(body)
|
||||
if (items.value.length) {
|
||||
await addProblemsToSet(
|
||||
created.id,
|
||||
items.value.map((item) => item.displayId),
|
||||
)
|
||||
const optional = items.value.filter((item) => !item.isRequired).map((item) => item.displayId)
|
||||
if (optional.length) {
|
||||
const rows = await getProblemSetProblems(created.id)
|
||||
for (const row of rows.filter((r) => optional.includes(r.problemDisplayId))) {
|
||||
await setProblemRequired(created.id, row.id, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
message.success(
|
||||
items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : "题单建好了",
|
||||
)
|
||||
router.replace({ name: "admin problemset edit", params: { problemSetId: created.id } })
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit.value) {
|
||||
loadProblemSetDetail()
|
||||
}
|
||||
})
|
||||
function removeSet() {
|
||||
const set = current.value!
|
||||
dialog.error({
|
||||
title: `删除题单「${set.title}」?`,
|
||||
content: set.participantCount
|
||||
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
|
||||
: "还没有人加入。删掉找不回来。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteProblemSet(set.id)
|
||||
router.replace({ name: "admin problemset list" })
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.problemSetId, load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
:title="isEdit ? '编辑题单' : '新建题单'"
|
||||
:back="
|
||||
isEdit
|
||||
? { name: 'admin problemset detail', params: { problemSetId } }
|
||||
: { name: 'admin problemset list' }
|
||||
"
|
||||
/>
|
||||
<PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }">
|
||||
<template v-if="editing" #actions>
|
||||
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank">
|
||||
到前台看这个题单
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-form :model="formData" label-placement="top">
|
||||
<n-flex>
|
||||
<n-form-item label="题单标题" required>
|
||||
<n-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入题单标题"
|
||||
maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
v-model:value="formData.difficulty"
|
||||
:options="difficultyOptions"
|
||||
placeholder="选择难度"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
v-model:value="formData.status"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="截止时间">
|
||||
<n-date-picker
|
||||
v-model:value="endTimeTimestamp"
|
||||
type="datetime"
|
||||
clearable
|
||||
placeholder="不设置则无截止时间"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="isEdit" label="是否可见">
|
||||
<n-switch v-model:value="formData.visible" />
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
<div v-if="ready" class="cols">
|
||||
<section class="card form">
|
||||
<div class="field">
|
||||
<span class="label">名字</span>
|
||||
<n-input v-model:value="form.title" placeholder="比如 字符串方法" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">说明</span>
|
||||
<n-input v-model:value="form.description" placeholder="选填,比如:第 5 章 字符串方法" />
|
||||
</div>
|
||||
|
||||
<n-form-item label="题单描述" required>
|
||||
<div class="field top">
|
||||
<span class="label">布置</span>
|
||||
<div class="stack">
|
||||
<n-flex :size="6" align="center">
|
||||
<button class="chip" :class="{ on: choice === 'none' }" @click="choice = 'none'">
|
||||
不布置
|
||||
</button>
|
||||
<button
|
||||
v-for="c in CHOICES"
|
||||
:key="c.days"
|
||||
class="chip"
|
||||
:class="{ on: choice === c.days }"
|
||||
@click="choice = c.days"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
<button class="chip" :class="{ on: choice === 'pick' }" @click="choice = 'pick'">
|
||||
选日期…
|
||||
</button>
|
||||
<n-date-picker
|
||||
v-if="choice === 'pick'"
|
||||
v-model:value="picked"
|
||||
type="date"
|
||||
:is-date-disabled="disabledDate"
|
||||
style="width: 150px"
|
||||
/>
|
||||
</n-flex>
|
||||
<span class="hint">{{ assignHint }}</span>
|
||||
<span class="hint">
|
||||
布置期内,题单里这几道题学生以前交的代码先藏起来(不管加没加入),在题单里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">公开</span>
|
||||
<n-switch v-model:value="form.visible" />
|
||||
<span class="hint">{{
|
||||
form.visible ? "学生能在题单列表里看到" : "藏起来,学生看不到也进不去"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="current" class="stats">
|
||||
<span>{{ current.participantCount }} 人加入 · {{ current.completedCount }} 人做完</span>
|
||||
<div class="spacer"></div>
|
||||
<a :href="`/problemset/${current.id}?tab=class`" target="_blank">看全班情况 ›</a>
|
||||
</div>
|
||||
|
||||
<div class="save">
|
||||
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
|
||||
<span class="hint"
|
||||
>{{ items.length }} 道题<template v-if="optionalCount">
|
||||
· {{ optionalCount }} 道选做</template
|
||||
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
|
||||
>
|
||||
<div class="spacer"></div>
|
||||
<n-button v-if="editing" text type="error" @click="removeSet">删除题单</n-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="right">
|
||||
<section class="card picker">
|
||||
<div class="picker-head">
|
||||
<b>题目</b>
|
||||
<span class="hint">{{ items.length }} 道 · 拖动排顺序 · 点「必做」切成选做</span>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
placeholder="请输入题单描述"
|
||||
:rows="4"
|
||||
v-model:value="keyword"
|
||||
clearable
|
||||
:loading="searching"
|
||||
placeholder="搜题号或题目名;也能一次粘贴多个题号,如 3065 3066 3067"
|
||||
@keyup.enter="pasted.length && addMany(pasted)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ isEdit ? "更新" : "创建" }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div v-if="pasted.length" class="results">
|
||||
<div class="result">
|
||||
<span class="grow">粘贴了 {{ pasted.length }} 个题号:{{ pasted.join("、") }}</span>
|
||||
<n-button size="small" type="primary" @click="addMany(pasted)">全部加进来</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="results.length" class="results">
|
||||
<div v-for="row in results" :key="row.id" class="result">
|
||||
<span class="muted num">{{ row._id }}</span>
|
||||
<span class="ell grow">{{ row.title }}</span>
|
||||
<span v-if="added(row._id)" class="muted tiny">已加入</span>
|
||||
<n-button v-else size="small" text type="primary" @click="addRow(row)">+ 加入</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="list">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.displayId"
|
||||
class="item"
|
||||
:class="{ dragging: dragging === index }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragenter.prevent="onDragEnter(index)"
|
||||
@dragover.prevent
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span class="grip" aria-hidden="true">⋮⋮</span>
|
||||
<b class="num idx">{{ index + 1 }}</b>
|
||||
<span class="muted num tiny pid">{{ item.displayId }}</span>
|
||||
<span class="ell grow">{{ item.title }}</span>
|
||||
<button
|
||||
class="chip small"
|
||||
:class="{ dashed: item.isRequired }"
|
||||
:title="item.isRequired ? '点一下改成选做' : '点一下改回必做'"
|
||||
@click="toggleRequired(item)"
|
||||
>
|
||||
{{ item.isRequired ? "必做" : "选做" }}
|
||||
</button>
|
||||
<n-button size="small" text :aria-label="`拿掉 ${item.title}`" @click="remove(item)"
|
||||
>✕</n-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty hint">还没加题。在上面输入题号,比如 3065</div>
|
||||
</section>
|
||||
|
||||
<section class="card picker">
|
||||
<div class="picker-head">
|
||||
<b>奖章</b>
|
||||
<span class="hint">学生做对几道就发;改了条件会按现有进度重发</span>
|
||||
<div class="spacer"></div>
|
||||
<n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button>
|
||||
</div>
|
||||
<div v-if="!editing" class="empty hint">先保存题单,再加奖章</div>
|
||||
<div v-else-if="badges.length" class="list">
|
||||
<div v-for="badge in badges" :key="badge.id" class="item badge">
|
||||
<img :src="badge.icon" alt="" class="badge-icon" />
|
||||
<b class="ell badge-name">{{ badge.name }}</b>
|
||||
<span class="chip small static">{{ badgeCondition(badge) }}</span>
|
||||
<span class="muted ell grow tiny">{{ badge.description }}</span>
|
||||
<span class="muted num tiny">{{ badge.earnedCount }} 人拿到</span>
|
||||
<n-button size="small" text type="primary" @click="openBadge(badge)">改</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
text
|
||||
:aria-label="`删掉 ${badge.name}`"
|
||||
@click="removeBadge(badge)"
|
||||
>✕</n-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty hint">还没有奖章。比如「做对 3 道」发一个、「全部做完」发一个</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BadgeModal
|
||||
v-model:show="badgeModal"
|
||||
:badge="editingBadge"
|
||||
:problems-count="items.length"
|
||||
@save="saveBadge"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cols {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.cardColor");
|
||||
}
|
||||
|
||||
.form {
|
||||
flex: 1 1 420px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 999 1 480px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.picker {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picker-head b {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field.top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field.top .label {
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ell {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
color: v-bind("theme.primaryColor");
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip.small {
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip.dashed {
|
||||
border-style: dashed;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.chip.static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: v-bind("theme.actionColor");
|
||||
}
|
||||
|
||||
.stats a {
|
||||
color: v-bind("theme.primaryColor");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.save {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: -4px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.result {
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result + .result {
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.list {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item {
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
background: v-bind("theme.cardColor");
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.item.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.item.badge {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.grip {
|
||||
cursor: grab;
|
||||
color: v-bind("theme.textColor3");
|
||||
letter-spacing: -3px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.idx {
|
||||
width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pid {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -5,87 +5,68 @@ import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminProblemSet } from "utils/types"
|
||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import { NTag, NSwitch } from "naive-ui"
|
||||
import { NButton, NSwitch, NTag } from "naive-ui"
|
||||
|
||||
/**
|
||||
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
|
||||
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
|
||||
*/
|
||||
const router = useRouter()
|
||||
const total = ref(0)
|
||||
const problemSets = ref<AdminProblemSet[]>([])
|
||||
|
||||
interface ProblemSetQuery {
|
||||
keyword: string
|
||||
difficulty: string
|
||||
status: string
|
||||
const { query, clearQuery } = usePagination<{ keyword: string }>({ keyword: "" })
|
||||
|
||||
function edit(row: AdminProblemSet) {
|
||||
router.push({ name: "admin problemset edit", params: { problemSetId: row.id } })
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemSetQuery>({
|
||||
keyword: "",
|
||||
difficulty: "",
|
||||
status: "",
|
||||
})
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "简单", value: "Easy" },
|
||||
{ label: "中等", value: "Medium" },
|
||||
{ label: "困难", value: "Hard" },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<AdminProblemSet>[] = [
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
|
||||
{ title: "ID", key: "id", width: 70 },
|
||||
{
|
||||
title: "题单",
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(NButton, { text: true, onClick: () => edit(row) }, { default: () => row.title }),
|
||||
},
|
||||
{ title: "题数", key: "problemsCount", width: 70 },
|
||||
{
|
||||
title: "加入 / 做完",
|
||||
key: "participantCount",
|
||||
width: 110,
|
||||
render: (row) => `${row.participantCount} / ${row.completedCount}`,
|
||||
},
|
||||
{ title: "奖章", key: "badgeCount", width: 70 },
|
||||
{
|
||||
title: "布置",
|
||||
key: "assignedUntil",
|
||||
width: 130,
|
||||
render: (row) =>
|
||||
row.assigning
|
||||
? h(
|
||||
NTag,
|
||||
{ type: "info", size: "small", bordered: false },
|
||||
{ default: () => `到 ${parseTime(row.assignedUntil!, "M月D日")}` },
|
||||
)
|
||||
: "—",
|
||||
},
|
||||
{
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: 120,
|
||||
key: "createdBy",
|
||||
width: 110,
|
||||
render: (row) => row.createdBy.username,
|
||||
},
|
||||
{
|
||||
title: "难度",
|
||||
key: "difficulty",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const difficultyMap = {
|
||||
Easy: { type: "success" as const, text: "简单" },
|
||||
Medium: { type: "warning" as const, text: "中等" },
|
||||
Hard: { type: "error" as const, text: "困难" },
|
||||
}
|
||||
const config = difficultyMap[row.difficulty]
|
||||
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const statusMap = {
|
||||
active: { type: "success" as const, text: "活跃" },
|
||||
archived: { type: "default" as const, text: "已归档" },
|
||||
draft: { type: "info" as const, text: "草稿" },
|
||||
}
|
||||
const config = statusMap[row.status]
|
||||
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
key: "createTime",
|
||||
width: 120,
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
title: "公开",
|
||||
key: "visible",
|
||||
width: 100,
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -94,84 +75,41 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
fixed: "right",
|
||||
width: 300,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemSetId: row.id,
|
||||
onUpdated: listProblemSets,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
async function listProblemSets() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemSetList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.difficulty,
|
||||
query.status,
|
||||
)
|
||||
const res = await getProblemSetList(offset, query.limit, query.keyword)
|
||||
total.value = res.total
|
||||
problemSets.value = res.results
|
||||
}
|
||||
|
||||
async function toggleVisible(problemSetId: number) {
|
||||
await toggleProblemSetVisible(problemSetId)
|
||||
problemSets.value = problemSets.value.map((it) => {
|
||||
if (it.id === problemSetId) {
|
||||
it.visible = !it.visible
|
||||
}
|
||||
return it
|
||||
})
|
||||
const updated = await toggleProblemSetVisible(problemSetId)
|
||||
problemSets.value = problemSets.value.map((it) => (it.id === problemSetId ? updated : it))
|
||||
}
|
||||
|
||||
onMounted(listProblemSets)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listProblemSets, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(() => [query.page, query.limit, query.difficulty, query.status], listProblemSets)
|
||||
watchDebounced(() => query.keyword, listProblemSets, { debounce: 500, maxWait: 1000 })
|
||||
watch(() => [query.page, query.limit], listProblemSets)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="题单列表">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin problemset create' })">
|
||||
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })">
|
||||
新建题单
|
||||
</n-button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
placeholder="搜题单名字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
style="width: 220px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
placeholder="全部难度"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.status"
|
||||
:options="statusOptions"
|
||||
placeholder="全部状态"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table striped :columns="columns" :data="problemSets" />
|
||||
|
||||
Reference in new issue
Block a user