Compare commits
2 Commits
eda1eb7eee
...
8eae4bea7b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eae4bea7b | |||
| f581029eb2 |
@@ -641,9 +641,18 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c)
|
|||||||
: eq(schema.submission.username, username)
|
: eq(schema.submission.username, username)
|
||||||
|
|
||||||
// 多取一条,好知道是不是被截断了
|
// 多取一条,好知道是不是被截断了
|
||||||
|
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION,
|
||||||
|
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: schema.submission.id, result: schema.submission.result })
|
.select({
|
||||||
|
id: schema.submission.id,
|
||||||
|
result: schema.submission.result,
|
||||||
|
createTime: schema.submission.createTime,
|
||||||
|
problem: schema.problem.displayId,
|
||||||
|
problemTitle: schema.problem.title,
|
||||||
|
})
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
|
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||||
.where(and(...scope.filters, identity))
|
.where(and(...scope.filters, identity))
|
||||||
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
|
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
|
||||||
.limit(STATISTICS_ITEMS_LIMIT + 1)
|
.limit(STATISTICS_ITEMS_LIMIT + 1)
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ import { formatISO, sub, type Duration } from "date-fns"
|
|||||||
import type { FlowchartStatistics } from "@oj2/contract"
|
import type { FlowchartStatistics } from "@oj2/contract"
|
||||||
import { getFlowchartStatistics } from "oj/api"
|
import { getFlowchartStatistics } from "oj/api"
|
||||||
import { DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants"
|
import { DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants"
|
||||||
|
import { useHiddenStudents } from "../composables/hiddenStudents"
|
||||||
import { Doughnut, Radar, Bar } from "vue-chartjs"
|
import { Doughnut, Radar, Bar } from "vue-chartjs"
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -237,66 +238,19 @@ const hasResult = computed(
|
|||||||
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
|
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
|
||||||
let wordcloudChart: ChartJS | null = null
|
let wordcloudChart: ChartJS | null = null
|
||||||
|
|
||||||
const HIDE_DURATION = 2 * 60 * 60 * 1000
|
const { hideMode, hideStudent, showAll, isHidden, notHidden } =
|
||||||
const STORAGE_KEY = "oj_hidden_students_flowchart"
|
useHiddenStudents("oj_hidden_students_flowchart")
|
||||||
|
|
||||||
function loadHidden(): Record<string, number> {
|
const visibleUnaccepted = computed(() => data.dataUnaccepted.filter(notHidden))
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hiddenStudents = ref<Record<string, number>>(loadHidden())
|
const hiddenCount = computed(
|
||||||
const hideMode = ref(false)
|
() => data.dataUnaccepted.filter((item) => isHidden(item.username)).length,
|
||||||
|
)
|
||||||
function saveHidden(d: Record<string, number>) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(d))
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideStudent(username: string) {
|
|
||||||
hiddenStudents.value = {
|
|
||||||
...hiddenStudents.value,
|
|
||||||
[username]: Date.now() + HIDE_DURATION,
|
|
||||||
}
|
|
||||||
saveHidden(hiddenStudents.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAll() {
|
|
||||||
hiddenStudents.value = {}
|
|
||||||
saveHidden({})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleUnaccepted = computed(() => {
|
|
||||||
const now = Date.now()
|
|
||||||
return data.dataUnaccepted.filter((item) => {
|
|
||||||
const exp = hiddenStudents.value[item.username]
|
|
||||||
return !exp || exp <= now
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const hiddenCount = computed(() => {
|
|
||||||
const now = Date.now()
|
|
||||||
return data.dataUnaccepted.filter((item) => {
|
|
||||||
const exp = hiddenStudents.value[item.username]
|
|
||||||
return !!exp && exp > now
|
|
||||||
}).length
|
|
||||||
})
|
|
||||||
|
|
||||||
const adjustedPersonCount = computed(() =>
|
const adjustedPersonCount = computed(() =>
|
||||||
Math.max(0, data.personCount - hiddenCount.value),
|
Math.max(0, data.personCount - hiddenCount.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const now = Date.now()
|
|
||||||
const cleaned = Object.fromEntries(
|
|
||||||
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
|
|
||||||
)
|
|
||||||
hiddenStudents.value = cleaned
|
|
||||||
saveHidden(cleaned)
|
|
||||||
})
|
|
||||||
|
|
||||||
const completionRate = computed(() => {
|
const completionRate = computed(() => {
|
||||||
if (adjustedPersonCount.value <= 0) return "0%"
|
if (adjustedPersonCount.value <= 0) return "0%"
|
||||||
const rate = Math.min(
|
const rate = Math.min(
|
||||||
|
|||||||
@@ -201,10 +201,12 @@ import { getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api"
|
|||||||
import { DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
|
import { DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import { useConfigStore } from "../store/config"
|
import { useConfigStore } from "../store/config"
|
||||||
|
import { useHiddenStudents } from "../composables/hiddenStudents"
|
||||||
import { Doughnut } from "vue-chartjs"
|
import { Doughnut } from "vue-chartjs"
|
||||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||||||
import { NButton, NFlex, NTag, NText, type DataTableRowKey } from "naive-ui"
|
import { NFlex, NTag, NText, NTooltip, type DataTableRowKey } from "naive-ui"
|
||||||
import { JUDGE_STATUS } from "utils/constants"
|
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
import type {
|
import type {
|
||||||
AttemptedStudent,
|
AttemptedStudent,
|
||||||
SubmissionStatisticsItems,
|
SubmissionStatisticsItems,
|
||||||
@@ -230,6 +232,62 @@ const options: SelectOption[] = [
|
|||||||
{ label: "全部时段", value: "all" },
|
{ label: "全部时段", value: "all" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轨迹方块的颜色。取值就是 JUDGE_STATUS 里那个 type,色号沿用 Naive UI 的语义色
|
||||||
|
* (项目里 ExerciseMatch.vue 等处也是直接写这几个值)。
|
||||||
|
*/
|
||||||
|
const ATTEMPT_COLORS: Record<string, string> = {
|
||||||
|
success: "#18a058",
|
||||||
|
error: "#d03050",
|
||||||
|
warning: "#f0a020",
|
||||||
|
info: "#2080f0",
|
||||||
|
default: "#909399",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个学生的提交按题目聚成组,给展开行画「状态轨迹」用。
|
||||||
|
*
|
||||||
|
* 原来展开行是一排提交编号按钮 —— 12 位十六进制本身没有信息量,一节课里学生在好几道
|
||||||
|
* 题之间来回跳,那一排看不出他到底卡在哪。现在一道题一行:题号、标题、交了几次、
|
||||||
|
* 过没过,后面跟一排按时间**从早到晚**的小方块,颜色就是判题状态。
|
||||||
|
* 「三红一绿」和「七红到底」一眼分得开。
|
||||||
|
*
|
||||||
|
* 排序按老师的用法来:**没过的排前面**,其中交得越多越靠前 —— 卡得最久的那道顶到眼前;
|
||||||
|
* 已通过的沉底,它们只是「做完了」,不需要再看。
|
||||||
|
*/
|
||||||
|
function groupByProblem(list: SubmissionStatisticsItems["items"]) {
|
||||||
|
const groups = new Map<string, {
|
||||||
|
problem: string
|
||||||
|
problemTitle: string
|
||||||
|
items: SubmissionStatisticsItems["items"]
|
||||||
|
}>()
|
||||||
|
for (const item of list) {
|
||||||
|
const group = groups.get(item.problem)
|
||||||
|
if (group) group.items.push(item)
|
||||||
|
else groups.set(item.problem, {
|
||||||
|
problem: item.problem,
|
||||||
|
problemTitle: item.problemTitle,
|
||||||
|
items: [item],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
// 后端按时间倒序给,轨迹要从早到晚读,所以翻过来
|
||||||
|
items: [...group.items].reverse(),
|
||||||
|
// 「语法未过」(ast_check_failed) 也算做出来了 —— 答案对了,只是没按要求写;
|
||||||
|
// 和统计表格上「已解决」那一列的口径保持一致
|
||||||
|
solved: group.items.some(
|
||||||
|
(item) =>
|
||||||
|
item.result === SubmissionStatus.accepted ||
|
||||||
|
item.result === SubmissionStatus.ast_check_failed,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
Number(a.solved) - Number(b.solved) || b.items.length - a.items.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function openSubmission(id: string) {
|
function openSubmission(id: string) {
|
||||||
window.open(`/submission/${id}`, "_blank", "noopener")
|
window.open(`/submission/${id}`, "_blank", "noopener")
|
||||||
}
|
}
|
||||||
@@ -240,24 +298,68 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
|||||||
renderExpand: (row) => {
|
renderExpand: (row) => {
|
||||||
const loaded = items[row.username]
|
const loaded = items[row.username]
|
||||||
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
|
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
|
||||||
return h(NFlex, { vertical: true, size: "small" }, () => [
|
return h(NFlex, { vertical: true, size: "medium" }, () => [
|
||||||
h(NFlex, { size: "small", wrap: true }, () =>
|
...groupByProblem(loaded.items).map((group) =>
|
||||||
loaded.items.map((item) =>
|
// 题头一栏、轨迹一栏,**外层不换行**:一道题交了几十发时方块要在自己那一栏里
|
||||||
|
// 折行,折下来的一行才会和上一行对齐
|
||||||
|
h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [
|
||||||
h(
|
h(
|
||||||
NButton,
|
NFlex,
|
||||||
{
|
{ size: 4, align: "center", wrap: false, style: "width: 200px; flex: none" },
|
||||||
size: "small",
|
() => [
|
||||||
tertiary: true,
|
h(NTag, { size: "small", bordered: false }, () => group.problem),
|
||||||
type: JUDGE_STATUS[item.result]?.type ?? "default",
|
h(
|
||||||
style: "width: 120px",
|
NText,
|
||||||
onClick: (event: MouseEvent) => {
|
{
|
||||||
event.stopPropagation()
|
depth: 2,
|
||||||
openSubmission(item.id)
|
style:
|
||||||
},
|
"overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
|
||||||
},
|
},
|
||||||
() => item.id.toString().slice(0, 12),
|
() => group.problemTitle,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
h(
|
||||||
|
NText,
|
||||||
|
{
|
||||||
|
depth: 3,
|
||||||
|
style: "width: 104px; flex: none",
|
||||||
|
},
|
||||||
|
() => `${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
|
||||||
|
),
|
||||||
|
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () =>
|
||||||
|
group.items.map((item) =>
|
||||||
|
h(
|
||||||
|
NTooltip,
|
||||||
|
{ delay: 200 },
|
||||||
|
{
|
||||||
|
trigger: () =>
|
||||||
|
h("button", {
|
||||||
|
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
|
||||||
|
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
|
||||||
|
style: {
|
||||||
|
width: "14px",
|
||||||
|
height: "14px",
|
||||||
|
padding: "0",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "3px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: ATTEMPT_COLORS[JUDGE_STATUS[item.result]?.type ?? "default"],
|
||||||
|
},
|
||||||
|
onClick: (event: MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
openSubmission(item.id)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
default: () =>
|
||||||
|
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
|
||||||
|
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
|
||||||
|
`${item.id.toString().slice(0, 12)}`,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
loaded.truncated
|
loaded.truncated
|
||||||
? h(
|
? h(
|
||||||
@@ -400,41 +502,8 @@ const hasResult = computed(
|
|||||||
() => count.total > 0 || listUnaccepted.value.length > 0,
|
() => count.total > 0 || listUnaccepted.value.length > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
const HIDE_DURATION = 2 * 60 * 60 * 1000
|
const { hideMode, hideStudent, showAll, notHidden } =
|
||||||
const HIDDEN_KEY = "oj_hidden_students"
|
useHiddenStudents("oj_hidden_students")
|
||||||
|
|
||||||
function loadHidden(): Record<string, number> {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(HIDDEN_KEY) ?? "{}")
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hiddenStudents = ref<Record<string, number>>(loadHidden())
|
|
||||||
const hideMode = ref(false)
|
|
||||||
|
|
||||||
function saveHidden(data: Record<string, number>) {
|
|
||||||
localStorage.setItem(HIDDEN_KEY, JSON.stringify(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideStudent(username: string) {
|
|
||||||
hiddenStudents.value = {
|
|
||||||
...hiddenStudents.value,
|
|
||||||
[username]: Date.now() + HIDE_DURATION,
|
|
||||||
}
|
|
||||||
saveHidden(hiddenStudents.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAll() {
|
|
||||||
hiddenStudents.value = {}
|
|
||||||
saveHidden({})
|
|
||||||
}
|
|
||||||
|
|
||||||
function notHidden(item: { username: string }) {
|
|
||||||
const exp = hiddenStudents.value[item.username]
|
|
||||||
return !exp || exp <= Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
|
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
|
||||||
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
|
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
|
||||||
@@ -524,13 +593,8 @@ const adjustedPersonRate = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const now = Date.now()
|
// 过期清理在 useHiddenStudents 里,这里只管「打开就查一次」——
|
||||||
const cleaned = Object.fromEntries(
|
// 老师是投在屏幕上盯着看的,不该还要先点一下按钮
|
||||||
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
|
|
||||||
)
|
|
||||||
hiddenStudents.value = cleaned
|
|
||||||
saveHidden(cleaned)
|
|
||||||
// 打开就查一次。老师是投在屏幕上盯着看的,不该还要先点一下按钮
|
|
||||||
handleStatistics()
|
handleStatistics()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
76
apps/web/src/shared/composables/hiddenStudents.ts
Normal file
76
apps/web/src/shared/composables/hiddenStudents.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { onMounted, ref } from "vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计面板的「暂时隐藏某个学生」。
|
||||||
|
*
|
||||||
|
* 老师是把面板投在屏幕上盯着看的,未完成名单里总有那么几个是请假/转班/学号错了的,
|
||||||
|
* 一直挂在上面会盖住真正需要盯的人。隐藏是**带过期时间**的(两小时,够一节课),
|
||||||
|
* 不是永久删除 —— 下节课自动回来,免得有人被无声地漏掉。
|
||||||
|
*
|
||||||
|
* 存在 localStorage 而不是后端:这是「这台电脑上这位老师这节课不想看谁」,
|
||||||
|
* 换个人、换台机器都不该继承。
|
||||||
|
*
|
||||||
|
* 原来这套(loadHidden / saveHidden / hideStudent / showAll / 过期清理)在
|
||||||
|
* StatisticsPanel.vue 和 FlowchartStatisticsPanel.vue 里各写了一遍,除了存储键
|
||||||
|
* 和一个参数名逐字相同;判断「有没有被隐藏」两边还各自内联了三处。
|
||||||
|
*
|
||||||
|
* @param storageKey localStorage 的键。**两个面板各用各的** —— 提交统计里隐掉的人
|
||||||
|
* 不该连带在流程图统计里也消失,那是两件事。
|
||||||
|
*/
|
||||||
|
export function useHiddenStudents(storageKey: string) {
|
||||||
|
/** 隐藏时长:两小时,一节课的量级 */
|
||||||
|
const HIDE_DURATION = 2 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function load(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(storageKey) ?? "{}")
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenStudents = ref<Record<string, number>>(load())
|
||||||
|
/** 面板上的「隐藏模式」开关:打开后每行才出现那个隐藏按钮 */
|
||||||
|
const hideMode = ref(false)
|
||||||
|
|
||||||
|
function save(data: Record<string, number>) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStudent(username: string) {
|
||||||
|
hiddenStudents.value = {
|
||||||
|
...hiddenStudents.value,
|
||||||
|
[username]: Date.now() + HIDE_DURATION,
|
||||||
|
}
|
||||||
|
save(hiddenStudents.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAll() {
|
||||||
|
hiddenStudents.value = {}
|
||||||
|
save({})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHidden(username: string) {
|
||||||
|
const expiresAt = hiddenStudents.value[username]
|
||||||
|
return !!expiresAt && expiresAt > Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 给 filter 用:`list.filter(notHidden)` */
|
||||||
|
function notHidden(item: { username: string }) {
|
||||||
|
return !isHidden(item.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 把已经到期的清掉再落一次盘,否则这张表只增不减
|
||||||
|
const now = Date.now()
|
||||||
|
const cleaned = Object.fromEntries(
|
||||||
|
Object.entries(hiddenStudents.value).filter(([, expiresAt]) => expiresAt > now),
|
||||||
|
)
|
||||||
|
hiddenStudents.value = cleaned
|
||||||
|
save(cleaned)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 不导出 hiddenStudents 本身:两个面板要的都是「这个人该不该显示」,
|
||||||
|
// 把那张表递出去只会让判断逻辑又散回各自的组件里
|
||||||
|
return { hideMode, hideStudent, showAll, isHidden, notHidden }
|
||||||
|
}
|
||||||
@@ -273,7 +273,22 @@ export const submissionStatisticsUserSchema = z.object({
|
|||||||
* 免得老师以为这人就交了这么多。
|
* 免得老师以为这人就交了这么多。
|
||||||
*/
|
*/
|
||||||
export const submissionStatisticsItemsSchema = z.object({
|
export const submissionStatisticsItemsSchema = z.object({
|
||||||
items: z.array(z.object({ id: z.string(), result: judgeStatusSchema })),
|
/**
|
||||||
|
* 展开某个学生时列出他这段时间的提交。**带上题目**:一节课里学生往往在好几道题
|
||||||
|
* 之间来回跳,一串只有编号的按钮看不出他卡在哪一道 —— 前端按题目分组展示。
|
||||||
|
*
|
||||||
|
* 字段名沿用 submissionListItemSchema 的口径:`problem` 是展示用题号(problem._id),
|
||||||
|
* `problemTitle` 是标题。
|
||||||
|
*/
|
||||||
|
items: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
result: judgeStatusSchema,
|
||||||
|
createTime: z.string(),
|
||||||
|
problem: z.string(),
|
||||||
|
problemTitle: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
truncated: z.boolean(),
|
truncated: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user