成绩评论

This commit is contained in:
biss committed 2026-08-22 17:17:43 +08:00
1 parent 9ba83c3dc1
commit 124c4ba033
13 files changed
+9123 -2

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ declare module 'vue' {
AcademicCalendarReminder: typeof import('./components/AcademicCalendarReminder.vue')['default']
AppUpdateManagementPanel: typeof import('./components/AppUpdateManagementPanel.vue')['default']
ChinesePostalCodeInput: typeof import('./components/ChinesePostalCodeInput.vue')['default']
CourseDiscussionPanel: typeof import('./components/CourseDiscussionPanel.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { Delete, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
type Reply = { id: string; content: string; isAnonymous: boolean; authorName: string; createdAt: string; canDelete: boolean }
type ReactionName = 'Like' | 'Insightful' | 'Agree' | 'Hooray' | 'Heart' | 'Rocket' | 'Eyes' | 'Laugh'
type ReactionOption = { type: ReactionName; emoji: string; label: string; countKey: string; activeKey: string }
type Comment = Reply & { replyCount: number; replies?: Reply[]; [key: string]: string | number | boolean | Reply[] | undefined }
const reactionOptions: ReactionOption[] = [
{ type: 'Like', emoji: '👍', label: '赞', countKey: 'likeCount', activeKey: 'hasLiked' },
{ type: 'Insightful', emoji: '💡', label: '有启发', countKey: 'insightfulCount', activeKey: 'hasInsightful' },
{ type: 'Agree', emoji: '🤝', label: '认同', countKey: 'agreeCount', activeKey: 'hasAgreed' },
{ type: 'Hooray', emoji: '🎉', label: '庆祝', countKey: 'hoorayCount', activeKey: 'hasHoorayed' },
{ type: 'Heart', emoji: '❤️', label: '喜欢', countKey: 'heartCount', activeKey: 'hasHearted' },
{ type: 'Rocket', emoji: '🚀', label: '很棒', countKey: 'rocketCount', activeKey: 'hasRocketed' },
{ type: 'Eyes', emoji: '👀', label: '关注', countKey: 'eyesCount', activeKey: 'hasEyed' },
{ type: 'Laugh', emoji: '😄', label: '好笑', countKey: 'laughCount', activeKey: 'hasLaughed' },
]
const props = defineProps<{ sheetId: string; courseName?: string }>()
const comments = ref<Comment[]>([]); const loading = ref(false); const submitting = ref(false)
const content = ref(''); const anonymous = ref(false); const hasMore = ref(false)
const cursor = ref<{ createdAt: string; id: string }>(); const drafts = reactive<Record<string, string>>({})
const replyAnonymous = reactive<Record<string, boolean>>({}); const replyOpen = reactive<Record<string, boolean>>({}); const replyLoading = reactive<Record<string, boolean>>({})
const base = () => `/grades/sheets/${props.sheetId}/discussion`
async function load(reset = false) {
if (!props.sheetId) return
if (reset) { comments.value = []; hasMore.value = false; cursor.value = undefined }
loading.value = true
try {
const data = (await http.get(`${base()}/comments`, { params: reset || !cursor.value ? undefined : { beforeCreatedAt: cursor.value.createdAt, beforeId: cursor.value.id } })).data
comments.value.push(...data.items); hasMore.value = data.hasMore; cursor.value = data.nextCursor ?? undefined
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false }
}
async function submit() {
const text = content.value.trim(); if (!text) return ElMessage.warning('请先输入评论内容。')
submitting.value = true
try { await http.post(`${base()}/comments`, { content: text, isAnonymous: anonymous.value }); content.value = ''; await load(true); ElMessage.success(anonymous.value ? '匿名评论已发布。' : '实名评论已发布。') }
catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { submitting.value = false }
}
function reactionCount(comment: Comment, option: ReactionOption) { return Number(comment[option.countKey] ?? 0) }
function reacted(comment: Comment, option: ReactionOption) { return comment[option.activeKey] === true }
async function react(comment: Comment, type: ReactionName) { try { await http.post(`${base()}/comments/${comment.id}/reactions/${type}`); await load(true) } catch (error) { ElMessage.error(apiErrorMessage(error)) } }
async function toggleReplies(comment: Comment) {
replyOpen[comment.id] = !replyOpen[comment.id]; if (!replyOpen[comment.id] || comment.replies) return
replyLoading[comment.id] = true
try { comment.replies = (await http.get(`${base()}/comments/${comment.id}/replies`)).data.items } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { replyLoading[comment.id] = false }
}
async function reply(comment: Comment) {
const text = drafts[comment.id]?.trim(); if (!text) return
try { await http.post(`${base()}/comments/${comment.id}/replies`, { content: text, isAnonymous: replyAnonymous[comment.id] === true }); drafts[comment.id] = ''; comment.replies = undefined; replyOpen[comment.id] = false; await toggleReplies(comment); await load(true) } catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function remove(comment: Reply) {
try { await ElMessageBox.confirm('删除后不能恢复,确定要删除这条评论吗?', '删除评论', { type: 'warning' }); await http.delete(`${base()}/comments/${comment.id}`); await load(true); ElMessage.success('评论已删除。') }
catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) }
}
watch(() => props.sheetId, () => load(true), { immediate: true })
</script>
<template>
<section class="course-discussion" v-loading="loading">
<header><div><span>COURSE DISCUSSION</span><h3>{{ courseName }} · 课程讨论</h3><p>仅限已发布该课程成绩的用户参与;匿名会隐藏姓名,但系统保留作者以便本人删除和管理员处理。</p></div><el-button :icon="Refresh" circle aria-label="刷新评论" @click="load(true)" /></header>
<el-input v-model="content" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="分享对这门课程成绩、学习收获或教学改进的看法" />
<div class="compose"><el-checkbox v-model="anonymous">匿名发布</el-checkbox><el-button type="primary" :loading="submitting" @click="submit">发布评论</el-button></div>
<el-empty v-if="!loading && !comments.length" :image-size="64" description="还没有评论,来说说你的看法吧" />
<ol v-else class="comments"><li v-for="comment in comments" :key="comment.id"><div class="meta"><strong>{{ comment.authorName || '未命名用户' }}</strong><span v-if="comment.isAnonymous">匿名</span><time>{{ new Date(comment.createdAt).toLocaleString('zh-CN') }}</time></div><p>{{ comment.content }}</p><div class="actions"><div class="reaction-bar"><button v-for="option in reactionOptions.filter(item => reactionCount(comment, item))" :key="option.type" type="button" class="reaction-chip" :class="{ active: reacted(comment, option) }" :aria-label="`${option.label} ${reactionCount(comment, option)}`" @click="react(comment, option.type)">{{ option.emoji }} <span>{{ reactionCount(comment, option) }}</span></button><el-popover placement="top-start" :width="260" trigger="click"><template #reference><el-button class="reaction-add" text size="small" aria-label="添加表情反应">😊</el-button></template><div class="reaction-picker" aria-label="选择表情反应"><button v-for="option in reactionOptions" :key="option.type" type="button" :class="{ active: reacted(comment, option) }" :aria-label="option.label" @click="react(comment, option.type)"><span>{{ option.emoji }}</span><small>{{ option.label }}</small></button></div></el-popover></div><el-button text size="small" @click="toggleReplies(comment)">回复{{ comment.replyCount ? ` (${comment.replyCount})` : '' }}</el-button><el-button v-if="comment.canDelete" text type="danger" size="small" :icon="Delete" @click="remove(comment)">删除</el-button></div><div v-if="replyOpen[comment.id]" class="replies" v-loading="replyLoading[comment.id]"><ol v-if="comment.replies?.length"><li v-for="item in comment.replies" :key="item.id"><div class="meta"><strong>{{ item.authorName }}</strong><span v-if="item.isAnonymous">匿名</span><time>{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</time></div><p>{{ item.content }}</p><el-button v-if="item.canDelete" text type="danger" size="small" @click="remove(item)">删除</el-button></li></ol><el-input v-model="drafts[comment.id]" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="回复这条评论" /><div class="compose"><el-checkbox v-model="replyAnonymous[comment.id]">匿名回复</el-checkbox><el-button type="primary" size="small" @click="reply(comment)">回复</el-button></div></div></li></ol>
<div v-if="hasMore" class="more"><el-button :loading="loading" @click="load()">加载更多评论</el-button></div>
</section>
</template>
<style scoped>
.course-discussion { margin-top:18px; padding:22px; background:var(--el-bg-color); border:1px solid var(--el-border-color-lighter); box-shadow:0 10px 30px rgb(21 47 82 / 5%); }.course-discussion header { display:flex; justify-content:space-between; gap:18px; margin-bottom:16px; }.course-discussion header span { color:#7b8797; font:10px Consolas,monospace; letter-spacing:.08em; }.course-discussion h3 { margin:5px 0; color:#173f78; font-size:17px; }.course-discussion header p { margin:0; color:#667085; font-size:12px; }.compose { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:10px; }.comments,.replies ol { display:grid; gap:0; margin:18px 0 0; padding:0; list-style:none; border-top:1px solid var(--el-border-color-lighter); }.comments>li { padding:15px 0; border-bottom:1px solid var(--el-border-color-lighter); }.meta { display:flex; flex-wrap:wrap; align-items:center; gap:8px; color:#667085; font-size:12px; }.meta strong { color:#18324f; font-size:13px; }.meta span { padding:2px 6px; color:#75511d; background:#fff6e7; border-radius:10px; font-size:10px; }.meta time { margin-left:auto; }.comments p { margin:9px 0 6px; color:#344054; white-space:pre-wrap; word-break:break-word; }.actions,.replies .compose { display:flex; flex-wrap:wrap; align-items:center; gap:4px; }.reaction-bar { display:flex; align-items:center; gap:5px; margin-right:4px; }.reaction-chip { display:inline-flex; align-items:center; gap:4px; min-height:27px; padding:2px 8px; color:#344054; background:#f6f8fb; border:1px solid #d8e0ea; border-radius:14px; cursor:pointer; }.reaction-chip:hover,.reaction-chip.active { color:#174ea6; background:#edf5ff; border-color:#79a8e2; }.reaction-chip span { font:600 11px/1 Consolas,monospace; }.reaction-add { min-width:28px; padding:3px 5px; border:1px dashed #c7d2df; border-radius:14px; }.reaction-picker { display:grid; grid-template-columns:repeat(4,1fr); gap:6px; }.reaction-picker button { display:grid; gap:2px; padding:7px 3px; color:#667085; background:transparent; border:1px solid transparent; border-radius:8px; cursor:pointer; }.reaction-picker button:hover,.reaction-picker button.active { color:#174ea6; background:#edf5ff; border-color:#bcd5f1; }.reaction-picker span { font-size:20px; line-height:1; }.reaction-picker small { font-size:10px; }.replies { margin-top:12px; padding:12px; background:#f7f9fc; border-left:3px solid #ccd9e9; }.replies ol { margin:0 0 12px; border:0; gap:10px; }.replies li { padding-bottom:10px; }.more { margin-top:16px; text-align:center; }@media (max-width:640px) { .course-discussion { padding:18px 14px; } }
</style>
@@ -7,6 +7,7 @@ import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from
import { CanvasRenderer } from 'echarts/renderers'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import CourseDiscussionPanel from '../components/CourseDiscussionPanel.vue'
echarts.use([
AriaComponent,
@@ -249,6 +250,7 @@ onBeforeUnmount(() => {
</div>
<div ref="comparisonChartElement" class="score-chart" />
</section>
<CourseDiscussionPanel v-if="report && rows.length" :sheet-id="String(route.params.sheetId)" :course-name="report.courseName" />
</div>
</template>
Loaded 3 of 13 files, more files were not shown because too many files have changed in this diff. Show more