Files
OJ2/apps/web/src/oj/user/components/ProfileCard.vue
T
xuyueandClaude Opus 5.5 e8a20d12f9 refactor(前端): shared / utils 不再 import oj 和 admin 下的任何东西
依赖方向原来是反的:顶栏、登录框、举手列表这些 shared 组件从 oj/ 里拿东西。
- 跨端的接口挪进 shared/api.ts(按 apps/web/CLAUDE.md 的分法):学生端题目列表、
  出题人列表、这节课的题、待弹的成就
- 这节课的 store → shared/store/lesson.ts;头像 RankAvatar → shared/components
- 纯函数挪进 utils/:举手列表的错法归类 errorKind、它依赖的答案错误分类 wrongAnswer
  和 Python 报错翻译 pythonError、题目列表行的 toProblemRow
- 只有后台在用的出题人下拉 AuthorSelect 挪回 admin/components

纯搬家。首页、题目列表、题目页、排名、课堂看板、后台题目列表打开无报错,
顶栏题号框敲 1001 回车直达题目页。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:11:21 +08:00

634 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 "shared/components/RankAvatar.vue"
import { classLabel } from "utils/classes"
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>