feat(个人主页): 主页改成名片 + 两栏,做对的题按天带题名;设置并进主页,点头像换头像、点笔改签名
- 名片:头像、班级、签名、做对几道 / 本班第几 / 成就,底下一排四个时间(第一次提交、最近一次提交、距上次提交、学习天数) - 做对的题按日期或按知识点,能搜;最上面一行是交过还没做对的 - 知识点本人和老师看得到;同学看多一张「你和他」 - 老师看学生:看他的智能分析 / 看他的提交 /「⋯」不计入排名、清空签名 - 换头像弹框:拖圆圈选一块,看领奖台和排名里多大,可退回名字的字 - 去掉「我的设置」页和下拉菜单里那一项;手机三个页签 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cbf5b9d922
commit
e368ed3eca
10 files changed
+1976
-363
No files matched your search
@@ -9,7 +9,7 @@ import {
|
|||||||
type ContestClassView,
|
type ContestClassView,
|
||||||
type ContestScoreboard,
|
type ContestScoreboard,
|
||||||
type FormatCodeResponse,
|
type FormatCodeResponse,
|
||||||
type Metrics,
|
type ProfileOverview,
|
||||||
type TutorialSummary,
|
type TutorialSummary,
|
||||||
type Tutorial,
|
type Tutorial,
|
||||||
type ClassActivity,
|
type ClassActivity,
|
||||||
@@ -395,8 +395,14 @@ export function setReaction(problemID: number, type: ReactionKey) {
|
|||||||
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
|
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) {
|
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">
|
<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 { levelName, MAX_LEVEL, remainingToNext, tagLink } from "oj/user/knowledge"
|
||||||
import type { KnowledgeLevel, KnowledgeMap } from "utils/types"
|
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 () => {
|
const theme = useThemeVars()
|
||||||
map.value = await getKnowledgeMap().catch(() => null)
|
|
||||||
})
|
|
||||||
|
|
||||||
const learned = computed(() =>
|
const learned = computed(() =>
|
||||||
(map.value?.tags ?? [])
|
props.map.tags
|
||||||
.filter((tag) => tag.level >= 1)
|
.filter((tag) => tag.level >= 1)
|
||||||
.sort((a, b) => b.level - a.level || b.solved - a.solved),
|
.sort((a, b) => b.level - a.level || b.solved - a.solved),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 班里同学在学、自己还没碰过的。别的(比如 Python 班的「C 语言」)不列 */
|
/** 班里同学在学、他还没碰过的。别的(比如 Python 班的「C 语言」)不列 */
|
||||||
const untouched = computed(() => {
|
const untouched = computed(() => {
|
||||||
const touched = new Set(map.value?.classTouched ?? [])
|
const touched = new Set(props.map.classTouched)
|
||||||
return (map.value?.tags ?? []).filter((tag) => tag.level === 0 && touched.has(tag.name))
|
return props.map.tags.filter((tag) => tag.level === 0 && touched.has(tag.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
function progress(tag: KnowledgeLevel) {
|
function progress(tag: KnowledgeLevel) {
|
||||||
@@ -34,65 +37,71 @@ function progress(tag: KnowledgeLevel) {
|
|||||||
|
|
||||||
function hint(tag: KnowledgeLevel) {
|
function hint(tag: KnowledgeLevel) {
|
||||||
const left = remainingToNext(tag)
|
const left = remainingToNext(tag)
|
||||||
if (left === null) return `做对了 ${tag.solved} 道,已经精通了`
|
if (left === null) return `做对 ${tag.solved} 道 · 已经精通`
|
||||||
return `做对 ${tag.solved} 道 · 再做 ${left} 道到「${levelName(tag.level + 1)}」`
|
return `做对 ${tag.solved} 道 · 再做 ${left} 道到「${levelName(tag.level + 1)}」`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card v-if="map && (learned.length || untouched.length)" class="knowledge" title="我的知识点">
|
<section class="card" :class="{ compact }">
|
||||||
<template #header-extra>
|
<h3 v-if="!compact">
|
||||||
<n-text depth="3" class="meta">入门 → 会了 → 熟练 → 精通</n-text>
|
{{ teacher ? "知识点" : "我的知识点" }}
|
||||||
</template>
|
<small v-if="!teacher">只有你和老师看得到</small>
|
||||||
<n-flex vertical :size="14">
|
</h3>
|
||||||
<router-link v-for="tag in learned" :key="tag.name" :to="tagLink(tag.name)" class="row">
|
<router-link v-for="tag in learned" :key="tag.name" :to="tagLink(tag.name)" class="row">
|
||||||
<div class="head">
|
<span class="head">
|
||||||
<span class="name">{{ tag.name }}</span>
|
<span class="name">{{ tag.name }}</span>
|
||||||
<span class="dots" :title="levelName(tag.level)">
|
<span class="dots" :title="levelName(tag.level)">
|
||||||
<span v-for="i in MAX_LEVEL" :key="i" class="dot" :class="{ on: i <= tag.level }" />
|
<i v-for="i in MAX_LEVEL" :key="i" :class="{ on: i <= tag.level }"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="level">{{ levelName(tag.level) }}</span>
|
<span class="level">{{ levelName(tag.level) }}</span>
|
||||||
<n-tag
|
<span v-if="tag.level > tag.levelAtWeekStart" class="up">这周升了</span>
|
||||||
v-if="tag.level > tag.levelAtWeekStart"
|
</span>
|
||||||
size="small"
|
<span class="track"><b :style="{ width: `${progress(tag)}%` }"></b></span>
|
||||||
type="success"
|
<span class="meta">{{ hint(tag) }}</span>
|
||||||
: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>
|
</router-link>
|
||||||
|
<div v-if="!learned.length && !untouched.length" class="meta empty">还没做对带知识点的题</div>
|
||||||
|
|
||||||
<div v-if="untouched.length">
|
<div v-if="untouched.length" class="untouched">
|
||||||
<n-text depth="3" class="meta">班里同学在学、你还没碰过:</n-text>
|
<span class="meta">班里在学、{{ teacher ? "他" : "你" }}还没碰过</span>
|
||||||
<n-flex :size="8" style="margin-top: 6px">
|
<div class="tags">
|
||||||
<router-link v-for="tag in untouched" :key="tag.name" :to="tagLink(tag.name)">
|
<router-link v-for="tag in untouched" :key="tag.name" :to="tagLink(tag.name)" class="tag">
|
||||||
<n-tag size="small">{{ tag.name }}</n-tag>
|
{{ tag.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</n-flex>
|
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-card>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.knowledge {
|
.card {
|
||||||
max-width: 760px;
|
padding: 14px 18px 12px;
|
||||||
margin: var(--oj-gap) auto 0;
|
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 {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
padding: 7px 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@@ -104,8 +113,12 @@ function hint(tag: KnowledgeLevel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
|
width: 72px;
|
||||||
|
font-size: var(--oj-fs-sec);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 5em;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dots {
|
.dots {
|
||||||
@@ -113,23 +126,87 @@ function hint(tag: KnowledgeLevel) {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dots i {
|
||||||
|
display: block;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: rgba(128, 128, 128, 0.25);
|
background: v-bind("theme.dividerColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot.on {
|
.dots i.on {
|
||||||
background-color: #18a058;
|
background: #18a058;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level {
|
.level {
|
||||||
font-size: var(--oj-fs-sec);
|
font-size: var(--oj-fs-meta);
|
||||||
color: #18a058;
|
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 {
|
.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);
|
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>
|
</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">
|
<script setup lang="ts">
|
||||||
import KnowledgeMap from "./components/KnowledgeMap.vue"
|
import type { ProfileOverview } from "@oj2/contract"
|
||||||
import { Icon } from "@iconify/vue"
|
import { getProfileOverview } from "oj/api"
|
||||||
import { getProfile } from "shared/api"
|
import { getProfile } from "shared/api"
|
||||||
import { durationToDays, parseTime } from "utils/functions"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
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 { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
import type { Profile } from "utils/types"
|
||||||
const route = useRoute()
|
import AchievementCard from "./components/AchievementCard.vue"
|
||||||
const router = useRouter()
|
import KnowledgeMap from "./components/KnowledgeMap.vue"
|
||||||
const userStore = useUserStore()
|
import ProfileCard from "./components/ProfileCard.vue"
|
||||||
|
import SolvedCard from "./components/SolvedCard.vue"
|
||||||
|
import VersusCard from "./components/VersusCard.vue"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在看自己的主页。知识点地图只在这时候挂:它不给别人看(见 KnowledgeMap.vue),
|
* 个人主页(设计稿「我的主页与设置」定稿)。排名、提交列表点名字也是到这里(?name=),
|
||||||
* 后端 /me/knowledge 也只认当前登录的人。带 ?name= 看的是别人,除非名字就是自己。
|
* 所以同一页有三种人看:本人、同学、老师。看的人不同,后端给的东西不同(见
|
||||||
|
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
|
||||||
*/
|
*/
|
||||||
const isSelf = computed(() => {
|
const route = useRoute()
|
||||||
const name = route.query.name as string | undefined
|
const userStore = useUserStore()
|
||||||
return !name || name === userStore.user?.username
|
const { isDesktop } = useBreakpoints()
|
||||||
})
|
|
||||||
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 isDefaultAvatar = computed(() => profile.value?.avatar.endsWith("default.png") ?? true)
|
const username = computed(
|
||||||
|
() => (route.query.name as string | undefined) || userStore.user?.username || "",
|
||||||
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 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() {
|
async function load() {
|
||||||
toggle(true)
|
const name = username.value
|
||||||
|
if (!name) return
|
||||||
try {
|
try {
|
||||||
const res = await getProfile(route.query.name as string)
|
const [p, o] = await Promise.all([getProfile(name), getProfileOverview(name)])
|
||||||
profile.value = res
|
if (name !== username.value) return
|
||||||
// 用户不存在时后端返回 null,后面的统计全都无从算起
|
profile.value = p
|
||||||
if (!res) return
|
overview.value = o
|
||||||
const acm = res.acmProblemsStatus.problems || {}
|
notFound.value = !p
|
||||||
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
|
|
||||||
} catch {
|
} catch {
|
||||||
achievementSummary.value = null
|
notFound.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 六项一格一项、三列两行。日期比数字长得多,字号单独压小一档(.stat-value.date),
|
watch(username, load, { immediate: true })
|
||||||
// 不然「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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
const solvedTitle = computed(() => (isSelf.value || teacherView.value ? "做对的题" : "他做对的题"))
|
||||||
init()
|
|
||||||
loadAchievementSummary()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex class="wrapper" vertical justify="center" align="center" v-if="!loading && profile">
|
<div class="oj-page profile-page">
|
||||||
<n-image
|
<n-spin :show="loading" :delay="150">
|
||||||
:width="112"
|
<n-empty v-if="notFound" description="没有这个用户" class="blank">
|
||||||
: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>
|
|
||||||
</template>
|
|
||||||
{{ a.name }}
|
|
||||||
</n-tooltip>
|
|
||||||
<n-text v-if="!achievementSummary.recent.length" depth="3"> 还没有获得成就 </n-text>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
<n-empty v-if="!loading && !profile" description="该用户不存在">
|
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<n-button @click="router.push('/')">返回主页</n-button>
|
<n-button @click="$router.push('/')">回首页</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-empty>
|
</n-empty>
|
||||||
|
|
||||||
|
<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>
|
</template>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrapper {
|
.profile-page {
|
||||||
max-width: 760px;
|
padding-bottom: 40px;
|
||||||
margin: var(--oj-gap) auto 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.body,
|
||||||
display: grid;
|
.column {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
.main {
|
||||||
.stats {
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr) 380px;
|
||||||
}
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat {
|
.blank {
|
||||||
display: flex;
|
padding: 80px 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -111,11 +111,6 @@ export const ojs: RouteRecordRaw = {
|
|||||||
component: () => import("oj/achievement/index.vue"),
|
component: () => import("oj/achievement/index.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "setting",
|
|
||||||
component: () => import("oj/user/setting.vue"),
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "message",
|
path: "message",
|
||||||
component: () => import("oj/user/message.vue"),
|
component: () => import("oj/user/message.vue"),
|
||||||
|
|||||||
@@ -191,12 +191,6 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
|
|||||||
icon: renderIcon("ph:sparkle"),
|
icon: renderIcon("ph:sparkle"),
|
||||||
props: { onClick: () => router.push("/ai-analysis") },
|
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: "divider", key: "theme-divider" },
|
||||||
{ type: "render", key: "theme", render: renderThemeRow },
|
{ type: "render", key: "theme", render: renderThemeRow },
|
||||||
{ type: "divider", key: "logout-divider" },
|
{ type: "divider", key: "logout-divider" },
|
||||||
|
|||||||
Reference in new issue
Block a user