Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07459928d | ||
|
|
e1b6a9e9fa | ||
|
|
6bf8c6e1d9 | ||
|
|
31aa5f6e74 |
No files matched your search
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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)`)],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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[])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +61,8 @@ const columns: DataTableColumn<AdminTag>[] = [
|
|||||||
if (e.key === "Escape") cancelEdit()
|
if (e.key === "Escape") cancelEdit()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: h(
|
: h(NFlex, { size: 8, align: "center" }, () => [
|
||||||
|
h(
|
||||||
NButton,
|
NButton,
|
||||||
{
|
{
|
||||||
text: true,
|
text: true,
|
||||||
@@ -49,6 +71,44 @@ const columns: DataTableColumn<AdminTag>[] = [
|
|||||||
},
|
},
|
||||||
() => 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,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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 rowProps(row: Contest) {
|
function openContest(row: Contest) {
|
||||||
return {
|
|
||||||
style: "cursor: pointer",
|
|
||||||
onClick() {
|
|
||||||
if (!userStore.isAuthed && row.contestType === ContestType.private) {
|
if (!userStore.isAuthed && row.contestType === ContestType.private) {
|
||||||
authStore.openLoginModal()
|
authStore.openLoginModal()
|
||||||
} else {
|
} else {
|
||||||
router.push("/contest/" + row.id)
|
router.push("/contest/" + row.id)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
function rowProps(row: Contest) {
|
||||||
|
return {
|
||||||
|
style: "cursor: pointer",
|
||||||
|
onClick: () => openContest(row),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 进行中 / 即将开始的比赛单独拎到最上面:表格按开始时间倒序,一场开了一周的
|
||||||
|
// 练习赛会被后来建的比赛压到第二页去,学生找不着正在比的那一场
|
||||||
|
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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
<n-tag :bordered="false">{{ problem._id }}</n-tag>
|
||||||
<h2 class="problemTitle">{{ problem.title }}</h2>
|
<h2 class="problemTitle">{{ problem.title }}</h2>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<p class="title" :style="style">
|
<n-flex align="center" :size="8" class="problem-meta">
|
||||||
<n-flex align="center">
|
<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>
|
<Icon icon="streamline-ultimate-color:checklist"></Icon>
|
||||||
描述
|
描述
|
||||||
</n-flex>
|
</h3>
|
||||||
</p>
|
|
||||||
<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>
|
||||||
输入
|
输入
|
||||||
</n-flex>
|
</h3>
|
||||||
</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>
|
||||||
输出
|
输出
|
||||||
</n-flex>
|
</h3>
|
||||||
</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">
|
||||||
|
<section v-for="(sample, index) of samples" :key="index" class="sample">
|
||||||
|
<n-flex align="center" justify="space-between" class="sample-head">
|
||||||
|
<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>
|
||||||
|
<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">
|
<template v-if="isSQL && sqlDisplay">
|
||||||
<p class="title" :style="style">
|
<h3 class="title">
|
||||||
<n-flex align="center">
|
|
||||||
<Icon icon="devicon:sqlite"></Icon>
|
<Icon icon="devicon:sqlite"></Icon>
|
||||||
数据表
|
数据表
|
||||||
</n-flex>
|
</h3>
|
||||||
</p>
|
|
||||||
<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>
|
||||||
期望结果
|
期望结果
|
||||||
</n-flex>
|
</h3>
|
||||||
</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>
|
||||||
提示
|
提示
|
||||||
</n-flex>
|
</h3>
|
||||||
</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>
|
||||||
来源
|
来源
|
||||||
</n-flex>
|
</h3>
|
||||||
</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>
|
||||||
相似题目推荐
|
相似题目推荐
|
||||||
</n-flex>
|
</h3>
|
||||||
</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],
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div class="tag-groups">
|
||||||
|
<div v-for="group in tagGroups" :key="group.category" class="tag-group">
|
||||||
|
<n-text depth="3" class="tag-group-label">{{ group.label }}</n-text>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
<n-tag
|
<n-tag
|
||||||
v-for="tag in tags"
|
v-for="tag in group.tags"
|
||||||
:closable="tag.checked"
|
:closable="tag.checked"
|
||||||
@close="chooseTag(tag)"
|
@close="chooseTag(tag)"
|
||||||
@click="chooseTag(tag)"
|
@click="chooseTag(tag)"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
:type="tag.checked ? 'success' : 'default'"
|
:type="tag.checked ? 'success' : 'default'"
|
||||||
|
class="tag-chip"
|
||||||
>
|
>
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</n-flex>
|
</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);
|
||||||
|
|||||||
@@ -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,53 +84,75 @@ 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)"
|
||||||
|
>
|
||||||
|
<n-flex justify="space-between" align="center" :wrap="false" :size="8">
|
||||||
|
<span class="set-title">{{ problemSet.title }}</span>
|
||||||
|
<n-tag v-if="problemSet.userProgress?.isCompleted" type="success" size="small" round>
|
||||||
|
已完成
|
||||||
|
</n-tag>
|
||||||
|
<n-tag v-else-if="problemSet.userProgress?.isJoined" type="info" size="small" round>
|
||||||
|
进行中
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<!-- 简介大多就是把标题再抄一遍,一样的就不重复显示 -->
|
||||||
|
<n-text
|
||||||
|
v-if="problemSet.description && problemSet.description !== problemSet.title"
|
||||||
|
depth="3"
|
||||||
|
class="set-desc"
|
||||||
|
>
|
||||||
|
{{ problemSet.description }}
|
||||||
|
</n-text>
|
||||||
|
|
||||||
|
<div class="set-progress">
|
||||||
|
<n-flex justify="space-between" align="center" class="set-progress-text">
|
||||||
|
<n-text depth="3">
|
||||||
|
<!-- 进度是加入/完成时存下的快照,分母用快照自己的 totalCount:题单后来
|
||||||
|
加了题的话,拿现在的 problemsCount 去除,就会出现「已完成」却是 5 / 6 -->
|
||||||
|
<template v-if="problemSet.userProgress?.isJoined">
|
||||||
|
已完成 {{ problemSet.userProgress.completedCount }} /
|
||||||
|
{{ problemSet.userProgress.totalCount }} 题
|
||||||
|
<template v-if="problemSet.problemsCount > problemSet.userProgress.totalCount">
|
||||||
|
· 新加了
|
||||||
|
{{ problemSet.problemsCount - problemSet.userProgress.totalCount }} 题
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>共 {{ problemSet.problemsCount }} 题 · 还没开始</template>
|
||||||
|
</n-text>
|
||||||
|
<!-- 线上题单全是简单难度,只有不是简单时才值得标出来 -->
|
||||||
|
<n-tag
|
||||||
|
v-if="problemSet.difficulty !== 'Easy'"
|
||||||
|
:type="getDifficultyTag(problemSet.difficulty).type"
|
||||||
|
size="small"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
{{ getDifficultyTag(problemSet.difficulty).text }}
|
{{ getDifficultyTag(problemSet.difficulty).text }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</n-flex>
|
<n-tag v-if="problemSet.status === 'archived'" size="small" :bordered="false">
|
||||||
</template>
|
已归档
|
||||||
<n-flex vertical size="large">
|
|
||||||
<n-flex justify="space-between" align="center">
|
|
||||||
<n-flex>
|
|
||||||
<Icon width="20" icon="streamline-emojis:blossom" />
|
|
||||||
<n-text>{{ problemSet.problemsCount }} 道题目</n-text>
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-flex align="center" style="height: 28px">
|
|
||||||
<!-- 用户进度显示 -->
|
|
||||||
<n-progress
|
|
||||||
v-if="problemSet.userProgress?.isJoined && !problemSet.userProgress?.isCompleted"
|
|
||||||
type="line"
|
|
||||||
:percentage="Math.round(problemSet.userProgress.progressPercentage)"
|
|
||||||
:height="4"
|
|
||||||
:border-radius="2"
|
|
||||||
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>
|
||||||
<n-tag v-if="problemSet.userProgress?.isCompleted" type="error"> 已完成 </n-tag>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
: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-flex align="center" justify="space-between">
|
<n-text depth="3" class="set-badges-label">
|
||||||
<n-text depth="3">
|
徽章 {{ problemSet.badges.filter((b) => b.isEarned).length }} /
|
||||||
创建于
|
{{ problemSet.badges.length }}
|
||||||
{{ parseTime(problemSet.createTime, "YYYY-MM-DD") }}
|
|
||||||
</n-text>
|
</n-text>
|
||||||
<n-flex>
|
|
||||||
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover">
|
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-image
|
<n-image
|
||||||
@@ -149,7 +161,8 @@ watch(() => [query.page, query.limit], listProblemSets)
|
|||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
object-fit="cover"
|
object-fit="cover"
|
||||||
:class="{ 'earned-badge': badge.isEarned }"
|
preview-disabled
|
||||||
|
:class="badge.isEarned ? 'earned-badge' : 'locked-badge'"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<n-flex vertical size="small">
|
<n-flex vertical size="small">
|
||||||
@@ -162,11 +175,8 @@ watch(() => [query.page, query.limit], listProblemSets)
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),这里同步置灰,
|
||||||
// 免得老师点了才发现不行
|
// 免得老师点了才发现不行
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<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>
|
</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 {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user