feat(个人主页): 后端一个接口给齐主页要的东西:做对的题带题名和日期、四个时间、本班第几、成就,老师和本人多拿知识点和没做对的题
- GET /profiles/:username/overview:同学看拿不到知识点和没做对的题,多一份「你和他」 - DELETE /me/avatar:不用图片,退回名字的字 - 知识点地图抽成 knowledgeMapOf,老师看学生也能用 - 删掉没人用了的 /users/:id/metrics Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
afbc240a19
commit
cbf5b9d922
3 files changed
+272
-127
No files matched your search
+202
-30
@@ -7,7 +7,7 @@ import {
|
||||
updateProfileRequestSchema,
|
||||
type KnowledgeLevel,
|
||||
type KnowledgeMap,
|
||||
type Metrics,
|
||||
type ProfileOverview,
|
||||
type ProblemRank,
|
||||
type WeeklyRank,
|
||||
type WeeklyRankItem,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
and,
|
||||
count,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
@@ -41,8 +42,9 @@ import { getBooleanOption } from "../services/options"
|
||||
import { sniffImageExtension } from "../services/image"
|
||||
import { getUserProfileById } from "../services/profile"
|
||||
import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling"
|
||||
import { localTime, weekStart } from "../time"
|
||||
import { asRecord, queryInteger, sampleUser } from "./helpers"
|
||||
import { audienceWhere, loadEntrants, SOLVED_RESULTS, standings } from "../services/ranking"
|
||||
import { localTime, termStart, weekStart } from "../time"
|
||||
import { asRecord, isTeacherOrAbove, sampleUser } from "./helpers"
|
||||
|
||||
export const accountRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -143,6 +145,181 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
|
||||
return success(c, profile)
|
||||
})
|
||||
|
||||
/**
|
||||
* 个人主页那一页的数据(设计稿「我的主页与设置」定稿)。看的人是本人、同学还是老师,
|
||||
* 由这里裁:交过没做对的题和知识点只给本人和老师;不计入排名只告诉本人和老师。
|
||||
*/
|
||||
accountRoutes.get("/profiles/:username/overview", requireAuth, async (c) => {
|
||||
const viewer = c.get("user")!
|
||||
const [target] = await db
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
className: schema.user.className,
|
||||
adminType: schema.user.adminType,
|
||||
rankHiddenAt: schema.user.rankHiddenAt,
|
||||
})
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`,
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!target) return failure(c, 404, "user-not-found", "没有这个用户")
|
||||
const privileged = target.id === viewer.id || isTeacherOrAbove(viewer)
|
||||
const isStudent = (STUDENT_ROLES as readonly string[]).includes(target.adminType)
|
||||
|
||||
const firstSolved = min(schema.submission.createTime)
|
||||
const lastTried = max(schema.submission.createTime)
|
||||
const [times, solved, unsolved, knowledge, achievements, achievementTotal, classStandings] =
|
||||
await Promise.all([
|
||||
// 比赛提交也算:首末提交、学习天数一直是连比赛一起算的
|
||||
db
|
||||
.select({
|
||||
first: min(schema.submission.createTime),
|
||||
latest: max(schema.submission.createTime),
|
||||
activeDays: countDistinct(sql`date(${localTime(schema.submission.createTime)})`),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(eq(schema.submission.userId, target.id)),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
solvedAt: firstSolved,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, target.id),
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.problemId, schema.problem.displayId, schema.problem.title)
|
||||
.orderBy(desc(firstSolved), schema.problem.displayId),
|
||||
privileged
|
||||
? db
|
||||
.select({
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
attempts: count(),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||
.where(
|
||||
and(eq(schema.submission.userId, target.id), isNull(schema.submission.contestId)),
|
||||
)
|
||||
.groupBy(schema.submission.problemId, schema.problem.displayId, schema.problem.title)
|
||||
.having(sql`not bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`)
|
||||
.orderBy(desc(lastTried))
|
||||
: null,
|
||||
privileged ? knowledgeMapOf(target.id, target.className) : null,
|
||||
db
|
||||
.select({
|
||||
id: schema.achievement.id,
|
||||
name: schema.achievement.name,
|
||||
description: schema.achievement.description,
|
||||
icon: schema.achievement.icon,
|
||||
rarity: schema.achievement.rarity,
|
||||
unlockTime: schema.userAchievement.unlockTime,
|
||||
})
|
||||
.from(schema.userAchievement)
|
||||
.innerJoin(
|
||||
schema.achievement,
|
||||
eq(schema.achievement.id, schema.userAchievement.achievementId),
|
||||
)
|
||||
.where(
|
||||
and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true)),
|
||||
)
|
||||
.orderBy(desc(schema.userAchievement.unlockTime), schema.achievement.order),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(schema.achievement)
|
||||
.where(eq(schema.achievement.visible, true)),
|
||||
// 这学期本班第几,和排名页同口径(不计入排名的人不在 rankedStudents 里,自然查不到)
|
||||
isStudent && target.className && !target.rankHiddenAt
|
||||
? loadEntrants(audienceWhere("class", target.className), termStart()).then((entrants) =>
|
||||
standings(entrants, null, true),
|
||||
)
|
||||
: [],
|
||||
])
|
||||
|
||||
const tagRows = solved.length
|
||||
? await db
|
||||
.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTag.id, schema.problemTags.problemtagId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemTag.category, "knowledge"),
|
||||
inArray(
|
||||
schema.problemTags.problemId,
|
||||
solved.map((row) => row.problemId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(schema.problemTag.name)
|
||||
: []
|
||||
const tagsOf = new Map<number, string[]>()
|
||||
for (const row of tagRows) {
|
||||
const list = tagsOf.get(row.problemId)
|
||||
if (list) list.push(row.name)
|
||||
else tagsOf.set(row.problemId, [row.name])
|
||||
}
|
||||
|
||||
const mine = classStandings.find((row) => row.entrant.id === target.id && row.solved > 0)
|
||||
|
||||
// 「你和他」:只有同学看同学才给
|
||||
let versus: ProfileOverview["versus"] = null
|
||||
if (!privileged) {
|
||||
const viewerSolved = await db
|
||||
.selectDistinct({ problemId: schema.submission.problemId })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, viewer.id),
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||
),
|
||||
)
|
||||
const theirs = new Set(solved.map((row) => row.problemId))
|
||||
const me = classStandings.find((row) => row.entrant.id === viewer.id && row.solved > 0)
|
||||
versus = {
|
||||
solved: viewerSolved.length,
|
||||
both: viewerSolved.filter((row) => theirs.has(row.problemId)).length,
|
||||
classRank: me ? { rank: me.rank, total: classStandings.length } : null,
|
||||
}
|
||||
}
|
||||
return success(c, {
|
||||
firstSubmissionAt: times[0]?.first ?? null,
|
||||
latestSubmissionAt: times[0]?.latest ?? null,
|
||||
activeDays: times[0]?.activeDays ?? 0,
|
||||
now: new Date().toISOString(),
|
||||
classRank: mine ? { rank: mine.rank, total: classStandings.length } : null,
|
||||
rankHidden: privileged && isStudent && !!target.rankHiddenAt,
|
||||
solved: solved.flatMap((row) =>
|
||||
row.solvedAt
|
||||
? [
|
||||
{
|
||||
displayId: row.displayId,
|
||||
title: row.title,
|
||||
solvedAt: row.solvedAt,
|
||||
tags: tagsOf.get(row.problemId) ?? [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
unsolved,
|
||||
knowledge,
|
||||
versus,
|
||||
achievements: { total: achievementTotal[0]?.total ?? 0, unlocked: achievements },
|
||||
} satisfies ProfileOverview)
|
||||
})
|
||||
|
||||
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
||||
const parsed = await parseBody(c, updateProfileRequestSchema, "Invalid profile payload")
|
||||
if (!parsed.success) return parsed.response
|
||||
@@ -179,24 +356,14 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
||||
return success(c, { avatar })
|
||||
})
|
||||
|
||||
accountRoutes.get("/users/:id/metrics", async (c) => {
|
||||
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 比赛提交也算:首末提交时间、学习天数都连比赛一起统计
|
||||
const [row] = await db
|
||||
.select({
|
||||
first: min(schema.submission.createTime),
|
||||
latest: max(schema.submission.createTime),
|
||||
activeDays: countDistinct(sql`date(${localTime(schema.submission.createTime)})`),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(eq(schema.submission.userId, userId))
|
||||
if (!row?.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
|
||||
return success(c, {
|
||||
now: new Date().toISOString(),
|
||||
first: row.first,
|
||||
latest: row.latest,
|
||||
activeDays: row.activeDays,
|
||||
} satisfies Metrics)
|
||||
/** 不用图片了,退回默认头像(前端画成名字最后一个字,见 RankAvatar.vue) */
|
||||
accountRoutes.delete("/me/avatar", requireAuth, async (c) => {
|
||||
const avatar = `${config.avatarUriPrefix}/default.png`
|
||||
await db
|
||||
.update(schema.userProfile)
|
||||
.set({ avatar })
|
||||
.where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||||
return success(c, { avatar })
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -342,6 +509,11 @@ function levelOf(solved: number, thresholds: number[]) {
|
||||
*/
|
||||
accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
return success(c, await knowledgeMapOf(user.id, user.className))
|
||||
})
|
||||
|
||||
/** 一个人的知识点地图:「我的知识点」和老师在个人主页上看学生共用 */
|
||||
async function knowledgeMapOf(userId: number, className: string | null): Promise<KnowledgeMap> {
|
||||
const tags = await db
|
||||
.select({
|
||||
id: schema.problemTag.id,
|
||||
@@ -361,7 +533,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
||||
.having(sql`count(*) >= ${KNOWLEDGE_MIN_PROBLEMS}`)
|
||||
const tagIds = tags.map((tag) => tag.id)
|
||||
if (!tagIds.length) return success(c, { tags: [], classTouched: [] } satisfies KnowledgeMap)
|
||||
if (!tagIds.length) return { tags: [], classTouched: [] }
|
||||
|
||||
const firstAccepted = db
|
||||
.select({
|
||||
@@ -371,7 +543,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
eq(schema.submission.userId, userId),
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.result, ACCEPTED_RESULTS),
|
||||
),
|
||||
@@ -398,7 +570,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
),
|
||||
)
|
||||
.groupBy(schema.problemTags.problemtagId),
|
||||
user.className
|
||||
className
|
||||
? db
|
||||
.select({
|
||||
tagId: schema.problemTags.problemtagId,
|
||||
@@ -412,7 +584,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.className, user.className),
|
||||
eq(schema.user.className, className),
|
||||
eq(schema.user.isDisabled, false),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
isNull(schema.submission.contestId),
|
||||
@@ -422,13 +594,13 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
)
|
||||
.groupBy(schema.problemTags.problemtagId)
|
||||
: [],
|
||||
user.className
|
||||
className
|
||||
? db
|
||||
.select({ total: count() })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.className, user.className),
|
||||
eq(schema.user.className, className),
|
||||
eq(schema.user.isDisabled, false),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
),
|
||||
@@ -444,7 +616,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
.map((row) => row.tagId),
|
||||
)
|
||||
|
||||
return success(c, {
|
||||
return {
|
||||
tags: tags
|
||||
.sort((a, b) => b.problemCount - a.problemCount)
|
||||
.map((tag) => {
|
||||
@@ -461,8 +633,8 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
|
||||
} satisfies KnowledgeLevel
|
||||
}),
|
||||
classTouched: tags.filter((tag) => touchedIds.has(tag.id)).map((tag) => tag.name),
|
||||
} satisfies KnowledgeMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { MOOD_MAX } from "@oj2/contract"
|
||||
import { updateProfile, uploadAvatar } from "oj/api"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
async function beforeUpload(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
|
||||
if (!data.file.file) return false
|
||||
if (data.file.file.size > 2 * 1024 * 1024) {
|
||||
message.warning("图片太大啦!不能超过 2 MB 啊")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function upload({ file }: UploadCustomRequestOptions) {
|
||||
try {
|
||||
await uploadAvatar(file.file!)
|
||||
message.success("上传成功")
|
||||
userStore.getMyProfile()
|
||||
} catch (err) {
|
||||
message.error("上传失败")
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
// 以前限 256 字,老的长签名还在库里:输入框挡不住已有的字,存之前先说清楚
|
||||
const length = userStore.profile?.mood?.trim().length ?? 0
|
||||
if (length > MOOD_MAX) {
|
||||
message.warning(`个性签名最多 ${MOOD_MAX} 个字,现在有 ${length} 个,删短一点再保存`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateProfile({
|
||||
realName: userStore.profile?.realName ?? "",
|
||||
mood: userStore.profile?.mood ?? "",
|
||||
})
|
||||
message.success("更改成功")
|
||||
} catch (err) {
|
||||
message.error("更改失败")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex class="container" vertical v-if="userStore.profile">
|
||||
<h2>个人信息设置</h2>
|
||||
<n-form>
|
||||
<n-avatar round :size="120" :src="userStore.profile.avatar" alt="头像" />
|
||||
<n-form-item label="">
|
||||
<n-upload
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
@before-upload="beforeUpload"
|
||||
:custom-request="upload"
|
||||
>
|
||||
<n-button>上传头像</n-button>
|
||||
</n-upload>
|
||||
</n-form-item>
|
||||
<!-- <n-form-item label="真名">
|
||||
<n-input v-model:value="userStore.profile.realName" />
|
||||
</n-form-item> -->
|
||||
<n-form-item label="个性签名">
|
||||
<n-input
|
||||
v-model:value="userStore.profile.mood"
|
||||
:maxlength="MOOD_MAX"
|
||||
show-count
|
||||
placeholder="会显示在排名和个人主页上"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-button @click="saveProfile">更改信息</n-button>
|
||||
</n-form>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding-top: 8px;
|
||||
gap: var(--oj-gap);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--oj-fs-title);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { sampleUserSchema } from "./common"
|
||||
import { achievementRaritySchema } from "./achievement"
|
||||
import { userProfileSchema } from "./auth"
|
||||
|
||||
/** 个性签名最多几个字。以前是 256,排名上要露出来后改成 30(九成签名本来就不超过 26 字) */
|
||||
@@ -19,14 +20,6 @@ export const updateProfileRequestSchema = z.object({
|
||||
mood: z.string().trim().max(MOOD_MAX, `个性签名最多 ${MOOD_MAX} 个字`).nullable().optional(),
|
||||
})
|
||||
|
||||
export const metricsSchema = z.object({
|
||||
now: z.string(),
|
||||
latest: z.string(),
|
||||
first: z.string(),
|
||||
/** 有提交的日历天数(东八区),不是首末提交之间跨了多少天 */
|
||||
activeDays: z.number().int(),
|
||||
})
|
||||
|
||||
export const rankProfileSchema = z.object({
|
||||
id: z.number().int(),
|
||||
user: sampleUserSchema,
|
||||
@@ -84,7 +77,6 @@ export type ProblemRank = z.infer<typeof problemRankSchema>
|
||||
export type RankProfile = z.infer<typeof rankProfileSchema>
|
||||
export type WeeklyRankItem = z.infer<typeof weeklyRankItemSchema>
|
||||
export type WeeklyRank = z.infer<typeof weeklyRankSchema>
|
||||
export type Metrics = z.infer<typeof metricsSchema>
|
||||
|
||||
export type PublicProfile = z.infer<typeof publicProfileSchema>
|
||||
|
||||
@@ -120,3 +112,72 @@ export const knowledgeMapSchema = z.object({
|
||||
|
||||
export type KnowledgeLevel = z.infer<typeof knowledgeLevelSchema>
|
||||
export type KnowledgeMap = z.infer<typeof knowledgeMapSchema>
|
||||
|
||||
/**
|
||||
* 个人主页(`/profiles/:username/overview`,设计稿「我的主页与设置」定稿)。
|
||||
*
|
||||
* 看自己、同学看、老师看是同一份接口,差别在后端按看的人裁:`unsolved` 和 `knowledge`
|
||||
* 只给本人和老师(知识点不开放给同学 —— 开放了就成了另一张排行榜),同学拿到的是 null。
|
||||
*/
|
||||
export const profileSolvedSchema = z.object({
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
/** 第一次做对的时刻,按它分天 */
|
||||
solvedAt: z.string(),
|
||||
/** 知识点标签(category = knowledge),「按知识点」看时按它分组 */
|
||||
tags: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const profileUnsolvedSchema = z.object({
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
attempts: z.number().int(),
|
||||
})
|
||||
|
||||
export const profileAchievementSchema = z.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
icon: z.string(),
|
||||
rarity: achievementRaritySchema,
|
||||
unlockTime: z.string(),
|
||||
})
|
||||
|
||||
export const profileOverviewSchema = z.object({
|
||||
/** 四个时间(用户 2026-10-12 要求保留)。一次没交过时前两个为 null、activeDays 为 0 */
|
||||
firstSubmissionAt: z.string().nullable(),
|
||||
latestSubmissionAt: z.string().nullable(),
|
||||
/** 有提交的日历天数(东八区),比赛提交也算 */
|
||||
activeDays: z.number().int(),
|
||||
now: z.string(),
|
||||
/** 这学期在本班第几(和排名页同口径)。没班级、一道没做对、不计入排名、不是学生时为 null */
|
||||
classRank: z.object({ rank: z.number().int(), total: z.number().int() }).nullable(),
|
||||
/** 被老师设成不计入排名。只告诉本人和老师,同学看到的恒为 false */
|
||||
rankHidden: z.boolean(),
|
||||
/** 做对的题,新的在前;比赛里的提交不算 */
|
||||
solved: z.array(profileSolvedSchema),
|
||||
/** 交过还没做对的题,最近交的在前;同学看时为 null */
|
||||
unsolved: z.array(profileUnsolvedSchema).nullable(),
|
||||
knowledge: knowledgeMapSchema.nullable(),
|
||||
/**
|
||||
* 同学看他时的「你和他」:你做对几道、其中几道他也做对了、你在本班第几(同班才有)。
|
||||
* 本人、老师看时为 null
|
||||
*/
|
||||
versus: z
|
||||
.object({
|
||||
solved: z.number().int(),
|
||||
both: z.number().int(),
|
||||
classRank: z.object({ rank: z.number().int(), total: z.number().int() }).nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
achievements: z.object({
|
||||
total: z.number().int(),
|
||||
/** 解锁了的,新的在前 */
|
||||
unlocked: z.array(profileAchievementSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
export type ProfileSolved = z.infer<typeof profileSolvedSchema>
|
||||
export type ProfileUnsolved = z.infer<typeof profileUnsolvedSchema>
|
||||
export type ProfileAchievement = z.infer<typeof profileAchievementSchema>
|
||||
export type ProfileOverview = z.infer<typeof profileOverviewSchema>
|
||||
Reference in new issue
Block a user