feat(公告): 公告页改成左边列表、右边全文,不用再一条条点开弹框;首页(学生和老师)的公告卡片点一条直接打开那一条,老师首页也改成最新三条
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 22:03:49 +08:00
1 parent 06c679579c
commit 93f80740ef
3 files changed
+291 -172

No files matched your search

+234 -91
View File
@@ -1,84 +1,151 @@
<script lang="ts" setup>
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getAnnouncement, getAnnouncementList } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
import NotFound from "shared/components/NotFound.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
import type { AnnouncementListItem } from "utils/types"
import TitleWithTag from "./components/TitleWithTag.vue"
import { errorCode } from "utils/api"
import { parseTime, zonedParts, zonedYear } from "utils/functions"
import type { Announcement, AnnouncementListItem } from "utils/types"
const total = ref(0)
const content = ref("")
const title = ref("")
const [show, toggleShow] = useToggle(false)
/**
* 公告:左边列表、右边全文,和后台 AI 报告一个排法。原来是一张四列的表、正文开弹框 ——
* 「发布人」一列 24 条全是同一个人,弹框又让人一条条点开再关。
* 选中哪条写在地址的 ?id= 上,首页的公告卡片直接链到这里那一条。
* 手机上放不下两栏:没带 id 只列表,带了 id 只全文、顶上一个「全部公告」回去。
* 一共才二十几条(四年 24 条),一次拉全,不分页
*/
const LIMIT = 100
const { isDesktop } = useBreakpoints()
const theme = useThemeVars()
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const query = reactive({
limit: 10,
page: 1,
})
async function showContent(announcement: AnnouncementListItem) {
const res = await getAnnouncement(announcement.id)
toggleShow(true)
title.value = announcement.title
content.value = res.content
}
const announcements = ref<AnnouncementListItem[]>([])
const loaded = ref(false)
async function listAnnouncements() {
const offset = (query.page - 1) * query.limit
const res = await getAnnouncementList(offset, query.limit)
total.value = res.total
onMounted(async () => {
const res = await getAnnouncementList(0, LIMIT)
announcements.value = res.results
loaded.value = true
})
const queryId = computed(() => {
const id = Number(route.query.id)
return Number.isInteger(id) && id > 0 ? id : null
})
/** 桌面上没带 id 就看第一条(置顶的排最前);手机上没带 id 就只看列表 */
const selectedId = computed(
() => queryId.value ?? (isDesktop.value ? (announcements.value[0]?.id ?? null) : null),
)
function select(id: number) {
router.replace({ query: { ...route.query, id } })
}
onMounted(listAnnouncements)
watch(query, listAnnouncements, { deep: true })
function backToList() {
const { id: _, ...rest } = route.query
router.replace({ query: rest })
}
const detail = ref<Announcement | null>(null)
const detailState = ref<"idle" | "loading" | "ok" | "missing" | "failed">("idle")
let seq = 0
async function loadDetail(id: number | null) {
const mine = ++seq
if (id === null) {
detail.value = null
detailState.value = "idle"
return
}
detailState.value = "loading"
try {
const res = await getAnnouncement(id)
if (mine !== seq) return
detail.value = res
detailState.value = "ok"
} catch (err) {
if (mine !== seq) return
detail.value = null
detailState.value = errorCode(err) === "announcement-not-found" ? "missing" : "failed"
}
}
watch(selectedId, loadDetail, { immediate: true })
function dateText(value: string) {
return parseTime(value, zonedParts(value)!.year === zonedYear() ? "M月D日" : "YYYY年M月D日")
}
/** 「2026年9月19日发布 · 9月23日改过」:同一天改的不写 */
function metaText(item: Announcement) {
const created = parseTime(item.createTime, "YYYY年M月D日")
const updated = parseTime(item.lastUpdateTime, "YYYY年M月D日")
return updated === created
? `${created}发布`
: `${created}发布 · ${dateText(item.lastUpdateTime)}改过`
}
const showList = computed(() => isDesktop.value || queryId.value === null)
const showReader = computed(() => isDesktop.value || queryId.value !== null)
</script>
<template>
<div class="page oj-page">
<h2>公告</h2>
<div class="table">
<div class="tr th">
<span>公告标题</span>
<span>标签</span>
<span>发布时间</span>
<span>发布人</span>
</div>
<div
v-for="row in announcements"
:key="row.id"
class="tr"
role="link"
tabindex="0"
@click="showContent(row)"
@keyup.enter="showContent(row)"
<n-empty v-if="loaded && !announcements.length" description="还没有公告" class="blank" />
<div v-else class="layout">
<div v-if="showList" class="list">
<button
v-for="item in announcements"
:key="item.id"
type="button"
class="item"
:class="{ on: isDesktop && item.id === selectedId }"
@click="select(item.id)"
>
<TitleWithTag :title="row.title" :top="row.top" />
<span
><n-tag size="small">{{ row.tag || "公告" }}</n-tag></span
>
<span class="muted num">{{ parseTime(row.createTime) }}</span>
<span class="muted">{{ row.createdBy.username }}</span>
<span class="item-title">
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
<span class="ellipsis">{{ item.title }}</span>
</span>
<span class="muted num">{{ item.tag || "公告" }} · {{ dateText(item.createTime) }}</span>
</button>
</div>
<div v-if="loaded && !announcements.length" class="empty muted">还没有公告</div>
<article v-if="showReader" class="reader">
<a v-if="!isDesktop" class="back" @click="backToList">‹ 全部公告</a>
<NotFound
v-if="detailState === 'missing'"
title="这条公告不存在"
hint="可能已经撤下了"
to="/announcement"
to-label="看全部公告"
/>
<NotFound v-else-if="detailState === 'failed'" icon="ph:wifi-slash" title="公告没拉下来">
<n-button type="primary" @click="loadDetail(selectedId)">再试一次</n-button>
</NotFound>
<n-spin v-else :show="detailState === 'loading'">
<template v-if="detail">
<header class="reader-head">
<h3>
<n-tag v-if="detail.top" type="warning" size="small" :bordered="false">
置顶
</n-tag>
{{ detail.title }}
</h3>
<span class="muted num">{{ detail.tag || "公告" }} · {{ metaText(detail) }}</span>
</header>
<div class="content" v-html="detail.content"></div>
</template>
<div v-else class="placeholder"></div>
</n-spin>
</article>
</div>
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
</div>
<n-modal
v-model:show="show"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="title"
>
<div v-html="content"></div>
</n-modal>
</template>
<style scoped>
@@ -93,7 +160,69 @@ h2 {
font-size: var(--oj-fs-title);
}
.blank {
margin-top: 60px;
}
.layout {
display: flex;
gap: var(--oj-gap);
align-items: flex-start;
}
.list {
width: 340px;
flex: none;
border: 1px solid v-bind("theme.dividerColor");
border-radius: var(--oj-radius);
overflow: hidden;
}
.item {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 12px 16px;
border: 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: none;
font: inherit;
text-align: left;
color: inherit;
cursor: pointer;
}
.item:last-child {
border-bottom: 0;
}
.item:hover {
background: v-bind("theme.hoverColor");
}
.item.on {
background: v-bind("theme.hoverColor");
box-shadow: inset 3px 0 0 v-bind("theme.primaryColor");
}
.item-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-size: var(--oj-fs-body);
}
.ellipsis {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.muted {
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
}
@@ -101,56 +230,70 @@ h2 {
font-variant-numeric: tabular-nums;
}
.table {
border: 1px solid v-bind("theme.borderColor");
.reader {
flex: 1 1 auto;
min-width: 0;
padding: 20px var(--oj-pad-x) 28px;
border-radius: var(--oj-radius);
overflow: hidden;
background: v-bind("theme.actionColor");
}
.tr {
display: grid;
grid-template-columns: minmax(200px, 1fr) 80px 170px 120px;
align-items: center;
gap: 14px;
min-height: var(--oj-row-h-roomy);
padding: 0 var(--oj-pad-x);
font-size: var(--oj-fs-body);
border-bottom: 1px solid v-bind("theme.dividerColor");
.back {
display: inline-block;
margin-bottom: 12px;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
cursor: pointer;
}
.tr > .muted {
font-size: var(--oj-fs-sec);
.reader-head {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 14px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.tr:last-child {
border-bottom: 0;
h3 {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-size: 20px;
}
.tr:not(.th):hover {
background: v-bind("theme.hoverColor");
/* 正文是后台富文本编辑器存的 HTML,和题面一样 16px,别缩 */
.content {
padding-top: 14px;
font-size: 16px;
line-height: 1.8;
overflow-wrap: anywhere;
}
.th {
min-height: var(--oj-head-h);
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
cursor: default;
.content :deep(p) {
margin: 0 0 8px;
}
.empty {
padding: 32px var(--oj-pad-x);
.content :deep(img) {
max-width: 100%;
height: auto;
}
@media (max-width: 760px) {
.tr {
grid-template-columns: 1fr auto;
.content :deep(a) {
color: v-bind("theme.primaryColor");
}
.tr > :nth-child(2),
.tr > :nth-child(4) {
display: none;
.placeholder {
min-height: 240px;
}
@media (max-width: 768px) {
.list {
width: 100%;
}
.reader {
padding: 16px;
}
}
</style>
+53 -50
View File
@@ -2,38 +2,37 @@
import type { ClassToday } from "@oj2/contract"
import type { AnnouncementListItem } from "utils/types"
import { useThemeVars } from "naive-ui"
import { getAnnouncement, getAnnouncementList, getClassToday } from "oj/api"
import { getAnnouncementList, getClassToday } from "oj/api"
import { classLabel } from "oj/submission/utils"
import { useUserStore } from "shared/store/user"
import { parseTime, zonedParts } from "utils/functions"
import { parseTime, zonedParts, zonedYear } from "utils/functions"
/**
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
* 下面是常用入口和置顶的公告
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
*/
const theme = useThemeVars()
const router = useRouter()
const userStore = useUserStore()
const today = ref<ClassToday | null>(null)
const pinned = ref<AnnouncementListItem[]>([])
const announcements = ref<AnnouncementListItem[]>([])
/** 最后一次有人交题在这么久以内,算「正在上课」 */
const LIVE_MS = 15 * 60 * 1000
onMounted(async () => {
const [classes, announcements] = await Promise.allSettled([
getClassToday(),
getAnnouncementList(0, 10),
])
const [classes, latest] = await Promise.allSettled([getClassToday(), getAnnouncementList(0, 3)])
if (classes.status === "fulfilled") today.value = classes.value
if (announcements.status === "fulfilled") {
pinned.value = announcements.value.results.filter((item) => item.top)
}
if (latest.status === "fulfilled") announcements.value = latest.value.results
})
function dateText(value: string) {
return parseTime(value, zonedParts(value)!.year === zonedYear() ? "M月D日" : "YYYY年M月D日")
}
const greeting = computed(() => {
const hour = zonedParts(new Date())!.hour
if (hour < 5) return "夜深了"
@@ -74,16 +73,6 @@ const shortcuts = computed(() =>
{ label: "后台", to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list", show: true },
].filter((it) => it.show),
)
const announcementShow = ref(false)
const announcementTitle = ref("")
const announcementContent = ref("")
async function openAnnouncement(item: AnnouncementListItem) {
const res = await getAnnouncement(item.id)
announcementTitle.value = item.title
announcementContent.value = res.content
announcementShow.value = true
}
</script>
<template>
@@ -141,30 +130,23 @@ async function openAnnouncement(item: AnnouncementListItem) {
</n-button>
</div>
<div
v-for="item in pinned"
:key="item.id"
class="card pinned"
role="button"
tabindex="0"
@click="openAnnouncement(item)"
@keyup.enter="openAnnouncement(item)"
>
<n-tag type="warning" size="small" :bordered="false">置顶</n-tag>
<b>{{ item.title }}</b>
<section v-if="announcements.length" class="card">
<div class="card-head">
<h3>公告</h3>
<span class="spacer"></span>
<router-link to="/announcement" class="muted" @click.stop>公告 →</router-link>
<router-link to="/announcement" class="muted">全部</router-link>
</div>
<n-modal
v-model:show="announcementShow"
preset="card"
:style="{ maxWidth: '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="announcementTitle"
<router-link
v-for="item in announcements"
:key="item.id"
:to="{ path: '/announcement', query: { id: item.id } }"
class="ann-row"
>
<div v-html="announcementContent"></div>
</n-modal>
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
<span class="ann-title">{{ item.title }}</span>
<span class="muted num">{{ dateText(item.createTime) }}</span>
</router-link>
</section>
</div>
</template>
@@ -299,15 +281,36 @@ async function openAnnouncement(item: AnnouncementListItem) {
gap: 10px;
}
.pinned {
flex-direction: row;
align-items: center;
gap: 10px;
padding: 14px 18px;
cursor: pointer;
.card-head {
display: flex;
align-items: baseline;
}
.pinned:hover {
border-color: v-bind("theme.primaryColor");
.ann-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 6px;
border-top: 1px solid v-bind("theme.dividerColor");
border-radius: 4px;
color: inherit;
text-decoration: none;
}
.ann-row:first-of-type {
border-top: 0;
}
.ann-row:hover {
background: v-bind("theme.hoverColor");
}
.ann-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+4 -31
View File
@@ -2,7 +2,6 @@
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from "oj/learn/lesson"
import { Icon } from "@iconify/vue"
import {
getAnnouncement,
getAnnouncementList,
getClassActivity,
getKnowledgeMap,
@@ -14,7 +13,6 @@ import {
getWeeklyRank,
} from "oj/api"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { pickHeadline, type Headline } from "oj/user/knowledge"
import { useUserStore } from "shared/store/user"
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
@@ -43,7 +41,6 @@ interface Track {
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const tracks = ref<Track[]>([])
const contests = ref<Contest[]>([])
@@ -313,17 +310,6 @@ function contestTime(contest: Contest) {
}
return `${parseTime(contest.startTime, "M月D日 HH:mm")} 开始 · 时长 ${duration(contest.startTime, contest.endTime)}`
}
const announcementShow = ref(false)
const announcementTitle = ref("")
const announcementContent = ref("")
async function openAnnouncement(item: AnnouncementListItem) {
const res = await getAnnouncement(item.id)
announcementTitle.value = item.title
announcementContent.value = res.content
announcementShow.value = true
}
</script>
<template>
@@ -528,32 +514,19 @@ async function openAnnouncement(item: AnnouncementListItem) {
<template #header-extra>
<router-link to="/announcement" class="more">全部</router-link>
</template>
<div
<router-link
v-for="item in announcements"
:key="item.id"
class="row clickable"
role="button"
tabindex="0"
@click="openAnnouncement(item)"
@keyup.enter="openAnnouncement(item)"
:to="{ path: '/announcement', query: { id: item.id } }"
class="row"
>
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
<span class="row-title">{{ item.title }}</span>
<n-text depth="3" class="row-meta">{{ dateText(item.createTime) }}</n-text>
</div>
</router-link>
</n-card>
</div>
</div>
<n-modal
v-model:show="announcementShow"
preset="card"
:style="{ maxWidth: isDesktop ? '70vw' : undefined, maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="announcementTitle"
>
<div v-html="announcementContent"></div>
</n-modal>
</div>
</template>