feat(个人主页): 主页改成名片 + 两栏,做对的题按天带题名;设置并进主页,点头像换头像、点笔改签名

- 名片:头像、班级、签名、做对几道 / 本班第几 / 成就,底下一排四个时间(第一次提交、最近一次提交、距上次提交、学习天数)
- 做对的题按日期或按知识点,能搜;最上面一行是交过还没做对的
- 知识点本人和老师看得到;同学看多一张「你和他」
- 老师看学生:看他的智能分析 / 看他的提交 /「⋯」不计入排名、清空签名
- 换头像弹框:拖圆圈选一块,看领奖台和排名里多大,可退回名字的字
- 去掉「我的设置」页和下拉菜单里那一项;手机三个页签

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 00:03:37 +08:00
1 parent cbf5b9d922
commit e368ed3eca
10 files changed
+1983 -370

No files matched your search

+9 -3
View File
@@ -9,7 +9,7 @@ import {
type ContestClassView,
type ContestScoreboard,
type FormatCodeResponse,
type Metrics,
type ProfileOverview,
type TutorialSummary,
type Tutorial,
type ClassActivity,
@@ -395,8 +395,14 @@ export function setReaction(problemID: number, type: ReactionKey) {
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
}
export function getMetrics(userid: number) {
return api.get<Metrics>(`users/${userid}/metrics`)
/** 个人主页那一页的数据:做对的题、四个时间、本班第几、成就,本人和老师多拿知识点和没做对的题 */
export function getProfileOverview(username: string) {
return api.get<ProfileOverview>(`profiles/${encodeURIComponent(username)}/overview`)
}
/** 不用图片了,退回默认头像(名字的字) */
export function resetAvatar() {
return api.delete<{ avatar: string }>("me/avatar")
}
export function getTutorial(id: number) {
@@ -0,0 +1,133 @@
<script setup lang="ts">
import type { ProfileOverview } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_COLOR } from "utils/constants"
import { parseTime } from "utils/functions"
/** 拿到的成就一个一个摆出来,新的在前;全部 41 个(含没拿到的)在成就页 */
const props = defineProps<{
achievements: ProfileOverview["achievements"]
username: string
/** 看的是别人:「全部成就」带上他的名字 */
other: boolean
compact?: boolean
}>()
const theme = useThemeVars()
const allLink = computed(() => ({
path: "/achievement",
query: props.other ? { name: props.username } : {},
}))
</script>
<template>
<section class="card" :class="{ compact }">
<h3>
成就 <small>{{ achievements.unlocked.length }} / {{ achievements.total }}</small>
<span class="grow"></span>
<router-link :to="allLink" class="link">全部成就 ›</router-link>
</h3>
<div v-if="achievements.unlocked.length" class="grid">
<n-tooltip v-for="item in achievements.unlocked" :key="item.id" :delay="200">
<template #trigger>
<span class="medal">
<span class="ring" :style="{ '--rarity': RARITY_COLOR[item.rarity] }">
<AchievementIcon :icon="item.icon" :size="26" />
</span>
<span class="name">{{ item.name }}</span>
</span>
</template>
{{ item.description }} · {{ parseTime(item.unlockTime, "YYYY年M月D日") }}
</n-tooltip>
</div>
<div v-else class="empty">还没有成就</div>
</section>
</template>
<style scoped>
.card {
padding: 14px 18px 16px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: var(--oj-fs-h2);
}
h3 small {
font-size: var(--oj-fs-sec);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.grow {
flex: 1;
}
.link {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: #18a058;
text-decoration: none;
}
.grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
row-gap: 14px;
margin-top: 12px;
}
.medal {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: default;
}
.ring {
width: 40px;
height: 40px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid var(--rarity);
display: flex;
align-items: center;
justify-content: center;
}
.name {
max-width: 100%;
font-size: 12px;
line-height: 16px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty {
padding: 16px 0 4px;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
}
.compact {
padding: 4px 0 0;
border: 0;
border-radius: 0;
background: none;
}
.compact .grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
</style>
@@ -0,0 +1,391 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { resetAvatar, uploadAvatar } from "oj/api"
import RankAvatar from "oj/rank/components/RankAvatar.vue"
import { useUserStore } from "shared/store/user"
/**
* 换头像(设置并进主页之后的入口):选一张图,拖圆圈选一块,旁边就是它在领奖台、
* 排名里的大小;也能退回「名字的字」。
*
* 裁剪在浏览器里做完、只传一张 256×256 的 JPEG 上去 —— 原来直接传原图、靠
* object-fit 居中截,脸常常被截掉一半;手机拍的照片也动辄好几 MB,过不了 2 MB 的限制。
*/
const props = defineProps<{ username: string; avatar: string | null }>()
const emit = defineEmits<{ changed: [] }>()
const show = defineModel<boolean>("show", { required: true })
const theme = useThemeVars()
const message = useMessage()
const userStore = useUserStore()
const STAGE = 280
const OUTPUT = 256
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
const src = ref("")
let image: HTMLImageElement | null = null
/** 图片在 280×280 舞台里的位置(等比缩放、居中) */
const fit = reactive({ x: 0, y: 0, w: 0, h: 0, scale: 1 })
/** 圆圈:圆心和半径,舞台坐标 */
const circle = reactive({ cx: 0, cy: 0, r: 0 })
const saving = ref(false)
const maxR = computed(() => Math.min(fit.w, fit.h) / 2)
const minR = computed(() => Math.min(24, maxR.value))
function clamp() {
circle.r = Math.min(maxR.value, Math.max(minR.value, circle.r))
circle.cx = Math.min(fit.x + fit.w - circle.r, Math.max(fit.x + circle.r, circle.cx))
circle.cy = Math.min(fit.y + fit.h - circle.r, Math.max(fit.y + circle.r, circle.cy))
}
function pick() {
fileInput.value?.click()
}
function clear() {
if (src.value) URL.revokeObjectURL(src.value)
src.value = ""
image = null
}
watch(show, (open) => {
if (!open) clear()
})
onBeforeUnmount(clear)
function onFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ""
if (!file) return
if (!file.type.startsWith("image/")) {
message.warning("要选一张图片")
return
}
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
clear()
image = img
src.value = url
const scale = Math.min(STAGE / img.naturalWidth, STAGE / img.naturalHeight)
fit.scale = scale
fit.w = img.naturalWidth * scale
fit.h = img.naturalHeight * scale
fit.x = (STAGE - fit.w) / 2
fit.y = (STAGE - fit.h) / 2
circle.cx = STAGE / 2
circle.cy = STAGE / 2
circle.r = maxR.value * 0.9
clamp()
}
img.onerror = () => {
URL.revokeObjectURL(url)
message.warning("这张图打不开,换一张试试")
}
img.src = url
}
let drag: { x: number; y: number; cx: number; cy: number } | null = null
function onDown(event: PointerEvent) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
drag = { x: event.clientX, y: event.clientY, cx: circle.cx, cy: circle.cy }
}
function onMove(event: PointerEvent) {
if (!drag) return
circle.cx = drag.cx + event.clientX - drag.x
circle.cy = drag.cy + event.clientY - drag.y
clamp()
}
function onUp() {
drag = null
}
function onWheel(event: WheelEvent) {
circle.r -= event.deltaY * 0.1
clamp()
}
/** 「大小」滑块:0–100 对应最小到最大的半径 */
const size = computed({
get: () =>
maxR.value > minR.value
? Math.round(((circle.r - minR.value) / (maxR.value - minR.value)) * 100)
: 100,
set: (value: number) => {
circle.r = minR.value + ((maxR.value - minR.value) * value) / 100
clamp()
},
})
/** 预览:同一块按目标大小缩放,用背景图画,不用再开一个 canvas */
function previewStyle(px: number) {
const k = px / (circle.r * 2)
return {
width: `${px}px`,
height: `${px}px`,
backgroundImage: `url(${src.value})`,
backgroundSize: `${fit.w * k}px ${fit.h * k}px`,
backgroundPosition: `${-(circle.cx - circle.r - fit.x) * k}px ${-(circle.cy - circle.r - fit.y) * k}px`,
}
}
async function save() {
if (!image) return
const canvas = document.createElement("canvas")
canvas.width = OUTPUT
canvas.height = OUTPUT
const ctx = canvas.getContext("2d")!
// 透明底的 PNG 存成 JPEG 会变黑,先铺白
ctx.fillStyle = "#ffffff"
ctx.fillRect(0, 0, OUTPUT, OUTPUT)
const side = (circle.r * 2) / fit.scale
ctx.drawImage(
image,
(circle.cx - circle.r - fit.x) / fit.scale,
(circle.cy - circle.r - fit.y) / fit.scale,
side,
side,
0,
0,
OUTPUT,
OUTPUT,
)
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.9),
)
if (!blob) {
message.error("没处理好这张图,换一张试试")
return
}
saving.value = true
try {
await uploadAvatar(new File([blob], "avatar.jpg", { type: "image/jpeg" }))
message.success("头像换好了")
userStore.getMyProfile()
emit("changed")
show.value = false
} catch {
message.error("没传上去,再试一次")
} finally {
saving.value = false
}
}
async function useLetter() {
saving.value = true
try {
await resetAvatar()
message.success("改成名字的字了")
userStore.getMyProfile()
emit("changed")
show.value = false
} catch {
message.error("没改成,再试一次")
} finally {
saving.value = false
}
}
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
title="换头像"
:style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }"
:bordered="false"
>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFile" />
<div class="body">
<div
v-if="src"
class="stage"
:style="{ width: `${STAGE}px`, height: `${STAGE}px` }"
@wheel.prevent="onWheel"
>
<img
:src="src"
alt=""
draggable="false"
:style="{
left: `${fit.x}px`,
top: `${fit.y}px`,
width: `${fit.w}px`,
height: `${fit.h}px`,
}"
/>
<span
class="circle"
role="slider"
aria-label="拖动选一块"
:aria-valuenow="size"
:style="{
left: `${circle.cx - circle.r}px`,
top: `${circle.cy - circle.r}px`,
width: `${circle.r * 2}px`,
height: `${circle.r * 2}px`,
}"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
></span>
</div>
<button v-else type="button" class="stage empty" @click="pick">
<RankAvatar :username="username" :avatar="avatar" :size="160" me />
<span class="pick"><Icon icon="ph:image" :width="18" />选一张图片</span>
</button>
<div class="side">
<template v-if="src">
<span class="muted">拖动圆圈选一块</span>
<span class="preview"><span class="dot" :style="previewStyle(56)"></span>领奖台</span>
<span class="preview"><span class="dot" :style="previewStyle(28)"></span>排名、顶栏</span>
<label class="size">
<span class="muted">大小</span>
<n-slider v-model:value="size" :step="1" :tooltip="false" />
</label>
<n-button size="small" class="fit" @click="pick">
<template #icon><Icon icon="ph:image" /></template>换一张
</n-button>
</template>
<template v-else>
<span class="preview">
<RankAvatar :username="username" :avatar="avatar" :size="56" me />领奖台
</span>
<span class="preview">
<RankAvatar :username="username" :avatar="avatar" :size="28" me />排名、顶栏
</span>
</template>
<a
v-if="avatar || src"
href="#"
class="link"
:class="{ disabled: saving }"
@click.prevent="!saving && useLetter()"
>
不用图片,用名字的字
</a>
</div>
</div>
<template #footer>
<div class="footer">
<n-button @click="show = false">取消</n-button>
<n-button type="primary" :disabled="!src" :loading="saving" @click="save">
用这张
</n-button>
</div>
</template>
</n-modal>
</template>
<style scoped>
.body {
display: flex;
gap: 22px;
flex-wrap: wrap;
}
.stage {
position: relative;
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
background: v-bind("theme.actionColor");
touch-action: none;
user-select: none;
}
.stage img {
position: absolute;
pointer-events: none;
}
.circle {
position: absolute;
box-sizing: border-box;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 0 600px rgba(0, 0, 0, 0.5);
cursor: move;
}
.stage.empty {
width: 280px;
height: 280px;
border: 1px dashed v-bind("theme.borderColor");
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
cursor: pointer;
font: inherit;
}
.pick {
display: inline-flex;
align-items: center;
gap: 6px;
color: #18a058;
font-size: var(--oj-fs-sec);
}
.side {
flex: 1;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 14px;
font-size: var(--oj-fs-meta);
}
.muted {
color: v-bind("theme.textColor3");
}
.preview {
display: flex;
align-items: center;
gap: 10px;
color: v-bind("theme.textColor3");
}
.dot {
flex-shrink: 0;
border-radius: 50%;
background-repeat: no-repeat;
}
.size {
display: flex;
flex-direction: column;
gap: 4px;
}
.fit {
align-self: flex-start;
}
.link {
color: #18a058;
text-decoration: none;
}
.link.disabled {
opacity: 0.5;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
+138 -61
View File
@@ -1,30 +1,33 @@
<script setup lang="ts">
import { getKnowledgeMap } from "oj/api"
import { useThemeVars } from "naive-ui"
import { levelName, MAX_LEVEL, remainingToNext, tagLink } from "oj/user/knowledge"
import type { KnowledgeLevel, KnowledgeMap } from "utils/types"
/**
* 个人主页上的知识点地图:「我学会了什么」,只和自己比。
* 个人主页上的知识点地图:「学会了什么」,只和自己比。
*
* 只在看**自己的**主页时挂(index.vue 判断),不开放给别人看 —— 开放了它就成了
* 另一张排行榜,基础弱的学生最不想被看到的就是这个。
* 本人和老师看得到,同学看不到(后端 /profiles/:username/overview 裁)—— 开放给同学
* 它就成了另一张排行榜,基础弱的学生最不想被看到的就是这个。
*/
const map = ref<KnowledgeMap | null>(null)
const props = defineProps<{
map: KnowledgeMap
/** 老师在看:标题不带「我的」,称呼用「他」 */
teacher?: boolean
compact?: boolean
}>()
onMounted(async () => {
map.value = await getKnowledgeMap().catch(() => null)
})
const theme = useThemeVars()
const learned = computed(() =>
(map.value?.tags ?? [])
props.map.tags
.filter((tag) => tag.level >= 1)
.sort((a, b) => b.level - a.level || b.solved - a.solved),
)
/** 班里同学在学、自己还没碰过的。别的(比如 Python 班的「C 语言」)不列 */
/** 班里同学在学、他还没碰过的。别的(比如 Python 班的「C 语言」)不列 */
const untouched = computed(() => {
const touched = new Set(map.value?.classTouched ?? [])
return (map.value?.tags ?? []).filter((tag) => tag.level === 0 && touched.has(tag.name))
const touched = new Set(props.map.classTouched)
return props.map.tags.filter((tag) => tag.level === 0 && touched.has(tag.name))
})
function progress(tag: KnowledgeLevel) {
@@ -34,65 +37,71 @@ function progress(tag: KnowledgeLevel) {
function hint(tag: KnowledgeLevel) {
const left = remainingToNext(tag)
if (left === null) return `做对了 ${tag.solved} 道,已经精通了`
if (left === null) return `做对 ${tag.solved} 道 · 已经精通`
return `做对 ${tag.solved} 道 · 再做 ${left} 道到「${levelName(tag.level + 1)}」`
}
</script>
<template>
<n-card v-if="map && (learned.length || untouched.length)" class="knowledge" title="我的知识点">
<template #header-extra>
<n-text depth="3" class="meta">入门 → 会了 → 熟练 → 精通</n-text>
</template>
<n-flex vertical :size="14">
<router-link v-for="tag in learned" :key="tag.name" :to="tagLink(tag.name)" class="row">
<div class="head">
<span class="name">{{ tag.name }}</span>
<span class="dots" :title="levelName(tag.level)">
<span v-for="i in MAX_LEVEL" :key="i" class="dot" :class="{ on: i <= tag.level }" />
</span>
<span class="level">{{ levelName(tag.level) }}</span>
<n-tag
v-if="tag.level > tag.levelAtWeekStart"
size="small"
type="success"
:bordered="false"
>
本周升级
</n-tag>
</div>
<n-progress
type="line"
:percentage="progress(tag)"
:show-indicator="false"
:height="6"
status="success"
/>
<n-text depth="3" class="meta">{{ hint(tag) }}</n-text>
</router-link>
<section class="card" :class="{ compact }">
<h3 v-if="!compact">
{{ teacher ? "知识点" : "我的知识点" }}
<small v-if="!teacher">只有你和老师看得到</small>
</h3>
<router-link v-for="tag in learned" :key="tag.name" :to="tagLink(tag.name)" class="row">
<span class="head">
<span class="name">{{ tag.name }}</span>
<span class="dots" :title="levelName(tag.level)">
<i v-for="i in MAX_LEVEL" :key="i" :class="{ on: i <= tag.level }"></i>
</span>
<span class="level">{{ levelName(tag.level) }}</span>
<span v-if="tag.level > tag.levelAtWeekStart" class="up">这周升了</span>
</span>
<span class="track"><b :style="{ width: `${progress(tag)}%` }"></b></span>
<span class="meta">{{ hint(tag) }}</span>
</router-link>
<div v-if="!learned.length && !untouched.length" class="meta empty">还没做对带知识点的题</div>
<div v-if="untouched.length">
<n-text depth="3" class="meta">班里同学在学、你还没碰过:</n-text>
<n-flex :size="8" style="margin-top: 6px">
<router-link v-for="tag in untouched" :key="tag.name" :to="tagLink(tag.name)">
<n-tag size="small">{{ tag.name }}</n-tag>
</router-link>
</n-flex>
<div v-if="untouched.length" class="untouched">
<span class="meta">班里在学、{{ teacher ? "他" : "你" }}还没碰过</span>
<div class="tags">
<router-link v-for="tag in untouched" :key="tag.name" :to="tagLink(tag.name)" class="tag">
{{ tag.name }}
</router-link>
</div>
</n-flex>
</n-card>
</div>
</section>
</template>
<style scoped>
.knowledge {
max-width: 760px;
margin: var(--oj-gap) auto 0;
.card {
padding: 14px 18px 12px;
display: flex;
flex-direction: column;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0 0 4px;
font-size: var(--oj-fs-h2);
display: flex;
align-items: baseline;
gap: 8px;
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 7px 0;
color: inherit;
text-decoration: none;
}
@@ -104,8 +113,12 @@ function hint(tag: KnowledgeLevel) {
}
.name {
width: 72px;
font-size: var(--oj-fs-sec);
font-weight: 500;
min-width: 5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dots {
@@ -113,23 +126,87 @@ function hint(tag: KnowledgeLevel) {
gap: 3px;
}
.dot {
.dots i {
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(128, 128, 128, 0.25);
background: v-bind("theme.dividerColor");
}
.dot.on {
background-color: #18a058;
.dots i.on {
background: #18a058;
}
.level {
font-size: var(--oj-fs-sec);
font-size: var(--oj-fs-meta);
color: #18a058;
}
.up {
padding: 1px 6px;
border-radius: 4px;
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-size: 12px;
}
.track {
display: block;
height: 6px;
border-radius: 3px;
background: v-bind("theme.dividerColor");
overflow: hidden;
}
.track b {
display: block;
height: 6px;
border-radius: 3px;
background: #18a058;
}
.meta {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.empty {
padding: 12px 0;
font-size: var(--oj-fs-sec);
}
.untouched {
margin-top: 6px;
padding-top: 10px;
border-top: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
gap: 8px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 10px;
border: 1px dashed v-bind("theme.borderColor");
border-radius: 13px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor2");
text-decoration: none;
}
.compact {
padding: 4px 0 0;
border: 0;
border-radius: 0;
background: none;
}
</style>
@@ -0,0 +1,633 @@
<script setup lang="ts">
import { MOOD_MAX, STUDENT_ROLES, type ProfileOverview } from "@oj2/contract"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { clearMood, setRankHidden, updateProfile } from "oj/api"
import RankAvatar from "oj/rank/components/RankAvatar.vue"
import { classLabel } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { useUserStore } from "shared/store/user"
import { durationToDays, parseTime } from "utils/functions"
import type { Profile } from "utils/types"
import AvatarCropModal from "./AvatarCropModal.vue"
/**
* 个人主页顶上那张名片(设计稿「我的主页与设置」定稿):头像、名字、班级、签名、三个数,
* 底下一排四个时间。设置并进了这里 —— 自己的主页上点头像换头像、点笔改签名,
* 没有单独的设置页了。
*/
const props = defineProps<{
profile: Profile
overview: ProfileOverview
isSelf: boolean
/** 老师在看学生:三个数的最后一格换成「交过还没做对」,按钮换成看他的… */
teacherView: boolean
compact?: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
const theme = useThemeVars()
const message = useMessage()
const dialog = useDialog()
const userStore = useUserStore()
const username = computed(() => props.profile.user.username)
const avatar = computed(() =>
props.profile.avatar.endsWith("/default.png") ? null : props.profile.avatar,
)
const isStudent = computed(() =>
(STUDENT_ROLES as readonly string[]).includes(props.profile.user.adminType),
)
interface Stat {
big: string | number
unit: string
sub: string
warn?: boolean
link?: { text: string; to: string }
}
const stats = computed(() => {
const o = props.overview
const list: Stat[] = [{ big: o.solved.length, unit: "道", sub: "做对" }]
if (o.classRank)
list.push({
big: `第 ${o.classRank.rank}`,
unit: "名",
sub: props.compact ? `本班 ${o.classRank.total} 人` : `本班 · 这学期 ${o.classRank.total} 人`,
link: props.isSelf ? { text: "看排名 ›", to: "/rank" } : undefined,
})
else if (o.rankHidden) list.push({ big: "不计入", unit: "排名", sub: "本班 · 这学期" })
if (props.teacherView)
list.push({ big: o.unsolved?.length ?? 0, unit: "道", sub: "交过还没做对", warn: true })
else
list.push({
big: o.achievements.unlocked.length,
unit: "个",
sub: `成就 · 一共 ${o.achievements.total} 个`,
})
return list
})
const times = computed(() => {
const o = props.overview
if (!o.firstSubmissionAt || !o.latestSubmissionAt) return []
return [
{ label: "第一次提交", value: parseTime(o.firstSubmissionAt) },
{ label: "最近一次提交", value: parseTime(o.latestSubmissionAt) },
{
label: "距上次提交",
// durationToDays 给的是「13天」「1月2天」,和旁边「13 天」对齐,数字和单位之间空一格
value: durationToDays(o.latestSubmissionAt, o.now).replace(/(\d)(?=[年月天])/g, "$1 "),
},
{ label: "学习天数", value: `${o.activeDays} 天` },
]
})
// ---------- 改签名(就地) ----------
const editing = ref(false)
const draft = ref("")
const saving = ref(false)
function startEdit() {
draft.value = props.profile.mood ?? ""
editing.value = true
}
async function saveMood() {
const mood = draft.value.trim()
if (mood.length > MOOD_MAX) {
message.warning(`个性签名最多 ${MOOD_MAX} 个字`)
return
}
saving.value = true
try {
await updateProfile({ mood })
if (userStore.profile) userStore.profile.mood = mood || null
editing.value = false
message.success(mood ? "签名改好了" : "签名清空了")
emit("changed")
} catch {
message.error("没保存上,再试一次")
} finally {
saving.value = false
}
}
// ---------- 换头像 ----------
const cropping = ref(false)
// ---------- 老师的「⋯」 ----------
const menu = computed(() => {
const items = []
if (isStudent.value) {
items.push({
key: "hidden",
label: props.overview.rankHidden ? "恢复计入排名" : "不计入排名",
})
items.push({ key: "mood", label: "清空个性签名", disabled: !props.profile.mood })
}
if (userStore.isSuperAdmin) items.push({ key: "admin", label: "在后台找这个人" })
return items
})
const router = useRouter()
function onMenu(key: string) {
const id = props.profile.user.id
if (key === "admin") {
router.push({ name: "admin user list", query: { keyword: username.value } })
} else if (key === "hidden") {
const hide = !props.overview.rankHidden
setRankHidden(id, hide)
.then(() => {
message.success(hide ? "已设成不计入排名" : "已恢复计入排名")
emit("changed")
})
.catch(() => message.error("没改成,再试一次"))
} else if (key === "mood") {
dialog.warning({
title: "清空个性签名",
content: `「${props.profile.mood}」会被清掉,他之后还能重新写。`,
positiveText: "清空",
negativeText: "取消",
onPositiveClick: () =>
clearMood(id)
.then(() => {
message.success("签名清空了")
emit("changed")
})
.catch(() => message.error("没清掉,再试一次")),
})
}
}
</script>
<template>
<section class="card" :class="{ compact }">
<div class="top">
<div class="who">
<button
v-if="isSelf"
type="button"
class="avatar-btn"
aria-label="换头像"
@click="cropping = true"
>
<RankAvatar :username="username" :avatar="avatar" :size="compact ? 64 : 96" me />
<span class="avatar-hover"><Icon icon="ph:camera" :width="20" />换头像</span>
<span class="avatar-badge"><Icon icon="ph:camera" :width="compact ? 14 : 16" /></span>
</button>
<RankAvatar v-else :username="username" :avatar="avatar" :size="compact ? 64 : 96" />
<div class="info">
<div class="name-line">
<UserName :username="username" class="name" />
<span v-if="profile.user.className" class="muted">
{{ classLabel(profile.user.className) }}
</span>
</div>
<form v-if="editing" class="mood-edit" @submit.prevent="saveMood">
<n-input
v-model:value="draft"
:maxlength="MOOD_MAX"
show-count
clearable
autofocus
placeholder="写点什么,让大家认识你"
aria-label="个性签名"
/>
<n-button type="primary" attr-type="submit" :loading="saving">保存</n-button>
<n-button @click="editing = false">取消</n-button>
</form>
<template v-else-if="isSelf">
<span v-if="profile.mood" class="mood">
{{ profile.mood }}
<button type="button" class="icon-btn" aria-label="改个性签名" @click="startEdit">
<Icon icon="ph:pencil-simple" :width="16" />
</button>
</span>
<button v-else type="button" class="write-mood" @click="startEdit">
<Icon icon="ph:pencil-simple" :width="14" />写一句个性签名
</button>
</template>
<span v-else-if="profile.mood" class="mood">{{ profile.mood }}</span>
<span v-else-if="teacherView" class="mood faint">没写个性签名</span>
<div v-if="!compact && (isSelf || teacherView)" class="actions">
<template v-if="isSelf">
<n-button size="small" @click="router.push('/ai-analysis')">
<template #icon><Icon icon="ph:sparkle" /></template>智能分析
</n-button>
<n-button size="small" @click="router.push('/submission?myself=1')">
<template #icon><Icon icon="ph:list-checks" /></template>我的提交
</n-button>
</template>
<template v-else>
<n-button
size="small"
type="primary"
@click="router.push({ name: 'ai', query: { username } })"
>
<template #icon><Icon icon="ph:sparkle" /></template>看他的智能分析
</n-button>
<n-button
size="small"
@click="
router.push({ path: '/submission', query: { username, exactUsername: '1' } })
"
>
<template #icon><Icon icon="ph:list-checks" /></template>看他的提交
</n-button>
<n-dropdown v-if="menu.length" :options="menu" trigger="click" @select="onMenu">
<n-button size="small" aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</template>
</div>
</div>
</div>
<div class="stats">
<div v-for="item in stats" :key="item.sub" class="stat">
<div class="num">
<b :class="{ warn: item.warn }">{{ item.big }}</b>
<span>{{ item.unit }}</span>
</div>
<div class="sub">{{ item.sub }}</div>
<router-link v-if="item.link && !compact" :to="item.link.to" class="link">
{{ item.link.text }}
</router-link>
</div>
</div>
</div>
<div v-if="times.length" class="times">
<div v-for="item in times" :key="item.label" class="time">
<span class="muted">{{ item.label }}</span>
<b>{{ item.value }}</b>
</div>
</div>
<div v-else class="times empty muted">还没交过题</div>
<div v-if="compact && (isSelf || teacherView)" class="actions wide">
<template v-if="isSelf">
<n-button @click="router.push('/ai-analysis')">
<template #icon><Icon icon="ph:sparkle" /></template>智能分析
</n-button>
<n-button @click="router.push('/submission?myself=1')">
<template #icon><Icon icon="ph:list-checks" /></template>我的提交
</n-button>
</template>
<template v-else>
<n-button type="primary" @click="router.push({ name: 'ai', query: { username } })">
看他的智能分析
</n-button>
<n-button
@click="router.push({ path: '/submission', query: { username, exactUsername: '1' } })"
>
看他的提交
</n-button>
</template>
</div>
<AvatarCropModal
v-if="isSelf"
v-model:show="cropping"
:username="username"
:avatar="avatar"
@changed="emit('changed')"
/>
</section>
</template>
<style scoped>
.card {
padding: 22px 24px 18px;
display: flex;
flex-direction: column;
gap: 18px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
.top {
display: flex;
align-items: center;
gap: 22px;
}
.who {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 22px;
}
.avatar-btn {
position: relative;
flex-shrink: 0;
padding: 0;
border: 0;
background: none;
border-radius: 50%;
cursor: pointer;
}
.avatar-hover {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
font-size: var(--oj-fs-meta);
opacity: 0;
transition: opacity 0.15s;
}
.avatar-btn:hover .avatar-hover,
.avatar-btn:focus-visible .avatar-hover {
opacity: 1;
}
.avatar-badge {
position: absolute;
right: -2px;
bottom: -2px;
width: 32px;
height: 32px;
box-sizing: border-box;
border-radius: 50%;
border: 1px solid v-bind("theme.borderColor");
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor2");
display: flex;
align-items: center;
justify-content: center;
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.name-line {
display: flex;
align-items: baseline;
gap: 12px;
max-width: 100%;
}
.name {
font-size: var(--oj-fs-title);
}
.name :deep(.name) {
font-weight: 700;
}
.muted {
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.mood {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
font-size: var(--oj-fs-body);
color: v-bind("theme.textColor2");
word-break: break-all;
}
.mood.faint {
color: v-bind("theme.textColor3");
}
.icon-btn {
flex-shrink: 0;
display: inline-flex;
padding: 4px;
border: 0;
border-radius: 4px;
background: none;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.icon-btn:hover {
color: #18a058;
background: rgba(24, 160, 88, 0.1);
}
.write-mood {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border: 1px dashed rgba(24, 160, 88, 0.5);
border-radius: 15px;
background: none;
color: #18a058;
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
}
.mood-edit {
display: flex;
gap: 8px;
width: 440px;
max-width: 100%;
}
.actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.stats {
display: flex;
align-items: flex-start;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 24px;
border-left: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
.stat:first-child {
border-left: 0;
}
.num {
display: flex;
align-items: baseline;
gap: 4px;
}
.num b {
font-size: 30px;
line-height: 38px;
font-variant-numeric: tabular-nums;
}
.num b.warn {
color: #c27c0e;
}
.num span {
font-size: var(--oj-fs-body);
}
.sub {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.link {
font-size: var(--oj-fs-meta);
color: #18a058;
text-decoration: none;
}
.times {
display: flex;
flex-wrap: wrap;
row-gap: 12px;
padding: 14px 0 0 118px;
border-top: 1px solid v-bind("theme.dividerColor");
}
.times.empty {
font-size: var(--oj-fs-sec);
}
.time {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 20px;
border-left: 1px solid v-bind("theme.dividerColor");
}
.time:first-child {
padding-left: 0;
border-left: 0;
}
.time .muted {
font-size: var(--oj-fs-meta);
}
.time b {
font-size: var(--oj-fs-body);
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 手机:名片竖着排,三个数一排一个框,四个时间两两一排 */
.compact {
padding: 18px 16px 16px;
gap: 12px;
}
.compact .top {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.compact .who {
gap: 14px;
align-items: flex-start;
}
.compact .name-line {
flex-direction: column;
gap: 2px;
}
.compact .name {
font-size: 20px;
}
.compact .mood-edit {
flex-wrap: wrap;
}
.compact .avatar-badge {
width: 26px;
height: 26px;
right: -4px;
bottom: -4px;
}
.compact .stats {
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
padding: 10px 0 8px;
}
.compact .stat {
flex: 1;
align-items: center;
padding: 0 4px;
}
.compact .num b {
font-size: 22px;
line-height: 28px;
}
.compact .num span {
font-size: var(--oj-fs-meta);
}
.compact .sub {
font-size: 12px;
white-space: normal;
text-align: center;
}
.compact .times {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
padding: 2px 4px 0;
border-top: 0;
}
.compact .time {
padding: 0;
border-left: 0;
}
.actions.wide {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: 0;
}
</style>
@@ -0,0 +1,446 @@
<script setup lang="ts">
import type { ProfileSolved, ProfileUnsolved } from "@oj2/contract"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { dayLabel } from "oj/ai/utils"
import { parseTime } from "utils/functions"
/**
* 做对的题:按天一行(或按知识点一行),一道题一块,带题名 —— 原来是一墙题号按钮,
* 学生认不得题号。最上面一行是交过还没做对的(本人叫「差一点」,老师看叫「还没做对」)。
*/
const props = defineProps<{
solved: ProfileSolved[]
/** 本人和老师才有;同学看是 null */
unsolved: ProfileUnsolved[] | null
title: string
/** 老师在看这个学生:没做对的题点进去看他的提交 */
username?: string
compact?: boolean
}>()
const theme = useThemeVars()
/** 一行最多先露几道,多的点「还有 N 道」 */
const PER_ROW = 12
/** 先露几行,「看更早的」一次再加这么多 */
const ROWS = 6
const mode = ref<"day" | "tag">("day")
const keyword = ref("")
const rowLimit = ref(ROWS)
const opened = ref(new Set<string>())
watch([mode, keyword], () => {
rowLimit.value = ROWS
opened.value = new Set()
})
const filtered = computed(() => {
const word = keyword.value.trim().toLowerCase()
if (!word) return props.solved
return props.solved.filter(
(item) =>
item.displayId.toLowerCase().includes(word) || item.title.toLowerCase().includes(word),
)
})
interface Row {
key: string
label: string
sub: string
items: ProfileSolved[]
}
const rows = computed<Row[]>(() => {
const groups = new Map<string, ProfileSolved[]>()
if (mode.value === "day") {
for (const item of filtered.value) {
const day = parseTime(item.solvedAt, "YYYY-MM-DD")
const list = groups.get(day)
if (list) list.push(item)
else groups.set(day, [item])
}
return [...groups].map(([day, items]) => {
const { date, weekday } = dayLabel(day)
return { key: day, label: date, sub: weekday, items }
})
}
for (const item of filtered.value) {
for (const tag of item.tags.length ? item.tags : ["其他"]) {
const list = groups.get(tag)
if (list) list.push(item)
else groups.set(tag, [item])
}
}
return [...groups]
.map(([tag, items]) => ({ key: tag, label: tag, sub: "", items }))
.sort(
(a, b) =>
Number(a.key === "其他") - Number(b.key === "其他") || b.items.length - a.items.length,
)
})
/** 搜的时候全摊开,不再收着 */
const searching = computed(() => !!keyword.value.trim())
const shownRows = computed(() =>
searching.value ? rows.value : rows.value.slice(0, rowLimit.value),
)
function itemsOf(row: Row) {
return searching.value || opened.value.has(row.key) ? row.items : row.items.slice(0, PER_ROW)
}
function open(row: Row) {
opened.value = new Set([...opened.value, row.key])
}
function unsolvedLink(item: ProfileUnsolved) {
return props.username
? {
path: "/submission",
query: { problem: item.displayId, username: props.username, exactUsername: "1" },
}
: `/problem/${item.displayId}`
}
</script>
<template>
<section class="card" :class="{ compact }">
<div class="head">
<h3>
{{ title }} <small>{{ solved.length }} 道</small>
</h3>
<span class="grow"></span>
<n-input
v-if="solved.length"
v-model:value="keyword"
class="search"
clearable
placeholder="搜题号或题目"
aria-label="搜题号或题目"
>
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<div v-if="solved.length" class="seg" role="group" aria-label="怎么排">
<button type="button" :class="{ on: mode === 'day' }" @click="mode = 'day'">按日期</button>
<button type="button" :class="{ on: mode === 'tag' }" @click="mode = 'tag'">
按知识点
</button>
</div>
</div>
<div v-if="unsolved?.length && !searching" class="row">
<div class="label warn">{{ username ? "还没做对" : "差一点" }}</div>
<div class="chips">
<router-link
v-for="item in unsolved"
:key="item.displayId"
:to="unsolvedLink(item)"
class="chip stuck"
>
<span class="id">{{ item.displayId }}</span>
<span class="t">{{ item.title }}</span>
<span class="s">交了 {{ item.attempts }} 次</span>
</router-link>
</div>
<div class="count">{{ unsolved.length }} 道</div>
</div>
<div v-for="row in shownRows" :key="row.key" class="row">
<div class="label">
<b>{{ row.label }}</b> <span v-if="row.sub" class="sub">{{ row.sub }}</span>
</div>
<div class="chips">
<router-link
v-for="item in itemsOf(row)"
:key="item.displayId"
:to="`/problem/${item.displayId}`"
class="chip ok"
>
<span class="id">{{ item.displayId }}</span>
<span class="t">{{ item.title }}</span>
</router-link>
<button
v-if="itemsOf(row).length < row.items.length"
type="button"
class="chip more"
@click="open(row)"
>
还有 {{ row.items.length - PER_ROW }} 道 <Icon icon="ph:caret-down" />
</button>
</div>
<div class="count">{{ row.items.length }} 道</div>
</div>
<div v-if="!searching && rows.length > rowLimit" class="foot">
<button type="button" class="more-link" @click="rowLimit += ROWS">
{{ mode === "day" ? "看更早的" : "看更多" }} <Icon icon="ph:caret-down" />
</button>
</div>
<div v-if="searching && !rows.length" class="empty">没有对得上的题</div>
<div v-if="!solved.length" class="empty">还没做对过题</div>
</section>
</template>
<style scoped>
.card {
padding: 14px 20px 10px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
.head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
h3 {
margin: 0;
font-size: var(--oj-fs-h2);
}
h3 small {
font-size: var(--oj-fs-sec);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.grow {
flex: 1;
}
.search {
width: 200px;
}
.seg {
display: inline-flex;
height: var(--oj-ctrl-h);
border: 1px solid v-bind("theme.borderColor");
border-radius: 4px;
overflow: hidden;
}
.seg button {
padding: 0 12px;
border: 0;
border-left: 1px solid v-bind("theme.borderColor");
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor2");
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
white-space: nowrap;
}
.seg button:first-child {
border-left: 0;
}
.seg button.on {
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-weight: 600;
}
.row {
display: flex;
gap: 12px;
padding: 10px 0;
border-top: 1px solid v-bind("theme.dividerColor");
}
.label {
width: 92px;
flex-shrink: 0;
line-height: 30px;
font-size: var(--oj-fs-body);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label .sub {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.label.warn {
color: #b86e00;
font-weight: 600;
font-size: var(--oj-fs-sec);
}
.chips {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.count {
width: 56px;
flex-shrink: 0;
text-align: right;
line-height: 30px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 300px;
height: 30px;
box-sizing: border-box;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 6px;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor1");
text-decoration: none;
white-space: nowrap;
}
.chip:hover {
filter: brightness(0.97);
}
.chip .id {
flex-shrink: 0;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.chip .t {
overflow: hidden;
text-overflow: ellipsis;
}
.chip .s {
flex-shrink: 0;
font-size: var(--oj-fs-meta);
font-weight: 700;
color: #b86e00;
}
.ok {
background: rgba(24, 160, 88, 0.12);
}
.stuck {
background: rgba(240, 160, 32, 0.15);
}
.more {
border-color: v-bind("theme.borderColor");
background: none;
color: v-bind("theme.textColor2");
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
}
.foot {
padding: 12px 0 2px;
text-align: center;
border-top: 1px solid v-bind("theme.dividerColor");
}
.more-link {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: none;
color: #18a058;
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
}
.empty {
padding: 24px 0 18px;
text-align: center;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
}
/* 手机:按日期、按知识点都改成一行一道(块在窄屏上一行只放得下一两个) */
.compact {
padding: 4px 0 0;
border: 0;
border-radius: 0;
background: none;
}
.compact .head h3,
.compact .head .grow {
display: none;
}
.compact .search {
width: auto;
flex: 1 0 100%;
}
.compact .seg {
flex: 1;
}
.compact .seg button {
flex: 1;
}
.compact .row {
flex-direction: column;
gap: 6px;
}
.compact .label {
width: auto;
line-height: 22px;
}
.compact .count {
display: none;
}
.compact .chips {
flex-direction: column;
gap: 0;
}
.compact .chip {
max-width: none;
height: 44px;
padding: 0;
border-radius: 0;
background: none;
border-top: 1px solid v-bind("theme.dividerColor");
font-size: var(--oj-fs-body);
}
.compact .chip .id {
width: 52px;
}
.compact .chip .t {
flex: 1;
}
.compact .more {
color: #18a058;
border-left: 0;
border-right: 0;
border-bottom: 0;
}
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { ProfileOverview } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
/** 同学看他的主页时的「你和他」:两根条比做对几道,下面一句你们重合多少、你在本班第几 */
const props = defineProps<{
versus: NonNullable<ProfileOverview["versus"]>
/** 他做对几道 */
theirs: number
}>()
const theme = useThemeVars()
const top = computed(() => Math.max(props.theirs, props.versus.solved, 1))
const line = computed(() => {
const { solved, both, classRank } = props.versus
const parts = []
if (solved)
parts.push(
both === solved
? `你做对的 ${solved} 道他都做对了`
: `你做对的 ${solved} 道里他做对了 ${both} 道`,
)
if (classRank) parts.push(`你在本班第 ${classRank.rank} 名`)
return parts.join(" · ")
})
</script>
<template>
<section class="card">
<h3>你和他</h3>
<div class="bar-row">
<span class="who">他</span>
<span class="lane"
><span class="bar his" :style="{ width: `${(theirs / top) * 100}%` }"></span
></span>
<b>{{ theirs }}</b>
</div>
<div class="bar-row">
<span class="who">你</span>
<span class="lane">
<span class="bar mine" :style="{ width: `${(versus.solved / top) * 100}%` }"></span>
</span>
<b>{{ versus.solved }}</b>
</div>
<div v-if="line" class="meta">{{ line }}</div>
</section>
</template>
<style scoped>
.card {
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
font-size: var(--oj-fs-h2);
}
.bar-row {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--oj-fs-sec);
}
.who {
width: 28px;
color: v-bind("theme.textColor3");
}
.lane {
flex: 1;
display: flex;
}
.bar {
height: 10px;
min-width: 2px;
border-radius: 2px 5px 5px 2px;
}
.his {
background: #2f6fd0;
}
.mine {
background: #18a058;
}
b {
width: 44px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.meta {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
</style>
+125 -295
View File
@@ -1,327 +1,157 @@
<script setup lang="ts">
import KnowledgeMap from "./components/KnowledgeMap.vue"
import { Icon } from "@iconify/vue"
import type { ProfileOverview } from "@oj2/contract"
import { getProfileOverview } from "oj/api"
import { getProfile } from "shared/api"
import { durationToDays, parseTime } from "utils/functions"
import type { AchievementSummary, Profile } from "utils/types"
import { getAchievementSummary } from "oj/achievement/api"
import { getMetrics } from "../api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
import type { Profile } from "utils/types"
import AchievementCard from "./components/AchievementCard.vue"
import KnowledgeMap from "./components/KnowledgeMap.vue"
import ProfileCard from "./components/ProfileCard.vue"
import SolvedCard from "./components/SolvedCard.vue"
import VersusCard from "./components/VersusCard.vue"
/**
* 在看自己的主页。知识点地图只在这时候挂:它不给别人看(见 KnowledgeMap.vue),
* 后端 /me/knowledge 也只认当前登录的人。带 ?name= 看的是别人,除非名字就是自己。
* 个人主页(设计稿「我的主页与设置」定稿)。排名、提交列表点名字也是到这里(?name=),
* 所以同一页有三种人看:本人、同学、老师。看的人不同,后端给的东西不同(见
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
*/
const isSelf = computed(() => {
const name = route.query.name as string | undefined
return !name || name === userStore.user?.username
})
const profile = ref<Profile | null>(null)
const problems = ref<string[]>([])
const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("")
const toLatestAt = ref("")
const learnDuration = ref("")
const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false)
const route = useRoute()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const isDefaultAvatar = computed(() => profile.value?.avatar.endsWith("default.png") ?? true)
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
const itemsPerRow = ref(8)
function updateItemsPerRow() {
if (!problemsFlexRef.value) return
const buttons = problemsFlexRef.value.querySelectorAll("button")
if (!buttons.length) return
const firstTop = buttons[0].offsetTop
let count = 0
for (const btn of buttons) {
if (btn.offsetTop === firstTop) count++
else break
}
if (count > 0) itemsPerRow.value = count
}
useResizeObserver(problemsFlexRef, updateItemsPerRow)
watch(problems, async () => {
await nextTick()
updateItemsPerRow()
})
const visibleProblems = computed(() =>
show.value ? problems.value : problems.value.slice(0, itemsPerRow.value * 3),
const username = computed(
() => (route.query.name as string | undefined) || userStore.user?.username || "",
)
const isSelf = computed(() => username.value === userStore.user?.username)
const teacherView = computed(() => !isSelf.value && userStore.isTeacherOrAbove)
const hasMoreProblems = computed(() => problems.value.length > itemsPerRow.value * 3)
const profile = ref<Profile | null>(null)
const overview = ref<ProfileOverview | null>(null)
const loading = ref(true)
const notFound = ref(false)
const tab = ref("solved")
async function init() {
toggle(true)
async function load() {
const name = username.value
if (!name) return
try {
const res = await getProfile(route.query.name as string)
profile.value = res
// 用户不存在时后端返回 null,后面的统计全都无从算起
if (!res) return
const acm = res.acmProblemsStatus.problems || {}
const ac: string[] = []
Object.keys(acm).forEach((id) => {
if (acm[id]["status"] === 0) {
ac.push(acm[id]["_id"])
}
})
ac.sort()
problems.value = ac
if (res.submissionNumber > 0) {
const metricsRes = await getMetrics(res.user.id)
firstSubmissionAt.value = parseTime(metricsRes.first)
latestSubmissionAt.value = parseTime(metricsRes.latest)
toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now)
learnDuration.value = `${metricsRes.activeDays} 天`
}
} finally {
toggle(false)
}
}
// 单独取,不塞进上面的 promises 数组:那里是按位置取 results[0]/[1] 的,
// 插一项进去会打乱既有索引。成就摘要取不到也不该影响整个个人主页
async function loadAchievementSummary() {
try {
const res = await getAchievementSummary((route.query.name as string) || undefined)
achievementSummary.value = res
const [p, o] = await Promise.all([getProfile(name), getProfileOverview(name)])
if (name !== username.value) return
profile.value = p
overview.value = o
notFound.value = !p
} catch {
achievementSummary.value = null
notFound.value = true
} finally {
loading.value = false
}
}
// 六项一格一项、三列两行。日期比数字长得多,字号单独压小一档(.stat-value.date),
// 不然「2026年9月14日」会把格子撑破
const metrics = computed(() => {
if (loading.value) return []
return [
{
icon: "fluent-emoji:candy",
title: profile.value?.acceptedNumber ?? 0,
content: "已解决",
animate: true,
},
{
icon: "fluent-emoji:thinking-face",
title: profile.value?.submissionNumber ?? 0,
content: "总提交",
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,
},
]
})
watch(username, load, { immediate: true })
onMounted(() => {
init()
loadAchievementSummary()
})
const solvedTitle = computed(() => (isSelf.value || teacherView.value ? "做对的题" : "他做对的题"))
</script>
<template>
<n-flex class="wrapper" vertical justify="center" align="center" v-if="!loading && profile">
<n-image
:width="112"
:height="112"
:src="profile.avatar"
:preview-disabled="isDefaultAvatar"
object-fit="cover"
:style="{
borderRadius: '50%',
overflow: 'hidden',
cursor: isDefaultAvatar ? 'default' : 'pointer',
}"
/>
<h2>{{ profile.user.username }}</h2>
<p class="desc">{{ profile.mood }}</p>
<n-button
v-if="userStore.isTeacherOrAbove"
type="info"
secondary
@click="
router.push({
name: 'ai',
query: { username: profile.user.username },
})
"
>
智能分析
</n-button>
</n-flex>
<div v-if="profile && profile.submissionNumber > 0" class="wrapper">
<div class="stats">
<div v-for="item in metrics" :key="item.content" class="stat">
<Icon :icon="item.icon" :width="28" />
<div class="stat-value" :class="{ date: item.date }">
<n-number-animation v-if="item.animate" :to="Number(item.title)" />
<template v-else>{{ item.title }}</template>
</div>
<n-text depth="3" class="stat-label">{{ item.content }}</n-text>
</div>
</div>
</div>
<!-- 成就摘要 -->
<n-card v-if="!loading && profile && achievementSummary" class="wrapper" hoverable>
<n-flex align="center" justify="space-between">
<n-flex align="center" :size="12">
<span class="achievement-title">
成就 {{ achievementSummary.unlocked }} /
{{ achievementSummary.total }}
</span>
<n-tag size="small" type="info"> {{ achievementSummary.percent }}% </n-tag>
</n-flex>
<n-button
text
type="primary"
@click="
router.push({
path: '/achievement',
query: route.query.name ? { name: route.query.name } : {},
})
"
>
查看全部
</n-button>
</n-flex>
<n-flex align="center" :size="10" class="achievement-recent">
<n-text v-if="achievementSummary.recent.length" depth="3"> 最近获得 </n-text>
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
<template #trigger>
<span class="achievement-icon">
<AchievementIcon :icon="a.icon" :size="24" />
</span>
<div class="oj-page profile-page">
<n-spin :show="loading" :delay="150">
<n-empty v-if="notFound" description="没有这个用户" class="blank">
<template #extra>
<n-button @click="$router.push('/')">回首页</n-button>
</template>
{{ a.name }}
</n-tooltip>
<n-text v-if="!achievementSummary.recent.length" depth="3"> 还没有获得成就 </n-text>
</n-flex>
</n-card>
</n-empty>
<KnowledgeMap v-if="!loading && profile && isSelf" />
<n-descriptions v-if="!loading && profile" class="wrapper" bordered>
<n-descriptions-item v-if="!!problems.length">
<template #label>
<n-flex justify="space-between" align="center">
<span>已解决的题目</span>
<n-button text type="primary" v-if="hasMoreProblems" @click="toggleShow(!show)">
{{ show ? "隐藏全部" : "显示全部" }}
</n-button>
</n-flex>
</template>
<div ref="problemsFlexRef">
<n-flex>
<n-button v-for="id in visibleProblems" :key="id" @click="router.push('/problem/' + id)">
{{ id }}
</n-button>
</n-flex>
<div v-else-if="profile && overview" class="body">
<ProfileCard
:profile="profile"
:overview="overview"
:is-self="isSelf"
:teacher-view="teacherView"
:compact="!isDesktop"
@changed="load"
/>
<div v-if="isDesktop" class="main">
<SolvedCard
:solved="overview.solved"
:unsolved="overview.unsolved"
:title="solvedTitle"
:username="teacherView ? profile.user.username : undefined"
/>
<div class="column">
<KnowledgeMap
v-if="overview.knowledge"
:map="overview.knowledge"
:teacher="teacherView"
/>
<AchievementCard
:achievements="overview.achievements"
:username="profile.user.username"
:other="!isSelf"
/>
<VersusCard
v-if="overview.versus"
:versus="overview.versus"
:theirs="overview.solved.length"
/>
</div>
</div>
<template v-else>
<VersusCard
v-if="overview.versus"
:versus="overview.versus"
:theirs="overview.solved.length"
/>
<n-tabs v-model:value="tab" type="line" animated>
<n-tab-pane name="solved" :tab="`做对的题 ${overview.solved.length}`">
<SolvedCard
:solved="overview.solved"
:unsolved="overview.unsolved"
:title="solvedTitle"
:username="teacherView ? profile.user.username : undefined"
compact
/>
</n-tab-pane>
<n-tab-pane v-if="overview.knowledge" name="knowledge" tab="知识点">
<KnowledgeMap :map="overview.knowledge" :teacher="teacherView" compact />
</n-tab-pane>
<n-tab-pane name="achievements" :tab="`成就 ${overview.achievements.unlocked.length}`">
<AchievementCard
:achievements="overview.achievements"
:username="profile.user.username"
:other="!isSelf"
compact
/>
</n-tab-pane>
</n-tabs>
</template>
</div>
</n-descriptions-item>
</n-descriptions>
<n-empty v-if="!loading && !profile" description="该用户不存在">
<template #extra>
<n-button @click="router.push('/')">返回主页</n-button>
</template>
</n-empty>
</n-spin>
</div>
</template>
<style scoped>
.wrapper {
max-width: 760px;
margin: var(--oj-gap) auto 0;
.profile-page {
padding-bottom: 40px;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
.body,
.column {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 600px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.main {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 16px;
align-items: start;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 20px 12px;
border-radius: var(--oj-radius);
border: 1px solid rgba(128, 128, 128, 0.2);
}
.stat-value {
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: var(--oj-fs-sec);
}
.stat-value.date {
font-size: 18px;
padding: 3px 0;
white-space: nowrap;
}
h2 {
margin: 4px 0 0;
font-weight: 600;
font-size: var(--oj-fs-title);
}
.desc {
margin: 0 auto;
word-wrap: break-word;
max-width: 100%;
font-size: var(--oj-fs-body);
}
.achievement-title {
font-weight: 600;
font-size: var(--oj-fs-h2);
}
.achievement-recent {
margin-top: 14px;
}
.achievement-icon {
display: inline-flex;
align-items: center;
font-size: 24px;
cursor: default;
.blank {
padding: 80px 0;
}
</style>
-5
View File
@@ -111,11 +111,6 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/achievement/index.vue"),
meta: { requiresAuth: true },
},
{
path: "setting",
component: () => import("oj/user/setting.vue"),
meta: { requiresAuth: true },
},
{
path: "message",
component: () => import("oj/user/message.vue"),
@@ -191,12 +191,6 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
icon: renderIcon("ph:sparkle"),
props: { onClick: () => router.push("/ai-analysis") },
},
{
label: "我的设置",
key: "setting",
icon: renderIcon("ph:gear-six"),
props: { onClick: () => router.push("/setting") },
},
{ type: "divider", key: "theme-divider" },
{ type: "render", key: "theme", render: renderThemeRow },
{ type: "divider", key: "logout-divider" },