feat(题单): 题单页重做——布置中的放最上面,详情顶上一条进度和奖章阶梯,没加入看得见题点不进;老师看全班、后台一屏编辑
Deploy / deploy (push) Canceled after 0s

- 列表:布置中的大卡片(做对几道、布置到哪天、再做对几道拿哪个奖章),其余小卡片,加入过的在前
- 详情:进度 + 奖章阶梯(下一个金圈「再做对 n 道」),题目两列写几点做对 / 交过几次;
  以前做对过的标「旧代码先藏着」;做完整条变绿,烟花只在做完那一刻放
- 老师多一个「全班情况」页签;题目页从链接直接进来没加入时,条上给「加入题单」
- 后台一屏编辑:布置一周 / 两周 / 四周 / 选日期,公开开关,粘贴多个题号一次加,拖动排序,奖章只剩两种条件
- 旧代码藏着的地方都说清楚是哪个题单、哪天之后能看
- 修掉列表卡片把选做题数说成「新加了 N 题」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 22:01:39 +08:00
1 parent 8c53e28dcb
commit c17f88cdbb
32 files changed
+2624 -2175

No files matched your search

+32 -98
View File
@@ -2,7 +2,6 @@ import api from "utils/api"
import { toAdminProblemRow } from "admin/transforms" import { toAdminProblemRow } from "admin/transforms"
import type { import type {
AcTrend, AcTrend,
AdminProblemSetProgress,
BatchProblemTagResponse, BatchProblemTagResponse,
GenerateSqlTestCaseResponse, GenerateSqlTestCaseResponse,
RenameTagResponse, RenameTagResponse,
@@ -40,6 +39,7 @@ import type {
AdminProblemSetList, AdminProblemSetList,
AdminProblemSet, AdminProblemSet,
AdminProblemSetProblem, AdminProblemSetProblem,
AddProblemsToSetResult,
TutorialListItem, TutorialListItem,
TagCategory, TagCategory,
} from "utils/types" } from "utils/types"
@@ -495,15 +495,9 @@ export function updateACMHelperChecked(
} }
// 题单管理 API // 题单管理 API
export function getProblemSetList( export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api.get<AdminProblemSetList>("admin/problem-sets", { 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}`) return api.get<AdminProblemSet>(`admin/problem-sets/${id}`)
} }
interface ProblemSetBody { export interface ProblemSetBody {
title?: string title: string
description?: string description: string
difficulty?: AdminProblemSet["difficulty"] /** 布置到哪天(东八区当天结束的 ISO 时刻);null = 不布置 */
status?: AdminProblemSet["status"] assignedUntil: string | null
// 表单里是 Date,出站要 ISO 串 visible: boolean
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 function createProblemSet(data: ProblemSetBody) { 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 }) { export function editProblemSet(id: number, data: ProblemSetBody) {
return api.put<AdminProblemSet>(`admin/problem-sets/${data.id}`, toProblemSetBody(data)) return api.put<AdminProblemSet>(`admin/problem-sets/${id}`, data)
} }
export function deleteProblemSet(id: number) { export function deleteProblemSet(id: number) {
@@ -548,49 +529,28 @@ export function toggleProblemSetVisible(id: number) {
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`) 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 // 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) { 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( /** 一次加好几道(题号),按给的顺序接在最后;找不到的、已经在里面的分开报回来 */
problemSetId: number, export function addProblemsToSet(problemSetId: number, problemIds: string[]) {
data: { return api.post<AddProblemsToSetResult>(`admin/problem-sets/${problemSetId}/problems`, {
problemId: string problemIds,
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 editProblemInSet( export function setProblemRequired(problemSetId: number, itemId: number, isRequired: boolean) {
problemSetId: number, return api.put(`admin/problem-sets/${problemSetId}/problems/${itemId}`, { isRequired })
problemSetProblemId: number,
data: {
order?: number
isRequired?: boolean
score?: number
hint?: string
},
) {
return api.put(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, data)
} }
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) { export function reorderProblemSetProblems(problemSetId: number, ids: number[]) {
return api.delete(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`) 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 // 题单奖章管理 API
@@ -598,52 +558,26 @@ 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 { export interface BadgeBody {
name?: string name: string
description?: string description: string
icon?: string icon: string
conditionType?: AdminProblemSetBadge["conditionType"] conditionType: AdminProblemSetBadge["conditionType"]
conditionValue?: number conditionValue: number
}
function toBadgeBody(data: BadgeBody) {
return {
name: data.name,
description: data.description ?? "",
icon: data.icon ?? "",
conditionType: data.conditionType,
conditionValue: data.conditionValue ?? 0,
}
} }
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) { export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
return api.post<AdminProblemSetBadge>( return api.post<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges`, data)
`admin/problem-sets/${problemSetId}/badges`,
toBadgeBody(data),
)
} }
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) { export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
return api.put<AdminProblemSetBadge>( return api.put<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges/${badgeId}`, data)
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
toBadgeBody(data),
)
} }
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) { export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`) 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() { export function getStuckProblems() {
return api.get<StuckProblem[]>("admin/problem-analytics/stuck") 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"> <script setup lang="ts">
import { useThemeVars } from "naive-ui"
import type { BadgeBody } from "admin/api"
import type { AdminProblemSetBadge } from "utils/types" import type { AdminProblemSetBadge } from "utils/types"
// 添加和编辑共用一个弹窗:传了 badge 就是编辑,null 就是添加。 /**
// 原来是两份八成相同的组件,改一处表单项另一处总要漏 * 加奖章 / 改奖章共用一个弹窗:传了 badge 就是改,null 就是加。
interface Props { * 条件只有两种:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了)。
show: boolean * 「做对 N 道」数的是做对的全部题(含选做),和后端 eligibleForBadge 同一个口径。
badge: AdminProblemSetBadge | null */
} 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 = { const ICONS = [1, 2, 3, 4, 5, 6].map((i) => `/badge-${i}.png`)
name: string
description: string
icon: string
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: number
}
interface Emits { const form = reactive<BadgeBody>({
(e: "update:show", value: boolean): void name: "",
(e: "create", data: BadgeFormData): void description: "",
(e: "update", data: BadgeFormData): void 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 valid = computed(
const badgeDescription = ref("") () =>
const badgeIcon = ref("") form.name.trim().length > 0 &&
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems") (form.conditionType === "all_problems" || form.conditionValue >= 1),
const badgeConditionValue = ref(1) )
// 预设奖章图标选项 function save() {
const BADGE_LEN = 6 if (!valid.value) return
const badgeIconOptions = [] emit("save", {
for (let i = 1; i <= BADGE_LEN; i++) { ...form,
badgeIconOptions.push({ name: form.name.trim(),
label: `奖章${i}`, conditionValue: form.conditionType === "all_problems" ? 0 : form.conditionValue,
value: `/badge-${i}.png`,
icon: `/badge-${i}.png`,
}) })
} }
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> </script>
<template> <template>
<n-modal <n-modal
:show="show" v-model:show="show"
preset="card" preset="card"
:title="badge ? '编辑奖章' : '添加奖章'" :title="badge ? '改奖章' : '加奖章'"
style="width: 500px" style="width: 460px"
@update:show="emit('update:show', $event)" :mask-closable="false"
> >
<n-form> <div class="form">
<n-form-item label="奖章名称" required> <div class="field">
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" /> <span class="label">图标</span>
</n-form-item> <div class="icons">
<n-form-item label="描述"> <button
<n-input v-model:value="badgeDescription" type="textarea" placeholder="奖章描述" required /> v-for="icon in ICONS"
</n-form-item> :key="icon"
<n-form-item label="图标" required> class="icon"
<n-flex align="center" gap="small"> :class="{ on: form.icon === icon }"
<div :aria-label="`选这个图标`"
v-for="option in badgeIconOptions" @click="form.icon = icon"
: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',
}"
> >
<n-image <img :src="icon" alt="" />
:src="option.icon" </button>
width="50"
height="50"
object-fit="cover"
preview-disabled
style="border-radius: 2px"
/>
</div> </div>
</n-flex> </div>
</n-form-item> <div class="field">
<n-flex align="center"> <span class="label">名字</span>
<n-form-item label="获得条件"> <n-input v-model:value="form.name" placeholder="比如 人上人" maxlength="100" />
<n-select </div>
style="width: 200px" <div class="field">
v-model:value="badgeConditionType" <span class="label">说明</span>
:options="conditionTypeOptions" <n-input v-model:value="form.description" placeholder="选填,学生拿到时看得到" />
/> </div>
</n-form-item> <div class="field">
<n-form-item label="条件值" v-if="badgeConditionType !== 'all_problems'"> <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 <n-input-number
style="width: 120px" v-if="form.conditionType === 'problem_count'"
v-model:value="badgeConditionValue" v-model:value="form.conditionValue"
placeholder="条件值" :min="1"
/> :max="Math.max(1, problemsCount)"
</n-form-item> style="width: 110px"
</n-flex> >
</n-form> <template #suffix>道</template>
</n-input-number>
</div>
<span class="hint">
{{
form.conditionType === "all_problems"
? "必做题全部做对就发。"
: "做对的题(选做也算)够这个数就发。"
}}改了条件会按大家现在的进度重新发、收。
</span>
</div>
<template #footer> <template #footer>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="handleCancel">取消</n-button> <n-button @click="show = false">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button> <n-button type="primary" :disabled="!valid" @click="save">保存</n-button>
</n-flex> </n-flex>
</template> </template>
</n-modal> </n-modal>
</template> </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>
-261
View File
@@ -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>
+792 -140
View File
@@ -1,168 +1,820 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui"
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import type { CreateProblemSetData, EditProblemSetData } from "utils/types" import { fromPickerValue, parseTime, toPickerValue, zonedParts } from "utils/functions"
import { fromPickerValue, toPickerValue } from "utils/functions" import type { AdminProblemRow, AdminProblemSet, AdminProblemSetBadge } from "utils/types"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api" 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 router = useRouter()
const message = useMessage() const message = useMessage()
const dialog = useDialog()
const theme = useThemeVars()
const problemSetId = computed(() => Number(route.params.problemSetId)) const editing = computed(() => !!props.problemSetId)
const isEdit = computed(() => !!problemSetId.value) 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>>({ const form = reactive({ title: "", description: "", visible: true })
title: "",
description: "",
difficulty: "Easy",
status: "draft",
visible: false,
endTime: null,
})
// 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
},
})
const difficultyOptions = [ const DAY = 86_400_000
{ label: "简单", value: "Easy" }, const OFFSET = 8 * 3_600_000
{ label: "中等", value: "Medium" }, const CHOICES = [
{ label: "困难", value: "Hard" }, { days: 7, label: "一周" },
{ days: 14, label: "两周" },
{ days: 28, label: "四周" },
] ]
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
const statusOptions = [ /** 东八区某一天结束的时刻(23:59:59.999) */
{ label: "活跃", value: "active" }, function endOfDay(ms: number) {
{ label: "已归档", value: "archived" }, const parts = zonedParts(new Date(ms))!
{ label: "草稿", value: "draft" }, return Date.UTC(parts.year, parts.month - 1, parts.day + 1) - OFFSET - 1
]
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,
}
} catch (err) {
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
}
} }
async function handleSubmit() { /** "none" 不布置 / 天数 / "pick" 自己选日期 */
if (!formData.value.title?.trim()) { const choice = ref<"none" | "pick" | number>(14)
message.error("请输入题单标题") 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)
})
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 return
} }
searching.value = true
if (!formData.value.description?.trim()) {
message.error("请输入题单描述")
return
}
loading.value = true
try { try {
if (isEdit.value) { results.value = (await getLibraryProblemList(0, 8, value.trim())).results
await editProblemSet(formData.value as EditProblemSetData)
message.success("题单更新成功")
} else {
await createProblemSet(formData.value as CreateProblemSetData)
message.success("题单创建成功")
}
router.push({ name: "admin problemset list" })
} catch (err) {
message.error((isEdit.value ? "更新" : "创建") + "题单失败:" + errorMessage(err, "未知错误"))
} finally { } finally {
loading.value = false searching.value = false
}
},
{ debounce: 300 },
)
const added = (displayId: string) =>
items.value.some((item) => item.displayId.toLowerCase() === displayId.toLowerCase())
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))
} }
} }
onMounted(() => { function addRow(row: AdminProblemRow) {
if (isEdit.value) { addMany([row._id], new Map([[row._id, row.title]]))
loadProblemSetDetail() }
async function toggleRequired(item: Item) {
item.isRequired = !item.isRequired
if (!editing.value) return
try {
await setProblemRequired(id.value, item.id, item.isRequired)
} catch (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 {
saving.value = false
}
}
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> </script>
<template> <template>
<div> <PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }">
<PageHeader <template v-if="editing" #actions>
:title="isEdit ? '编辑题单' : '新建题单'" <n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank">
:back=" 到前台看这个题单
isEdit
? { name: 'admin problemset detail', params: { problemSetId } }
: { name: 'admin problemset list' }
"
/>
<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>
<n-form-item label="题单描述" required>
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="请输入题单描述"
:rows="4"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? "更新" : "创建" }}
</n-button> </n-button>
</n-form-item> </template>
</n-form> </PageHeader>
<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>
<div class="field">
<span class="label">说明</span>
<n-input v-model:value="form.description" placeholder="选填,比如:第 5 章 字符串方法" />
</div>
<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="keyword"
clearable
:loading="searching"
placeholder="搜题号或题目名;也能一次粘贴多个题号,如 3065 3066 3067"
@keyup.enter="pasted.length && addMany(pasted)"
/>
<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> </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>
+53 -115
View File
@@ -5,87 +5,68 @@ import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { AdminProblemSet } from "utils/types" import type { AdminProblemSet } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api" import { getProblemSetList, toggleProblemSetVisible } from "../api"
import Actions from "./components/Actions.vue" import { NButton, NSwitch, NTag } from "naive-ui"
import { NTag, NSwitch } from "naive-ui"
/**
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
*/
const router = useRouter()
const total = ref(0) const total = ref(0)
const problemSets = ref<AdminProblemSet[]>([]) const problemSets = ref<AdminProblemSet[]>([])
interface ProblemSetQuery { const { query, clearQuery } = usePagination<{ keyword: string }>({ keyword: "" })
keyword: string
difficulty: string function edit(row: AdminProblemSet) {
status: string 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>[] = [ const columns: DataTableColumn<AdminProblemSet>[] = [
{ title: "ID", key: "id", width: 80 }, { title: "ID", key: "id", width: 70 },
{ title: "标题", key: "title", minWidth: 200 }, {
{ title: "描述", key: "description", minWidth: 300, ellipsis: true }, 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: "创建者", title: "创建者",
key: "created_by", key: "createdBy",
width: 120, width: 110,
render: (row) => row.createdBy.username, 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: "创建时间", title: "创建时间",
key: "create_time", key: "createTime",
width: 180, width: 120,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"), render: (row) => parseTime(row.createTime, "YYYY-MM-DD"),
}, },
{ {
title: "可见", title: "公开",
key: "visible", key: "visible",
width: 100, width: 80,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.visible, value: row.visible,
@@ -94,84 +75,41 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
onUpdateValue: () => toggleVisible(row.id), onUpdateValue: () => toggleVisible(row.id),
}), }),
}, },
{
title: "选项",
key: "actions",
fixed: "right",
width: 300,
render: (row) =>
h(Actions, {
problemSetId: row.id,
onUpdated: listProblemSets,
}),
},
] ]
async function listProblemSets() { async function listProblemSets() {
if (query.page < 1) query.page = 1 if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
const res = await getProblemSetList( const res = await getProblemSetList(offset, query.limit, query.keyword)
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
total.value = res.total total.value = res.total
problemSets.value = res.results problemSets.value = res.results
} }
async function toggleVisible(problemSetId: number) { async function toggleVisible(problemSetId: number) {
await toggleProblemSetVisible(problemSetId) const updated = await toggleProblemSetVisible(problemSetId)
problemSets.value = problemSets.value.map((it) => { problemSets.value = problemSets.value.map((it) => (it.id === problemSetId ? updated : it))
if (it.id === problemSetId) {
it.visible = !it.visible
}
return it
})
} }
onMounted(listProblemSets) onMounted(listProblemSets)
watchDebounced(() => query.keyword, listProblemSets, { debounce: 500, maxWait: 1000 })
// 监听搜索关键词变化(防抖) watch(() => [query.page, query.limit], listProblemSets)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.difficulty, query.status], listProblemSets)
</script> </script>
<template> <template>
<PageHeader title="题单列表"> <PageHeader title="题单列表">
<template #actions> <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> </n-button>
</template> </template>
<template #filters> <template #filters>
<n-input <n-input
v-model:value="query.keyword" v-model:value="query.keyword"
placeholder="输入标题关键字" placeholder="搜题单名字"
clearable clearable
@clear="clearQuery" @clear="clearQuery"
style="width: 220px" 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> </template>
</PageHeader> </PageHeader>
<n-data-table striped :columns="columns" :data="problemSets" /> <n-data-table striped :columns="columns" :data="problemSets" />
+15 -25
View File
@@ -36,10 +36,10 @@ import {
type HeatmapItem, type HeatmapItem,
type SolvedList, type SolvedList,
type ProblemSet, type ProblemSet,
type ProblemSetBadge, type ProblemSetClassView,
type ProblemSetList, type ProblemSetList,
type ProblemSetLock,
type ProblemSetProblem, type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge, type UserBadge,
type FlowchartStatistics, type FlowchartStatistics,
type SubmissionStatistics, type SubmissionStatistics,
@@ -482,23 +482,15 @@ export function retryFlowchartSubmission(submissionId: string) {
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`) return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
} }
/** 自己在这道题上评完的每一次(早的在前),加入题单之前的已经滤掉 */ /** 自己在这道题上评完的每一次(早的在前),题单布置期内藏着的已经滤掉 */
export function getFlowchartScores(problemId: number) { export function getFlowchartScores(problemId: number) {
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`) return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
} }
// ==================== 题单相关API ==================== // ==================== 题单相关API ====================
export function getProblemSetList( export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
offset = 0, return api.get<ProblemSetList>("problem-sets", { params: { offset, limit, keyword } })
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
} }
export function getProblemSetDetail(id: number) { export function getProblemSetDetail(id: number) {
@@ -517,20 +509,18 @@ export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`) return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
} }
export function getProblemSetBadges(problemSetId: number) { /** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`) export function getProblemSetClassView(problemSetId: number, className = "") {
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
params: className ? { className } : {},
})
} }
export function getProblemSetUserProgress( /** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */
problemSetId: number, export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
params?: { return api.get<ProblemSetLock[]>("problem-sets/locks", {
limit?: number params: "id" in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
offset?: number })
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
return api.get<ProblemSetProgressList>(`problem-sets/${problemSetId}/user-progress`, { params })
} }
export function getExercises(tutorialId: number): Promise<Exercise[]> { export function getExercises(tutorialId: number): Promise<Exercise[]> {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import type { RouteLocationRaw } from "vue-router" import type { RouteLocationRaw } from "vue-router"
import { getProblemSetDetail, getProblemSetProblems } from "oj/api" import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart" import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { useLessonStore } from "oj/store/lesson" import { useLessonStore } from "oj/store/lesson"
@@ -161,6 +161,7 @@ const setBar = computed(() => {
const done = required.filter((item) => item.isCompleted).length const done = required.filter((item) => item.isCompleted).length
return { return {
title: problemSet.value.title, title: problemSet.value.title,
joined: problemSet.value.userProgress.isJoined,
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } }, home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
done, done,
total: required.length, total: required.length,
@@ -171,6 +172,22 @@ const setBar = computed(() => {
} }
}) })
// 没加入就点进了题(直接打开的链接):在条上给个「加入」,不然做对了也不记进度
const joiningSet = ref(false)
async function joinHere() {
const id = ctx.value.problemSetId
if (!id || joiningSet.value) return
joiningSet.value = true
try {
await joinProblemSet(Number(id))
problemSet.value = await getProblemSetDetail(Number(id))
} catch {
// 加入失败就还是那条提示,回题单页再点一次
} finally {
joiningSet.value = false
}
}
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉 // 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
onSolvedHere(() => { onSolvedHere(() => {
const item = setProblems.value.find( const item = setProblems.value.find(
@@ -294,13 +311,19 @@ onSolvedHere(() => {
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`"> <router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
‹ {{ setBar.title }} ‹ {{ setBar.title }}
</router-link> </router-link>
<div class="set-progress" aria-hidden="true"> <div v-if="setBar.joined" class="set-progress" aria-hidden="true">
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" /> <div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
</div> </div>
<span class="meta"> <span v-if="setBar.joined" class="meta">
做完 {{ setBar.done }}/{{ setBar.total }} 做完 {{ setBar.done }}/{{ setBar.total }}
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template> <template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
</span> </span>
<template v-else>
<span class="meta">还没加入,在这里做对了不算进度</span>
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere"
>加入题单</n-button
>
</template>
<div class="spacer" /> <div class="spacer" />
<component <component
:is="setBar.previous ? 'router-link' : 'span'" :is="setBar.previous ? 'router-link' : 'span'"
@@ -236,7 +236,8 @@ function goCode() {
</template> </template>
</div> </div>
<p v-if="hiddenCount" class="note"> <p v-if="hiddenCount" class="note">
另外 {{ hiddenCount }} 次是加入题单之前画的,先藏起来了:在题单里画到 A 或 S 就解锁 另外 {{ hiddenCount }} 次是题单布置之前画的,先藏起来了:在题单里画到 A 或
S,或者布置期结束就解锁
</p> </p>
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 --> <!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
@@ -3,6 +3,7 @@ import { useThemeVars } from "naive-ui"
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api" import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import LockNote from "oj/problemset/components/LockNote.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
@@ -139,7 +140,7 @@ type Loaded = { code: string; language: LANGUAGE }
const details = reactive<Record<string, Loaded | "loading" | "failed">>({}) const details = reactive<Record<string, Loaded | "loading" | "failed">>({})
async function toggle(row: SubmissionListItem) { async function toggle(row: SubmissionListItem) {
// 加入题单之前的提交被锁住,点不开(后端也不会给代码) // 题单布置期内藏着的旧提交,点不开(后端也不会给代码)
if (!row.showLink) return if (!row.showLink) return
if (expanded.value === row.id) { if (expanded.value === row.id) {
expanded.value = null expanded.value = null
@@ -332,7 +333,7 @@ watch(query, () => {
</div> </div>
</div> </div>
</template> </template>
<!-- 加入题单之前交的那几次:合成一行,点不开(后端也不给代码) --> <!-- 题单布置期内藏着的那几次:合成一行,点不开(后端也不给代码) -->
<div v-if="lockedCount" class="locked"> <div v-if="lockedCount" class="locked">
<svg <svg
width="13" width="13"
@@ -349,8 +350,9 @@ watch(query, () => {
<path d="M8 11V8a4 4 0 018 0v3" /> <path d="M8 11V8a4 4 0 018 0v3" />
</svg> </svg>
<span> <span>
加入题单之前的 有 {{ lockedCount }} 次提交先藏起来了:<LockNote
{{ lockedCount }} 次提交先藏起来了,在题单里做出这道题就能看(题单过了截止时间也会解锁) :problem="{ displayId: problemDisplayId }"
/>
</span> </span>
</div> </div>
</div> </div>
+37
View File
@@ -0,0 +1,37 @@
import type { ProblemSet, ProblemSetBadge } from "utils/types"
/**
* 奖章的两种条件和后端 eligibleForBadge 同一个口径:
* - 全部做完:必做题全对(completedCount === totalCount)
* - 做对 N 道:做对的全部题数(含选做,solvedCount)≥ N
*/
export function badgeCondition(badge: ProblemSetBadge) {
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
}
/** 拿到这枚奖章还差几道;已经拿到为 0 */
export function badgeShortfall(badge: ProblemSetBadge, set: ProblemSet) {
if (badge.isEarned) return 0
const progress = set.userProgress
const remaining =
badge.conditionType === "all_problems"
? set.requiredCount - progress.completedCount
: badge.conditionValue - progress.solvedCount
return Math.max(1, remaining)
}
/** 阶梯顺序:按要做对的题数从少到多,「全部做完」排最后 */
export function ladder(set: ProblemSet) {
const need = (badge: ProblemSetBadge) =>
badge.conditionType === "all_problems" ? Number.MAX_SAFE_INTEGER : badge.conditionValue
return [...set.badges].sort((a, b) => need(a) - need(b) || a.id - b.id)
}
/** 下一枚最近的奖章和还差几道;全拿到了(或者没有奖章)为 null */
export function nextBadge(set: ProblemSet) {
const pending = ladder(set).filter((badge) => !badge.isEarned)
if (pending.length === 0) return null
const scored = pending.map((badge) => ({ badge, left: badgeShortfall(badge, set) }))
scored.sort((a, b) => a.left - b.left)
return scored[0]!
}
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
import { badgeCondition, badgeShortfall, ladder, nextBadge } from "../badges"
/**
* 奖章阶梯(设计稿「题单重设计」详情 B):一排奖章连成一条线,拿到的亮、写哪天拿的;
* 下一个要拿的套金圈、写「再做对 n 道」;更远的灰着、写条件。
*/
const props = defineProps<{ set: ProblemSet }>()
const theme = useThemeVars()
const tone = useTone()
const steps = computed(() => {
const next = nextBadge(props.set)?.badge.id
return ladder(props.set).map((badge) => ({
badge,
next: badge.id === next,
sub: badge.isEarned
? badge.earnedTime
? parseTime(badge.earnedTime, "M月D日")
: "拿到了"
: badge.id === next && props.set.userProgress.isJoined
? `再做对 ${badgeShortfall(badge, props.set)} 道`
: badgeCondition(badge),
}))
})
/** 连线亮到最后一枚拿到的奖章那里 */
const filled = computed(() => {
const last = steps.value.map((step) => step.badge.isEarned).lastIndexOf(true)
return steps.value.length > 1 && last > 0 ? last / (steps.value.length - 1) : 0
})
const warning = computed(() => tone("warning"))
</script>
<template>
<div class="ladder" :style="{ width: `${steps.length * 92}px` }">
<div v-if="steps.length > 1" class="track">
<div class="track-fill" :style="{ width: `${filled * 100}%` }"></div>
</div>
<div
v-for="step in steps"
:key="step.badge.id"
class="step"
:title="`${step.badge.name} · ${badgeCondition(step.badge)}${step.badge.description ? `\n${step.badge.description}` : ''}`"
>
<span class="medal" :class="{ next: step.next }">
<img
:src="step.badge.icon"
:alt="step.badge.name"
:class="{ locked: !step.badge.isEarned }"
/>
</span>
<b class="name" :class="{ dim: !step.badge.isEarned }">{{ step.badge.name }}</b>
<span class="sub" :class="{ urge: step.next && !step.badge.isEarned }">{{ step.sub }}</span>
</div>
</div>
</template>
<style scoped>
.ladder {
position: relative;
display: flex;
flex-shrink: 0;
}
.track {
position: absolute;
left: 46px;
right: 46px;
top: 21px;
height: 3px;
background: v-bind("theme.dividerColor");
}
.track-fill {
height: 100%;
background: v-bind("theme.successColor");
}
.step {
position: relative;
width: 92px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.medal {
width: 44px;
height: 44px;
border-radius: 50%;
background: v-bind("theme.cardColor");
display: flex;
align-items: center;
justify-content: center;
}
.medal.next {
box-shadow: 0 0 0 2px #f0b44c;
}
.medal img {
width: 36px;
height: 36px;
}
.locked {
filter: grayscale(1);
opacity: 0.35;
}
.name {
max-width: 88px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name.dim {
color: v-bind("theme.textColor3");
font-weight: 400;
}
.sub {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.sub.urge {
color: v-bind("warning.color");
font-weight: 600;
}
</style>
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getProblemSetClassView } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSetClassView } from "utils/types"
/**
* 老师看一个班在题单里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题,
* 格子里写几点做对 / 错了几次;紫色角 = 加入之前就做对过(可能是凭记忆默写的)。
* 没加入的人在上面点名。班级默认是服务端推断的(最近在做的那个班),可以换。
* 点格子到提交列表看这个学生这道题的代码。
*/
const props = defineProps<{ problemSetId: number }>()
const theme = useThemeVars()
const tone = useTone()
const data = ref<ProblemSetClassView | null>(null)
const className = ref("")
const loading = ref(false)
async function load() {
loading.value = true
try {
data.value = await getProblemSetClassView(props.problemSetId, className.value)
className.value = data.value.className ?? ""
} finally {
loading.value = false
}
}
onMounted(load)
watch(className, (value, old) => {
if (old && value !== data.value?.className) load()
})
function classLabel(name: string) {
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
}
const classOptions = computed(() =>
(data.value?.classes ?? []).map((item) => ({
value: item.className,
label: `${classLabel(item.className)}(${item.joined}/${item.size} 人加入)`,
})),
)
const joined = computed(() => data.value?.students.filter((s) => s.joinTime) ?? [])
const absent = computed(() => data.value?.students.filter((s) => !s.joinTime) ?? [])
const average = computed(() =>
joined.value.length
? joined.value.reduce((sum, s) => sum + s.completedCount, 0) / joined.value.length
: 0,
)
const finished = computed(
() => joined.value.filter((s) => data.value && s.completedCount >= data.value.totalCount).length,
)
const beforeCount = computed(() =>
joined.value.reduce((sum, s) => sum + s.cells.filter((cell) => cell.solvedBefore).length, 0),
)
const columns = computed(
() => `22px 72px 44px repeat(${data.value?.problems.length ?? 1}, minmax(72px, 1fr))`,
)
function name(student: { realName: string | null; username: string }) {
return student.realName || student.username
}
function cellLink(username: string, problemId: string) {
return `/submission?username=${encodeURIComponent(username)}&problem=${encodeURIComponent(problemId)}`
}
function cellTime(time: string) {
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
return parseTime(time, today ? "HH:mm" : "M/D")
}
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
const error = computed(() => tone("error"))
</script>
<template>
<section class="card">
<div v-if="data && !data.className" class="empty muted">还没有学生加入这个题单</div>
<template v-else-if="data">
<div class="summary">
<n-select
v-model:value="className"
:options="classOptions"
size="small"
class="pick"
:consistent-menu-width="false"
/>
<span
><b class="num big">{{ joined.length }}</b
><span class="muted"> / {{ data.students.length }} 人加入</span></span
>
<span
><span class="muted">平均做对</span> <b class="num big">{{ average.toFixed(1) }}</b
><span class="muted"> / {{ data.totalCount }} 道</span></span
>
<span
><span class="muted">做完</span> <b class="num big">{{ finished }}</b
><span class="muted"> 人</span></span
>
<span v-if="absent.length" class="absent ell" :title="absent.map(name).join('、')"
>没加入:{{ absent.map(name).join("、") }}</span
>
<div class="spacer"></div>
<span v-if="beforeCount" class="legend-before"
><span class="corner"></span>加入前就做对过({{ beforeCount }} 格)</span
>
</div>
<div class="scroll">
<div class="grid" :style="{ gridTemplateColumns: columns }">
<div class="head"></div>
<div class="head">姓名</div>
<div class="head right">做对</div>
<div v-for="(p, i) in data.problems" :key="p.id" class="head col" :title="p.title">
<b class="ell">{{ i + 1 }} {{ p.title }}</b>
<span class="num"
>{{ p.solved }} 人对<template v-if="!p.isRequired"> · 选做</template></span
>
</div>
<template v-for="(s, idx) in joined" :key="s.userId">
<div class="cell muted num tiny">{{ idx + 1 }}</div>
<div class="cell ell" :title="s.username">
<b>{{ name(s) }}</b>
</div>
<div class="cell right num">
<b>{{ s.completedCount }}</b>
</div>
<a
v-for="(c, i) in s.cells"
:key="i"
:href="cellLink(s.username, data.problems[i]!._id)"
target="_blank"
class="box"
:class="{ ok: c.solvedTime, bad: !c.solvedTime && c.wrongCount }"
:title="`${name(s)} · ${data.problems[i]!.title}${c.solvedBefore ? ' · 加入前就做对过' : ''}`"
>
<template v-if="c.solvedTime"
>✓ <span class="num">{{ cellTime(c.solvedTime) }}</span></template
>
<template v-else-if="c.wrongCount">错 {{ c.wrongCount }} 次</template>
<span v-if="c.solvedBefore" class="corner"></span>
</a>
</template>
</div>
</div>
<div class="foot muted">
<span>绿 = 在题单里做对(几点做对的)</span><span>黄 = 交过还没对</span
><span>紫色角 = 加入前就做对过</span><span>点格子看这个学生这道题的提交</span>
<div class="spacer"></div>
<span>按学号排</span>
</div>
</template>
<div v-else class="empty"><n-spin size="small" /></div>
</section>
</template>
<style scoped>
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
overflow: hidden;
}
.empty {
padding: 40px;
text-align: center;
}
.summary {
min-height: 52px;
box-sizing: border-box;
padding: 8px 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 20px;
font-size: 13px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.pick {
width: 240px;
}
.big {
font-size: 18px;
}
.absent {
max-width: 420px;
color: v-bind("error.color");
}
.legend-before {
display: inline-flex;
align-items: center;
gap: 6px;
color: #5b3fa8;
}
.legend-before .corner {
position: static;
}
.scroll {
overflow-x: auto;
}
.grid {
display: grid;
column-gap: 6px;
padding: 0 16px 8px;
}
.head {
position: sticky;
top: 0;
height: 44px;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.cardColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
margin-bottom: 4px;
}
.head.col {
align-items: center;
line-height: 16px;
min-width: 0;
overflow: hidden;
}
.head.col b {
max-width: 100%;
color: v-bind("theme.textColor2");
}
.right {
text-align: right;
align-items: flex-end;
padding-right: 8px;
}
.cell {
height: 32px;
display: flex;
align-items: center;
font-size: 14px;
min-width: 0;
}
.cell.right {
justify-content: flex-end;
}
.box {
position: relative;
height: 26px;
margin: 3px 0;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 12px;
text-decoration: none;
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor3");
}
.box.ok {
background: v-bind("success.background");
color: v-bind("success.color");
}
.box.bad {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.box:hover {
outline: 1px solid v-bind("theme.primaryColor");
}
.corner {
position: absolute;
top: 2px;
right: 3px;
width: 0;
height: 0;
border-top: 8px solid #7a5fd0;
border-left: 8px solid transparent;
}
.foot {
min-height: 30px;
box-sizing: border-box;
padding: 6px 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 12px;
border-top: 1px solid v-bind("theme.dividerColor");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getProblemSetLocks } from "oj/api"
import { parseTime } from "utils/functions"
import type { ProblemSetLock } from "utils/types"
/**
* 「旧代码为什么藏着」那一句(设计稿「题单重设计」旧代码藏着时学生看到的):
* 题单《X》布置中,这道题以前的代码先藏着 · 在题单里做对,或 M月D日 之后就能看。
* 规则在后端 problemSetLockCutoffs;查不到是哪个题单(比如刚过期)就说通用的一句
*/
const props = defineProps<{ problem: { id: number } | { displayId: string } }>()
const theme = useThemeVars()
const locks = ref<ProblemSetLock[]>([])
watch(
() => ("id" in props.problem ? props.problem.id : props.problem.displayId),
async () => {
try {
locks.value = await getProblemSetLocks(props.problem)
} catch {
locks.value = []
}
},
{ immediate: true },
)
const first = computed(() => locks.value[0])
</script>
<template>
<span v-if="first">
题单<router-link :to="{ name: 'problemset', params: { problemSetId: first.problemSetId } }"
>《{{ first.title }}》</router-link
>布置中,这道题以前的代码先藏着 · 在题单里做对,或
{{ parseTime(first.assignedUntil, "M月D日") }} 之后就能看
</span>
<span v-else>这道题正在题单里布置,以前的代码先藏着 · 在题单里做对,或者布置期结束就能看</span>
</template>
<style scoped>
a {
color: v-bind("theme.primaryColor");
text-decoration: none;
}
</style>
@@ -1,123 +0,0 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
import { parseTime } from "utils/functions"
import UserBadge from "shared/components/UserBadge.vue"
import { useUserStore } from "shared/store/user"
interface Props {
problemSet: ProblemSet
isJoined: boolean
isJoining: boolean
userBadges: UserBadgeType[]
}
interface Emits {
(e: "join"): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const userStore = useUserStore()
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
// 进度一律读 userProgress:它的分母是**必做题数**,而 problemsCount 是总题数。
// 拿总题数当分母的话,做完全部必做题的人会看到「9 / 10、90%」,而同一张卡片上
// 又标着「已完成」—— 题单 6 那 10 个人正是这种。
function getProgressPercentage() {
return Math.round(props.problemSet?.userProgress?.progressPercentage ?? 0)
}
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
const optionalCount = computed(
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
)
const endTimeText = computed(() =>
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
)
function handleJoin() {
emit("join")
}
</script>
<template>
<n-card style="margin-bottom: 24px">
<n-flex justify="space-between" align="center">
<n-flex align="center">
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<!-- 截止时间不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
所以这里要连着解释一句,否则学生只看到一个日期,不知道它管什么 -->
<n-tooltip trigger="hover" v-if="endTimeText">
<template #trigger>
<n-tag type="info">截止 {{ endTimeText }}</n-tag>
</template>
这个时间之前,你在加入题单之前提交过的代码是看不到的;在题单里做出该题即可解锁。
</n-tooltip>
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
<n-tooltip trigger="hover" v-if="problemSet.description">
<template #trigger>
<Icon width="20" icon="fluent-emoji:information" />
</template>
{{ problemSet.description }}
</n-tooltip>
</n-flex>
<n-flex align="center" v-if="userStore.isAuthed">
<!-- 用户徽章显示区域 - 只在已加入且有徽章时显示 -->
<n-flex v-if="isJoined && userBadges.length > 0" align="center">
<n-text>已获徽章</n-text>
<UserBadge v-for="badge in userBadges" :key="badge.id" :badge="badge" />
</n-flex>
<!-- 完成进度 - 只在已加入时显示 -->
<n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text>
<n-text>
{{ problemSet.userProgress?.completedCount ?? 0 }} /
{{ problemSet.userProgress?.totalCount ?? 0 }}
</n-text>
<n-text depth="3" v-if="optionalCount > 0"> (另有 {{ optionalCount }} 道选做) </n-text>
</n-flex>
<n-progress
v-if="isJoined"
:percentage="getProgressPercentage()"
:height="8"
:border-radius="4"
style="width: 200px"
/>
<n-button
v-if="!isJoined"
type="primary"
size="large"
:loading="isJoining"
@click="handleJoin"
>
加入题单
</n-button>
<n-tag v-else type="success" size="large">
<template #icon>
<Icon icon="ph:check-circle-fill" />
</template>
已加入
</n-tag>
</n-flex>
</n-flex>
</n-card>
</template>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSetProblem } from "utils/types"
import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
problems: ProblemSetProblem[]
isJoined: boolean
}
interface Emits {
(e: "problem-click", problemId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { isDesktop } = useBreakpoints()
function handleProblemClick(problemId: string) {
emit("problem-click", problemId)
}
</script>
<template>
<div>
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
<n-grid-item v-for="(problemSetProblem, index) in problems" :key="problemSetProblem.id">
<n-card
hoverable
@click="handleProblemClick(problemSetProblem.problem._id)"
style="cursor: pointer"
>
<n-flex align="center">
<Icon
style="margin-right: 10px"
width="48"
icon="fluent-emoji:check-mark-button"
v-if="problemSetProblem.isCompleted"
/>
<n-flex vertical style="flex: 1">
<n-flex align="center">
<n-h4 style="margin: 0">#{{ index + 1 }}</n-h4>
<n-h4 style="margin: 0">
{{ problemSetProblem.problem.title }}
</n-h4>
</n-flex>
<n-flex align="center" size="small">
<n-tag
v-if="problemSetProblem.problem.difficulty"
:type="getTagColor(problemSetProblem.problem.difficulty)"
size="small"
>
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag>
<n-text type="info">分数:{{ problemSetProblem.score }}</n-text>
<n-text v-if="!problemSetProblem.isRequired">(选做)</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<div class="tip">
<n-text depth="3">题目完成后会自动返回题单页面</n-text>
</div>
</div>
</template>
<style scoped>
.tip {
padding-top: 24px;
text-align: center;
}
</style>
@@ -1,267 +0,0 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { h, computed, ref, onMounted, watch } from "vue"
import { watchDebounced } from "@vueuse/core"
import { parseTime } from "utils/functions"
import type { ProblemSetProgress } from "utils/types"
import { getProblemSetUserProgress } from "../../api"
import { NFlex, NTag } from "naive-ui"
import { usePagination } from "shared/composables/pagination"
import Pagination from "shared/components/Pagination.vue"
const route = useRoute()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const progress = ref<ProblemSetProgress[]>([])
const loading = ref(false)
const total = ref(0)
const statistics = ref<{
total: number
completed: number
avgProgress: number
} | null>(null)
const classFilter = ref<string>("")
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">("")
const allProblems = ref<Array<{ id: number; _id: string; title: string }>>([])
// 完成度筛选选项
const completionOptions = [
{ label: "全部", value: "" },
{ label: "未开始", value: "not_started" },
{ label: "进行中", value: "in_progress" },
{ label: "已完成", value: "completed" },
]
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
// 加载用户进度数据
async function loadUserProgress() {
loading.value = true
const offset = (query.page - 1) * query.limit
const params: {
limit?: number
offset?: number
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
} = {
limit: query.limit,
offset,
}
if (classFilter.value.trim()) {
params.className = classFilter.value.trim()
}
if (completionFilter.value) {
params.completionStatus = completionFilter.value
}
try {
const res = await getProblemSetUserProgress(problemSetId.value, params)
progress.value = res.results
total.value = res.total
// 使用后端返回的统计数据(基于所有数据)
if (res.statistics) {
statistics.value = res.statistics
}
// 保存所有题目信息
if (res.problems) {
allProblems.value = res.problems
}
} catch (err) {
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
// 请求一失败(403、断网)转圈就永远停不下来
message.error("加载用户进度失败:" + errorMessage(err, "未知错误"))
} finally {
loading.value = false
}
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], loadUserProgress)
// 监听班级过滤变化(防抖)
watchDebounced(
classFilter,
() => {
query.page = 1 // 重置到第一页
loadUserProgress()
},
{ debounce: 500 },
)
// 监听完成度筛选变化
watch(completionFilter, () => {
query.page = 1 // 重置到第一页
loadUserProgress()
})
// 使用后端返回的统计数据
const stats = computed(() => {
if (statistics.value) {
return {
total: statistics.value.total,
completed: statistics.value.completed,
avgProgress: Math.round(statistics.value.avgProgress),
}
}
// 如果后端还没有返回统计数据,使用默认值
return {
total: total.value,
completed: 0,
avgProgress: 0,
}
})
onMounted(loadUserProgress)
// 定义表格列
const progressColumns = [
{
title: "排名",
key: "rank",
width: 80,
render: (_row: ProblemSetProgress, index: number) => {
// 计算全局排名:当前页偏移 + 当前行索引 + 1
const globalRank = (query.page - 1) * query.limit + index + 1
return globalRank
},
},
{
title: "用户",
key: "user.username",
width: 120,
render: (row: ProblemSetProgress) => row.user.username,
},
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "已完成数量",
key: "completedProblemsCount",
width: 100,
},
{
title: "已/未完成题目",
key: "completed_problems",
width: 300,
render: (row: ProblemSetProgress) => {
if (row.progressPercentage === 100) {
return "全部题目已完成"
}
if (row.progressPercentage > 50 && row.progressPercentage < 100) {
const completedProblemIds = new Set(row.completedProblems.map((p: any) => p.id))
const incompleteProblems = allProblems.value.filter((p) => !completedProblemIds.has(p.id))
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
incompleteProblems.map((problem) =>
h(
NTag,
{ type: "warning", size: "small", style: "margin: 2px" },
() => `${problem._id}: ${problem.title}`,
),
),
),
])
}
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
row.completedProblems.map((problem: any) =>
h(
NTag,
{
type: "success",
size: "small",
style: "margin: 2px",
},
() => `${problem._id}: ${problem.title}`,
),
),
),
])
},
},
{
title: "进度",
key: "progress_percentage",
width: 120,
render: (row: ProblemSetProgress) => {
return `${row.progressPercentage.toFixed(0)}%`
},
},
{
title: "状态",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => {
if (row.isCompleted) {
return h(NTag, { type: "success" }, () => "已完成")
} else {
return h(NTag, { type: "warning" }, () => "进行中")
}
},
},
]
</script>
<template>
<div>
<!-- 过滤条件 -->
<n-form label-placement="left" inline>
<n-form-item label="班级">
<n-input
v-model:value="classFilter"
placeholder="输入班级名称"
style="width: 200px"
clearable
/>
</n-form-item>
<n-form-item label="完成度:">
<n-select
v-model:value="completionFilter"
:options="completionOptions"
placeholder="完成度"
style="width: 160px"
clearable
/>
</n-form-item>
</n-form>
<!-- 统计信息卡片 -->
<n-grid :cols="3" :x-gap="16" style="margin-bottom: 16px">
<n-grid-item>
<n-card size="small">
<n-statistic label="总参与人数" :value="stats.total" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="已完成人数" :value="stats.completed" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="平均进度" :value="stats.avgProgress.toFixed(0) + '%'" />
</n-card>
</n-grid-item>
</n-grid>
<n-data-table
:loading="loading"
:columns="progressColumns"
:data="progress"
:pagination="false"
:bordered="false"
:single-line="false"
/>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</div>
</template>
+611 -104
View File
@@ -1,130 +1,637 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet, getUserBadges } from "../api" import { parseTime } from "utils/functions"
import type { ProblemSet, ProblemSetProblem, UserBadge as UserBadgeType } from "utils/types" import type { ProblemSet, ProblemSetProblem } from "utils/types"
import { useFireworks } from "../problem/composables/useFireworks" import { useAuthModalStore } from "shared/store/authModal"
import ProblemSetHeader from "./components/ProblemSetHeader.vue"
import ProblemSetProblemsList from "./components/ProblemSetProblemsList.vue"
import UserProgressView from "./components/UserProgressView.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { useFireworks } from "../problem/composables/useFireworks"
import BadgeLadder from "./components/BadgeLadder.vue"
import ClassView from "./components/ClassView.vue"
/**
* 题单详情(设计稿「题单重设计」详情 B):顶上一条是进度和奖章阶梯,下面题目分两列。
*
* - 还没加入:题目看得见、点不进去,右边一个大按钮「加入题单,开始做」。加入是用户要留的
* 一步(「只是打开就算加入的话,会有疑惑的」),所以这里要让人一眼看懂为什么要点。
* - 布置期内以前做对过的题标「以前做对过 · 旧代码先藏着」,规则见后端 problemSetLockCutoffs。
* - 做完了:整条变绿,写几点做完、用了多久。烟花只在做完的那一刻放(做对最后一道、
* 1.5 秒后跳回来的那一下),以后再打开不放。
* - 老师多一个「全班情况」页签。
*/
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const theme = useThemeVars()
const { celebrate } = useFireworks() const tone = useTone()
const userStore = useUserStore() const userStore = useUserStore()
const authStore = useAuthModalStore()
const { celebrate } = useFireworks()
const problemSetId = computed(() => Number(route.params.problemSetId)) const id = computed(() => Number(route.params.problemSetId))
const set = ref<ProblemSet | null>(null)
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([]) const problems = ref<ProblemSetProblem[]>([])
const isJoined = ref(false) const missing = ref(false)
const isJoining = ref(false) const joining = ref(false)
const userBadges = ref<UserBadgeType[]>([]) const tab = useRouteQuery<string>("tab", "problems")
const activeTab = ref("problems")
async function loadProblemSetDetail() { async function load() {
const res = await getProblemSetDetail(problemSetId.value) missing.value = false
problemSet.value = res try {
isJoined.value = res.userProgress?.isJoined || false const [detail, list] = await Promise.all([
} getProblemSetDetail(id.value),
getProblemSetProblems(id.value),
async function loadProblems() { ])
const res = await getProblemSetProblems(problemSetId.value) set.value = detail
problems.value = res problems.value = list
} } catch {
missing.value = true
async function loadUserBadges() { return
if (!isJoined.value) return }
const done = set.value.userProgress.completeTime
const res = await getUserBadges() if (set.value.userProgress.isCompleted && done && Date.now() - Date.parse(done) < 60_000) {
userBadges.value = res.filter(
(badge: UserBadgeType) => badge.badge.problemSetId === problemSetId.value,
)
}
async function init() {
await Promise.all([loadProblemSetDetail(), loadProblems()])
if (isJoined.value) {
if (problemSet.value?.userProgress?.isCompleted) {
celebrate() celebrate()
} }
loadUserBadges()
}
} }
async function handleProblemClick(problemId: string) { watch(id, load)
if (!userStore.isAuthed) { onMounted(load)
message.warning("请先登录!")
return const progress = computed(() => set.value?.userProgress)
} const joined = computed(() => !!progress.value?.isJoined)
if (!isJoined.value) { /** 老师不用加入也能点进题目去看(不记进度),也不给他摆「加入」按钮 */
message.warning("请先点击【加入题单】按钮!") const canOpen = computed(() => joined.value || userStore.isTeacherOrAbove)
return const optional = computed(() => (set.value ? set.value.problemsCount - set.value.requiredCount : 0))
} const percent = computed(() =>
router.push({ set.value && progress.value?.isJoined && set.value.requiredCount
? Math.min(100, (progress.value.completedCount / set.value.requiredCount) * 100)
: 0,
)
const hiddenCount = computed(() => problems.value.filter((item) => item.oldCodeHidden).length)
/** 做完用了多久:从加入到做完 */
const spent = computed(() => {
const p = progress.value
if (!p?.joinTime || !p.completeTime) return ""
const minutes = Math.round((Date.parse(p.completeTime) - Date.parse(p.joinTime)) / 60_000)
if (minutes < 60) return `${Math.max(1, minutes)} 分钟`
if (minutes < 24 * 60) return `${Math.round(minutes / 60)} 小时`
return `${Math.round(minutes / 1440)} 天`
})
const columns = computed(() => {
const half = Math.ceil(problems.value.length / 2)
return [problems.value.slice(0, half), problems.value.slice(half)].filter((col) => col.length)
})
function indexOf(item: ProblemSetProblem) {
return problems.value.indexOf(item) + 1
}
function solvedAt(time: string) {
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
return parseTime(time, today ? "HH:mm" : "M月D日")
}
function problemLink(item: ProblemSetProblem) {
return {
name: "problemset problem", name: "problemset problem",
params: { params: { problemSetId: id.value, problemID: item.problem._id },
problemSetId: problemSetId.value,
problemID: problemId,
},
})
}
async function handleJoinProblemSet() {
if (isJoining.value) return
isJoining.value = true
try {
await joinProblemSet(problemSetId.value)
isJoined.value = true
message.success("成功加入题单!")
// 加入题单后加载用户徽章
await loadUserBadges()
} catch (err) {
message.error("加入题单失败:" + errorMessage(err, "未知错误"))
} finally {
isJoining.value = false
} }
} }
// 「用户进度」那一栏调的是 requireTeacher 的接口(Teacher Admin | Super Admin), async function join() {
// 所以这里的条件必须跟它一致。以前写的是「超管 或 自己完成了题单」,两边正好错开: if (!userStore.isAuthed) {
// 学生做完题单会看到这一栏、点进去 403;而真正该用它的 Teacher Admin 反倒看不到。 authStore.openLoginModal()
const showTabs = computed(() => userStore.isTeacherOrAbove) return
}
if (joining.value) return
joining.value = true
try {
await joinProblemSet(id.value)
message.success("加入了,现在开始在这里做对的题都会记进度")
await load()
} catch (err) {
message.error(errorMessage(err, "加入失败"))
} finally {
joining.value = false
}
}
onMounted(init) /** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
const canEdit = computed(
() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id),
)
const info = computed(() => tone("info"))
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
</script> </script>
<template> <template>
<div v-if="problemSet"> <n-result
<ProblemSetHeader v-if="missing"
:problem-set="problemSet" status="404"
:is-joined="isJoined" title="题单不存在"
:is-joining="isJoining" description="可能还没公开,或者已经删掉了"
:user-badges="userBadges" >
@join="handleJoinProblemSet" <template #footer>
/> <n-button @click="router.push({ name: 'problemsets' })">回到题单列表</n-button>
<n-tabs v-if="showTabs" v-model:value="activeTab" animated> </template>
<n-tab-pane name="problems" tab="题目列表"> </n-result>
<ProblemSetProblemsList
:problems="problems" <div v-else-if="set" class="page">
:is-joined="isJoined" <section class="band" :class="{ done: progress?.isCompleted }">
@problem-click="handleProblemClick" <span v-if="progress?.isCompleted" class="done-mark" aria-hidden="true">✓</span>
/> <div class="head">
</n-tab-pane> <div class="row">
<n-tab-pane name="progress" tab="用户进度"> <router-link :to="{ name: 'problemsets' }" class="muted back">‹ 题单</router-link>
<UserProgressView /> <h2 class="ell">{{ set.title }}</h2>
</n-tab-pane> <span v-if="set.assigning" class="pill assign"
</n-tabs> >布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span
<ProblemSetProblemsList >
v-else </div>
:problems="problems" <template v-if="progress?.isCompleted">
:is-joined="isJoined" <div class="done-text">
@problem-click="handleProblemClick" <b>全部做完了</b> · {{ parseTime(progress.completeTime!, "M月D日 HH:mm") }}
/> <template v-if="spent">,加入后用了 {{ spent }}</template>
</div>
</template>
<template v-else-if="joined">
<div class="row baseline">
<span class="muted small">做对</span>
<span class="num count">{{ progress!.completedCount }}</span>
<span class="muted num">/ {{ set.requiredCount }} 道</span>
<span class="muted tiny gap">
<template v-if="optional > 0">另有 {{ optional }} 道选做 · </template>
{{ parseTime(progress!.joinTime!, "M月D日 HH:mm") }} 加入
</span>
</div>
<div class="bar"><div class="fill" :style="{ width: `${percent}%` }"></div></div>
</template>
<div v-else class="muted small">
{{ set.createdBy.username }} 出的 · {{ set.problemsCount }} 道<template
v-if="optional > 0"
>,{{ optional }} 道选做</template
><template v-else>,全部必做</template>
<template v-if="!set.badges.length"> · 这个题单没有奖章</template>
</div>
<div v-if="set.description && set.description !== set.title" class="sec small desc">
{{ set.description }}
</div>
</div>
<template v-if="set.badges.length">
<span class="divider"></span>
<div class="ladder-box"><BadgeLadder :set="set" /></div>
</template>
<div class="spacer"></div>
<div v-if="!joined && !userStore.isTeacherOrAbove" class="join">
<n-button type="primary" size="large" :loading="joining" @click="join">
加入题单,开始做
</n-button>
<span class="muted tiny">加入以后,在题单里做对的题才算进度</span>
</div>
</section>
<div v-if="!joined && hiddenCount" class="notice" :style="purple">
<svg
class="lock"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
其中 <b>{{ hiddenCount }}</b> 道你以前做对过。布置期内(到
{{
parseTime(set.assignedUntil!, "M月D日")
}})这几道的旧代码先藏起来,在题单里重新做对就能看。
</div>
<div v-if="userStore.isTeacherOrAbove" class="tabs">
<button class="seg" :class="{ on: tab !== 'class' }" @click="tab = 'problems'">题目</button>
<button class="seg" :class="{ on: tab === 'class' }" @click="tab = 'class'">全班情况</button>
<div class="spacer"></div>
<n-button
v-if="canEdit"
size="small"
@click="router.push({ name: 'admin problemset edit', params: { problemSetId: set.id } })"
>
编辑题单
</n-button>
</div>
<ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" />
<template v-else>
<div v-if="problems.length" class="cols">
<section v-for="(col, ci) in columns" :key="ci" class="card list">
<component
:is="canOpen ? 'router-link' : 'div'"
v-for="item in col"
:key="item.id"
:to="canOpen ? problemLink(item) : undefined"
class="prow"
:class="{ off: !canOpen }"
>
<span v-if="item.isCompleted" class="dot done-dot">✓</span>
<span v-else-if="item.wrongCount" class="dot tried-dot">!</span>
<span v-else class="dot empty-dot"></span>
<span class="muted num idx">{{ indexOf(item) }}</span>
<span class="ell ptitle">{{ item.problem.title }}</span>
<span v-if="!item.isRequired" class="opt">选做</span>
<span v-if="item.oldCodeHidden" class="old" :style="purple"
><svg
class="lock"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg
>以前做对过 · 旧代码先藏着</span
>
<div class="spacer"></div>
<span v-if="item.isCompleted && item.solvedTime" class="muted num tiny"
>{{ solvedAt(item.solvedTime) }} 做对</span
>
<span v-else-if="item.wrongCount && joined" class="tiny tried"
>交过 {{ item.wrongCount }} 次</span
>
<span v-else-if="canOpen" class="go">去做 ›</span>
</component>
</section>
</div>
<n-empty v-else description="这个题单还没有题目" />
<div v-if="joined && problems.length" class="muted tiny foot">做对一道会自动回到这里</div>
</template>
</div> </div>
</template> </template>
<style scoped></style> <style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.band {
box-sizing: border-box;
padding: 14px 20px 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 24px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.band.done {
background: v-bind("success.background");
border-color: transparent;
}
.done-mark {
width: 48px;
height: 48px;
border-radius: 50%;
flex-shrink: 0;
background: v-bind("theme.successColor");
color: #ffffff;
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.done-text {
font-size: 14px;
color: v-bind("success.color");
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
width: 380px;
max-width: 100%;
min-width: 0;
}
.head h2 {
margin: 0;
font-size: 20px;
}
.desc {
line-height: 1.5;
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.row.baseline {
align-items: baseline;
gap: 6px;
}
.back {
flex-shrink: 0;
font-size: 13px;
text-decoration: none;
}
.count {
font-size: 24px;
font-weight: 700;
}
.gap {
margin-left: 6px;
}
.bar {
height: 8px;
border-radius: 4px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fill {
height: 100%;
background: v-bind("theme.successColor");
}
.divider {
width: 1px;
align-self: stretch;
background: v-bind("theme.dividerColor");
}
.ladder-box {
max-width: 100%;
overflow-x: auto;
}
.join {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.lock {
flex-shrink: 0;
vertical-align: -1px;
}
.old .lock {
margin-right: 3px;
}
.notice {
box-sizing: border-box;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
}
.tabs {
display: flex;
align-items: center;
}
.seg {
height: 30px;
padding: 0 14px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.seg:first-child {
border-radius: 4px 0 0 4px;
}
.seg + .seg {
border-left: none;
border-radius: 0 4px 4px 0;
}
.seg.on {
color: v-bind("success.color");
background: v-bind("success.background");
font-weight: 600;
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 12px;
align-items: start;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
overflow: hidden;
}
.list {
display: flex;
flex-direction: column;
}
.prow {
height: 40px;
flex-shrink: 0;
box-sizing: border-box;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor1");
text-decoration: none;
}
.prow:last-child {
border-bottom: none;
}
a.prow:hover {
background: v-bind("theme.hoverColor");
}
.prow.off {
color: v-bind("theme.textColor3");
}
.dot {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.done-dot {
background: v-bind("theme.successColor");
color: #ffffff;
}
.tried-dot {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.empty-dot {
border: 1.5px dashed v-bind("theme.borderColor");
}
.idx {
width: 20px;
text-align: right;
font-size: 13px;
flex-shrink: 0;
}
.ptitle {
font-size: 14px;
}
.opt {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
border: 1px solid v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.old {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.tried {
color: v-bind("warning.color");
}
.go {
font-size: 13px;
color: v-bind("theme.primaryColor");
flex-shrink: 0;
}
.foot {
text-align: center;
}
.pill {
height: 22px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.assign {
color: v-bind("info.color");
background: v-bind("info.background");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: 13px;
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.cols {
grid-template-columns: minmax(0, 1fr);
}
.head {
width: 100%;
}
.divider {
display: none;
}
.join {
align-items: stretch;
width: 100%;
}
}
</style>
+353 -212
View File
@@ -1,263 +1,404 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouteQuery } from "@vueuse/router" import { useThemeVars } from "naive-ui"
import { getProblemSetList } from "../api" import { getProblemSetList } from "oj/api"
import { rgba, useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types" import type { ProblemSet } from "utils/types"
import Pagination from "shared/components/Pagination.vue" import { badgeCondition, ladder, nextBadge } from "./badges"
import { usePagination } from "shared/composables/pagination"
/**
* 题单列表(设计稿「题单重设计」列表 A):老师正在布置的题单单独一排大卡片 —— 做对几道、
* 布置到哪天、再做对几道拿哪个奖章;其余题单一排排小卡片,加入过的在前。
*
* 题单一共十几个,一次全取回来在前端分组,不分页
*/
const router = useRouter() const router = useRouter()
const theme = useThemeVars()
const tone = useTone()
const total = ref(0) const sets = ref<ProblemSet[]>([])
const problemSets = ref<ProblemSet[]>([]) const loaded = ref(false)
const keyword = ref("")
interface ProblemSetQuery { onMounted(async () => {
keyword: string try {
} sets.value = (await getProblemSetList(0, 250)).results
} finally {
// 使用分页 composable loaded.value = true
const { query, clearQuery } = usePagination<ProblemSetQuery>(
{
keyword: useRouteQuery("keyword", "").value,
},
{
defaultLimit: 30,
},
)
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)
total.value = res.total
problemSets.value = res.results
}
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
} }
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function goToProblemSet(problemSetId: number) {
router.push(`/problemset/${problemSetId}`)
}
function getConditionText(conditionType: string, conditionValue: number): string {
const conditionMap: Record<string, string> = {
all_problems: "完成所有题目",
problem_count: `完成 ${conditionValue} 道题目`,
score: `达到 ${conditionValue} 分`,
}
return conditionMap[conditionType] || "未知条件"
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
}) })
// 监听其他查询条件变化 const shown = computed(() => {
watch(() => [query.page, query.limit], listProblemSets) const word = keyword.value.trim().toLowerCase()
if (!word) return sets.value
return sets.value.filter(
(set) => set.title.toLowerCase().includes(word) || set.description.toLowerCase().includes(word),
)
})
/** 布置中的:快到期的在前 */
const assigning = computed(() =>
shown.value
.filter((set) => set.assigning)
.sort((a, b) => Date.parse(a.assignedUntil!) - Date.parse(b.assignedUntil!)),
)
/** 其他:加入过的在前(最近加入的最前),再按新建排 */
const others = computed(() =>
shown.value
.filter((set) => !set.assigning)
.sort((a, b) => {
const ja = a.userProgress.joinTime
const jb = b.userProgress.joinTime
if (ja && jb) return Date.parse(jb) - Date.parse(ja)
if (ja || jb) return ja ? -1 : 1
return Date.parse(b.createTime) - Date.parse(a.createTime)
}),
)
const joinedOthers = computed(() => others.value.filter((set) => set.userProgress.isJoined).length)
function description(set: ProblemSet) {
// 简介大多就是把标题再抄一遍,一样的就不重复显示
return set.description && set.description !== set.title ? set.description : ""
}
function optional(set: ProblemSet) {
return set.problemsCount - set.requiredCount
}
function percent(set: ProblemSet) {
const progress = set.userProgress
return progress.isJoined && set.requiredCount
? Math.min(100, (progress.completedCount / set.requiredCount) * 100)
: 0
}
function open(set: ProblemSet) {
router.push({ name: "problemset", params: { problemSetId: set.id } })
}
const info = computed(() => tone("info"))
const success = computed(() => tone("success"))
// 做完的卡片只淡淡地染一点绿(设计稿),用 success.background 那一档太抢眼
const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
</script> </script>
<template> <template>
<n-flex vertical size="large"> <div class="page">
<!-- 难度和状态两个筛选器撤了:线上 16 个题单全是 Easy / active,选「中等」「困难」 <div class="top">
「已归档」永远是空列表。接口那两个 query 参数还在,哪天真的用起这两个字段, <h2>题单</h2>
把 select 加回来即可。 --> <div class="spacer"></div>
<n-space> <n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
<n-input
v-model:value="query.keyword"
placeholder="搜索题单..."
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-space>
<div v-if="problemSets.length > 0" class="set-grid">
<div
v-for="problemSet in problemSets"
:key="problemSet.id"
class="set-card"
:class="{ completed: problemSet.userProgress?.isCompleted }"
@click="goToProblemSet(problemSet.id)"
>
<n-flex justify="space-between" align="center" :wrap="false" :size="8">
<span class="set-title">{{ problemSet.title }}</span>
<n-tag v-if="problemSet.userProgress?.isCompleted" type="success" size="small" round>
已完成
</n-tag>
<n-tag v-else-if="problemSet.userProgress?.isJoined" type="info" size="small" round>
进行中
</n-tag>
</n-flex>
<!-- 简介大多就是把标题再抄一遍,一样的就不重复显示 -->
<n-text
v-if="problemSet.description && problemSet.description !== problemSet.title"
depth="3"
class="set-desc"
>
{{ problemSet.description }}
</n-text>
<div class="set-progress">
<n-flex justify="space-between" align="center" class="set-progress-text">
<n-text depth="3">
<!-- 进度是加入/完成时存下的快照,分母用快照自己的 totalCount:题单后来
加了题的话,拿现在的 problemsCount 去除,就会出现「已完成」却是 5 / 6 -->
<template v-if="problemSet.userProgress?.isJoined">
已完成 {{ problemSet.userProgress.completedCount }} /
{{ problemSet.userProgress.totalCount }} 题
<template v-if="problemSet.problemsCount > problemSet.userProgress.totalCount">
· 新加了
{{ problemSet.problemsCount - problemSet.userProgress.totalCount }} 题
</template>
</template>
<template v-else>共 {{ problemSet.problemsCount }} 题 · 还没开始</template>
</n-text>
<!-- 线上题单全是简单难度,只有不是简单时才值得标出来 -->
<n-tag
v-if="problemSet.difficulty !== 'Easy'"
:type="getDifficultyTag(problemSet.difficulty).type"
size="small"
:bordered="false"
>
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<n-tag v-if="problemSet.status === 'archived'" size="small" :bordered="false">
已归档
</n-tag>
</n-flex>
<n-progress
type="line"
:percentage="Math.round(problemSet.userProgress?.progressPercentage ?? 0)"
:show-indicator="false"
:height="6"
status="success"
/>
</div> </div>
<n-flex v-if="problemSet.badges?.length" align="center" :size="6" class="set-badges"> <template v-if="assigning.length">
<n-text depth="3" class="set-badges-label"> <div class="sect">
徽章 {{ problemSet.badges.filter((b) => b.isEarned).length }} / 布置中<span class="muted">老师现在布置的 · {{ assigning.length }} 个</span>
{{ problemSet.badges.length }} </div>
</n-text> <div class="big-grid">
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover"> <a
<template #trigger> v-for="set in assigning"
<n-image :key="set.id"
:href="`/problemset/${set.id}`"
class="card big"
:class="{ done: set.userProgress.isCompleted }"
@click.prevent="open(set)"
>
<div class="row">
<b class="ell title">{{ set.title }}</b>
<span v-if="set.userProgress.isCompleted" class="pill done-pill">✓ 做完了</span>
</div>
<div class="muted ell desc">{{ description(set) }}</div>
<div class="row baseline">
<template v-if="set.userProgress.isJoined">
<span class="num count">{{ set.userProgress.completedCount }}</span>
<span class="muted num">/ {{ set.requiredCount }}</span>
</template>
<span v-else class="muted small">还没加入 · {{ set.requiredCount }} 道</span>
<span v-if="optional(set) > 0" class="muted tiny">另有 {{ optional(set) }} 道选做</span>
<div class="spacer"></div>
<span class="pill assign">布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span>
</div>
<div class="bar"><div class="fill" :style="{ width: `${percent(set)}%` }"></div></div>
<div v-if="nextBadge(set)" class="row hint">
<img :src="nextBadge(set)!.badge.icon" alt="" class="icon locked" />
<span>
再做对 <b class="num">{{ nextBadge(set)!.left }}</b> 道拿「{{
nextBadge(set)!.badge.name
}}」
</span>
</div>
<div v-else-if="set.badges.length" class="row hint got">
<img
v-for="badge in ladder(set)"
:key="badge.id"
:src="badge.icon" :src="badge.icon"
:alt="badge.name" :alt="badge.name"
width="24" :title="badge.name"
height="24" class="icon"
object-fit="cover"
preview-disabled
:class="badge.isEarned ? 'earned-badge' : 'locked-badge'"
/> />
</template> <span>奖章全拿到了</span>
<n-flex vertical size="small">
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span>
<span>
获取条件:
{{ getConditionText(badge.conditionType, badge.conditionValue) }}
</span>
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text>
</n-flex>
</n-tooltip>
</n-flex>
</div> </div>
<div v-else class="muted hint">这个题单没有奖章</div>
</a>
</div>
</template>
<div v-if="others.length" class="sect">
{{ assigning.length ? "其他题单" : "题单" }}
<span class="muted"
>{{ others.length }} 个<template v-if="joinedOthers"> · 加入过的在前</template></span
>
</div>
<div class="small-grid">
<a
v-for="set in others"
:key="set.id"
:href="`/problemset/${set.id}`"
class="card small-card"
:class="{ done: set.userProgress.isCompleted }"
@click.prevent="open(set)"
>
<div class="row">
<b class="ell">{{ set.title }}</b>
<span v-if="set.userProgress.isCompleted" class="green tiny">✓</span>
</div>
<div class="row tiny">
<span v-if="set.userProgress.isJoined" class="num">
做对 <b>{{ set.userProgress.completedCount }}</b> / {{ set.requiredCount }}
</span>
<span v-else class="muted num"
>{{ set.problemsCount }} 道 · {{ set.joinedCount }} 人做过</span
>
<div class="spacer"></div>
<img
v-for="badge in ladder(set)"
:key="badge.id"
:src="badge.icon"
:alt="badge.name"
:title="`${badge.name} · ${badgeCondition(badge)}${badge.isEarned ? ' · 已拿到' : ''}`"
class="mini"
:class="{ locked: !badge.isEarned }"
/>
</div>
</a>
</div> </div>
<Pagination <n-empty
v-if="problemSets.length > 0" v-if="loaded && !shown.length"
:total="total" :description="keyword ? '没有找到这样的题单' : '还没有题单'"
v-model:limit="query.limit"
v-model:page="query.page"
/> />
</n-flex> </div>
<n-empty v-if="problemSets.length === 0"></n-empty>
</template> </template>
<style scoped> <style scoped>
.set-grid { .page {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.set-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.top h2 {
margin: 0;
font-size: 20px;
}
.search {
width: 220px;
}
.spacer {
flex-grow: 1;
}
.sect {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: v-bind("theme.textColor2");
}
.sect .muted {
font-weight: 400;
}
.muted {
color: v-bind("theme.textColor3");
}
.green {
color: v-bind("success.color");
}
.small {
font-size: 13px;
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.row.baseline {
align-items: baseline;
}
.big-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.small-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px; gap: 10px;
padding: 16px 18px; }
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2); .card {
cursor: pointer; box-sizing: border-box;
min-width: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
text-decoration: none;
transition: transition:
border-color 0.2s, border-color 0.2s,
box-shadow 0.2s; box-shadow 0.2s;
} }
.set-card:hover { .card:hover {
border-color: #18a058; border-color: v-bind("theme.primaryColor");
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
} }
.set-card.completed { .card.done {
background-color: rgba(24, 160, 88, 0.05); background: v-bind("doneBackground");
} }
.set-title { .big {
font-size: 16px; padding: 14px 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.set-desc {
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.set-progress {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
margin-top: auto;
} }
.set-progress-text { .title {
font-size: 13px; flex-grow: 1;
font-size: 16px;
} }
.set-badges-label { .desc {
height: 17px;
font-size: 12px; font-size: 12px;
margin-right: 2px;
} }
.earned-badge { .count {
border: 2px solid #ffd700; font-size: 22px;
border-radius: 50%; font-weight: 700;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
} }
/* 没拿到的徽章压成灰色:原来拿没拿到只差一圈金边,一排看下去分不清 */ .pill {
.locked-badge { height: 22px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.assign {
color: v-bind("info.color");
background: v-bind("info.background");
}
.done-pill {
color: v-bind("success.color");
background: v-bind("success.background");
font-weight: 600;
}
.bar {
height: 6px;
border-radius: 3px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fill {
height: 100%;
background: v-bind("theme.successColor");
}
.hint {
height: 20px;
font-size: 12px;
color: v-bind("theme.textColor2");
}
.hint.got {
gap: 4px;
color: v-bind("success.color");
}
.hint.got span {
margin-left: 4px;
}
.icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.mini {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.locked {
filter: grayscale(1); filter: grayscale(1);
opacity: 0.45; opacity: 0.4;
}
.small-card {
height: 74px;
padding: 10px 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.small-card b {
font-size: 14px;
} }
</style> </style>
+1 -1
View File
@@ -62,7 +62,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
* 1.5 秒后跳回题单,都只认这一下 —— 进页面读到以前的 A 不该再跳一次 * 1.5 秒后跳回题单,都只认这一下 —— 进页面读到以前的 A 不该再跳一次
*/ */
const evaluatedSeq = ref(0) const evaluatedSeq = ref(0)
/** 这道题评完的每一次,早的在前;hidden = 加入题单之前、被藏起来的次数 */ /** 这道题评完的每一次,早的在前;hidden = 题单布置期内被藏起来的次数 */
const scores = ref<FlowchartScores["scores"]>([]) const scores = ref<FlowchartScores["scores"]>([])
const hiddenCount = ref(0) const hiddenCount = ref(0)
const submissionCount = computed(() => scores.value.length) const submissionCount = computed(() => scores.value.length)
@@ -202,7 +202,9 @@ const canRetry = computed(
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" /> <Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 --> <!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 -->
<span class="locked-title">这张流程图看不到</span> <span class="locked-title">这张流程图看不到</span>
<span class="muted">别人画的看不到;自己的在题单里做完这道题就能看到。</span> <span class="muted"
>别人画的看不到;自己以前画的在题单布置期内先藏着,在题单里做对或者布置期结束就能看到。</span
>
</div> </div>
<div v-else class="body"> <div v-else class="body">
<div class="graph-box"> <div class="graph-box">
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import LockNote from "oj/problemset/components/LockNote.vue"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api" import { getSubmission } from "oj/api"
@@ -388,8 +389,8 @@ function openStandalone() {
</n-button> </n-button>
</template> </template>
<template v-else-if="locked === 'problemset'"> <template v-else-if="locked === 'problemset'">
<span class="locked-title">这道题在你加入的题单里</span> <span class="locked-title">这份代码在题单布置期内先藏着</span>
<span class="muted">加入题单之前交的代码先藏起来,在题单里做完这道题就能看到。</span> <span class="muted"><LockNote :problem="{ displayId: row.problemDisplayId }" /></span>
</template> </template>
<template v-else> <template v-else>
<span class="locked-title">登录之后才能看代码</span> <span class="locked-title">登录之后才能看代码</span>
@@ -3,7 +3,7 @@ import { useDark } from "@vueuse/core"
export type Tone = "success" | "error" | "warning" | "info" | "default" export type Tone = "success" | "error" | "warning" | "info" | "default"
function rgba(hex: string, alpha: number) { export function rgba(hex: string, alpha: number) {
const value = hex.replace("#", "") const value = hex.replace("#", "")
const full = const full =
value.length === 3 value.length === 3
+5 -1
View File
@@ -968,7 +968,11 @@ function dayBreak(index: number) {
<span <span
v-if="!row.showLink" v-if="!row.showLink"
class="lock-hint" class="lock-hint"
:title="othersRow(row) ? '别人的代码看不到' : '在题单里做完才能看'" :title="
othersRow(row)
? '别人的代码看不到'
: '题单布置期内先藏着:在题单里做对,或者布置期结束就能看'
"
> >
<Icon icon="ph:lock-simple" :width="13" /> <Icon icon="ph:lock-simple" :width="13" />
</span> </span>
+5 -3
View File
@@ -337,12 +337,14 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
// 原来的「详情」页(题目 / 奖章 / 进度三个页签)并进了一屏编辑,老链接转过去
{ {
path: "problemset/:problemSetId", path: "problemset/:problemSetId",
name: "admin problemset detail", name: "admin problemset detail",
component: () => import("admin/problemset/detail.vue"), redirect: (to) => ({
props: true, name: "admin problemset edit",
meta: { requiresTeacherAdmin: true }, params: { problemSetId: to.params.problemSetId },
}),
}, },
{ {
path: "ai/reports", path: "ai/reports",
@@ -1,87 +0,0 @@
<template>
<n-popover trigger="hover" placement="top">
<template #trigger>
<div class="badge-container">
<img
:src="badge.badge.icon"
:alt="badge.badge.name"
class="badge-icon"
@error="handleImageError"
/>
</div>
</template>
<n-card size="small" class="badge-popover">
<n-flex vertical>
<n-text strong>{{ badge.badge.name }}</n-text>
<n-tag type="info"> 获得条件:{{ getConditionText() }} </n-tag>
<n-text depth="3">
获得时间:{{ parseTime(badge.earnedTime, "YYYY-MM-DD HH:mm:ss") }}
</n-text>
</n-flex>
</n-card>
</n-popover>
</template>
<script setup lang="ts">
import type { UserBadge } from "utils/types"
import { parseTime } from "utils/functions"
interface Props {
badge: UserBadge
}
const props = defineProps<Props>()
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
// 如果图片加载失败,显示默认图标
img.src = "/badge-1.png" // 使用默认徽章图标
}
function getConditionText() {
const { conditionType, conditionValue } = props.badge.badge
switch (conditionType) {
case "all_problems":
return "完成所有题目"
case "problem_count":
return `完成 ${conditionValue} 道题目`
case "score":
return `获得 ${conditionValue} 分`
default:
return "未知条件"
}
}
</script>
<style scoped>
.badge-container {
position: relative;
display: inline-block;
cursor: pointer;
width: 44px;
height: 44px;
}
.badge-icon {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e0e0;
transition: all 0.3s ease;
}
.badge-icon:hover {
transform: scale(1.1);
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.badge-popover {
max-width: 280px;
}
.badge-popover .n-space {
gap: 6px;
}
</style>
+4 -24
View File
@@ -195,31 +195,12 @@ export type {
ProblemSetList, ProblemSetList,
ProblemSetBadge, ProblemSetBadge,
ProblemSetProblem, ProblemSetProblem,
ProblemSetProgress, ProblemSetClassView,
ProblemSetProgressList, ProblemSetClassStudent,
ProblemSetLock,
UserBadge, UserBadge,
} from "@oj2/contract" } from "@oj2/contract"
export type { CompletedProblem } from "@oj2/contract"
export interface CreateProblemSetData {
title: string
description: string
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
endTime?: Date | null
}
export interface EditProblemSetData {
id: number
title?: string
description?: string
difficulty?: "Easy" | "Medium" | "Hard"
status?: "active" | "archived" | "draft"
endTime?: Date | null
visible?: boolean
}
export interface Code { export interface Code {
language: LANGUAGE language: LANGUAGE
value: string value: string
@@ -339,12 +320,11 @@ export type {
AdminUser, AdminUser,
AdminUserList, AdminUserList,
AdminContestList, AdminContestList,
AdminProblemSetProgress,
AdminProblemSetProblem, AdminProblemSetProblem,
AdminProblemSet, AdminProblemSet,
AdminProblemSetList, AdminProblemSetList,
AdminProblemSetBadge, AdminProblemSetBadge,
AddProblemToSetRequest, AddProblemsToSetResult,
UpdateProblemInSetRequest, UpdateProblemInSetRequest,
CreateProblemSetBadgeRequest, CreateProblemSetBadgeRequest,
AdminAiReport, AdminAiReport,