Compare commits

..
4 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 d07459928d feat(标签): 题目列表的标签分成「知识点」「主题」两组,新建的标签在后台标出待归类
Deploy / deploy (push) Canceled after 0s
- 迁移 0022:problem_tag 加 category(knowledge / theme)和 category_confirmed;
  存量 25 个题材、题集类标签归主题,其余归知识点,全部记为已确认
- 前台题目列表按两组显示标签,组内按题目数从多到少
- 后台标签管理:每行切换分类;出题时新建的标签默认知识点、标「待归类」并排在最前,
  页头提示待归类数量,可点「确认」保留默认;加分类筛选
- 新接口 PUT /admin/problem-tags/:id/category,设置分类即视为确认

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:38:43 +08:00
xuyueandClaude Opus 5.5 e1b6a9e9fa feat(前台): 题单卡片显示完成进度,手机上题目列表不再横向溢出,个人主页数据排得更紧凑
- 题单卡片:完成进度条和「已完成 x / y 题」,「已完成」改成绿色,未获得的徽章置灰;
  进度按加入时的快照算,题单后来加了题就注明「新加了 N 题」,不再出现「已完成」却是 5 / 6
- 题目列表:手机上只留状态、编号、题目、难度四列;标签默认收起,选中的标签显示在按钮旁
- 个人主页:六项数据保留,改成三列两行的小卡片,头像缩小

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:27:00 +08:00
xuyueandClaude Opus 5.5 6bf8c6e1d9 style(前台): 列表页去掉表头 emoji、已结束的比赛改成灰色;比赛列表置顶进行中的比赛,排名页先看榜单
- 前台 53 处表头改成纯文字,删掉只剩这一个用途的 utils/renders.ts
- 「已结束」从红色改灰色(比赛列表 / 详情 / 后台 / 首页一起)
- 比赛列表第一页、无筛选时顶部列出进行中和即将开始的比赛
- 内容少的页面页脚贴底,不再悬在半屏
- 排名页两张柱状图挪到最后,第一屏是 Top100、本周进步榜和我的排名

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:13:29 +08:00
xuyueandClaude Opus 5.5 31aa5f6e74 feat(前台): 登录后首页改成个人首页;题目详情页的代码要求挪到最前面、样例并排显示
- 登录后 / 是个人首页:继续学习、最近提交、进行中/即将开始的比赛、公告;
  没登录仍是题目列表,题目列表另有 /problem 地址,顶栏「题目」指向它
- 继续学习按服务端留痕取最近打开的一课,不用 localStorage(机房一机多人)
- 题目详情:标题下加难度与时空限制,代码要求提到最前,小节收紧,
  样例输入/输出并排;工具栏「提交信息」「更多操作」改为「本题提交」「更多」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:08:47 +08:00
31 changed files with 5394 additions and 410 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
-- 标签分「知识点 / 主题」两类,前台题目列表按两组显示,含义见契约 tagCategorySchema。
-- 带常量默认值的加列只改目录不重写表;新标签默认算知识点、且分类「未确认」,
-- 后台标签管理把未确认的排在最前面,等老师点一下归类。
ALTER TABLE "problem_tag" ADD COLUMN "category" text DEFAULT 'knowledge' NOT NULL;--> statement-breakpoint
ALTER TABLE "problem_tag" ADD COLUMN "category_confirmed" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- 存量标签的归类:按名字挑出主题类标签,其余留在知识点。按 lower(name) 匹配
-- (和唯一索引 problem_tag_name_ci_unique 同一口径),库里没有的名字空转即可。
-- 必会题、技能队、力扣是题集 / 来源而不是知识点,也归主题。
UPDATE "problem_tag" SET "category" = 'theme' WHERE lower("name") IN (
'弱智吧', '二次元', '狐妖小红娘', '第五人格', '农药', '哈利波特', '九大学科', '原',
'职一第一深情', '异兽', '反诈', '三角洲', '火影', '神秘复苏', 'blue archive',
'空洞骑士', '周杰伦', 'lol', '奥特曼', '无畏契约', '图书馆', '物联网',
'必会题', '技能队', '力扣'
);--> statement-breakpoint
-- 上面这版归类已经人工过目,存量标签一律算确认过;之后新建的才进「待归类」
UPDATE "problem_tag" SET "category_confirmed" = true;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -155,6 +155,13 @@
"when": 1789990062378, "when": 1789990062378,
"tag": "0021_ai_hint_level", "tag": "0021_ai_hint_level",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1790512513639,
"tag": "0022_add_tag_category",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -52,6 +52,7 @@ import type {
ReactionKey, ReactionKey,
SqlConfig, SqlConfig,
SqlDisplay, SqlDisplay,
TagCategory,
TutorialType, TutorialType,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
@@ -738,6 +739,10 @@ export const problemTag = pgTable(
{ {
id: serial().primaryKey().notNull(), id: serial().primaryKey().notNull(),
name: text().notNull(), name: text().notNull(),
category: text().notNull().default("knowledge").$type<TagCategory>(),
// 分类有没有人确认过。出题时顺手新建的标签一律先落在「知识点」,这一列为 false,
// 后台标签管理把它们排在最前面等老师归类;存量标签由 0022 统一标成 true
categoryConfirmed: boolean("category_confirmed").notNull().default(false),
}, },
() => [uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`)], () => [uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`)],
) )
+25 -3
View File
@@ -2,6 +2,7 @@ import {
batchProblemTagRequestSchema, batchProblemTagRequestSchema,
generateFlowchartRequestSchema, generateFlowchartRequestSchema,
renameTagRequestSchema, renameTagRequestSchema,
setTagCategoryRequestSchema,
type AcTrend, type AcTrend,
type AdminTag, type AdminTag,
type BatchProblemTagResponse, type BatchProblemTagResponse,
@@ -54,15 +55,22 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
.select({ .select({
id: schema.problemTag.id, id: schema.problemTag.id,
name: schema.problemTag.name, name: schema.problemTag.name,
category: schema.problemTag.category,
categoryConfirmed: schema.problemTag.categoryConfirmed,
problemCount: countDistinct(schema.problemTags.problemId), problemCount: countDistinct(schema.problemTags.problemId),
}) })
.from(schema.problemTag) .from(schema.problemTag)
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id)) .leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined) .where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name) .groupBy(schema.problemTag.id)
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些), // 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0 // 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0。
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name)) // 待归类的新标签排最前面(false 在 true 之前),不然会沉在几十个老标签底下
.orderBy(
asc(schema.problemTag.categoryConfirmed),
desc(countDistinct(schema.problemTags.problemId)),
asc(schema.problemTag.name),
)
return success(c, rows satisfies AdminTag[]) return success(c, rows satisfies AdminTag[])
}) })
@@ -134,6 +142,20 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
} satisfies RenameTagResponse) } satisfies RenameTagResponse)
}) })
adminTagRoutes.put("/problem-tags/:id/category", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await parseBody(c, setTagCategoryRequestSchema, "分类只能是知识点或主题")
if (!parsed.success) return parsed.response
const [row] = await db
.update(schema.problemTag)
// 选了分类就算确认过,哪怕选的还是默认的「知识点」
.set({ category: parsed.data.category, categoryConfirmed: true })
.where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id })
if (!row) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
return success(c, null)
})
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => { adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010) // 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
+2 -1
View File
@@ -193,6 +193,7 @@ problemRoutes.get("/problem-tags", async (c) => {
.select({ .select({
id: schema.problemTag.id, id: schema.problemTag.id,
name: schema.problemTag.name, name: schema.problemTag.name,
category: schema.problemTag.category,
problemCount: countDistinct(schema.problemTags.problemId), problemCount: countDistinct(schema.problemTags.problemId),
}) })
.from(schema.problemTag) .from(schema.problemTag)
@@ -206,7 +207,7 @@ problemRoutes.get("/problem-tags", async (c) => {
), ),
) )
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined) .where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name) .groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category)
.having(sql`count(${schema.problemTags.problemId}) > 0`) .having(sql`count(${schema.problemTags.problemId}) > 0`)
.orderBy(asc(schema.problemTag.name)) .orderBy(asc(schema.problemTag.name))
return success(c, rows satisfies Tag[]) return success(c, rows satisfies Tag[])
-1
View File
@@ -158,7 +158,6 @@ Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属
- `utils/functions.ts` — `parseTime` / `zonedParts` / `zonedYear`(东八区时间口径,见上)、 - `utils/functions.ts` — `parseTime` / `zonedParts` / `zonedYear`(东八区时间口径,见上)、
`duration`、压缩与剪贴板等杂项 `duration`、压缩与剪贴板等杂项
- `utils/judge.ts` — Judge-related utilities - `utils/judge.ts` — Judge-related utilities
- `utils/renders.ts` — Table column render helpers for Naive UI DataTable
### Environment Variables ### Environment Variables
+5
View File
@@ -43,6 +43,7 @@ import type {
AdminProblemSet, AdminProblemSet,
AdminProblemSetProblem, AdminProblemSetProblem,
TutorialListItem, TutorialListItem,
TagCategory,
} from "utils/types" } from "utils/types"
export function getBaseInfo() { export function getBaseInfo() {
@@ -114,6 +115,10 @@ export function renameTag(id: number, name: string) {
return api.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name }) return api.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name })
} }
export function setTagCategory(id: number, category: TagCategory) {
return api.put<null>(`admin/problem-tags/${id}/category`, { category })
}
export function deleteTag(id: number) { export function deleteTag(id: number) {
return api.delete(`admin/problem-tags/${id}`) return api.delete(`admin/problem-tags/${id}`)
} }
+91 -13
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import { NButton, NFlex, NInput } from "naive-ui" import { NButton, NFlex, NInput, NRadioButton, NRadioGroup, NTag } from "naive-ui"
import type { AdminTag } from "utils/types" import type { AdminTag, TagCategory } from "utils/types"
import { deleteTag, getTagAdminList, renameTag } from "../api" import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
import TagProblemsModal from "./components/TagProblemsModal.vue" import TagProblemsModal from "./components/TagProblemsModal.vue"
const message = useMessage() const message = useMessage()
@@ -10,6 +10,27 @@ const dialog = useDialog()
const tags = ref<AdminTag[]>([]) const tags = ref<AdminTag[]>([])
const keyword = ref("") const keyword = ref("")
type CategoryFilter = "" | "pending" | TagCategory
const categoryFilter = ref<CategoryFilter>("")
// 出题时新建的标签默认算「知识点」、还没人确认过,数出来挂在页头提醒
const pendingCount = computed(() => tags.value.filter((t) => !t.categoryConfirmed).length)
// 标签总共几十个,分类筛选在前端做,不必为它再加一个 query 参数
const visibleTags = computed(() => {
const filter = categoryFilter.value
if (!filter) return tags.value
if (filter === "pending") return tags.value.filter((t) => !t.categoryConfirmed)
return tags.value.filter((t) => t.category === filter)
})
const categoryOptions = computed(() => [
{ label: "全部分类", value: "" },
{ label: `待归类(${pendingCount.value})`, value: "pending" },
{ label: "知识点", value: "knowledge" },
{ label: "主题", value: "theme" },
])
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
const editingName = ref("") const editingName = ref("")
@@ -40,15 +61,54 @@ const columns: DataTableColumn<AdminTag>[] = [
if (e.key === "Escape") cancelEdit() if (e.key === "Escape") cancelEdit()
}, },
}) })
: h( : h(NFlex, { size: 8, align: "center" }, () => [
NButton, h(
{ NButton,
text: true, {
type: "primary", text: true,
onClick: () => openTagProblems(row), type: "primary",
}, onClick: () => openTagProblems(row),
() => row.name, },
), () => row.name,
),
row.categoryConfirmed
? null
: h(NTag, { size: "small", type: "warning", bordered: false }, () => "待归类"),
]),
},
{
// 前台题目列表按这一列把标签分成「知识点」「主题」两组
title: "分类",
key: "category",
width: 240,
render: (row) =>
h(NFlex, { size: 8, align: "center", wrap: false }, () => [
h(
NRadioGroup,
{
size: "small",
value: row.category,
onUpdateValue: (v: TagCategory) => changeCategory(row, v),
},
() => [
h(NRadioButton, { value: "knowledge" }, () => "知识点"),
h(NRadioButton, { value: "theme" }, () => "主题"),
],
),
// 新标签默认已经是「知识点」,点它不会触发 update,所以单给一个确认按钮
row.categoryConfirmed
? null
: h(
NButton,
{
size: "small",
type: "primary",
secondary: true,
onClick: () => changeCategory(row, row.category),
},
() => "确认",
),
]),
}, },
{ {
title: "题目数", title: "题目数",
@@ -125,6 +185,13 @@ async function saveTag(tag: AdminTag) {
listTags() listTags()
} }
async function changeCategory(tag: AdminTag, category: TagCategory) {
await setTagCategory(tag.id, category)
tag.category = category
tag.categoryConfirmed = true
message.success(`「${tag.name}」已归到${category === "theme" ? "主题" : "知识点"}`)
}
function confirmDelete(tag: AdminTag) { function confirmDelete(tag: AdminTag) {
dialog.warning({ dialog.warning({
title: "删除标签", title: "删除标签",
@@ -148,9 +215,14 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
<PageHeader title="标签管理"> <PageHeader title="标签管理">
<template #filters> <template #filters>
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable /> <n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable />
<n-select v-model:value="categoryFilter" :options="categoryOptions" style="width: 160px" />
</template> </template>
</PageHeader> </PageHeader>
<n-data-table striped :columns="columns" :data="tags" /> <n-alert v-if="pendingCount > 0" type="warning" :bordered="false" class="pending-alert">
有 {{ pendingCount }} 个新标签还没归类(出题时新建的标签默认算「知识点」)。
前台题目列表按分类把标签分成「知识点」「主题」两组,选好分类或点「确认」即可。
</n-alert>
<n-data-table striped :columns="columns" :data="visibleTags" />
<TagProblemsModal <TagProblemsModal
v-model:show="showTagProblems" v-model:show="showTagProblems"
:tag-id="activeTag?.id ?? 0" :tag-id="activeTag?.id ?? 0"
@@ -158,3 +230,9 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
@changed="listTags" @changed="listTags"
/> />
</template> </template>
<style scoped>
.pending-alert {
margin-bottom: 12px;
}
</style>
+4 -5
View File
@@ -4,7 +4,6 @@ import { getAnnouncement, getAnnouncementList } from "oj/api"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import type { AnnouncementListItem } from "utils/types" import type { AnnouncementListItem } from "utils/types"
import TitleWithTag from "./components/TitleWithTag.vue" import TitleWithTag from "./components/TitleWithTag.vue"
@@ -22,25 +21,25 @@ const query = reactive({
const columns: DataTableColumn<AnnouncementListItem>[] = [ const columns: DataTableColumn<AnnouncementListItem>[] = [
{ {
key: "title", key: "title",
title: renderTableTitle("公告标题", "streamline-emojis:fire"), title: "公告标题",
render: (row) => h(TitleWithTag, { title: row.title, top: row.top }), render: (row) => h(TitleWithTag, { title: row.title, top: row.top }),
minWidth: 300, minWidth: 300,
}, },
{ {
key: "tag", key: "tag",
title: renderTableTitle("标签", "fluent-emoji-flat:keycap-hashtag"), title: "标签",
width: 100, width: 100,
render: (row) => h(NTag, () => row.tag || "公告"), render: (row) => h(NTag, () => row.tag || "公告"),
}, },
{ {
key: "createTime", key: "createTime",
title: renderTableTitle("发布时间", "fluent-emoji-flat:eight-oclock"), title: "发布时间",
render: (row) => parseTime(row.createTime), render: (row) => parseTime(row.createTime),
width: 180, width: 180,
}, },
{ {
key: "username", key: "username",
title: renderTableTitle("发布人", "streamline-emojis:ghost"), title: "发布人",
render: (row) => row.createdBy.username, render: (row) => row.createdBy.username,
width: 120, width: 120,
}, },
+98 -14
View File
@@ -9,8 +9,7 @@ import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { CONTEST_STATUS, ContestType } from "utils/constants" import { CONTEST_STATUS, ContestStatus, ContestType } from "utils/constants"
import { renderTableTitle } from "utils/renders"
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
@@ -48,7 +47,7 @@ const tags: SelectOption[] = [
const columns: DataTableColumn<Contest>[] = [ const columns: DataTableColumn<Contest>[] = [
{ {
title: renderTableTitle("状态", "streamline-emojis:collision"), title: "状态",
key: "status", key: "status",
width: 100, width: 100,
render: (row) => render: (row) =>
@@ -59,25 +58,25 @@ const columns: DataTableColumn<Contest>[] = [
), ),
}, },
{ {
title: renderTableTitle("比赛", "streamline-emojis:bouquet"), title: "比赛",
key: "title", key: "title",
minWidth: 360, minWidth: 360,
render: (row) => h(ContestTitle, { contest: row }), render: (row) => h(ContestTitle, { contest: row }),
}, },
{ {
title: renderTableTitle("标签", "fluent-emoji-flat:keycap-hashtag"), title: "标签",
key: "tag", key: "tag",
width: 100, width: 100,
render: (row) => h(NTag, () => row.tag), render: (row) => h(NTag, () => row.tag),
}, },
{ {
title: renderTableTitle("开始时间", "fluent-emoji-flat:eleven-thirty"), title: "开始时间",
key: "start_time", key: "start_time",
width: 180, width: 180,
render: (row) => parseTime(row.startTime), render: (row) => parseTime(row.startTime),
}, },
{ {
title: renderTableTitle("比赛时长", "streamline-emojis:fishing-pole"), title: "比赛时长",
key: "duration", key: "duration",
width: 180, width: 180,
render: (row) => duration(row.startTime, row.endTime), render: (row) => duration(row.startTime, row.endTime),
@@ -116,18 +115,46 @@ watchDebounced(() => query.keyword, listContests, {
// 监听其他查询条件变化 // 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests) watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function openContest(row: Contest) {
if (!userStore.isAuthed && row.contestType === ContestType.private) {
authStore.openLoginModal()
} else {
router.push("/contest/" + row.id)
}
}
function rowProps(row: Contest) { function rowProps(row: Contest) {
return { return {
style: "cursor: pointer", style: "cursor: pointer",
onClick() { onClick: () => openContest(row),
if (!userStore.isAuthed && row.contestType === ContestType.private) {
authStore.openLoginModal()
} else {
router.push("/contest/" + row.id)
}
},
} }
} }
// 进行中 / 即将开始的比赛单独拎到最上面:表格按开始时间倒序,一场开了一周的
// 练习赛会被后来建的比赛压到第二页去,学生找不着正在比的那一场
const active = ref<Contest[]>([])
const showActive = computed(
() =>
active.value.length > 0 && query.page === 1 && !query.keyword && !query.status && !query.tag,
)
async function listActive() {
const base = { offset: 0, limit: 6, keyword: "", tag: "" }
const [underway, upcoming] = await Promise.all([
getContestList({ ...base, status: ContestStatus.underway }),
getContestList({ ...base, status: ContestStatus.not_started }),
])
active.value = [...underway.results, ...upcoming.results].slice(0, 6)
}
onMounted(listActive)
function activeTime(row: Contest) {
if (row.status === ContestStatus.underway) {
return `${parseTime(row.endTime, "M月D日 HH:mm")} 结束`
}
return `${parseTime(row.startTime, "M月D日 HH:mm")} 开始`
}
</script> </script>
<template> <template>
<n-flex vertical size="large"> <n-flex vertical size="large">
@@ -157,7 +184,64 @@ function rowProps(row: Contest) {
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-space> </n-space>
<div v-if="showActive" class="active-grid">
<div
v-for="contest in active"
:key="contest.id"
class="active-card"
:class="{ underway: contest.status === ContestStatus.underway }"
@click="openContest(contest)"
>
<n-flex align="center" :size="8">
<n-tag :type="CONTEST_STATUS[contest.status].type" size="small" :bordered="false">
{{ CONTEST_STATUS[contest.status].name }}
</n-tag>
<n-text depth="3" class="active-meta">{{ contest.tag }}</n-text>
</n-flex>
<ContestTitle :contest="contest" class="active-title" />
<n-text depth="3" class="active-meta">
{{ activeTime(contest) }} · 时长 {{ duration(contest.startTime, contest.endTime) }}
</n-text>
</div>
</div>
<n-data-table :bordered="false" :columns="columns" :data="data" :row-props="rowProps" /> <n-data-table :bordered="false" :columns="columns" :data="data" :row-props="rowProps" />
</n-flex> </n-flex>
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" /> <Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
</template> </template>
<style scoped>
.active-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.active-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2);
cursor: pointer;
transition: border-color 0.2s;
}
.active-card.underway {
border-color: rgba(24, 160, 88, 0.5);
background-color: rgba(24, 160, 88, 0.06);
}
.active-card:hover {
border-color: #18a058;
}
.active-title {
font-size: 16px;
font-weight: 500;
}
.active-meta {
font-size: 12px;
}
</style>
+5 -6
View File
@@ -2,7 +2,6 @@
import type { ProblemRow } from "utils/types" import type { ProblemRow } from "utils/types"
import ProblemStatus from "oj/problem/components/ProblemStatus.vue" import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import { renderTableTitle } from "utils/renders"
const props = defineProps<{ contestID: string }>() const props = defineProps<{ contestID: string }>()
@@ -10,29 +9,29 @@ const router = useRouter()
const contestStore = useContestStore() const contestStore = useContestStore()
const problemsColumns: DataTableColumn<ProblemRow>[] = [ const problemsColumns: DataTableColumn<ProblemRow>[] = [
{ {
title: renderTableTitle("状态", "streamline-ultimate-color:music-note-1"), title: "状态",
key: "status", key: "status",
width: 100, width: 100,
render: (row) => h(ProblemStatus, { status: row.status }), render: (row) => h(ProblemStatus, { status: row.status }),
}, },
{ {
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"), title: "编号",
key: "_id", key: "_id",
width: 100, width: 100,
}, },
{ {
title: renderTableTitle("题目", "streamline-emojis:rice-ball"), title: "题目",
key: "title", key: "title",
minWidth: 200, minWidth: 200,
}, },
{ {
title: renderTableTitle("提交数", "streamline-emojis:clinking-beer-mugs"), title: "提交数",
key: "submissionCount", key: "submissionCount",
align: "center", align: "center",
width: 120, width: 120,
}, },
{ {
title: renderTableTitle("通过率", "streamline-emojis:clapping-hands-1"), title: "通过率",
key: "acRate", key: "acRate",
align: "center", align: "center",
width: 120, width: 120,
+3 -4
View File
@@ -7,7 +7,6 @@ import { useContestStore } from "oj/store/contest"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { ContestStatus } from "utils/constants" import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import type { ContestRank, ProblemRow } from "utils/types" import type { ContestRank, ProblemRow } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue" import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue" import LineChart from "../components/LineChart.vue"
@@ -45,7 +44,7 @@ const { query } = usePagination({}, { defaultLimit: 50 })
const columns = ref<DataTableColumn<ContestRank>[]>([ const columns = ref<DataTableColumn<ContestRank>[]>([
{ {
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"), title: "编号",
key: "id", key: "id",
width: 80, width: 80,
fixed: "left", fixed: "left",
@@ -53,7 +52,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
render: (_, index) => index + (query.page - 1) * query.limit + 1, render: (_, index) => index + (query.page - 1) * query.limit + 1,
}, },
{ {
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"), title: "用户",
key: "username", key: "username",
width: 120, width: 120,
fixed: "left", fixed: "left",
@@ -70,7 +69,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
), ),
}, },
{ {
title: renderTableTitle("正确数/总提交", "streamline-ultimate-color:color-palette"), title: "正确数/总提交",
key: "submission", key: "submission",
width: 140, width: 140,
align: "center", align: "center",
+486
View File
@@ -0,0 +1,486 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import {
getAnnouncement,
getAnnouncementList,
getContestList,
getLearnProgress,
getSubmissions,
getTutorials,
getWeeklyRank,
} from "oj/api"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { ContestStatus, CONTEST_STATUS } from "utils/constants"
import { duration, parseTime, zonedParts } from "utils/functions"
import type { AnnouncementListItem, Contest, SubmissionListItem, WeeklyRank } from "utils/types"
type TutorialType = "python" | "c"
interface Track {
type: TutorialType
label: string
/** 接着学的那一课,1 起 */
step: number
title: string
total: number
/** 打开过的课数 */
read: number
lastViewedAt: string | null
}
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const tracks = ref<Track[]>([])
const contests = ref<Contest[]>([])
const submissions = ref<SubmissionListItem[]>([])
const announcements = ref<AnnouncementListItem[]>([])
const weekly = ref<WeeklyRank | null>(null)
const loaded = ref(false)
const keyword = ref("")
const greeting = computed(() => {
const hour = zonedParts(new Date())!.hour
if (hour < 5) return "夜深了"
if (hour < 11) return "早上好"
if (hour < 13) return "中午好"
if (hour < 18) return "下午好"
return "晚上好"
})
const weeklyText = computed(() => {
const me = weekly.value?.me
if (!weekly.value) return ""
const where = weekly.value.scope === "class" ? "班里" : "全站"
if (!me) return "本周还没有新通过的题,做出 1 题就能上进步榜"
return `本周新通过 ${me.solvedCount} 题,${where}第 ${me.rank} 名`
})
/**
* 接着学哪一课看的是**服务端留痕**里最近打开的那一课,不是导航栏用的
* localStorage:机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
*/
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
const [titles, progress] = await Promise.all([getTutorials(type), getLearnProgress(type)])
if (!titles.length) return null
const seen = progress.filter((p) => p.lastViewedAt)
const latest = seen.reduce<(typeof seen)[number] | null>(
(acc, p) => (!acc || p.lastViewedAt! > acc.lastViewedAt! ? p : acc),
null,
)
const index = latest ? titles.findIndex((t) => t.id === latest.tutorialId) : -1
const step = index >= 0 ? index + 1 : 1
return {
type,
label,
step,
title: titles[step - 1]!.title,
total: titles.length,
read: seen.length,
lastViewedAt: latest?.lastViewedAt ?? null,
}
}
async function loadTracks() {
const res = await Promise.all([loadTrack("python", "Python"), loadTrack("c", "C 语言")])
// 最近在学的那门排前面;两门都没碰过就按原顺序
tracks.value = res
.filter((t): t is Track => !!t)
.sort((a, b) => (b.lastViewedAt ?? "").localeCompare(a.lastViewedAt ?? ""))
}
async function loadContests() {
const query = { offset: 0, limit: 5, keyword: "", tag: "" }
const [underway, upcoming] = await Promise.all([
getContestList({ ...query, status: ContestStatus.underway }),
getContestList({ ...query, status: ContestStatus.not_started }),
])
contests.value = [...underway.results, ...upcoming.results].slice(0, 5)
}
async function loadSubmissions() {
const res = await getSubmissions({ myself: "1", offset: 0, limit: 6, language: "" })
submissions.value = res.results
}
async function loadAnnouncements() {
const res = await getAnnouncementList(0, 4)
announcements.value = res.results
}
async function loadWeekly() {
weekly.value = await getWeeklyRank(userStore.user?.className ? "class" : "global")
}
async function load() {
// 各块互不依赖,哪块挂了就空着那一块,别让一个接口拖垮整页
await Promise.allSettled([
loadTracks(),
loadContests(),
loadSubmissions(),
loadAnnouncements(),
loadWeekly(),
])
loaded.value = true
}
watch(
() => userStore.isFinished && userStore.isAuthed,
(ready) => ready && load(),
{ immediate: true },
)
function learnLink(track: Track) {
return `/learn/${track.type}/${track.step.toString().padStart(2, "0")}`
}
function searchProblem() {
const value = keyword.value.trim()
router.push({ path: "/problem", query: value ? { keyword: value } : {} })
}
function openSubmission(row: SubmissionListItem) {
if (row.showLink) router.push(`/submission/${row.id}`)
else router.push(`/problem/${row.problemDisplayId}`)
}
/** 刚刚 / 12 分钟前 / 今天 14:03 / 9月21日 */
function relativeTime(value: string) {
const diff = Date.now() - new Date(value).getTime()
if (diff < 60_000) return "刚刚"
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
const now = zonedParts(new Date())!
const then = zonedParts(value)!
if (now.year === then.year && now.month === then.month && now.day === then.day) {
return parseTime(value, "今天 HH:mm")
}
return parseTime(value, now.year === then.year ? "M月D日" : "YYYY年M月D日")
}
function contestTime(contest: Contest) {
if (contest.status === ContestStatus.underway) {
return `${parseTime(contest.endTime, "M月D日 HH:mm")} 结束`
}
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>
<div class="home">
<section class="hero">
<div>
<h1 class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
<n-text depth="3">
已解决 {{ userStore.profile?.acceptedNumber ?? 0 }} 题 · 共提交
{{ userStore.profile?.submissionNumber ?? 0 }} 次
<template v-if="weeklyText">
·
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
</template>
</n-text>
</div>
<n-input-group class="search">
<n-input
v-model:value="keyword"
placeholder="题号或题目名"
clearable
@keyup.enter="searchProblem"
/>
<n-button type="primary" @click="searchProblem">找题</n-button>
</n-input-group>
</section>
<div class="grid">
<div class="column">
<n-card title="继续学习" size="small" :bordered="false" class="card">
<n-flex vertical :size="12">
<router-link
v-for="track in tracks"
:key="track.type"
:to="learnLink(track)"
class="track"
>
<div class="track-main">
<n-text depth="3" class="track-label">
{{ track.label }} · 第 {{ track.step }} / {{ track.total }} 课
</n-text>
<div class="track-title">{{ track.title }}</div>
<n-progress
type="line"
:percentage="Math.round((track.read / track.total) * 100)"
status="success"
:show-indicator="false"
:height="6"
/>
<n-text depth="3" class="track-meta">
已打开 {{ track.read }} 课
<template v-if="track.lastViewedAt">
· 上次 {{ relativeTime(track.lastViewedAt) }}
</template>
</n-text>
</div>
<n-button :type="track.lastViewedAt ? 'primary' : 'default'" tag="span">
{{ track.lastViewedAt ? "继续" : "开始" }}
</n-button>
</router-link>
<n-empty v-if="loaded && !tracks.length" description="还没有公开的教程" />
</n-flex>
</n-card>
<n-card title="最近提交" size="small" :bordered="false" class="card">
<template #header-extra>
<router-link to="/submission?myself=1" class="more">全部</router-link>
</template>
<div
v-for="row in submissions"
:key="row.id"
class="row clickable"
@click="openSubmission(row)"
>
<SubmissionResultTag :result="row.result" size="small" />
<span class="row-title">{{ row.problemDisplayId }} {{ row.problemTitle }}</span>
<n-text depth="3" class="row-meta">{{ relativeTime(row.createTime) }}</n-text>
</div>
<n-empty v-if="loaded && !submissions.length" description="还没有提交过">
<template #extra>
<n-button size="small" @click="router.push('/problem')">去做题</n-button>
</template>
</n-empty>
</n-card>
</div>
<div class="column">
<n-card title="比赛" size="small" :bordered="false" class="card">
<template #header-extra>
<router-link to="/contest" class="more">全部</router-link>
</template>
<router-link
v-for="contest in contests"
:key="contest.id"
:to="`/contest/${contest.id}`"
class="row stacked"
>
<n-flex align="center" :size="8" :wrap="false">
<n-tag :type="CONTEST_STATUS[contest.status].type" size="small" :bordered="false">
{{ CONTEST_STATUS[contest.status].name }}
</n-tag>
<span class="row-title">{{ contest.title }}</span>
</n-flex>
<n-text depth="3" class="row-meta">{{ contestTime(contest) }}</n-text>
</router-link>
<n-text v-if="loaded && !contests.length" depth="3">
现在没有进行中或即将开始的比赛
</n-text>
</n-card>
<n-card title="公告" size="small" :bordered="false" class="card">
<template #header-extra>
<router-link to="/announcement" class="more">全部</router-link>
</template>
<div
v-for="item in announcements"
:key="item.id"
class="row clickable"
@click="openAnnouncement(item)"
>
<n-tag v-if="item.top" type="error" size="small" :bordered="false">置顶</n-tag>
<span class="row-title">{{ item.title }}</span>
<n-text depth="3" class="row-meta">{{ parseTime(item.createTime, "M月D日") }}</n-text>
</div>
</n-card>
<n-card size="small" :bordered="false" class="card">
<n-flex :size="8" class="shortcuts">
<n-button secondary @click="router.push('/problem')">
<template #icon><Icon icon="fluent-emoji:memo" /></template>
题库
</n-button>
<n-button secondary @click="router.push('/problemset')">
<template #icon><Icon icon="fluent-emoji:clipboard" /></template>
题单
</n-button>
<n-button secondary @click="router.push('/achievement')">
<template #icon><Icon icon="fluent-emoji:sports-medal" /></template>
成就
</n-button>
<n-button secondary @click="router.push('/ai-analysis')">
<template #icon><Icon icon="fluent-emoji:crystal-ball" /></template>
智能分析
</n-button>
</n-flex>
</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>
<style scoped>
.home {
max-width: 1200px;
margin: 0 auto;
}
.hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin: 8px 0 20px;
}
.hello {
font-size: 24px;
font-weight: 600;
margin: 0 0 6px;
}
.search {
width: 320px;
max-width: 100%;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 16px;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
.column {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.card {
border-radius: 8px;
background-color: var(--n-color-embedded, rgba(128, 128, 128, 0.06));
}
.track {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
border-radius: 6px;
color: inherit;
text-decoration: none;
background-color: rgba(128, 128, 128, 0.06);
transition: background-color 0.2s;
}
.track:hover {
background-color: rgba(24, 160, 88, 0.1);
}
.track-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.track-label,
.track-meta,
.row-meta {
font-size: 12px;
}
.track-title {
font-size: 16px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
border-radius: 4px;
color: inherit;
text-decoration: none;
}
.row + .row {
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.row.stacked {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.clickable,
a.row {
cursor: pointer;
}
.clickable:hover,
a.row:hover {
background-color: rgba(128, 128, 128, 0.08);
}
.row-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
flex-shrink: 0;
}
.more,
.plain-link {
color: inherit;
text-decoration: none;
}
.more {
font-size: 13px;
opacity: 0.7;
}
.more:hover,
.plain-link:hover {
color: #18a058;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { useUserStore } from "shared/store/user"
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
const Dashboard = defineAsyncComponent(() => import("./dashboard.vue"))
const ProblemList = defineAsyncComponent(() => import("oj/problem/list.vue"))
const userStore = useUserStore()
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
// 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表
const showDashboard = computed(() =>
userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED),
)
</script>
<template>
<Dashboard v-if="showDashboard" />
<ProblemList v-else />
</template>
@@ -76,7 +76,7 @@ const showCollabBar = computed(() => collabHere.value && userStore.isTeacherOrAb
/** /**
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏 * 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
* (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。 * (语言 / 提交 / 本题提交 / 课堂统计 / 更多 / 求助)在 1280 的机房屏上放不下。
*/ */
const helpButtonText = computed(() => { const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") { if (collabStore.helpStatus === "active") {
@@ -122,7 +122,7 @@ const menuOptions = computed<DropdownOption[]>(() => {
if (!isDesktop.value) { if (!isDesktop.value) {
if (showGoSubmissionButton.value) { if (showGoSubmissionButton.value) {
options.push({ options.push({
label: "提交信息", label: "本题提交",
key: "submissions", key: "submissions",
}) })
} }
@@ -272,7 +272,7 @@ onMounted(() => {
<SubmitCode v-else /> <SubmitCode v-else />
<n-button v-if="isDesktop && showGoSubmissionButton" :size="buttonSize" @click="goSubmissions"> <n-button v-if="isDesktop && showGoSubmissionButton" :size="buttonSize" @click="goSubmissions">
提交信息 本题提交
</n-button> </n-button>
<n-button <n-button
@@ -283,14 +283,14 @@ onMounted(() => {
课堂统计 课堂统计
</n-button> </n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上提交信息 / 课堂统计 --> <!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上本题提交 / 课堂统计 -->
<n-dropdown <n-dropdown
v-if="menuOptions.length" v-if="menuOptions.length"
trigger="click" trigger="click"
:options="menuOptions" :options="menuOptions"
@select="handleMenuSelect" @select="handleMenuSelect"
> >
<n-button :size="buttonSize">更多操作</n-button> <n-button :size="buttonSize">更多</n-button>
</n-dropdown> </n-dropdown>
<template v-if="showCollabBar"> <template v-if="showCollabBar">
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import type { ProblemDetail, ProblemRow, ProblemStatus } from "utils/types" import type { ProblemDetail, ProblemRow, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue" import Copy from "shared/components/Copy.vue"
@@ -20,8 +21,6 @@ type Sample = ProblemDetail["samples"][number] & {
loading: boolean loading: boolean
} }
const theme = useThemeVars()
const style = computed(() => "color: " + theme.value.primaryColor)
const isDark = useDark() const isDark = useDark()
const route = useRoute() const route = useRoute()
const codeStore = useCodeStore() const codeStore = useCodeStore()
@@ -190,16 +189,47 @@ function type(status: ProblemStatus) {
</n-alert> </n-alert>
</template> </template>
<n-flex align="center"> <header class="problem-head">
<n-tag>{{ problem._id }}</n-tag> <n-flex align="center" :size="10" :wrap="false">
<h2 class="problemTitle">{{ problem.title }}</h2> <n-tag :bordered="false">{{ problem._id }}</n-tag>
</n-flex> <h2 class="problemTitle">{{ problem.title }}</h2>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:checklist"></Icon>
描述
</n-flex> </n-flex>
</p> <n-flex align="center" :size="8" class="problem-meta">
<n-tag
v-if="problem.difficulty"
size="small"
:bordered="false"
:type="getTagColor(problem.difficulty)"
>
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
<n-text depth="3" v-if="!isSQL">
时间限制 {{ problem.timeLimit }} ms · 内存限制 {{ problem.memoryLimit }} MB
</n-text>
</n-flex>
</header>
<!-- 代码要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
<div v-if="astRequirements.length > 0" class="requirements">
<n-flex align="center" :size="6" class="requirements-head">
<Icon icon="streamline-ultimate-color:check-button" :width="18"></Icon>
<span>代码要求</span>
<n-text depth="3" class="requirements-note">提交时会逐条检查</n-text>
</n-flex>
<div v-for="[lang, rules] in astRequirements" :key="lang" class="requirements-lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span>
<n-flex :size="6">
<n-tag v-for="(rule, i) in rules" :key="i" :type="KIND_TAG_TYPE[rule.kind]" size="small">
{{ rule.description }}
</n-tag>
</n-flex>
</div>
</div>
<h3 class="title">
<Icon icon="streamline-ultimate-color:checklist"></Icon>
描述
</h3>
<MdPreview <MdPreview
preview-theme="vuepress" preview-theme="vuepress"
:model-value="problem.description" :model-value="problem.description"
@@ -207,24 +237,20 @@ function type(status: ProblemStatus) {
/> />
<template v-if="!isSQL"> <template v-if="!isSQL">
<p class="title" :style="style"> <h3 class="title">
<n-flex align="center"> <Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon> 输入
输入 </h3>
</n-flex>
</p>
<MdPreview <MdPreview
preview-theme="vuepress" preview-theme="vuepress"
:model-value="problem.inputDescription" :model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
/> />
<p class="title" :style="style"> <h3 class="title">
<n-flex align="center"> <Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon> 输出
输出 </h3>
</n-flex>
</p>
<MdPreview <MdPreview
preview-theme="vuepress" preview-theme="vuepress"
:model-value="problem.outputDescription" :model-value="problem.outputDescription"
@@ -232,13 +258,51 @@ function type(status: ProblemStatus) {
/> />
</template> </template>
<template v-if="isSQL && sqlDisplay"> <template v-if="!isSQL">
<p class="title" :style="style"> <section v-for="(sample, index) of samples" :key="index" class="sample">
<n-flex align="center"> <n-flex align="center" justify="space-between" class="sample-head">
<Icon icon="devicon:sqlite"></Icon> <h3 class="title">
数据表 <Icon icon="streamline-emojis:microscope"></Icon>
例子 {{ index + 1 }}
</h3>
<n-button
size="small"
secondary
:type="type(sample.status) || 'default'"
:loading="sample.loading"
@click="test(sample, index)"
>
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex> </n-flex>
</p> <div class="sample-grid">
<div class="sample-box">
<n-flex align="center" justify="space-between" class="sample-label">
<span>输入</span>
<Copy :value="sample.input" />
</n-flex>
<pre class="testcase">{{ sample.input }}</pre>
</div>
<div class="sample-box">
<n-flex align="center" justify="space-between" class="sample-label">
<span>输出</span>
<Copy :value="sample.output" />
</n-flex>
<pre class="testcase">{{ sample.output }}</pre>
</div>
</div>
<div v-if="sample.msg" class="sample-box sample-result">
<div class="sample-label">运行结果</div>
<pre class="testcase">{{ sample.msg }}</pre>
</div>
</section>
</template>
<template v-if="isSQL && sqlDisplay">
<h3 class="title">
<Icon icon="devicon:sqlite"></Icon>
数据表
</h3>
<div v-for="t in sqlDisplay.tables" :key="t.name"> <div v-for="t in sqlDisplay.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p> <p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable <SQLDataTable
@@ -249,12 +313,10 @@ function type(status: ProblemStatus) {
/> />
</div> </div>
<p class="title" :style="style"> <h3 class="title">
<n-flex align="center"> <Icon icon="streamline-ultimate-color:check-button"></Icon>
<Icon icon="streamline-ultimate-color:check-button"></Icon> 期望结果
期望结果 </h3>
</n-flex>
</p>
<template v-if="sqlExpectedQuery"> <template v-if="sqlExpectedQuery">
<SQLDataTable <SQLDataTable
:columns="sqlExpectedQuery.columns" :columns="sqlExpectedQuery.columns"
@@ -279,12 +341,10 @@ function type(status: ProblemStatus) {
</template> </template>
<div v-if="problem.hint"> <div v-if="problem.hint">
<p class="title" :style="style"> <h3 class="title">
<n-flex align="center"> <Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon> 提示
提示 </h3>
</n-flex>
</p>
<MdPreview <MdPreview
preview-theme="preview" preview-theme="preview"
:model-value="problem.hint" :model-value="problem.hint"
@@ -292,72 +352,11 @@ function type(status: ProblemStatus) {
/> />
</div> </div>
<!-- 代码要求(AST 规则) -->
<div v-if="astRequirements.length > 0">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
要求
</n-flex>
</p>
<div v-for="[lang, rules] in astRequirements" :key="lang">
<p v-if="astRequirements.length > 1" class="lang-label">
{{ lang }}
</p>
<n-list bordered style="margin-bottom: 8px">
<n-list-item v-for="(rule, i) in rules" :key="i">
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
</n-list-item>
</n-list>
</div>
</div>
<template v-if="!isSQL">
<div v-for="(sample, index) of samples" :key="index">
<n-flex align="center">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:microscope"></Icon>
例子 {{ index + 1 }}
</n-flex>
</p>
<n-button size="small" :type="type(sample.status)" @click="test(sample, index)">
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex>
<n-descriptions bordered :column="2" label-style="width: 50%; min-width: 100px">
<n-descriptions-item>
<template #label>
<n-flex>
<span>输入</span>
<Copy :value="sample.input" />
</n-flex>
</template>
<div class="testcase">{{ sample.input }}</div>
</n-descriptions-item>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输出</span>
<Copy :value="sample.output" />
</n-flex>
</template>
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
<n-descriptions-item label="运行结果" v-if="sample.msg">
<div class="testcase">{{ sample.msg }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
</template>
<div v-if="problem.source"> <div v-if="problem.source">
<p class="title" :style="style"> <h3 class="title">
<n-flex align="center"> <Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon> 来源
来源 </h3>
</n-flex>
</p>
<MdPreview <MdPreview
preview-theme="vuepress" preview-theme="vuepress"
:model-value="problem.source" :model-value="problem.source"
@@ -368,12 +367,10 @@ function type(status: ProblemStatus) {
<!-- 相似题目推荐 --> <!-- 相似题目推荐 -->
<div v-if="similarProblems.length > 0"> <div v-if="similarProblems.length > 0">
<n-divider /> <n-divider />
<p class="title" :style="style"> <h3 class="title">
<n-flex align="center"> <Icon icon="streamline-ultimate-color:like"></Icon>
<Icon icon="streamline-ultimate-color:like"></Icon> 相似题目推荐
相似题目推荐 </h3>
</n-flex>
</p>
<n-list bordered> <n-list bordered>
<n-list-item v-for="sp in similarProblems" :key="sp._id"> <n-list-item v-for="sp in similarProblems" :key="sp._id">
<n-flex align="center" justify="space-between"> <n-flex align="center" justify="space-between">
@@ -414,28 +411,121 @@ function type(status: ProblemStatus) {
</template> </template>
<style scoped> <style scoped>
.problem-head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.problemTitle { .problemTitle {
margin: 0; margin: 0;
font-size: 22px;
line-height: 1.3;
}
.problem-meta {
font-size: 13px;
} }
.title { .title {
font-size: 20px; display: flex;
margin: 12px 0; align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
margin: 20px 0 4px;
} }
.testcase { /* md-editor 的预览自带一圈 padding 和段落外边距,放在小节标题下面显得很散 */
font-size: 14px; :deep(.md-editor-preview-wrapper) {
white-space: pre; padding: 0;
font-family: "Monaco";
} }
.status-alert { :deep(.md-editor-preview > :first-child) {
margin-bottom: 16px; margin-top: 0;
}
:deep(.md-editor-preview > :last-child) {
margin-bottom: 0;
}
.requirements {
padding: 10px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
display: flex;
flex-direction: column;
gap: 8px;
}
.requirements-head {
font-weight: 600;
}
.requirements-note {
font-size: 12px;
font-weight: normal;
}
.requirements-lang {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
} }
.lang-label { .lang-label {
font-weight: 600; font-weight: 600;
margin: 8px 0 4px; font-size: 13px;
}
.sample {
margin-top: 20px;
}
.sample-head {
margin-bottom: 8px;
}
.sample-head .title {
margin: 0;
}
.sample-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px;
}
.sample-box {
min-width: 0;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
padding: 6px 10px 10px;
}
.sample-result {
margin-top: 8px;
}
.sample-label {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.testcase {
margin: 0;
font-size: 14px;
white-space: pre;
overflow-x: auto;
font-family: Monaco, Consolas, monospace;
}
.status-alert {
margin-bottom: 16px;
} }
.sqlTableName { .sqlTableName {
@@ -7,7 +7,6 @@ import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants" import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import type { SubmissionListItem } from "utils/types" import type { SubmissionListItem } from "utils/types"
import SubmissionDetail from "oj/submission/detail.vue" import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -31,13 +30,13 @@ function showCodePanel(id: string, problem: string) {
const columns: DataTableColumn<SubmissionListItem>[] = [ const columns: DataTableColumn<SubmissionListItem>[] = [
{ {
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"), title: "提交时间",
key: "create_time", key: "create_time",
width: 200, width: 200,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"), render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
}, },
{ {
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"), title: "编号",
key: "id", key: "id",
minWidth: 160, minWidth: 160,
render: (row) => { render: (row) => {
@@ -68,13 +67,13 @@ const columns: DataTableColumn<SubmissionListItem>[] = [
}, },
}, },
{ {
title: renderTableTitle("状态", "streamline-emojis:panda-face"), title: "状态",
key: "status", key: "status",
width: 140, width: 140,
render: (row) => h(SubmissionResultTag, { result: row.result }), render: (row) => h(SubmissionResultTag, { result: row.result }),
}, },
{ {
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"), title: "语言",
key: "language", key: "language",
width: 100, width: 100,
render: (row) => LANGUAGE_SHOW_VALUE[row.language], render: (row) => LANGUAGE_SHOW_VALUE[row.language],
+88 -26
View File
@@ -13,7 +13,6 @@ import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { renderTableTitle } from "utils/renders"
import ProblemStatus from "./components/ProblemStatus.vue" import ProblemStatus from "./components/ProblemStatus.vue"
import AuthorSelect from "shared/components/AuthorSelect.vue" import AuthorSelect from "shared/components/AuthorSelect.vue"
import ProblemListTitle from "./components/ProblemListTitle.vue" import ProblemListTitle from "./components/ProblemListTitle.vue"
@@ -64,12 +63,11 @@ const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { isDesktop } = useBreakpoints() const { isDesktop, isMobile } = useBreakpoints()
const problems = ref<ProblemRow[]>([]) const problems = ref<ProblemRow[]>([])
const total = ref(0) const total = ref(0)
const tags = ref<Tag[]>([]) const tags = ref<Tag[]>([])
const [showTag, toggleShowTag] = useToggle(isDesktop.value)
// 使用分页 composable // 使用分页 composable
const { query, clearQuery } = usePagination<ProblemQuery>({ const { query, clearQuery } = usePagination<ProblemQuery>({
@@ -80,6 +78,10 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
sort: useRouteQuery("sort", "").value, sort: useRouteQuery("sort", "").value,
}) })
// 标签默认收起:四十多个标签在桌面上要占三行,把题目表格顶到半屏以下。
// 从带 ?tag= 的链接进来时展开,让人看得见是哪个标签在起作用
const [showTag, toggleShowTag] = useToggle(!!query.tag)
async function listProblems() { async function listProblems() {
if (query.page < 1) query.page = 1 if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
@@ -102,6 +104,22 @@ async function listTags() {
})) }))
} }
// 知识点在前、主题在后;组内按题目数从多到少,最常用的「输入输出」「选择结构」
// 排在最前面,不再和一堆游戏名按拼音混排
const TAG_GROUPS = [
{ category: "knowledge", label: "知识点" },
{ category: "theme", label: "主题" },
] as const
const tagGroups = computed(() =>
TAG_GROUPS.map((group) => ({
...group,
tags: tags.value
.filter((t) => t.category === group.category)
.sort((a, b) => b.problemCount - a.problemCount),
})).filter((group) => group.tags.length > 0),
)
function chooseTag(tag: Tag) { function chooseTag(tag: Tag) {
query.tag = tag.checked ? "" : tag.name query.tag = tag.checked ? "" : tag.name
tags.value = tags.value.map((t) => { tags.value = tags.value.map((t) => {
@@ -160,57 +178,70 @@ onMounted(() => {
const baseColumns: DataTableColumn<ProblemRow>[] = [ const baseColumns: DataTableColumn<ProblemRow>[] = [
{ {
title: renderTableTitle("状态", "streamline-emojis:high-voltage"), title: "状态",
key: "status", key: "status",
width: 80, width: 80,
align: "center", align: "center",
render: (row) => h(ProblemStatus, { status: row.status }), render: (row) => h(ProblemStatus, { status: row.status }),
}, },
{ {
title: renderTableTitle("编号", "streamline-ultimate-color:board-game-dice-1"), title: "编号",
key: "_id", key: "_id",
width: 100, width: 100,
}, },
{ {
title: renderTableTitle("题目", "streamline-ultimate-color:fruit-watermelon"), title: "题目",
key: "title", key: "title",
minWidth: 200, minWidth: 200,
render: (row) => h(ProblemListTitle, { problem: row }), render: (row) => h(ProblemListTitle, { problem: row }),
}, },
{ {
title: renderTableTitle("难度", "streamline-emojis:lady-beetle"), title: "难度",
key: "difficulty", key: "difficulty",
width: 100, width: 100,
render: (row) => render: (row) =>
row.difficulty ? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty) : null, row.difficulty ? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty) : null,
}, },
{ {
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"), title: "标签",
key: "tags", key: "tags",
width: 260, width: 260,
render: (row) => h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))), render: (row) => h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
}, },
{ {
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"), title: "出题者",
key: "author", key: "author",
width: 130, width: 130,
}, },
{ {
title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"), title: "提交数",
key: "submissionCount", key: "submissionCount",
align: "center", align: "center",
width: 100, width: 100,
}, },
{ {
title: renderTableTitle("通过率", "streamline-emojis:victory-hand-2"), title: "通过率",
key: "acRate", key: "acRate",
width: 100, width: 100,
align: "center", align: "center",
}, },
] ]
// 手机上只留判断「做不做这题」要的几列,其余的挤进 390 宽的屏幕只会横向溢出
const MOBILE_WIDTHS: Record<string, { width?: number; minWidth?: number }> = {
status: { width: 60 },
_id: { width: 72 },
title: { minWidth: 120 },
difficulty: { width: 72 },
}
const columns = computed(() => const columns = computed(() =>
userStore.isAuthed ? baseColumns : baseColumns.filter((c: any) => c.key !== "status"), baseColumns
.filter((c: any) => {
if (c.key === "status" && !userStore.isAuthed) return false
return !isMobile.value || c.key in MOBILE_WIDTHS
})
.map((c: any) => (isMobile.value ? { ...c, ...MOBILE_WIDTHS[c.key] } : c)),
) )
function rowProps(row: ProblemRow) { function rowProps(row: ProblemRow) {
@@ -264,29 +295,38 @@ function rowProps(row: ProblemRow) {
<n-form-item> <n-form-item>
<n-button @click="toggleShowTag()" quaternary icon-placement="right"> <n-button @click="toggleShowTag()" quaternary icon-placement="right">
<template #icon> <template #icon>
<Icon v-if="showTag" icon="ph:caret-down"></Icon> <Icon v-if="showTag" icon="ph:caret-up"></Icon>
<Icon v-else icon="ph:caret-up"></Icon> <Icon v-else icon="ph:caret-down"></Icon>
</template> </template>
标签 标签
</n-button> </n-button>
</n-form-item> </n-form-item>
<n-form-item v-if="query.tag && !showTag">
<n-tag type="success" closable @close="query.tag = ''">{{ query.tag }}</n-tag>
</n-form-item>
</n-form> </n-form>
</n-space> </n-space>
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" /> <Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
</div> </div>
<n-collapse-transition :show="showTag"> <n-collapse-transition :show="showTag">
<n-flex> <div class="tag-groups">
<n-tag <div v-for="group in tagGroups" :key="group.category" class="tag-group">
v-for="tag in tags" <n-text depth="3" class="tag-group-label">{{ group.label }}</n-text>
:closable="tag.checked" <n-flex>
@close="chooseTag(tag)" <n-tag
@click="chooseTag(tag)" v-for="tag in group.tags"
:key="tag.id" :closable="tag.checked"
:type="tag.checked ? 'success' : 'default'" @close="chooseTag(tag)"
> @click="chooseTag(tag)"
{{ tag.name }} :key="tag.id"
</n-tag> :type="tag.checked ? 'success' : 'default'"
</n-flex> class="tag-chip"
>
{{ tag.name }}
</n-tag>
</n-flex>
</div>
</div>
</n-collapse-transition> </n-collapse-transition>
<n-data-table :bordered="false" :data="problems" :columns="columns" :row-props="rowProps" /> <n-data-table :bordered="false" :data="problems" :columns="columns" :row-props="rowProps" />
</n-flex> </n-flex>
@@ -312,6 +352,28 @@ function rowProps(row: ProblemRow) {
min-width: 0; min-width: 0;
} }
.tag-groups {
display: flex;
flex-direction: column;
gap: 10px;
}
.tag-group {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: start;
gap: 8px;
}
.tag-group-label {
font-size: 13px;
line-height: 28px;
}
.tag-chip {
cursor: pointer;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.problem-list-toolbar { .problem-list-toolbar {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
+156 -80
View File
@@ -1,15 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api" import { getProblemSetList } from "../api"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types" import type { ProblemSet } from "utils/types"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useBreakpoints } from "shared/composables/breakpoints"
const router = useRouter() const router = useRouter()
const { isDesktop } = useBreakpoints()
const total = ref(0) const total = ref(0)
const problemSets = ref<ProblemSet[]>([]) const problemSets = ref<ProblemSet[]>([])
@@ -61,12 +57,6 @@ function getConditionText(conditionType: string, conditionValue: number): string
return conditionMap[conditionType] || "未知条件" return conditionMap[conditionType] || "未知条件"
} }
function getProgressColor(percentage: number) {
if (percentage >= 80) return "#18a058" // 绿色
if (percentage >= 50) return "#f0a020" // 橙色
return "#d03050" // 红色
}
onMounted(listProblemSets) onMounted(listProblemSets)
// 监听搜索关键词变化(防抖) // 监听搜索关键词变化(防抖)
@@ -94,79 +84,99 @@ watch(() => [query.page, query.limit], listProblemSets)
/> />
</n-space> </n-space>
<n-grid v-if="problemSets.length > 0" :cols="isDesktop ? 3 : 1" :x-gap="16" :y-gap="16"> <div v-if="problemSets.length > 0" class="set-grid">
<n-grid-item v-for="problemSet in problemSets" :key="problemSet.id"> <div
<n-card hoverable @click="goToProblemSet(problemSet.id)" style="cursor: pointer"> v-for="problemSet in problemSets"
<template #header> :key="problemSet.id"
<n-flex justify="space-between" align="center"> class="set-card"
<n-text strong>{{ problemSet.title }}</n-text> :class="{ completed: problemSet.userProgress?.isCompleted }"
<n-tag :type="getDifficultyTag(problemSet.difficulty).type"> @click="goToProblemSet(problemSet.id)"
{{ getDifficultyTag(problemSet.difficulty).text }} >
</n-tag> <n-flex justify="space-between" align="center" :wrap="false" :size="8">
</n-flex> <span class="set-title">{{ problemSet.title }}</span>
</template> <n-tag v-if="problemSet.userProgress?.isCompleted" type="success" size="small" round>
<n-flex vertical size="large"> 已完成
<n-flex justify="space-between" align="center"> </n-tag>
<n-flex> <n-tag v-else-if="problemSet.userProgress?.isJoined" type="info" size="small" round>
<Icon width="20" icon="streamline-emojis:blossom" /> 进行中
<n-text>{{ problemSet.problemsCount }} 道题目</n-text> </n-tag>
</n-flex> </n-flex>
<n-flex align="center" style="height: 28px"> <!-- 简介大多就是把标题再抄一遍,一样的就不重复显示 -->
<!-- 用户进度显示 --> <n-text
<n-progress v-if="problemSet.description && problemSet.description !== problemSet.title"
v-if="problemSet.userProgress?.isJoined && !problemSet.userProgress?.isCompleted" depth="3"
type="line" class="set-desc"
:percentage="Math.round(problemSet.userProgress.progressPercentage)" >
:height="4" {{ problemSet.description }}
:border-radius="2" </n-text>
style="width: 100px"
:color="getProgressColor(problemSet.userProgress.progressPercentage)"
/>
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
<n-tag
v-if="problemSet.userProgress?.isJoined && !problemSet.userProgress?.isCompleted"
type="warning"
>
已加入
</n-tag>
<n-tag v-if="problemSet.userProgress?.isCompleted" type="error"> 已完成 </n-tag>
</n-flex>
</n-flex>
<!-- 奖章显示 --> <div class="set-progress">
<n-flex align="center" justify="space-between"> <n-flex justify="space-between" align="center" class="set-progress-text">
<n-text depth="3"> <n-text depth="3">
创建于 <!-- 进度是加入/完成时存下的快照,分母用快照自己的 totalCount:题单后来
{{ parseTime(problemSet.createTime, "YYYY-MM-DD") }} 加了题的话,拿现在的 problemsCount 去除,就会出现「已完成」却是 5 / 6 -->
</n-text> <template v-if="problemSet.userProgress?.isJoined">
<n-flex> 已完成 {{ problemSet.userProgress.completedCount }} /
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover"> {{ problemSet.userProgress.totalCount }} 题
<template #trigger> <template v-if="problemSet.problemsCount > problemSet.userProgress.totalCount">
<n-image · 新加了
:src="badge.icon" {{ problemSet.problemsCount - problemSet.userProgress.totalCount }} 题
:alt="badge.name" </template>
width="24" </template>
height="24" <template v-else>共 {{ problemSet.problemsCount }} 题 · 还没开始</template>
object-fit="cover" </n-text>
:class="{ 'earned-badge': badge.isEarned }" <!-- 线上题单全是简单难度,只有不是简单时才值得标出来 -->
/> <n-tag
</template> v-if="problemSet.difficulty !== 'Easy'"
<n-flex vertical size="small"> :type="getDifficultyTag(problemSet.difficulty).type"
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span> size="small"
<span> :bordered="false"
获取条件: >
{{ getConditionText(badge.conditionType, badge.conditionValue) }} {{ getDifficultyTag(problemSet.difficulty).text }}
</span> </n-tag>
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text> <n-tag v-if="problemSet.status === 'archived'" size="small" :bordered="false">
</n-flex> 已归档
</n-tooltip> </n-tag>
</n-flex>
</n-flex>
</n-flex> </n-flex>
</n-card> <n-progress
</n-grid-item> type="line"
</n-grid> :percentage="Math.round(problemSet.userProgress?.progressPercentage ?? 0)"
:show-indicator="false"
:height="6"
status="success"
/>
</div>
<n-flex v-if="problemSet.badges?.length" align="center" :size="6" class="set-badges">
<n-text depth="3" class="set-badges-label">
徽章 {{ problemSet.badges.filter((b) => b.isEarned).length }} /
{{ problemSet.badges.length }}
</n-text>
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover">
<template #trigger>
<n-image
:src="badge.icon"
:alt="badge.name"
width="24"
height="24"
object-fit="cover"
preview-disabled
:class="badge.isEarned ? 'earned-badge' : 'locked-badge'"
/>
</template>
<n-flex vertical size="small">
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span>
<span>
获取条件:
{{ getConditionText(badge.conditionType, badge.conditionValue) }}
</span>
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text>
</n-flex>
</n-tooltip>
</n-flex>
</div>
</div>
<Pagination <Pagination
v-if="problemSets.length > 0" v-if="problemSets.length > 0"
@@ -179,9 +189,75 @@ watch(() => [query.page, query.limit], listProblemSets)
</template> </template>
<style scoped> <style scoped>
.set-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.set-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2);
cursor: pointer;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.set-card:hover {
border-color: #18a058;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.set-card.completed {
background-color: rgba(24, 160, 88, 0.05);
}
.set-title {
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.set-desc {
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.set-progress {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: auto;
}
.set-progress-text {
font-size: 13px;
}
.set-badges-label {
font-size: 12px;
margin-right: 2px;
}
.earned-badge { .earned-badge {
border: 2px solid #ffd700; border: 2px solid #ffd700;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4); box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
} }
/* 没拿到的徽章压成灰色:原来拿没拿到只差一圈金边,一排看下去分不清 */
.locked-badge {
filter: grayscale(1);
opacity: 0.45;
}
</style> </style>
+37 -36
View File
@@ -22,7 +22,6 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import { durationFromValue, getACRate, parseTime } from "utils/functions" import { durationFromValue, getACRate, parseTime } from "utils/functions"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants" import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import Chart from "./components/Chart.vue" import Chart from "./components/Chart.vue"
import RankMedal from "./components/RankMedal.vue" import RankMedal from "./components/RankMedal.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
@@ -151,14 +150,14 @@ function rowClassName(row: Rank) {
const columns: DataTableColumn<Rank>[] = [ const columns: DataTableColumn<Rank>[] = [
{ {
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"), title: "排名",
key: "index", key: "index",
width: 100, width: 100,
align: "center", align: "center",
render: (_, index) => h(RankMedal, { index, page: query.page, limit: query.limit }), render: (_, index) => h(RankMedal, { index, page: query.page, limit: query.limit }),
}, },
{ {
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"), title: "用户",
key: "username", key: "username",
width: 240, width: 240,
render: (row) => render: (row) =>
@@ -193,24 +192,24 @@ const columns: DataTableColumn<Rank>[] = [
]), ]),
}, },
{ {
title: renderTableTitle("个性签名", "streamline-emojis:no-one-under-eighteen"), title: "个性签名",
key: "mood", key: "mood",
minWidth: 200, minWidth: 200,
}, },
{ {
title: renderTableTitle("已解决", "streamline-emojis:raised-fist-1"), title: "已解决",
key: "acceptedNumber", key: "acceptedNumber",
width: 120, width: 120,
align: "center", align: "center",
}, },
{ {
title: renderTableTitle("提交数", "streamline-ultimate-color:space-rocket-earth"), title: "提交数",
key: "submissionNumber", key: "submissionNumber",
width: 120, width: 120,
align: "center", align: "center",
}, },
{ {
title: renderTableTitle("正确率", "streamline-ultimate-color:gift-box-1"), title: "正确率",
key: "rate", key: "rate",
width: 120, width: 120,
align: "center", align: "center",
@@ -261,7 +260,7 @@ const subOptions = computed<Duration>(
// 宽度也压到 1280 那一档能整张放下,不出横向滚动条 // 宽度也压到 1280 那一档能整张放下,不出横向滚动条
const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [ const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
{ {
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"), title: "排名",
key: "rank", key: "rank",
width: 100, width: 100,
align: "center", align: "center",
@@ -269,7 +268,7 @@ const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
render: (row) => h(RankMedal, { index: row.rank - 1, page: 1, limit: 10 }), render: (row) => h(RankMedal, { index: row.rank - 1, page: 1, limit: 10 }),
}, },
{ {
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"), title: "用户",
key: "username", key: "username",
minWidth: 120, minWidth: 120,
ellipsis: { tooltip: true }, ellipsis: { tooltip: true },
@@ -285,13 +284,13 @@ const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
), ),
}, },
{ {
title: renderTableTitle("新解决", "fluent-emoji:party-popper"), title: "新解决",
key: "solvedCount", key: "solvedCount",
width: 100, width: 100,
align: "center", align: "center",
}, },
{ {
title: renderTableTitle("提交", "streamline-emojis:rocket"), title: "提交",
key: "submissionCount", key: "submissionCount",
width: 90, width: 90,
align: "center", align: "center",
@@ -525,31 +524,6 @@ watch(
<template> <template>
<n-flex vertical size="large"> <n-flex vertical size="large">
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-card :bordered="false">
<template #header>
<div style="height: 34px">全服 Top10</div>
</template>
<Chart v-if="rankChart.length" :type="ChartType.Rank" :rank-data="rankChart" />
<n-empty v-else style="padding: 20px 0"></n-empty>
</n-card>
</n-gi>
<n-gi :span="1">
<n-card :bordered="false">
<template #header>日活 Top10</template>
<template #header-extra>
<n-select style="width: 120px" :options="options" v-model:value="duration" />
</template>
<Chart
v-if="activityChart.length"
:type="ChartType.Activity"
:rank-data="activityChart"
/>
<n-empty v-else style="padding: 20px 0"></n-empty>
</n-card>
</n-gi>
</n-grid>
<!-- <!--
两张榜并排:左边全服总榜(分母是历史全部 AC,名次几乎不动),右边本周进步榜 两张榜并排:左边全服总榜(分母是历史全部 AC,名次几乎不动),右边本周进步榜
(分母只有这一周)。同一屏里对照着看,「追不上」和「这周还能进前十」是一眼的事。 (分母只有这一周)。同一屏里对照着看,「追不上」和「这周还能进前十」是一眼的事。
@@ -687,6 +661,33 @@ watch(
</n-card> </n-card>
</n-gi> </n-gi>
</n-grid> </n-grid>
<!-- 图表放最后:它和上面的榜单是同一份数据换个画法,放第一屏的话学生
得往下滚才看得到自己在哪 -->
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-card :bordered="false">
<template #header>
<div style="height: 34px">全服 Top10</div>
</template>
<Chart v-if="rankChart.length" :type="ChartType.Rank" :rank-data="rankChart" />
<n-empty v-else style="padding: 20px 0"></n-empty>
</n-card>
</n-gi>
<n-gi :span="1">
<n-card :bordered="false">
<template #header>日活 Top10</template>
<template #header-extra>
<n-select style="width: 120px" :options="options" v-model:value="duration" />
</template>
<Chart
v-if="activityChart.length"
:type="ChartType.Activity"
:rank-data="activityChart"
/>
<n-empty v-else style="padding: 20px 0"></n-empty>
</n-card>
</n-gi>
</n-grid>
</n-flex> </n-flex>
<n-modal <n-modal
+14 -15
View File
@@ -18,7 +18,6 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "utils/constants" import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import { FlowchartSubmissionStatus } from "utils/types" import { FlowchartSubmissionStatus } from "utils/types"
// 流程图提交的四种状态,列表里原来一列都没有 —— // 流程图提交的四种状态,列表里原来一列都没有 ——
@@ -356,13 +355,13 @@ watch(
const columns = computed(() => { const columns = computed(() => {
const res: DataTableColumn<SubmissionListItem>[] = [ const res: DataTableColumn<SubmissionListItem>[] = [
{ {
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"), title: "提交时间",
key: "create_time", key: "create_time",
minWidth: 200, minWidth: 200,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"), render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
}, },
{ {
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"), title: "提交编号",
key: "id", key: "id",
minWidth: 200, minWidth: 200,
render: (row) => render: (row) =>
@@ -372,13 +371,13 @@ const columns = computed(() => {
}), }),
}, },
{ {
title: renderTableTitle("状态", "streamline-emojis:panda-face"), title: "状态",
key: "status", key: "status",
minWidth: 140, minWidth: 140,
render: (row) => h(SubmissionResultTag, { result: row.result }), render: (row) => h(SubmissionResultTag, { result: row.result }),
}, },
{ {
title: renderTableTitle("题目", "streamline-emojis:blossom"), title: "题目",
key: "problem", key: "problem",
minWidth: 360, minWidth: 360,
render: (row) => { render: (row) => {
@@ -413,13 +412,13 @@ const columns = computed(() => {
}, },
}, },
{ {
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"), title: "语言",
key: "language", key: "language",
minWidth: 120, minWidth: 120,
render: (row) => LANGUAGE_SHOW_VALUE[row.language], render: (row) => LANGUAGE_SHOW_VALUE[row.language],
}, },
{ {
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"), title: "用户",
key: "username", key: "username",
minWidth: 200, minWidth: 200,
render: (row) => render: (row) =>
@@ -438,7 +437,7 @@ const columns = computed(() => {
] ]
if (!route.params.contestID && userStore.isTeacherOrAbove) { if (!route.params.contestID && userStore.isTeacherOrAbove) {
res.push({ res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"), title: "选项",
key: "rejudge", key: "rejudge",
render: (row) => render: (row) =>
h( h(
@@ -459,12 +458,12 @@ const columns = computed(() => {
const flowchartColumns = computed(() => { const flowchartColumns = computed(() => {
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [ const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
{ {
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"), title: "提交时间",
key: "create_time", key: "create_time",
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"), render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
}, },
{ {
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"), title: "提交编号",
key: "id", key: "id",
render: (row) => render: (row) =>
h(FlowchartLink, { h(FlowchartLink, {
@@ -473,7 +472,7 @@ const flowchartColumns = computed(() => {
}), }),
}, },
{ {
title: renderTableTitle("题目", "streamline-emojis:blossom"), title: "题目",
key: "problem_title", key: "problem_title",
render: (row) => render: (row) =>
h( h(
@@ -487,7 +486,7 @@ const flowchartColumns = computed(() => {
), ),
}, },
{ {
title: renderTableTitle("状态", "fluent-emoji:hourglass-not-done"), title: "状态",
key: "status", key: "status",
render: (row) => { render: (row) => {
const tag = FLOWCHART_STATUS_TAG[row.status] const tag = FLOWCHART_STATUS_TAG[row.status]
@@ -499,7 +498,7 @@ const flowchartColumns = computed(() => {
}, },
}, },
{ {
title: renderTableTitle("评分", "streamline-ultimate-color:analytics-bars-3d"), title: "评分",
key: "ai_score", key: "ai_score",
// 只有评完的才有分数。没评完也渲染 Grade 的话会显示成 0 分, // 只有评完的才有分数。没评完也渲染 Grade 的话会显示成 0 分,
// 看着像「评了但得了 0 分」 // 看着像「评了但得了 0 分」
@@ -512,7 +511,7 @@ const flowchartColumns = computed(() => {
: h(NText, { depth: 3 }, () => "—"), : h(NText, { depth: 3 }, () => "—"),
}, },
{ {
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"), title: "用户",
key: "username", key: "username",
minWidth: 200, minWidth: 200,
render: (row) => render: (row) =>
@@ -531,7 +530,7 @@ const flowchartColumns = computed(() => {
] ]
if (!route.params.contestID && userStore.isTeacherOrAbove) { if (!route.params.contestID && userStore.isTeacherOrAbove) {
res.push({ res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"), title: "选项",
key: "retry", key: "retry",
// 后端只接受已完成 / 已失败的重判(其余返回 409),这里同步置灰, // 后端只接受已完成 / 已失败的重判(其余返回 409),这里同步置灰,
// 免得老师点了才发现不行 // 免得老师点了才发现不行
+74 -57
View File
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api" import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions" import { durationToDays, parseTime } from "utils/functions"
import type { AchievementSummary, Profile } from "utils/types" import type { AchievementSummary, Profile } from "utils/types"
import { getAchievementSummary } from "oj/achievement/api" import { getAchievementSummary } from "oj/achievement/api"
@@ -23,8 +21,6 @@ const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle() const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false) const [show, toggleShow] = useToggle(false)
const { isDesktop } = useBreakpoints()
const isDefaultAvatar = computed(() => profile.value?.avatar.endsWith("default.png") ?? true) const isDefaultAvatar = computed(() => profile.value?.avatar.endsWith("default.png") ?? true)
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef") const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
@@ -95,41 +91,45 @@ async function loadAchievementSummary() {
} }
} }
// 六项一格一项、三列两行。日期比数字长得多,字号单独压小一档(.stat-value.date),
// 不然「2026年9月14日」会把格子撑破
const metrics = computed(() => { const metrics = computed(() => {
if (loading.value) return [] if (loading.value) return []
return [ return [
{
icon: "fluent-emoji:face-with-peeking-eye",
title: learnDuration.value,
content: "总共学习天数",
},
{
icon: "fluent-emoji:cheese-wedge",
title: toLatestAt.value,
content: "距离上次提交",
},
{
icon: "fluent-emoji:dog-face",
title: latestSubmissionAt.value,
content: "最新一次提交时间",
},
{
icon: "fluent-emoji:cat-with-wry-smile",
title: firstSubmissionAt.value,
content: "第一次提交时间",
},
{ {
icon: "fluent-emoji:candy", icon: "fluent-emoji:candy",
title: profile.value?.acceptedNumber ?? 0, title: profile.value?.acceptedNumber ?? 0,
content: "已解决的题目数量", content: "已解决",
animate: true, animate: true,
}, },
{ {
icon: "fluent-emoji:thinking-face", icon: "fluent-emoji:thinking-face",
title: profile.value?.submissionNumber ?? 0, title: profile.value?.submissionNumber ?? 0,
content: "总提交数量", content: "总提交",
animate: true, animate: true,
}, },
{
icon: "fluent-emoji:face-with-peeking-eye",
title: learnDuration.value,
content: "学习天数",
},
{
icon: "fluent-emoji:cheese-wedge",
title: toLatestAt.value,
content: "距上次提交",
},
{
icon: "fluent-emoji:dog-face",
title: latestSubmissionAt.value,
content: "最新一次提交时间",
date: true,
},
{
icon: "fluent-emoji:cat-with-wry-smile",
title: firstSubmissionAt.value,
content: "第一次提交时间",
date: true,
},
] ]
}) })
@@ -141,8 +141,8 @@ onMounted(() => {
<template> <template>
<n-flex class="wrapper" vertical justify="center" align="center" v-if="!loading && profile"> <n-flex class="wrapper" vertical justify="center" align="center" v-if="!loading && profile">
<n-image <n-image
:width="140" :width="96"
:height="140" :height="96"
:src="profile.avatar" :src="profile.avatar"
:preview-disabled="isDefaultAvatar" :preview-disabled="isDefaultAvatar"
object-fit="cover" object-fit="cover"
@@ -169,30 +169,18 @@ onMounted(() => {
</n-button> </n-button>
</n-flex> </n-flex>
<n-grid <div v-if="profile && profile.submissionNumber > 0" class="wrapper">
v-if="profile && profile.submissionNumber > 0" <div class="stats">
class="wrapper" <div v-for="item in metrics" :key="item.content" class="stat">
:cols="isDesktop ? 2 : 1" <Icon :icon="item.icon" :width="28" />
:x-gap="10" <div class="stat-value" :class="{ date: item.date }">
:y-gap="10" <n-number-animation v-if="item.animate" :to="Number(item.title)" />
> <template v-else>{{ item.title }}</template>
<n-gi v-for="item in metrics" :key="item.content"> </div>
<n-card hoverable> <n-text depth="3" class="stat-label">{{ item.content }}</n-text>
<n-flex align="center"> </div>
<Icon :icon="item.icon" :width="isDesktop ? 50 : 40" /> </div>
<div> </div>
<Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" />
<template v-else>
{{ item.title }}
</template>
</Component>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</div>
</n-flex>
</n-card>
</n-gi>
</n-grid>
<!-- 成就摘要 --> <!-- 成就摘要 -->
<n-card v-if="!loading && profile && achievementSummary" class="wrapper" hoverable> <n-card v-if="!loading && profile && achievementSummary" class="wrapper" hoverable>
@@ -262,13 +250,42 @@ onMounted(() => {
margin: 16px auto 0; margin: 16px auto 0;
} }
.number { .stats {
margin-bottom: 0; display: grid;
font-weight: bold; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
} }
.number-label { @media (max-width: 600px) {
margin: 0; .stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 8px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2);
}
.stat-value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
}
.stat-value.date {
font-size: 16px;
padding: 3px 0;
white-space: nowrap;
} }
h2 { h2 {
+3 -1
View File
@@ -4,7 +4,9 @@ export const ojs: RouteRecordRaw = {
path: "/", path: "/",
component: () => import("shared/layout/default.vue"), component: () => import("shared/layout/default.vue"),
children: [ children: [
{ path: "", component: () => import("oj/problem/list.vue") }, // 登录后是个人首页,没登录还是题目列表(见 oj/home/index.vue)
{ path: "", component: () => import("oj/home/index.vue"), name: "home" },
{ path: "problem", component: () => import("oj/problem/list.vue"), name: "problems" },
{ {
path: "problem/:problemID", path: "problem/:problemID",
component: () => import("oj/problem/detail.vue"), component: () => import("oj/problem/detail.vue"),
+3 -3
View File
@@ -83,8 +83,8 @@ const titleTags = computed(() =>
) )
// 一级路径就是菜单 key,对不上的页面(/user、/setting、/achievement 等) // 一级路径就是菜单 key,对不上的页面(/user、/setting、/achievement 等)
// 自然没有一项亮着 // 自然没有一项亮着。根路径没登录时就是题目列表,登录了是个人首页,不亮任何一项
const active = computed(() => route.path.split("/")[1] || "problem") const active = computed(() => route.path.split("/")[1] || (userStore.isAuthed ? "" : "problem"))
async function handleLogout() { async function handleLogout() {
await userStore.signOut() await userStore.signOut()
@@ -127,7 +127,7 @@ const menus = computed<MenuOption[]>(() => [
], ],
}, },
{ {
label: () => h(RouterLink, { to: "/" }, { default: () => "题目" }), label: () => h(RouterLink, { to: "/problem" }, { default: () => "题目" }),
key: "problem", key: "problem",
icon: renderIcon("fluent-emoji:memo"), icon: renderIcon("fluent-emoji:memo"),
}, },
+7 -1
View File
@@ -24,7 +24,12 @@ watch(
</script> </script>
<template> <template>
<n-layout position="absolute"> <!-- 滚动容器竖排、内容区撑满剩余高度:内容少的页面(比赛列表、班级 PK)
页脚才会贴在底上,而不是悬在半屏 -->
<n-layout
position="absolute"
content-style="display: flex; flex-direction: column; min-height: 100%"
>
<n-layout-header bordered style="padding: 8px"> <n-layout-header bordered style="padding: 8px">
<!-- 居中限宽套在外面,别用 class 传给 Header:那样 Header 就永远只能有 <!-- 居中限宽套在外面,别用 class 传给 Header:那样 Header 就永远只能有
一个根节点,多一个根就是 "Extraneous non-props attributes" 警告 一个根节点,多一个根就是 "Extraneous non-props attributes" 警告
@@ -34,6 +39,7 @@ watch(
</div> </div>
</n-layout-header> </n-layout-header>
<n-layout-content <n-layout-content
style="flex: 1 0 auto"
content-style="padding: 16px; overflow-x: initial; max-width: 2000px; margin: 0 auto;" content-style="padding: 16px; overflow-x: initial; max-width: 2000px; margin: 0 auto;"
> >
<router-view></router-view> <router-view></router-view>
+3 -2
View File
@@ -120,7 +120,7 @@ export const JUDGE_STATUS: {
export const CONTEST_STATUS: { export const CONTEST_STATUS: {
[key in ContestStatus]: { [key in ContestStatus]: {
name: string name: string
type: "error" | "success" | "warning" type: "default" | "success" | "warning"
} }
} = { } = {
// 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位 // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
@@ -136,9 +136,10 @@ export const CONTEST_STATUS: {
name: "进行中", name: "进行中",
type: "success", type: "success",
}, },
// 灰的不是红的:列表里九成是已结束的比赛,一片红像满屏报错
"-1": { "-1": {
name: "已结束", name: "已结束",
type: "error", type: "default",
}, },
} }
-9
View File
@@ -1,9 +0,0 @@
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
export function renderTableTitle(title: string, icon: string): any {
return h(NFlex, { align: "center", size: 4 }, () => [
h(Icon, { icon: icon, width: 20, height: 20 }),
title,
])
}
+1 -1
View File
@@ -80,7 +80,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
* 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`, * 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`,
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。 * shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
*/ */
export type { Tag } from "@oj2/contract" export type { Tag, TagCategory } from "@oj2/contract"
export type { export type {
AdminTag, AdminTag,
+8
View File
@@ -12,6 +12,7 @@ import {
problemDifficultySchema, problemDifficultySchema,
sqlConfigSchema, sqlConfigSchema,
sqlDisplaySchema, sqlDisplaySchema,
tagCategorySchema,
} from "./problem" } from "./problem"
// 放这里而不是 roles.ts:roles.ts 要保持不 import zod,理由见那边的文件头 // 放这里而不是 roles.ts:roles.ts 要保持不 import zod,理由见那边的文件头
@@ -472,9 +473,16 @@ export const adminProblemSetProgressSchema = z.object({
export const adminTagSchema = z.object({ export const adminTagSchema = z.object({
id: z.number().int(), id: z.number().int(),
name: z.string(), name: z.string(),
category: tagCategorySchema,
/** 分类有没有人确认过。出题时新建的标签是 false,默认先算知识点,等老师归类 */
categoryConfirmed: z.boolean(),
problemCount: z.number().int(), problemCount: z.number().int(),
}) })
export const setTagCategoryRequestSchema = z.object({
category: tagCategorySchema,
})
export const renameTagRequestSchema = z.object({ export const renameTagRequestSchema = z.object({
name: z.string().trim().min(1).max(64), name: z.string().trim().min(1).max(64),
}) })
+9
View File
@@ -427,9 +427,17 @@ export const problemListItemSchema = z.object({
export const problemListSchema = paginatedSchema(problemListItemSchema) export const problemListSchema = paginatedSchema(problemListItemSchema)
/**
* 标签分两类:`knowledge` 是知识点(循环结构、字符串……),`theme` 是题目的
* 题材(LOL、周杰伦、哈利波特……)。两类原来混在一起,题目列表上四十多个标签
* 挤成一片,学生想按知识点找题得在一堆游戏名里挑。新标签默认算知识点。
*/
export const tagCategorySchema = z.enum(["knowledge", "theme"])
export const tagSchema = z.object({ export const tagSchema = z.object({
id: z.number().int(), id: z.number().int(),
name: z.string(), name: z.string(),
category: tagCategorySchema,
problemCount: z.number().int().nonnegative(), problemCount: z.number().int().nonnegative(),
}) })
@@ -454,6 +462,7 @@ export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
export type ProblemListItem = z.infer<typeof problemListItemSchema> export type ProblemListItem = z.infer<typeof problemListItemSchema>
export type ProblemList = z.infer<typeof problemListSchema> export type ProblemList = z.infer<typeof problemListSchema>
export type Tag = z.infer<typeof tagSchema> export type Tag = z.infer<typeof tagSchema>
export type TagCategory = z.infer<typeof tagCategorySchema>
export type ProblemAuthor = z.infer<typeof problemAuthorSchema> export type ProblemAuthor = z.infer<typeof problemAuthorSchema>
export type SqlConfig = z.infer<typeof sqlConfigSchema> export type SqlConfig = z.infer<typeof sqlConfigSchema>
export type SqlDisplay = z.infer<typeof sqlDisplaySchema> export type SqlDisplay = z.infer<typeof sqlDisplaySchema>