Compare commits

..
7 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 c4d55bb88e fix(reaction): fix disabled-tooltip hover and post-AC dialog copy
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
- SubmitCode.vue: AC dialog title still told students to '打星
  评分' after the reaction UI moved to emoji buttons. Rewritten to
  match the emoji-based interaction, with no star/rating language.
- ProblemReaction.vue: n-tooltip's hover trigger merges its
  mouseenter/mouseleave onto the single root element of the trigger
  slot. That root was an n-button, and disabled form controls never
  dispatch mouse events, so the two disabled-state tooltips ('完成本题后可以评价'
  and the max-selection message) never appeared. Wrap each button in
  a non-disabled <span> and make that the tooltip root so hover
  events fire regardless of the button's disabled state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:12:55 -06:00
xuyueandClaude Opus 5 c91c923fd6 refactor(reaction): 清理旧的评论代码
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:55:11 -06:00
xuyueandClaude Opus 5 9c6c6e1784 fix(reaction): dedupe sort request when not on page 1
handleSorter previously mutated query.page then called listStats()
synchronously, while a separate page/limit watcher also fired on the
page reset, producing two identical requests whenever sorting was
triggered from page 2+. ordering is now part of the watched sources
so page+ordering mutations from handleSorter batch into a single
watcher run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:47:19 -06:00
xuyueandClaude Opus 5 a8cc7c855a feat(reaction): 后台题目反馈统计页
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:42:25 -06:00
xuyueandClaude Opus 5 e153064d87 fix(reaction): guard toggle against out-of-order responses
用请求代号确保只有最新一次点击的响应会写回本地状态,乐观更新的
回滚路径同样受保护,避免旧请求晚到覆盖新状态。不引入防抖、不禁用按钮。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:38:24 -06:00
xuyueandClaude Opus 5 0af91b1e20 feat(reaction): 学生端表情条组件
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:27:45 -06:00
xuyueandClaude Opus 5 d16e3dddb8 feat(reaction): 前端表情常量、类型与 API
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:19:39 -06:00
14 changed files with 311 additions and 386 deletions
+8 -7
View File
@@ -287,16 +287,17 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
return http.post("admin/announcement", announcement)
}
export function getCommentList(offset = 0, limit = 10, problem: string) {
return http.get("admin/comment", {
params: { offset, limit, problem },
export function getReactionStats(
offset = 0,
limit = 10,
problem: string,
ordering: string,
) {
return http.get("admin/reaction", {
params: { offset, limit, problem, ordering },
})
}
export function deleteComment(id: number) {
return http.delete("admin/comment", { params: { id } })
}
export async function getTutorialList() {
const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data
-114
View File
@@ -1,114 +0,0 @@
<template>
<n-flex justify="space-between" class="titleWrapper">
<h2 class="title">评论列表只列出有内容的</h2>
<div>
<n-input
v-model:value="query.problem"
clearable
placeholder="输入题目序号"
/>
</div>
</n-flex>
<n-data-table striped :columns="columns" :data="comments" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<script lang="ts" setup>
import { NButton } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import type { Comment } from "utils/types"
import { getCommentList } from "../api"
import CommentActions from "./components/CommentActions.vue"
const comments = ref<Comment[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
problem: "",
})
const columns: DataTableColumn<Comment>[] = [
{
title: "题目",
key: "problem",
width: 100,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/problem/" + row.problem, "_blank"),
},
() => row.problem,
),
},
{
title: "提交",
key: "submission",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/submission/" + row.submission, "_blank"),
},
() => row.submission.slice(0, 12),
),
},
{ title: "描述评分", key: "description_rating", width: 100 },
{ title: "难度评分", key: "difficulty_rating", width: 100 },
{ title: "综合评分", key: "comprehensive_rating", width: 100 },
{ title: "用户", key: "user.username", width: 150 },
{
title: "时间",
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
width: 200,
},
{
title: "内容",
key: "content",
minWidth: 200,
maxWidth: 300,
ellipsis: true,
},
{
title: "选项",
key: "action",
width: 100,
render: (row) =>
h(CommentActions, { commentID: row.id, onDeleted: listComments }),
},
]
async function listComments() {
const offset = (query.page - 1) * query.limit
const res = await getCommentList(offset, query.limit, query.problem)
comments.value = res.data.results
total.value = res.data.total
}
onMounted(listComments)
watch(() => [query.page, query.limit], listComments)
watchDebounced(() => query.problem, listComments, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
@@ -1,22 +0,0 @@
<script lang="ts" setup>
import { deleteComment } from "admin/api"
const props = defineProps<{ commentID: number }>()
const emit = defineEmits(["deleted"])
const message = useMessage()
async function submit() {
await deleteComment(props.commentID)
message.success("成功删除")
emit("deleted")
}
</script>
<template>
<n-popconfirm @positive-click="submit">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这条评论吗
</n-popconfirm>
</template>
+113
View File
@@ -0,0 +1,113 @@
<template>
<n-flex justify="space-between" class="titleWrapper">
<h2 class="title">题目反馈统计</h2>
<div>
<n-input
v-model:value="query.problem"
clearable
placeholder="输入题目序号"
/>
</div>
</n-flex>
<n-data-table
striped
:columns="columns"
:data="rows"
:scroll-x="1100"
remote
@update:sorter="handleSorter"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { NButton, NFlex } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { REACTIONS } from "utils/constants"
import type { ReactionStatsRow } from "utils/types"
import { getReactionStats } from "../api"
const rows = ref<ReactionStatsRow[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
problem: "",
ordering: "-users",
})
const columns: DataTableColumn<ReactionStatsRow>[] = [
{
title: "题目",
key: "pid",
width: 100,
fixed: "left",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/problem/" + row.pid, "_blank"),
},
() => row.pid,
),
},
{ title: "标题", key: "title", minWidth: 180, ellipsis: true },
{ title: "表态人数", key: "users", width: 110, sorter: true },
...REACTIONS.map((item) => ({
title: () =>
h(NFlex, { align: "center", size: 4, wrap: false }, () => [
h(Icon, { icon: item.icon, width: 18 }),
item.label,
]),
key: item.key,
width: 110,
sorter: true,
})),
]
function handleSorter(sorter: { columnKey: string; order: string | false }) {
if (!sorter || !sorter.order) {
query.ordering = "-users"
} else {
query.ordering =
(sorter.order === "descend" ? "-" : "") + String(sorter.columnKey)
}
query.page = 1
}
async function listStats() {
const offset = (query.page - 1) * query.limit
const res = await getReactionStats(
offset,
query.limit,
query.problem,
query.ordering,
)
rows.value = res.data.results
total.value = res.data.total
}
onMounted(listStats)
watch(() => [query.page, query.limit, query.ordering], listStats)
watchDebounced(() => query.problem, listStats, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+5 -14
View File
@@ -3,6 +3,7 @@ import { filterResult } from "oj/transforms"
import type {
Exercise,
Problem,
ReactionKey,
Submission,
SubmissionListPayload,
SubmitCodePayload,
@@ -223,22 +224,12 @@ export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } })
}
export function createComment(data: {
problem_id: number
description_rating: number
difficulty_rating: number
comprehensive_rating: number
content: string
}) {
return http.post("comment", data)
export function getReaction(problemID: number) {
return http.get("reaction", { params: { problem_id: problemID } })
}
export function getComment(problemID: number) {
return http.get("comment", { params: { problem_id: problemID } })
}
export function getCommentStatistics(problemID: number) {
return http.get("comment/statistics", { params: { problem_id: problemID } })
export function setReaction(problemID: number, types: ReactionKey[]) {
return http.post("reaction", { problem_id: problemID, types })
}
// TODO: 这个API有问题
@@ -1,197 +0,0 @@
<template>
<n-alert type="error" v-if="!userStore.isAuthed" title="请先登录" />
<div v-else>
<n-alert
v-if="problem?.my_status !== 0"
class="title"
type="error"
title="请先完成该题"
></n-alert>
<div v-else>
<n-alert class="title" type="success" :title="title"></n-alert>
<n-form>
<n-form-item
label-width="220"
label-align="left"
label="题目是否描述清楚"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in description_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="description_rating" />
</n-form-item>
<n-form-item
label-width="220"
label-align="left"
:label="
'难度是否匹配(此题是' + DIFFICULTY[problem.difficulty] + '的)'
"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in difficulty_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="difficulty_rating" />
</n-form-item>
<n-form-item
label-width="220"
label-align="left"
label="综合评分"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in difficulty_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="comprehensive_rating" />
</n-form-item>
<n-form-item
v-if="!hasCommented"
label="对这道题的评价(可选,注意文明用语)"
>
<n-input v-model:value="content" type="textarea" />
</n-form-item>
<n-form-item v-if="hasCommented && content" label="你对这道题的评价:">
{{ content }}
</n-form-item>
<n-button
v-if="hasCommented && showStatistics"
type="primary"
@click="getComments"
>
查看统计
</n-button>
<n-button v-if="!hasCommented" type="primary" @click="submit">
提交
</n-button>
</n-form>
<div v-if="showStatistics">
<n-descriptions
class="list"
v-if="count"
:column="4"
bordered
label-placement="left"
>
<n-descriptions-item label="评论">
{{ count }}
</n-descriptions-item>
<n-descriptions-item label="描述">
{{ rating.description }}
</n-descriptions-item>
<n-descriptions-item label="难度">
{{ rating.difficulty }}
</n-descriptions-item>
<n-descriptions-item label="综合">
{{ rating.comprehensive }}
</n-descriptions-item>
</n-descriptions>
<n-empty class="list" v-if="show && count === 0">
暂无记录快去评论吧
</n-empty>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY } from "utils/constants"
import { createComment, getComment, getCommentStatistics } from "oj/api"
import { useUserStore } from "shared/store/user"
interface Props {
showStatistics?: boolean
}
const { showStatistics = true } = defineProps<Props>()
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const content = ref("")
const description_rating = ref(0)
const difficulty_rating = ref(0)
const comprehensive_rating = ref(0)
const [show, toggleShow] = useToggle()
const [hasCommented, toggleHasCommented] = useToggle()
const count = ref(0)
const rating = reactive({
description: 0,
difficulty: 0,
comprehensive: 0,
})
const title = computed(() => {
if (hasCommented.value) return "这道题你已经打过分了,你的评分如下:"
return "你已经完成了这道题,请给这道题打分吧!"
})
async function submit() {
if (
description_rating.value === 0 ||
difficulty_rating.value === 0 ||
comprehensive_rating.value === 0
) {
message.error("请完成打分")
return
}
const data = {
problem_id: problem.value!.id,
content: content.value,
description_rating: description_rating.value,
difficulty_rating: difficulty_rating.value,
comprehensive_rating: comprehensive_rating.value,
}
await createComment(data)
toggleHasCommented(true)
message.success("提交成功")
}
async function getComments() {
const res = await getCommentStatistics(problem.value!.id)
toggleShow(true)
if (!res.data) return
count.value = res.data.count
rating.description = res.data.rating.description
rating.difficulty = res.data.rating.difficulty
rating.comprehensive = res.data.rating.comprehensive
}
async function getMyComment() {
const res = await getComment(problem.value!.id)
if (!res.data) return
content.value = res.data.content
description_rating.value = res.data.description_rating
difficulty_rating.value = res.data.difficulty_rating
comprehensive_rating.value = res.data.comprehensive_rating
toggleHasCommented(true)
}
onMounted(getMyComment)
</script>
<style scoped>
.title {
margin-bottom: 24px;
}
.list {
margin-top: 24px;
}
</style>
@@ -0,0 +1,122 @@
<template>
<n-alert v-if="!userStore.isAuthed" type="error" title="请先登录" />
<div v-else ref="container" class="reactions">
<n-tooltip v-for="item in REACTIONS" :key="item.key" trigger="hover">
<template #trigger>
<span class="reaction-trigger">
<n-button
size="small"
:disabled="isDisabled(item.key)"
:type="mine.includes(item.key) ? 'primary' : 'default'"
:ghost="mine.includes(item.key)"
@click="toggle(item.key)"
>
<Icon :icon="item.icon" :width="18" />
<span v-if="showLabel" class="label">{{ item.label }}</span>
<span v-if="counts" class="count">{{ counts[item.key] }}</span>
</n-button>
</span>
</template>
{{ tooltipOf(item.key, item.label) }}
</n-tooltip>
</div>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { getReaction, setReaction } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { MAX_REACTIONS, REACTIONS } from "utils/constants"
import type { ReactionCounts, ReactionKey } from "utils/types"
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const container = ref<HTMLElement | null>(null)
const { width } = useElementSize(container)
// 七个按钮带中文标签大约需要 560px,放不下就只留图标和计数
const showLabel = computed(() => width.value >= 560)
const mine = ref<ReactionKey[]>([])
const counts = ref<ReactionCounts | null>(null)
const solved = computed(() => problem.value?.my_status === 0)
function isDisabled(key: ReactionKey) {
if (!solved.value) return true
if (mine.value.includes(key)) return false
return mine.value.length >= MAX_REACTIONS
}
function tooltipOf(key: ReactionKey, label: string) {
if (!solved.value) return "完成本题后可以评价"
if (isDisabled(key)) return `最多选 ${MAX_REACTIONS} 个,先取消一个`
if (counts.value) return `${counts.value[key]} 人选了「${label}`
return label
}
// 连续点击不做防抖、不禁用按钮,靠请求代号保证后到的旧响应不会覆盖新状态:
// 每次点击自增一次,只有仍是最新请求时才把结果写回本地状态
let requestSeq = 0
async function toggle(key: ReactionKey) {
if (!problem.value) return
const prevMine = [...mine.value]
const prevCounts = counts.value ? { ...counts.value } : null
const selected = mine.value.includes(key)
const next = selected
? mine.value.filter((k) => k !== key)
: [...mine.value, key]
mine.value = next
if (counts.value) counts.value[key] += selected ? -1 : 1
const seq = ++requestSeq
try {
const res = await setReaction(problem.value.id, next)
if (seq !== requestSeq) return
mine.value = res.data.mine
counts.value = res.data.counts
} catch {
if (seq !== requestSeq) return
mine.value = prevMine
counts.value = prevCounts
message.error("操作失败,请重试")
}
}
async function load() {
if (!problem.value) return
const res = await getReaction(problem.value.id)
mine.value = res.data.mine
counts.value = res.data.counts
}
onMounted(() => {
if (userStore.isAuthed) load()
})
</script>
<style scoped>
.reactions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
}
.reaction-trigger {
display: inline-flex;
flex-shrink: 0;
}
.label {
margin-left: 4px;
}
.count {
margin-left: 6px;
opacity: 0.7;
}
</style>
+7 -7
View File
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import {
formatCode,
getComment,
getReaction,
submitCode,
updateProblemSetProgress,
} from "oj/api"
@@ -23,8 +23,8 @@ import {
} from "oj/problem/utils/pythonSyntaxCheck"
// ==================== 异步组件 ====================
const ProblemComment = defineAsyncComponent(
() => import("./ProblemComment.vue"),
const ProblemReaction = defineAsyncComponent(
() => import("./ProblemReaction.vue"),
)
// ==================== 基础状态 ====================
@@ -72,8 +72,8 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
// ==================== AC后显示评论框 ====================
const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => {
const res = await getComment(problem.value!.id)
if (!res.data) {
const res = await getReaction(problem.value!.id)
if (res.data.mine.length === 0) {
commentPanel.value = true
}
},
@@ -259,11 +259,11 @@ watch(
<!-- 评价弹窗 -->
<n-modal
preset="card"
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)"
title="恭喜你成功提交,说说你对这道题的感受吧"
:mask-closable="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel"
>
<ProblemComment :showStatistics="false" />
<ProblemReaction />
</n-modal>
</template>
+5 -5
View File
@@ -23,8 +23,8 @@ const ProblemInfo = defineAsyncComponent(
const ProblemSubmission = defineAsyncComponent(
() => import("./components/ProblemSubmission.vue"),
)
const ProblemComment = defineAsyncComponent(
() => import("./components/ProblemComment.vue"),
const ProblemReaction = defineAsyncComponent(
() => import("./components/ProblemReaction.vue"),
)
const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"),
@@ -174,7 +174,7 @@ watch(
tab="题目点评"
:disabled="!!problemSetId"
>
<ProblemComment />
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
@@ -226,7 +226,7 @@ watch(
tab="题目点评"
:disabled="!!problemSetId"
>
<ProblemComment />
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
@@ -266,7 +266,7 @@ watch(
tab="点评"
:disabled="!!problemSetId"
>
<ProblemComment />
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
+3 -3
View File
@@ -258,9 +258,9 @@ export const admins: RouteRecordRaw = {
meta: { requiresSuperAdmin: true },
},
{
path: "comment/list",
name: "admin comment list",
component: () => import("admin/communication/comments.vue"),
path: "reaction/list",
name: "admin reaction list",
component: () => import("admin/communication/reactions.vue"),
meta: { requiresSuperAdmin: true },
},
{
+3 -4
View File
@@ -118,10 +118,10 @@ const options = computed<MenuOption[]>(() => {
label: () =>
h(
RouterLink,
{ to: "/admin/comment/list" },
{ default: () => "评论" },
{ to: "/admin/reaction/list" },
{ default: () => "题目反馈" },
),
key: "admin comment list",
key: "admin reaction list",
},
{
label: () =>
@@ -177,7 +177,6 @@ const active = computed(() => {
if (path.startsWith("/admin/problem")) return "admin problem list"
if (path.startsWith("/admin/contest")) return "admin contest list"
if (path.startsWith("/admin/user")) return "admin user list"
if (path.startsWith("/admin/comment")) return "admin comment list"
if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
+25 -1
View File
@@ -1,4 +1,4 @@
import type { AchievementRarity, SUBMISSION_RESULT } from "./types"
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
export enum SubmissionStatus {
compile_error = -2,
@@ -346,3 +346,27 @@ export const USERNAME_CLASS_RE = new RegExp(
/** 班级号作为数字时的上下界,给 n-input-number 用 */
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1
export const REACTIONS: {
key: ReactionKey
label: string
icon: string
}[] = [
{
key: "too_easy",
label: "太简单",
icon: "fluent-emoji:smiling-face-with-sunglasses",
},
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
{
key: "confusing",
label: "没看懂",
icon: "fluent-emoji:face-with-spiral-eyes",
},
{ key: "buggy", label: "题目有错", icon: "fluent-emoji:bug" },
{ key: "learned", label: "学到了", icon: "fluent-emoji:light-bulb" },
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
]
export const MAX_REACTIONS = 3
-2
View File
@@ -12,7 +12,6 @@ export function usePermissions() {
canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin),
canManageTutorials: computed(() => userStore.isSuperAdmin),
canManageSystemConfig: computed(() => userStore.isSuperAdmin),
canSendMessages: computed(() => userStore.isSuperAdmin),
@@ -65,7 +64,6 @@ export function checkRoutePermission(routeName: string): boolean {
"admin announcement list",
"admin announcement create",
"admin announcement edit",
"admin comment list",
"admin message list",
"admin tutorial list",
"admin tutorial create",
+20 -10
View File
@@ -594,16 +594,26 @@ export interface CreateMessage {
message: string
}
export interface Comment {
id: number
problem: string
submission: string
content: string
description_rating: 1 | 2 | 3 | 4 | 5
difficulty_rating: 1 | 2 | 3 | 4 | 5
comprehensive_rating: 1 | 2 | 3 | 4 | 5
create_time: Date
user: SampleUser
export type ReactionKey =
| "too_easy"
| "too_hard"
| "confusing"
| "buggy"
| "learned"
| "interesting"
| "want_explain"
export type ReactionCounts = Record<ReactionKey, number>
export interface ReactionState {
mine: ReactionKey[]
counts: ReactionCounts | null
}
export interface ReactionStatsRow extends ReactionCounts {
pid: string
title: string
users: number
}
export interface Tutorial {