feat(个人主页): 主页改成名片 + 两栏,做对的题按天带题名;设置并进主页,点头像换头像、点笔改签名
- 名片:头像、班级、签名、做对几道 / 本班第几 / 成就,底下一排四个时间(第一次提交、最近一次提交、距上次提交、学习天数) - 做对的题按日期或按知识点,能搜;最上面一行是交过还没做对的 - 知识点本人和老师看得到;同学看多一张「你和他」 - 老师看学生:看他的智能分析 / 看他的提交 /「⋯」不计入排名、清空签名 - 换头像弹框:拖圆圈选一块,看领奖台和排名里多大,可退回名字的字 - 去掉「我的设置」页和下拉菜单里那一项;手机三个页签 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cbf5b9d922
commit
e368ed3eca
10 files changed
+1983
-370
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user