Compare commits
2
Commits
f3956ad6a1
...
93f80740ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f80740ef | ||
|
|
06c679579c |
No files matched your search
@@ -1,14 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-flex align="center" :size="8" :wrap="false">
|
|
||||||
<n-tag type="error" size="small" v-if="top">置顶</n-tag>
|
|
||||||
<span>{{ title }}</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
top: boolean
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
</script>
|
|
||||||
@@ -1,84 +1,151 @@
|
|||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
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 { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { parseTime } from "utils/functions"
|
import { errorCode } from "utils/api"
|
||||||
import type { AnnouncementListItem } from "utils/types"
|
import { parseTime, zonedParts, zonedYear } from "utils/functions"
|
||||||
import TitleWithTag from "./components/TitleWithTag.vue"
|
import type { Announcement, AnnouncementListItem } from "utils/types"
|
||||||
|
|
||||||
const total = ref(0)
|
/**
|
||||||
const content = ref("")
|
* 公告:左边列表、右边全文,和后台 AI 报告一个排法。原来是一张四列的表、正文开弹框 ——
|
||||||
const title = ref("")
|
* 「发布人」一列 24 条全是同一个人,弹框又让人一条条点开再关。
|
||||||
const [show, toggleShow] = useToggle(false)
|
* 选中哪条写在地址的 ?id= 上,首页的公告卡片直接链到这里那一条。
|
||||||
|
* 手机上放不下两栏:没带 id 只列表,带了 id 只全文、顶上一个「全部公告」回去。
|
||||||
|
* 一共才二十几条(四年 24 条),一次拉全,不分页
|
||||||
|
*/
|
||||||
|
const LIMIT = 100
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
const theme = useThemeVars()
|
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 announcements = ref<AnnouncementListItem[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
async function listAnnouncements() {
|
onMounted(async () => {
|
||||||
const offset = (query.page - 1) * query.limit
|
const res = await getAnnouncementList(0, LIMIT)
|
||||||
const res = await getAnnouncementList(offset, query.limit)
|
|
||||||
total.value = res.total
|
|
||||||
announcements.value = res.results
|
announcements.value = res.results
|
||||||
loaded.value = true
|
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)
|
function backToList() {
|
||||||
watch(query, listAnnouncements, { deep: true })
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page oj-page">
|
<div class="page oj-page">
|
||||||
<h2>公告</h2>
|
<h2>公告</h2>
|
||||||
<div class="table">
|
|
||||||
<div class="tr th">
|
<n-empty v-if="loaded && !announcements.length" description="还没有公告" class="blank" />
|
||||||
<span>公告标题</span>
|
|
||||||
<span>标签</span>
|
<div v-else class="layout">
|
||||||
<span>发布时间</span>
|
<div v-if="showList" class="list">
|
||||||
<span>发布人</span>
|
<button
|
||||||
</div>
|
v-for="item in announcements"
|
||||||
<div
|
:key="item.id"
|
||||||
v-for="row in announcements"
|
type="button"
|
||||||
:key="row.id"
|
class="item"
|
||||||
class="tr"
|
:class="{ on: isDesktop && item.id === selectedId }"
|
||||||
role="link"
|
@click="select(item.id)"
|
||||||
tabindex="0"
|
|
||||||
@click="showContent(row)"
|
|
||||||
@keyup.enter="showContent(row)"
|
|
||||||
>
|
|
||||||
<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="item-title">
|
||||||
<span class="muted">{{ row.createdBy.username }}</span>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -93,7 +160,69 @@ h2 {
|
|||||||
font-size: var(--oj-fs-title);
|
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 {
|
.muted {
|
||||||
|
font-size: var(--oj-fs-sec);
|
||||||
color: v-bind("theme.textColor3");
|
color: v-bind("theme.textColor3");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,56 +230,70 @@ h2 {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.reader {
|
||||||
border: 1px solid v-bind("theme.borderColor");
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px var(--oj-pad-x) 28px;
|
||||||
border-radius: var(--oj-radius);
|
border-radius: var(--oj-radius);
|
||||||
overflow: hidden;
|
background: v-bind("theme.actionColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr {
|
.back {
|
||||||
display: grid;
|
display: inline-block;
|
||||||
grid-template-columns: minmax(200px, 1fr) 80px 170px 120px;
|
margin-bottom: 12px;
|
||||||
align-items: center;
|
font-size: var(--oj-fs-sec);
|
||||||
gap: 14px;
|
color: v-bind("theme.textColor3");
|
||||||
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");
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr > .muted {
|
.reader-head {
|
||||||
font-size: var(--oj-fs-sec);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr:last-child {
|
h3 {
|
||||||
border-bottom: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr:not(.th):hover {
|
/* 正文是后台富文本编辑器存的 HTML,和题面一样 16px,别缩 */
|
||||||
background: v-bind("theme.hoverColor");
|
.content {
|
||||||
|
padding-top: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th {
|
.content :deep(p) {
|
||||||
min-height: var(--oj-head-h);
|
margin: 0 0 8px;
|
||||||
font-size: var(--oj-fs-meta);
|
|
||||||
color: v-bind("theme.textColor3");
|
|
||||||
background: v-bind("theme.actionColor");
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.content :deep(img) {
|
||||||
padding: 32px var(--oj-pad-x);
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
.content :deep(a) {
|
||||||
.tr {
|
color: v-bind("theme.primaryColor");
|
||||||
grid-template-columns: 1fr auto;
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.list {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr > :nth-child(2),
|
.reader {
|
||||||
.tr > :nth-child(4) {
|
padding: 16px;
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,6 +3,8 @@ import { useThemeVars } from "naive-ui"
|
|||||||
import { useTone } from "oj/submission/composables/tone"
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
import { ContestStatus } from "utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
import { parseTime, secondsToDuration } from "utils/functions"
|
import { parseTime, secondsToDuration } from "utils/functions"
|
||||||
|
import NotFound from "shared/components/NotFound.vue"
|
||||||
|
import { errorCode } from "utils/api"
|
||||||
import { useContestStore } from "../store/contest"
|
import { useContestStore } from "../store/contest"
|
||||||
import ContestBadge from "./components/ContestBadge.vue"
|
import ContestBadge from "./components/ContestBadge.vue"
|
||||||
import ContestInfo from "./components/ContestInfo.vue"
|
import ContestInfo from "./components/ContestInfo.vue"
|
||||||
@@ -34,18 +36,27 @@ async function check() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拉比赛失败:原来没接,链接不对就是一整页空白 */
|
||||||
|
const failure = ref<null | "missing" | "failed">(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
failure.value = null
|
||||||
|
try {
|
||||||
|
await contestStore.init(props.contestID)
|
||||||
|
} catch (err) {
|
||||||
|
if (contestStore.contest) return
|
||||||
|
failure.value = errorCode(err) === "contest-not-found" ? "missing" : "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => contestStore.contestStatus,
|
() => contestStore.contestStatus,
|
||||||
(nv, ov) => {
|
(nv, ov) => {
|
||||||
if (nv === ContestStatus.underway && ov == ContestStatus.not_started) {
|
if (nv === ContestStatus.underway && ov == ContestStatus.not_started) load()
|
||||||
contestStore.init(props.contestID)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(load)
|
||||||
contestStore.init(props.contestID)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(contestStore.clear)
|
onBeforeUnmount(contestStore.clear)
|
||||||
|
|
||||||
@@ -95,7 +106,17 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="contest" class="contest-page">
|
<NotFound
|
||||||
|
v-if="failure === 'missing'"
|
||||||
|
title="这场比赛不存在"
|
||||||
|
hint="可能是链接复制错了,或者这场比赛还没公开"
|
||||||
|
to="/contest"
|
||||||
|
to-label="去比赛列表"
|
||||||
|
/>
|
||||||
|
<NotFound v-else-if="failure === 'failed'" icon="ph:wifi-slash" title="比赛没拉下来">
|
||||||
|
<n-button type="primary" @click="load()">再试一次</n-button>
|
||||||
|
</NotFound>
|
||||||
|
<div v-else-if="contest" class="contest-page">
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<ContestBadge :status="status" />
|
<ContestBadge :status="status" />
|
||||||
<h2 class="title">{{ contest.title }}</h2>
|
<h2 class="title">{{ contest.title }}</h2>
|
||||||
|
|||||||
@@ -2,38 +2,37 @@
|
|||||||
import type { ClassToday } from "@oj2/contract"
|
import type { ClassToday } from "@oj2/contract"
|
||||||
import type { AnnouncementListItem } from "utils/types"
|
import type { AnnouncementListItem } from "utils/types"
|
||||||
import { useThemeVars } from "naive-ui"
|
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 { classLabel } from "oj/submission/utils"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { parseTime, zonedParts } from "utils/functions"
|
import { parseTime, zonedParts, zonedYear } from "utils/functions"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
|
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
|
||||||
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
|
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
|
||||||
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
|
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
|
||||||
* 下面是常用入口和置顶的公告
|
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
|
||||||
*/
|
*/
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const today = ref<ClassToday | null>(null)
|
const today = ref<ClassToday | null>(null)
|
||||||
const pinned = ref<AnnouncementListItem[]>([])
|
const announcements = ref<AnnouncementListItem[]>([])
|
||||||
|
|
||||||
/** 最后一次有人交题在这么久以内,算「正在上课」 */
|
/** 最后一次有人交题在这么久以内,算「正在上课」 */
|
||||||
const LIVE_MS = 15 * 60 * 1000
|
const LIVE_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [classes, announcements] = await Promise.allSettled([
|
const [classes, latest] = await Promise.allSettled([getClassToday(), getAnnouncementList(0, 3)])
|
||||||
getClassToday(),
|
|
||||||
getAnnouncementList(0, 10),
|
|
||||||
])
|
|
||||||
if (classes.status === "fulfilled") today.value = classes.value
|
if (classes.status === "fulfilled") today.value = classes.value
|
||||||
if (announcements.status === "fulfilled") {
|
if (latest.status === "fulfilled") announcements.value = latest.value.results
|
||||||
pinned.value = announcements.value.results.filter((item) => item.top)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function dateText(value: string) {
|
||||||
|
return parseTime(value, zonedParts(value)!.year === zonedYear() ? "M月D日" : "YYYY年M月D日")
|
||||||
|
}
|
||||||
|
|
||||||
const greeting = computed(() => {
|
const greeting = computed(() => {
|
||||||
const hour = zonedParts(new Date())!.hour
|
const hour = zonedParts(new Date())!.hour
|
||||||
if (hour < 5) return "夜深了"
|
if (hour < 5) return "夜深了"
|
||||||
@@ -74,16 +73,6 @@ const shortcuts = computed(() =>
|
|||||||
{ label: "后台", to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list", show: true },
|
{ label: "后台", to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list", show: true },
|
||||||
].filter((it) => it.show),
|
].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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -141,30 +130,23 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<section v-if="announcements.length" class="card">
|
||||||
v-for="item in pinned"
|
<div class="card-head">
|
||||||
:key="item.id"
|
<h3>公告</h3>
|
||||||
class="card pinned"
|
<span class="spacer"></span>
|
||||||
role="button"
|
<router-link to="/announcement" class="muted">全部</router-link>
|
||||||
tabindex="0"
|
</div>
|
||||||
@click="openAnnouncement(item)"
|
<router-link
|
||||||
@keyup.enter="openAnnouncement(item)"
|
v-for="item in announcements"
|
||||||
>
|
:key="item.id"
|
||||||
<n-tag type="warning" size="small" :bordered="false">置顶</n-tag>
|
:to="{ path: '/announcement', query: { id: item.id } }"
|
||||||
<b>{{ item.title }}</b>
|
class="ann-row"
|
||||||
<span class="spacer"></span>
|
>
|
||||||
<router-link to="/announcement" class="muted" @click.stop>公告 →</router-link>
|
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
|
||||||
</div>
|
<span class="ann-title">{{ item.title }}</span>
|
||||||
|
<span class="muted num">{{ dateText(item.createTime) }}</span>
|
||||||
<n-modal
|
</router-link>
|
||||||
v-model:show="announcementShow"
|
</section>
|
||||||
preset="card"
|
|
||||||
:style="{ maxWidth: '70vw', maxHeight: '80vh' }"
|
|
||||||
:content-style="{ overflow: 'auto' }"
|
|
||||||
:title="announcementTitle"
|
|
||||||
>
|
|
||||||
<div v-html="announcementContent"></div>
|
|
||||||
</n-modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -299,15 +281,36 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned {
|
.card-head {
|
||||||
flex-direction: row;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 10px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned:hover {
|
.ann-row {
|
||||||
border-color: v-bind("theme.primaryColor");
|
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>
|
</style>
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from "oj/learn/lesson"
|
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from "oj/learn/lesson"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import {
|
import {
|
||||||
getAnnouncement,
|
|
||||||
getAnnouncementList,
|
getAnnouncementList,
|
||||||
getClassActivity,
|
getClassActivity,
|
||||||
getKnowledgeMap,
|
getKnowledgeMap,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
getWeeklyRank,
|
getWeeklyRank,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
import { pickHeadline, type Headline } from "oj/user/knowledge"
|
import { pickHeadline, type Headline } from "oj/user/knowledge"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
@@ -43,7 +41,6 @@ interface Track {
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
|
|
||||||
const tracks = ref<Track[]>([])
|
const tracks = ref<Track[]>([])
|
||||||
const contests = ref<Contest[]>([])
|
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)}`
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -528,32 +514,19 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<router-link to="/announcement" class="more">全部</router-link>
|
<router-link to="/announcement" class="more">全部</router-link>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<router-link
|
||||||
v-for="item in announcements"
|
v-for="item in announcements"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="row clickable"
|
:to="{ path: '/announcement', query: { id: item.id } }"
|
||||||
role="button"
|
class="row"
|
||||||
tabindex="0"
|
|
||||||
@click="openAnnouncement(item)"
|
|
||||||
@keyup.enter="openAnnouncement(item)"
|
|
||||||
>
|
>
|
||||||
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
|
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
|
||||||
<span class="row-title">{{ item.title }}</span>
|
<span class="row-title">{{ item.title }}</span>
|
||||||
<n-text depth="3" class="row-meta">{{ dateText(item.createTime) }}</n-text>
|
<n-text depth="3" class="row-meta">{{ dateText(item.createTime) }}</n-text>
|
||||||
</div>
|
</router-link>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useSubmissionStore } from "oj/store/submission"
|
|||||||
import { useFlowchartStore } from "oj/store/flowchart"
|
import { useFlowchartStore } from "oj/store/flowchart"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import type { RouteLocationNormalized } from "vue-router"
|
import type { RouteLocationNormalized } from "vue-router"
|
||||||
|
import NotFound from "shared/components/NotFound.vue"
|
||||||
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好
|
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好
|
||||||
import SubmissionEffects from "./components/SubmissionEffects.vue"
|
import SubmissionEffects from "./components/SubmissionEffects.vue"
|
||||||
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
||||||
@@ -40,9 +41,10 @@ interface Props {
|
|||||||
problemSetId?: string
|
problemSetId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { problemID, contestID = "" } = defineProps<Props>()
|
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
|
||||||
|
|
||||||
const errMsg = ref("无数据")
|
/** 拉题面失败的原因:null 是还在拉(或拉到了),别的就整页换成 NotFound */
|
||||||
|
const failure = ref<null | "missing" | "not-started" | "failed">(null)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -226,6 +228,7 @@ async function init() {
|
|||||||
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
|
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
|
||||||
// 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现
|
// 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现
|
||||||
loadProblemEditor()
|
loadProblemEditor()
|
||||||
|
failure.value = null
|
||||||
try {
|
try {
|
||||||
const res = await getProblem(problemID, contestID)
|
const res = await getProblem(problemID, contestID)
|
||||||
if (mine !== initSeq) return
|
if (mine !== initSeq) return
|
||||||
@@ -233,12 +236,25 @@ async function init() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (mine !== initSeq) return
|
if (mine !== initSeq) return
|
||||||
problem.value = null
|
problem.value = null
|
||||||
if (errorCode(err) === "contest-not-started") {
|
const code = errorCode(err)
|
||||||
errMsg.value = "比赛还没有开始"
|
failure.value =
|
||||||
}
|
code === "contest-not-started"
|
||||||
|
? "not-started"
|
||||||
|
: code === "problem-not-found"
|
||||||
|
? "missing"
|
||||||
|
: "failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
|
|
||||||
|
/** 题不存在时回哪:比赛里回比赛,题单里回题单,否则回题目列表 */
|
||||||
|
const backTo = computed(() =>
|
||||||
|
contestID
|
||||||
|
? { to: `/contest/${contestID}`, toLabel: "回到比赛" }
|
||||||
|
: problemSetId
|
||||||
|
? { to: `/problemset/${problemSetId}`, toLabel: "回到题单" }
|
||||||
|
: { to: "/problem", toLabel: "去题目列表" },
|
||||||
|
)
|
||||||
watch(() => problemID, init)
|
watch(() => problemID, init)
|
||||||
|
|
||||||
// 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 ——
|
// 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 ——
|
||||||
@@ -262,7 +278,7 @@ onBeforeUnmount(() => {
|
|||||||
// 流程图那边同理,还在评的那次不再跟(照常评完落库)
|
// 流程图那边同理,还在评的那次不再跟(照常评完落库)
|
||||||
useFlowchartStore().reset()
|
useFlowchartStore().reset()
|
||||||
problem.value = null
|
problem.value = null
|
||||||
errMsg.value = "无数据"
|
failure.value = null
|
||||||
myFlowchartStore.hide()
|
myFlowchartStore.hide()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -372,7 +388,23 @@ onBeforeUnmount(() => {
|
|||||||
<MobileActionBar />
|
<MobileActionBar />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n-empty v-else :description="errMsg"></n-empty>
|
<NotFound
|
||||||
|
v-else-if="failure === 'not-started'"
|
||||||
|
icon="ph:clock"
|
||||||
|
title="比赛还没有开始"
|
||||||
|
hint="开始以后才能看题"
|
||||||
|
:to="`/contest/${contestID}`"
|
||||||
|
to-label="回到比赛"
|
||||||
|
/>
|
||||||
|
<NotFound v-else-if="failure === 'failed'" icon="ph:wifi-slash" title="题目没拉下来">
|
||||||
|
<n-button type="primary" @click="init()">再试一次</n-button>
|
||||||
|
</NotFound>
|
||||||
|
<NotFound
|
||||||
|
v-else-if="failure === 'missing'"
|
||||||
|
title="这道题不存在"
|
||||||
|
hint="可能是题号打错了,或者这道题还没公开"
|
||||||
|
v-bind="backTo"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ProblemSet, ProblemSetProblem } from "utils/types"
|
|||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { useFireworks } from "../problem/composables/useFireworks"
|
import { useFireworks } from "../problem/composables/useFireworks"
|
||||||
|
import NotFound from "shared/components/NotFound.vue"
|
||||||
import BadgeLadder from "./components/BadgeLadder.vue"
|
import BadgeLadder from "./components/BadgeLadder.vue"
|
||||||
import ClassView from "./components/ClassView.vue"
|
import ClassView from "./components/ClassView.vue"
|
||||||
|
|
||||||
@@ -133,16 +134,13 @@ const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-result
|
<NotFound
|
||||||
v-if="missing"
|
v-if="missing"
|
||||||
status="404"
|
title="这个题单不存在"
|
||||||
title="题单不存在"
|
hint="可能还没公开,或者已经删掉了"
|
||||||
description="可能还没公开,或者已经删掉了"
|
to="/problemset"
|
||||||
>
|
to-label="回到题单列表"
|
||||||
<template #footer>
|
/>
|
||||||
<n-button @click="router.push({ name: 'problemsets' })">回到题单列表</n-button>
|
|
||||||
</template>
|
|
||||||
</n-result>
|
|
||||||
|
|
||||||
<div v-else-if="set" class="page oj-page">
|
<div v-else-if="set" class="page oj-page">
|
||||||
<section class="band" :class="{ done: progress?.isCompleted }">
|
<section class="band" :class="{ done: progress?.isCompleted }">
|
||||||
|
|||||||
@@ -146,6 +146,14 @@ export const ojs: RouteRecordRaw = {
|
|||||||
name: "problemset problem",
|
name: "problemset problem",
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
// 兜底:没有它,打错地址是一整页空白,连顶栏都没有。vue-router 按路径具体程度排序,
|
||||||
|
// 不按注册顺序,所以它吃不掉别的路由;/admin 下打错的也落到这里
|
||||||
|
{
|
||||||
|
path: ":pathMatch(.*)*",
|
||||||
|
name: "not found",
|
||||||
|
component: () => import("shared/components/NotFound.vue"),
|
||||||
|
props: { title: "这个页面不存在", hint: "可能是地址打错了,或者这个页面已经下线" },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import type { RouteLocationRaw } from "vue-router"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「找不到」的整页提示:地址打错、题目 / 比赛 / 题单不存在或没公开、比赛还没开始。
|
||||||
|
* 原来各页各写各的 —— 打错地址是一整页空白、题号不对只有一个「无数据」,学生看不出出了什么事。
|
||||||
|
* 样子跟提交详情的「这条提交不存在」一致:图标、一句话、一句为什么、一个回去的按钮。
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
hint?: string
|
||||||
|
icon?: string
|
||||||
|
to?: RouteLocationRaw
|
||||||
|
toLabel?: string
|
||||||
|
}>(),
|
||||||
|
{ hint: "", icon: "ph:magnifying-glass", to: "/", toLabel: "回首页" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const router = useRouter()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="not-found">
|
||||||
|
<Icon :icon="props.icon" :width="40" class="icon" />
|
||||||
|
<span class="title">{{ props.title }}</span>
|
||||||
|
<span v-if="props.hint" class="hint">{{ props.hint }}</span>
|
||||||
|
<div class="actions">
|
||||||
|
<slot>
|
||||||
|
<n-button type="primary" @click="router.push(props.to)">{{ props.toLabel }}</n-button>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-found {
|
||||||
|
min-height: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: var(--oj-fs-sec);
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in new issue
Block a user