Compare commits
2
Commits
689d40b116
...
cbeb0737ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbeb0737ef | ||
|
|
85108df9d8 |
No files matched your search
@@ -0,0 +1,60 @@
|
||||
-- 成就清单的五处毛病(我的成就重设计,2026-10-13)。只改配置和进度,已经拿到的一个不收回。
|
||||
--
|
||||
-- 1. 下架拿不到的:六边形战士要用过 5 种语言,判题机只剩 C / C++ / Python;
|
||||
-- 坚持三天 / 一周按天算,学生跟着课表来,基本拿不到(按周的「周周向上」那一串已经顶上了)。
|
||||
UPDATE "achievement" SET "visible" = false
|
||||
WHERE ("metric" = 'languages_used' AND "threshold" = 5) OR "metric" = 'max_ac_streak_days';
|
||||
--> statement-breakpoint
|
||||
UPDATE "achievement" SET "description" = 'C、C++、Python 都用过'
|
||||
WHERE "metric" = 'languages_used' AND "threshold" = 3;
|
||||
--> statement-breakpoint
|
||||
-- 2. 稀有度倒挂:错 10 次才做对是黄金、错 20 次反而是白银
|
||||
UPDATE "achievement" SET "rarity" = 'silver' WHERE "metric" = 'max_wa_before_ac' AND "threshold" = 10;
|
||||
--> statement-breakpoint
|
||||
-- 3. 图标撞了:屡败屡战和持之以恒都是火,百发百中和一发入魂是同一个靶子(noto:direct-hit 是 bullseye 的别名)
|
||||
UPDATE "achievement" SET "rarity" = 'gold', "icon" = 'noto:flexed-biceps'
|
||||
WHERE "metric" = 'max_wa_before_ac' AND "threshold" = 20;
|
||||
--> statement-breakpoint
|
||||
UPDATE "achievement" SET "icon" = 'noto:hundred-points'
|
||||
WHERE "metric" = 'first_try_ac_count' AND "threshold" = 100;
|
||||
--> statement-breakpoint
|
||||
-- 4. 代码行数只算做对的、空行不算:原来交什么都算,贴一大段就是「长篇巨制」
|
||||
UPDATE "achievement" SET "description" = '做对的代码有 ' || "threshold" || ' 行(空行不算)'
|
||||
WHERE "metric" = 'max_code_lines';
|
||||
--> statement-breakpoint
|
||||
-- 按新口径重算所有人的 max_code_lines(判题结算里的口径见 services/achievements.ts):
|
||||
-- 不含比赛,做对 = ACCEPTED(0) / AST_CHECK_FAILED(10)
|
||||
UPDATE "user_stat" AS target
|
||||
SET "metrics" = jsonb_set(target."metrics", '{max_code_lines}', to_jsonb(coalesce(fresh.n, 0)))
|
||||
FROM (
|
||||
SELECT st."id", m.n
|
||||
FROM "user_stat" st
|
||||
LEFT JOIN (
|
||||
SELECT s."user_id", max((
|
||||
SELECT count(*) FROM regexp_split_to_table(s."code", E'\n') AS line
|
||||
WHERE btrim(line, E' \t\r') <> ''
|
||||
))::int AS n
|
||||
FROM "submission" s
|
||||
WHERE s."contest_id" IS NULL AND s."result" IN (0, 10)
|
||||
GROUP BY s."user_id"
|
||||
) m ON m."user_id" = st."user_id"
|
||||
) fresh
|
||||
WHERE target."id" = fresh."id"
|
||||
AND (target."metrics" -> 'max_code_lines') IS DISTINCT FROM to_jsonb(coalesce(fresh.n, 0));
|
||||
--> statement-breakpoint
|
||||
-- 5. 奖杯收藏家数的「已拿到几个」只数上架的(判题结算和 refreshUnlockedCount 同步改了)
|
||||
UPDATE "user_stat" AS target
|
||||
SET "metrics" = jsonb_set(target."metrics", '{achievement_unlocked_count}', to_jsonb(fresh.value))
|
||||
FROM (
|
||||
SELECT s."id", coalesce(c.value, 0) AS value
|
||||
FROM "user_stat" s
|
||||
LEFT JOIN (
|
||||
SELECT ua."user_id", count(*)::int AS value
|
||||
FROM "user_achievement" ua
|
||||
JOIN "achievement" a ON a."id" = ua."achievement_id"
|
||||
WHERE a."rarity" <> 'platinum' AND a."visible"
|
||||
GROUP BY ua."user_id"
|
||||
) c ON c."user_id" = s."user_id"
|
||||
) fresh
|
||||
WHERE target."id" = fresh."id"
|
||||
AND (target."metrics" -> 'achievement_unlocked_count') IS DISTINCT FROM to_jsonb(fresh.value);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -225,6 +225,13 @@
|
||||
"when": 1791766110876,
|
||||
"tag": "0031_tutorial_project_and_problems",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1791821976519,
|
||||
"tag": "0032_achievement_cleanup",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import {
|
||||
markAchievementsReadSchema,
|
||||
STUDENT_ROLES,
|
||||
type Achievement,
|
||||
type AchievementClassBoard,
|
||||
type AchievementList,
|
||||
type AchievementSummary,
|
||||
type PendingAchievement,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, inArray, isNull, max } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { currentWeekStreak } from "../services/problem-progress"
|
||||
import { asRecord } from "./helpers"
|
||||
|
||||
export const achievementRoutes = new Hono<AppEnv>()
|
||||
@@ -18,14 +20,22 @@ export const achievementRoutes = new Hono<AppEnv>()
|
||||
async function resolveUser(requested: string | undefined, currentId: number) {
|
||||
if (!requested) {
|
||||
const [current] = await db
|
||||
.select({ id: schema.user.id, username: schema.user.username })
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
})
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, currentId))
|
||||
.limit(1)
|
||||
return current ?? null
|
||||
}
|
||||
const [target] = await db
|
||||
.select({ id: schema.user.id, username: schema.user.username })
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
})
|
||||
.from(schema.user)
|
||||
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false)))
|
||||
.limit(1)
|
||||
@@ -43,9 +53,10 @@ function pendingData(row: { achievement: typeof schema.achievement.$inferSelect
|
||||
}
|
||||
|
||||
achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||
const me = c.get("user")!
|
||||
const target = await resolveUser(c.req.query("username"), me.id)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const [achievements, unlockedRows, statRows, activeRows] = await Promise.all([
|
||||
const [achievements, unlockedRows, statRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(schema.achievement)
|
||||
@@ -57,15 +68,31 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||
.from(schema.userStat)
|
||||
.where(eq(schema.userStat.userId, target.id))
|
||||
.limit(1),
|
||||
db.select({ value: count() }).from(schema.user).where(eq(schema.user.isDisabled, false)),
|
||||
])
|
||||
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
|
||||
const metrics = asRecord(statRows[0]?.metrics)
|
||||
const active = activeRows[0]?.value ?? 0
|
||||
const weekStreak = currentWeekStreak(metrics)
|
||||
// 隐藏的成就:同一个指标上拿到过一档,这一串就整串亮出来(拿到「一发入魂」就看得到「神射手」)
|
||||
const revealedMetrics = new Set(
|
||||
achievements.filter((item) => unlocked.has(item.id)).map((item) => item.metric),
|
||||
)
|
||||
const classHolders = target.className
|
||||
? new Map(
|
||||
(
|
||||
await db
|
||||
.select({ id: schema.userAchievement.achievementId, n: count() })
|
||||
.from(schema.userAchievement)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.userAchievement.userId))
|
||||
.where(classMembers(target.className))
|
||||
.groupBy(schema.userAchievement.achievementId)
|
||||
).map((row) => [row.id, row.n]),
|
||||
)
|
||||
: null
|
||||
const result = achievements.map((achievement) => {
|
||||
const record = unlocked.get(achievement.id)
|
||||
const masked = achievement.hidden && !record
|
||||
const progress = metrics[achievement.metric]
|
||||
const masked = achievement.hidden && !record && !revealedMetrics.has(achievement.metric)
|
||||
const progress =
|
||||
achievement.metric === "max_ac_week_streak" ? weekStreak : metrics[achievement.metric]
|
||||
return {
|
||||
id: achievement.id,
|
||||
name: masked ? "???" : achievement.name,
|
||||
@@ -80,61 +107,100 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||
unlockTime: record?.unlockTime ?? null,
|
||||
backfilled: record?.backfilled ?? false,
|
||||
progress: masked ? null : typeof progress === "number" ? progress : 0,
|
||||
unlockRate: active > 0 ? Math.round((achievement.unlockCount / active) * 1000) / 10 : 0,
|
||||
holders: achievement.unlockCount,
|
||||
classHolders: classHolders ? (classHolders.get(achievement.id) ?? 0) : null,
|
||||
} satisfies Achievement
|
||||
})
|
||||
const classBoard = target.id === me.id && target.className ? await buildClassBoard(target) : null
|
||||
return success(c, {
|
||||
username: target.username,
|
||||
achievements: result,
|
||||
classBoard,
|
||||
} satisfies AchievementList)
|
||||
})
|
||||
|
||||
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const [achievements, unlockedRows] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.achievement.id, rarity: schema.achievement.rarity })
|
||||
.from(schema.achievement)
|
||||
.where(eq(schema.achievement.visible, true)),
|
||||
/** 本班 = 同一个班级的学生(含学生管理员),停用的不算 */
|
||||
function classMembers(className: string) {
|
||||
return and(
|
||||
eq(schema.user.className, className),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
)
|
||||
}
|
||||
|
||||
const CLASS_RECENT = 5
|
||||
const CLASS_MOST = 5
|
||||
|
||||
async function buildClassBoard(target: {
|
||||
id: number
|
||||
className: string | null
|
||||
}): Promise<AchievementClassBoard> {
|
||||
const className = target.className!
|
||||
const [recent, counts] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
record: schema.userAchievement,
|
||||
achievement: schema.achievement,
|
||||
username: schema.user.username,
|
||||
achievementId: schema.userAchievement.achievementId,
|
||||
unlockTime: schema.userAchievement.unlockTime,
|
||||
})
|
||||
.from(schema.userAchievement)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.userAchievement.userId))
|
||||
.innerJoin(
|
||||
schema.achievement,
|
||||
eq(schema.userAchievement.achievementId, schema.achievement.id),
|
||||
eq(schema.achievement.id, schema.userAchievement.achievementId),
|
||||
)
|
||||
.where(
|
||||
and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true)),
|
||||
and(
|
||||
classMembers(className),
|
||||
eq(schema.achievement.visible, true),
|
||||
eq(schema.userAchievement.backfilled, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.userAchievement.unlockTime)),
|
||||
])
|
||||
const labels = {
|
||||
bronze: "青铜",
|
||||
silver: "白银",
|
||||
gold: "黄金",
|
||||
platinum: "白金",
|
||||
}
|
||||
const rarities = ["bronze", "silver", "gold", "platinum"] as const
|
||||
const total = achievements.length
|
||||
const unlocked = unlockedRows.length
|
||||
return success(c, {
|
||||
username: target.username,
|
||||
total,
|
||||
unlocked,
|
||||
percent: total > 0 ? Math.round((unlocked / total) * 1000) / 10 : 0,
|
||||
rarity: rarities.map((rarity) => ({
|
||||
rarity,
|
||||
label: labels[rarity],
|
||||
total: achievements.filter((item) => item.rarity === rarity).length,
|
||||
unlocked: unlockedRows.filter((item) => item.achievement.rarity === rarity).length,
|
||||
})),
|
||||
recent: unlockedRows.slice(0, 10).map(pendingData),
|
||||
} satisfies AchievementSummary)
|
||||
.orderBy(desc(schema.userAchievement.unlockTime))
|
||||
.limit(CLASS_RECENT),
|
||||
// 不计入排名的人(老师在排名里关掉的)也不进「成就最多」
|
||||
db
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
count: count(schema.achievement.id),
|
||||
last: max(schema.userAchievement.unlockTime),
|
||||
})
|
||||
.from(schema.user)
|
||||
.leftJoin(schema.userAchievement, eq(schema.userAchievement.userId, schema.user.id))
|
||||
.leftJoin(
|
||||
schema.achievement,
|
||||
and(
|
||||
eq(schema.achievement.id, schema.userAchievement.achievementId),
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
.where(and(classMembers(className), isNull(schema.user.rankHiddenAt)))
|
||||
.groupBy(schema.user.id, schema.user.username),
|
||||
])
|
||||
// 一样多的名次相同;同名次里先拿到的(最后一个成就拿得早的)排前面
|
||||
counts.sort(
|
||||
(a, b) =>
|
||||
b.count - a.count ||
|
||||
(a.last ?? "").localeCompare(b.last ?? "") ||
|
||||
a.username.localeCompare(b.username),
|
||||
)
|
||||
const ranked = counts.map((row) => ({
|
||||
...row,
|
||||
rank: counts.findIndex((other) => other.count === row.count) + 1,
|
||||
}))
|
||||
const top = ranked.slice(0, CLASS_MOST)
|
||||
const mine = ranked.find((row) => row.id === target.id)
|
||||
return {
|
||||
className,
|
||||
recent,
|
||||
most: top.map((row) => ({ username: row.username, count: row.count, rank: row.rank })),
|
||||
me:
|
||||
mine && !top.some((row) => row.id === target.id)
|
||||
? { rank: mine.rank, count: mine.count }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
||||
const rows = await db
|
||||
|
||||
@@ -92,12 +92,12 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{
|
||||
key: "max_code_lines",
|
||||
name: "最长代码行数",
|
||||
helpText: "提交过的最长代码有多少行",
|
||||
helpText: "做对的提交里最长的代码有多少行(空行不算,不含比赛)",
|
||||
},
|
||||
{
|
||||
key: "achievement_unlocked_count",
|
||||
name: "已解锁成就数",
|
||||
helpText: "已解锁的成就数量(不含白金档)",
|
||||
helpText: "已解锁的成就数量(不含白金档和已下架的)",
|
||||
meta: true,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -192,9 +192,11 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
||||
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
|
||||
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
|
||||
// 只算做对的、空行不算:原来交什么都算,贴一大段就拿到「长篇巨制」(0032 按这个口径重算过存量)
|
||||
if (accepted)
|
||||
value.max_code_lines = Math.max(
|
||||
numberMetric(value, "max_code_lines"),
|
||||
row.submission.code.split(/\r?\n/).length,
|
||||
row.submission.code.split("\n").filter((line) => line.trim() !== "").length,
|
||||
)
|
||||
await tx
|
||||
.update(schema.userStat)
|
||||
@@ -213,6 +215,7 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
and(
|
||||
eq(schema.userAchievement.userId, row.submission.userId),
|
||||
ne(schema.achievement.rarity, "platinum"),
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
@@ -269,7 +272,11 @@ export async function updateAchievementsForProblemSet(userId: number) {
|
||||
.from(schema.userAchievement)
|
||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(
|
||||
and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum")),
|
||||
and(
|
||||
eq(schema.userAchievement.userId, userId),
|
||||
ne(schema.achievement.rarity, "platinum"),
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
await db
|
||||
@@ -406,7 +413,7 @@ export function metaAchievements() {
|
||||
/**
|
||||
* 按 `user_achievement` 重算 `achievement_unlocked_count`,返回实际改动了的用户 id。
|
||||
*
|
||||
* 口径和判题结算一致:已解锁的**非白金**成就数。只 `jsonb_set` 这一个键、只写值变了的行,
|
||||
* 口径和判题结算一致:已解锁的、还上架的**非白金**成就数(下架的不算,见 0032)。只 `jsonb_set` 这一个键、只写值变了的行,
|
||||
* 不整体覆盖 `metrics` —— 整体写回会和并发判题写的其它指标互相踩。
|
||||
* 不传 `userIds` 就是全体有 `user_stat` 的用户(`recount` 存量订正用)。
|
||||
*/
|
||||
@@ -423,7 +430,7 @@ export async function refreshUnlockedCount(userIds?: number[]) {
|
||||
select ua.user_id, count(*)::int as value
|
||||
from ${schema.userAchievement} ua
|
||||
join ${schema.achievement} a on a.id = ua.achievement_id
|
||||
where a.rarity <> 'platinum'
|
||||
where a.rarity <> 'platinum' and a.visible
|
||||
group by ua.user_id
|
||||
) c on c.user_id = s.user_id
|
||||
where true ${scope}
|
||||
|
||||
@@ -99,6 +99,19 @@ function numberOf(metrics: Record<string, unknown>, key: string) {
|
||||
return typeof value === "number" ? value : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 现在连着几周有新做对。`max_ac_week_streak` 是历史最长,要拿「锲而不舍」得从现在的连续数起;
|
||||
* 只在最近一次新通过落在本周或上周时还作数,再早就已经断了。
|
||||
*/
|
||||
export function currentWeekStreak(metrics: Record<string, unknown>) {
|
||||
const monday = calendarDay(weekStart())
|
||||
const lastWeek = typeof metrics._last_ac_week === "string" ? metrics._last_ac_week : ""
|
||||
const previousMonday = dayText(dayNumber(monday) - 7)
|
||||
return lastWeek === monday || lastWeek === previousMonday
|
||||
? numberOf(metrics, "_current_ac_week_streak")
|
||||
: 0
|
||||
}
|
||||
|
||||
export async function buildProblemProgress(userId: number): Promise<ProblemProgress> {
|
||||
const [states, statRows, achievements, unlockedRows] = await Promise.all([
|
||||
problemStates(userId),
|
||||
@@ -159,13 +172,7 @@ export async function buildProblemProgress(userId: number): Promise<ProblemProgr
|
||||
for (const [day, n] of Object.entries(asRecord(metrics._ac_per_day))) {
|
||||
if (day >= monday && typeof n === "number") weekSolved += n
|
||||
}
|
||||
// 当前连续周数只在最近一次新通过落在本周或上周时还作数,再早就已经断了
|
||||
const lastWeek = typeof metrics._last_ac_week === "string" ? metrics._last_ac_week : ""
|
||||
const previousMonday = dayText(dayNumber(monday) - 7)
|
||||
const weekStreak =
|
||||
lastWeek === monday || lastWeek === previousMonday
|
||||
? numberOf(metrics, "_current_ac_week_streak")
|
||||
: 0
|
||||
const weekStreak = currentWeekStreak(metrics)
|
||||
|
||||
const unlocked = new Set(unlockedRows.map((row) => row.id))
|
||||
let next: ProblemProgress["next"] = null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import api from "utils/api"
|
||||
import type { AchievementList, AchievementSummary, PendingAchievement } from "utils/types"
|
||||
import type { AchievementList, PendingAchievement } from "utils/types"
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return api.get<AchievementList>("achievements", {
|
||||
@@ -7,12 +7,6 @@ export function getAchievements(name?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getAchievementSummary(name?: string) {
|
||||
return api.get<AchievementSummary>("achievements/summary", {
|
||||
params: name ? { username: name } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getPendingAchievements() {
|
||||
return api.get<PendingAchievement[]>("achievements/pending")
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Achievement } from "utils/types"
|
||||
|
||||
const props = defineProps<{ achievement: Achievement }>()
|
||||
|
||||
// 边框用原色,tag 里的文字用跟主题走的那套
|
||||
const rarityTextColor = useRarityColor()
|
||||
|
||||
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null,
|
||||
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
|
||||
const masked = computed(() => props.achievement.hidden && !props.achievement.unlocked)
|
||||
|
||||
// 获得率低于 5% 的加稀有闪光边框
|
||||
const isRare = computed(() => props.achievement.unlockRate > 0 && props.achievement.unlockRate < 5)
|
||||
|
||||
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
|
||||
// 画成百分比毫无意义,改成直接显示当前最好成绩
|
||||
const showProgressBar = computed(
|
||||
() =>
|
||||
!masked.value &&
|
||||
!props.achievement.unlocked &&
|
||||
props.achievement.operator === "gte" &&
|
||||
props.achievement.threshold !== null,
|
||||
)
|
||||
|
||||
const showBestSoFar = computed(
|
||||
() =>
|
||||
!masked.value &&
|
||||
!props.achievement.unlocked &&
|
||||
props.achievement.operator === "lte" &&
|
||||
props.achievement.threshold !== null,
|
||||
)
|
||||
|
||||
const percent = computed(() => {
|
||||
const { progress, threshold } = props.achievement
|
||||
if (threshold === null || threshold <= 0) return 100
|
||||
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
|
||||
})
|
||||
|
||||
const unlockDate = computed(() => {
|
||||
const { unlockTime, backfilled } = props.achievement
|
||||
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
|
||||
if (backfilled || !unlockTime) return "已获得"
|
||||
// 走 parseTime 而不是 toLocaleDateString():后者按浏览器时区渲染,
|
||||
// 站内所有日期都是东八区口径
|
||||
return `${parseTime(unlockTime)} 获得`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
class="card"
|
||||
:class="{ locked: !achievement.unlocked, rare: isRare }"
|
||||
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
|
||||
>
|
||||
<n-thing>
|
||||
<template #avatar>
|
||||
<AchievementIcon :icon="achievement.icon" :size="40" />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-text strong>{{ achievement.name }}</n-text>
|
||||
<n-tag
|
||||
size="tiny"
|
||||
:color="{
|
||||
borderColor: RARITY_COLOR[achievement.rarity],
|
||||
textColor: rarityTextColor[achievement.rarity],
|
||||
}"
|
||||
>
|
||||
{{ RARITY_LABEL[achievement.rarity] }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<n-text depth="3">{{ achievement.description }}</n-text>
|
||||
</template>
|
||||
|
||||
<n-flex align="center" :size="8" :wrap="false">
|
||||
<template v-if="achievement.unlocked">
|
||||
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
|
||||
<n-text depth="3" class="nowrap"> 仅 {{ achievement.unlockRate }}% 的人获得 </n-text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showProgressBar">
|
||||
<n-progress
|
||||
style="flex: 1"
|
||||
type="line"
|
||||
:percentage="percent"
|
||||
:height="6"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<n-text depth="3" class="nowrap">
|
||||
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showBestSoFar">
|
||||
<n-text depth="3" class="nowrap"> 目标 ≤ {{ achievement.threshold }} </n-text>
|
||||
<n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
|
||||
当前最好 {{ achievement.progress }}
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<n-text v-else depth="3" class="nowrap"> 仅 {{ achievement.unlockRate }}% 的人获得 </n-text>
|
||||
</n-flex>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
height: 100%;
|
||||
border-radius: var(--oj-radius);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.locked {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rare {
|
||||
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Achievement, AchievementClassBoard } from "utils/types"
|
||||
import Medal from "./Medal.vue"
|
||||
|
||||
/**
|
||||
* 「本班」:最近谁拿到了什么、成就最多的前五名(自己不在里面就钉在底下)。
|
||||
* 同一个人同一刻拿到的几个并成一行(一次提交常常连着解锁两三个);
|
||||
* 自己还没找到的隐藏成就只说「彩蛋」,不把名字漏出来。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
board: AchievementClassBoard
|
||||
achievements: Achievement[]
|
||||
me: string
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const byId = computed(() => new Map(props.achievements.map((item) => [item.id, item])))
|
||||
|
||||
const recent = computed(() => {
|
||||
const rows: { key: string; username: string; time: string; items: Achievement[] }[] = []
|
||||
for (const row of props.board.recent) {
|
||||
const item = byId.value.get(row.achievementId)
|
||||
if (!item) continue
|
||||
const last = rows.at(-1)
|
||||
if (last && last.username === row.username && last.time === row.unlockTime)
|
||||
last.items.push(item)
|
||||
else
|
||||
rows.push({
|
||||
key: `${row.username}-${row.unlockTime}-${row.achievementId}`,
|
||||
username: row.username,
|
||||
time: row.unlockTime,
|
||||
items: [item],
|
||||
})
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function what(items: Achievement[]) {
|
||||
const known = items.filter((item) => item.metric !== null).map((item) => item.name)
|
||||
const eggs = items.length - known.length
|
||||
if (eggs) known.push(eggs === 1 ? "一个彩蛋" : `${eggs} 个彩蛋`)
|
||||
return known.join("、")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card" :class="{ compact }">
|
||||
<h3>本班</h3>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<span class="sub">最近拿到</span>
|
||||
<div v-for="row in recent" :key="row.key" class="line">
|
||||
<Medal
|
||||
:icon="row.items[0]!.icon"
|
||||
:rarity="row.items[0]!.rarity"
|
||||
:size="28"
|
||||
:state="row.items[0]!.metric === null ? 'unknown' : 'on'"
|
||||
/>
|
||||
<span class="what">
|
||||
<UserName :username="row.username" />
|
||||
<span class="muted">拿到</span>
|
||||
{{ what(row.items) }}
|
||||
</span>
|
||||
<span class="date">{{ parseTime(row.time, "M月D日") }}</span>
|
||||
</div>
|
||||
<span v-if="!recent.length" class="empty">这学期还没人拿到</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<span class="sub">成就最多</span>
|
||||
<div
|
||||
v-for="row in board.most"
|
||||
:key="row.username"
|
||||
class="line rank"
|
||||
:class="{ mine: row.username === me }"
|
||||
>
|
||||
<span class="no">{{ row.rank }}</span>
|
||||
<UserName :username="row.username" />
|
||||
<b>{{ row.count }}</b>
|
||||
</div>
|
||||
<div v-if="board.me" class="line rank mine pinned">
|
||||
<span class="no">{{ board.me.rank }}</span>
|
||||
<UserName :username="me" />
|
||||
<b>{{ board.me.count }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 14px 20px 12px;
|
||||
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);
|
||||
}
|
||||
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 200px;
|
||||
gap: 28px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 38px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
}
|
||||
|
||||
.what {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.date,
|
||||
.no,
|
||||
.empty {
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px 0;
|
||||
font-size: var(--oj-fs-sec);
|
||||
}
|
||||
|
||||
.rank .no {
|
||||
width: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rank b {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mine {
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.primaryColor + '1a'");
|
||||
}
|
||||
|
||||
.pinned {
|
||||
border-top: 1px dashed v-bind("theme.borderColor");
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: 12px 14px 8px;
|
||||
}
|
||||
|
||||
.compact .cols {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import type { UserBadge } from "utils/types"
|
||||
import type { Group } from "../series"
|
||||
import Medal from "./Medal.vue"
|
||||
import SeriesRow from "./SeriesRow.vue"
|
||||
|
||||
/** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「题单和比赛」组末尾是拿到的题单奖章 */
|
||||
const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const showBadges = computed(() => props.group.name === "题单和比赛" && !!props.badges?.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card" :class="{ compact }">
|
||||
<h3>
|
||||
{{ group.name }}
|
||||
<small>{{ group.got }} / {{ group.total }}</small>
|
||||
</h3>
|
||||
<SeriesRow v-for="item in group.series" :key="item.metric" :series="item" :compact="compact" />
|
||||
<div v-if="group.masked" class="masked">
|
||||
<span class="masked-title">还有 {{ group.masked }} 个没找到</span>
|
||||
<div class="masked-list">
|
||||
<Medal v-for="i in group.masked" :key="i" state="unknown" :size="compact ? 30 : 34" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showBadges" class="badges">
|
||||
<span class="masked-title">拿到的题单奖章</span>
|
||||
<div class="badge-list">
|
||||
<n-tooltip v-for="b in badges" :key="b.id" :delay="150">
|
||||
<template #trigger>
|
||||
<router-link
|
||||
:to="{ name: 'problemset', params: { problemSetId: b.problemSet.id } }"
|
||||
class="badge"
|
||||
>
|
||||
<AchievementIcon :icon="b.badge.icon" :size="32" />
|
||||
<span>{{ b.badge.name }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
{{ b.badge.description }} · 来自题单「{{ b.problemSet.title }}」
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 14px 20px 20px;
|
||||
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-meta);
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.masked,
|
||||
.badges {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.masked-title {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.masked-list,
|
||||
.badge-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor1");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.badge span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: 12px 14px 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { RARITY_COLOR } from "utils/constants"
|
||||
import type { AchievementRarity } from "utils/types"
|
||||
|
||||
/**
|
||||
* 一枚徽章:拿到的是稀有度色的圈,没拿到的灰掉,还没找到的隐藏成就是虚线问号。
|
||||
* `ring` 给了就在外面画一圈进度(「快拿到了」那几个)。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: string
|
||||
rarity?: AchievementRarity
|
||||
size?: number
|
||||
state?: "on" | "off" | "unknown"
|
||||
ring?: number | null
|
||||
}>(),
|
||||
{ icon: "", rarity: "bronze", size: 40, state: "on", ring: null },
|
||||
)
|
||||
|
||||
const theme = useThemeVars()
|
||||
const color = computed(() => RARITY_COLOR[props.rarity])
|
||||
const r = computed(() => props.size / 2 + 3)
|
||||
const box = computed(() => props.size + 8)
|
||||
const circumference = computed(() => 2 * Math.PI * r.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="medal" :style="{ width: `${size}px`, height: `${size}px` }">
|
||||
<svg
|
||||
v-if="ring !== null && state !== 'unknown'"
|
||||
class="arc"
|
||||
:width="box"
|
||||
:height="box"
|
||||
:viewBox="`0 0 ${box} ${box}`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle :cx="box / 2" :cy="box / 2" :r="r" fill="none" class="arc-track" stroke-width="3" />
|
||||
<circle
|
||||
:cx="box / 2"
|
||||
:cy="box / 2"
|
||||
:r="r"
|
||||
fill="none"
|
||||
class="arc-fill"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="`${circumference * Math.min(1, ring)} ${circumference}`"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="state === 'unknown'" class="disc unknown" :style="{ fontSize: `${size * 0.42}px` }">
|
||||
?
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="disc"
|
||||
:class="state"
|
||||
:style="state === 'on' ? { borderColor: color, background: `${color}26` } : undefined"
|
||||
>
|
||||
<AchievementIcon :icon="icon" :size="Math.round(size * 0.56)" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.medal {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.arc {
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
top: -4px;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.arc-track {
|
||||
stroke: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.arc-fill {
|
||||
stroke: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.disc {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.disc.off {
|
||||
border-color: v-bind("theme.borderColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
}
|
||||
|
||||
.disc.off :deep(svg),
|
||||
.disc.off :deep(img),
|
||||
.disc.off :deep(span) {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.disc.unknown {
|
||||
border: 1.5px dashed v-bind("theme.borderColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind("theme.textColor3");
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import type { Near } from "../series"
|
||||
import Medal from "./Medal.vue"
|
||||
|
||||
/** 「快拿到了」:每个只说还差什么,进度条底下是现在几 / 要几、本班几人有 */
|
||||
defineProps<{ items: Near[]; compact?: boolean }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const rarityText = useRarityColor()
|
||||
|
||||
function now(item: Near) {
|
||||
const { series, achievement } = item
|
||||
return series.streak
|
||||
? `现在连着 ${series.value} ${series.unit}`
|
||||
: `${series.value} / ${achievement.threshold} ${series.unit}`
|
||||
}
|
||||
|
||||
const percent = (item: Near) => Math.round((item.series.value / item.achievement.threshold!) * 100)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card" :class="{ compact }">
|
||||
<h3>快拿到了</h3>
|
||||
<div v-for="item in items" :key="item.achievement.id" class="item">
|
||||
<Medal
|
||||
:icon="item.achievement.icon"
|
||||
:rarity="item.achievement.rarity"
|
||||
:size="compact ? 44 : 48"
|
||||
state="off"
|
||||
:ring="item.series.value / item.achievement.threshold!"
|
||||
/>
|
||||
<div class="body">
|
||||
<div class="head">
|
||||
<b>{{ item.achievement.name }}</b>
|
||||
<span class="tag" :style="{ color: rarityText[item.achievement.rarity] }">
|
||||
{{ RARITY_LABEL[item.achievement.rarity] }}
|
||||
</span>
|
||||
<span class="remain">{{ item.remain }}</span>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="percent(item)"
|
||||
:height="6"
|
||||
:show-indicator="false"
|
||||
:color="theme.primaryColor"
|
||||
:border-radius="3"
|
||||
:fill-border-radius="3"
|
||||
/>
|
||||
<div class="foot">
|
||||
<span>{{ now(item) }}</span>
|
||||
<span v-if="item.achievement.classHolders !== null">
|
||||
本班 {{ item.achievement.classHolders }} 人有
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 14px 20px 6px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: var(--oj-fs-h2);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 0;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--oj-fs-body);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.remain {
|
||||
margin-left: auto;
|
||||
font-size: var(--oj-fs-sec);
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 手机:「还差什么」换到名字下面一行 */
|
||||
.compact {
|
||||
padding: 12px 14px 4px;
|
||||
}
|
||||
|
||||
.compact .head {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.compact .remain {
|
||||
margin-left: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Achievement } from "utils/types"
|
||||
import type { Series } from "../series"
|
||||
import Medal from "./Medal.vue"
|
||||
|
||||
/**
|
||||
* 一条成就线:左边线名和现在的数,右边几档按门槛排在一根进度线上,下一档头上标「差几」。
|
||||
* 档位等距摆,不按门槛比例(1 / 10 / 50 / 100 / 200 按比例画,前两档会挤成一个点),
|
||||
* 进度在相邻两档之间按比例插值。手机上线名挪到上面,线占满一行。
|
||||
*/
|
||||
const props = defineProps<{ series: Series; compact?: boolean }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
// 「差几」的底色:主色压淡,明暗主题都跟着走
|
||||
const gapBg = computed(() => `${theme.value.primaryColor}22`)
|
||||
const size = computed(() => (props.compact ? 34 : 40))
|
||||
// 少于三档也按三格排,一档、两档的线不会被拉得很长
|
||||
const cols = computed(() => Math.max(props.series.items.length, 3))
|
||||
const center = (i: number) => ((i + 0.5) / cols.value) * 100
|
||||
|
||||
const fill = computed(() => {
|
||||
const { items, value } = props.series
|
||||
// 连续几周给的是「现在连着几」,断过的人可能比已经拿到的那档还小 —— 线至少画到拿到的最后一档
|
||||
let lastOn = -1
|
||||
items.forEach((item, i) => {
|
||||
if (item.unlocked) lastOn = i
|
||||
})
|
||||
return Math.max(lastOn < 0 ? 0 : center(lastOn), reach(items, value))
|
||||
})
|
||||
|
||||
function reach(items: Achievement[], value: number) {
|
||||
let prevT = 0
|
||||
let prevX = 0
|
||||
for (const [i, item] of items.entries()) {
|
||||
const t = item.threshold ?? 0
|
||||
const x = center(i)
|
||||
if (value < t) return prevX + ((value - prevT) / (t - prevT)) * (x - prevX)
|
||||
prevT = t
|
||||
prevX = x
|
||||
}
|
||||
return prevX
|
||||
}
|
||||
|
||||
function gap(item: Achievement) {
|
||||
if (item !== props.series.next || props.series.value <= 0) return null
|
||||
const r = (item.threshold ?? 0) - props.series.value
|
||||
return r > 0 ? r : null
|
||||
}
|
||||
|
||||
function when(item: Achievement) {
|
||||
if (!item.unlocked) return `现在 ${props.series.value} ${props.series.unit}`
|
||||
// 补发的不写日期:一次补发会给几百人盖上同一个时间戳
|
||||
if (item.backfilled || !item.unlockTime) return "已拿到"
|
||||
return `${parseTime(item.unlockTime, "M月D日")}拿到`
|
||||
}
|
||||
|
||||
function holders(item: Achievement) {
|
||||
const site = `全站 ${item.holders} 人有`
|
||||
return item.classHolders === null ? site : `本班 ${item.classHolders} 人有 · ${site}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row" :class="{ compact }">
|
||||
<div class="label">
|
||||
<span class="name">{{ series.label }}</span>
|
||||
<span class="value">
|
||||
<b>{{ series.streak ? "现在 " : "" }}{{ series.value }}</b>
|
||||
<small>{{ series.unit }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="track" :style="{ height: `${size + 36}px` }">
|
||||
<span
|
||||
class="line"
|
||||
:style="{ top: `${size / 2 - 2}px`, width: `${center(series.items.length - 1)}%` }"
|
||||
></span>
|
||||
<span class="line done" :style="{ top: `${size / 2 - 2}px`, width: `${fill}%` }"></span>
|
||||
<div
|
||||
v-for="(item, i) in series.items"
|
||||
:key="item.id"
|
||||
class="tier"
|
||||
:class="{ on: item.unlocked }"
|
||||
:style="{ left: `${center(i)}%` }"
|
||||
>
|
||||
<n-tooltip :delay="150">
|
||||
<template #trigger>
|
||||
<span class="trigger">
|
||||
<span v-if="gap(item)" class="gap" :style="{ bottom: `${size + 4}px` }">
|
||||
差 {{ gap(item) }}
|
||||
</span>
|
||||
<Medal
|
||||
:icon="item.icon"
|
||||
:rarity="item.rarity"
|
||||
:size="size"
|
||||
:state="item.unlocked ? 'on' : 'off'"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tip">
|
||||
<b>{{ item.name }}</b> · {{ RARITY_LABEL[item.rarity] }}<br />
|
||||
{{ item.description }}<br />
|
||||
{{ when(item) }} · {{ holders(item) }}
|
||||
</div>
|
||||
</n-tooltip>
|
||||
<span class="tier-name">{{ item.name }}</span>
|
||||
<span class="tier-at">{{ item.threshold }} {{ series.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 118px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.value b {
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.value small {
|
||||
margin-left: 3px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.line.done {
|
||||
background: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.tier {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 92px;
|
||||
margin-left: -46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
position: relative;
|
||||
display: block;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
background: v-bind(gapBg);
|
||||
}
|
||||
|
||||
.tier-name {
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.tier.on .tier-name {
|
||||
color: v-bind("theme.textColor1");
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tier-at {
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tip {
|
||||
max-width: 240px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* 手机:线名挪到上面,线占满一行 */
|
||||
.compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.compact .label {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.compact .value b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.compact .tier {
|
||||
width: 72px;
|
||||
margin-left: -36px;
|
||||
}
|
||||
|
||||
.compact .tier-name {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,53 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getAchievements } from "oj/achievement/api"
|
||||
import { getUserBadges } from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import type { Achievement, AchievementRarity, AchievementSummary, UserBadge } from "utils/types"
|
||||
import AchievementCard from "./components/AchievementCard.vue"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
|
||||
import type { AchievementList, AchievementRarity, UserBadge } from "utils/types"
|
||||
import ClassCard from "./components/ClassCard.vue"
|
||||
import GroupCard from "./components/GroupCard.vue"
|
||||
import NearCard from "./components/NearCard.vue"
|
||||
import { buildGroups, nearest, type GroupName } from "./series"
|
||||
|
||||
/**
|
||||
* 我的成就(设计稿「我的成就重设计」定稿,画布 page ach):
|
||||
* 顶上「快拿到了」和「本班」并排,底下 38 个成就收成一条条线、分五组。
|
||||
* 看别人的(排名、主页点进来,?name=)只有成就线 —— 「快拿到了」是给自己定目标的,
|
||||
* 「本班」后端也只在看自己时给。
|
||||
*/
|
||||
const route = useRoute()
|
||||
const name = computed(() => (route.query.name as string) || undefined)
|
||||
|
||||
// 标签和进度条同色,整行读作一个单位
|
||||
const rarityColor = useRarityColor()
|
||||
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const summary = ref<AchievementSummary | null>(null)
|
||||
|
||||
// 白金排最前,青铜垫底:稀有的先亮相,接口给的顺序是反的
|
||||
const RARITY_RANK: Record<AchievementRarity, number> = {
|
||||
platinum: 0,
|
||||
gold: 1,
|
||||
silver: 2,
|
||||
bronze: 3,
|
||||
}
|
||||
|
||||
const rarities = computed(() =>
|
||||
[...(summary.value?.rarity ?? [])].sort((a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity]),
|
||||
)
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const userStore = useUserStore()
|
||||
const theme = useThemeVars()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const tab = ref("all")
|
||||
const loading = ref(true)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (tab.value === "unlocked") return achievements.value.filter((a) => a.unlocked)
|
||||
if (tab.value === "locked") return achievements.value.filter((a) => !a.unlocked)
|
||||
return achievements.value
|
||||
})
|
||||
const name = computed(() => (route.query.name as string) || undefined)
|
||||
const data = ref<AchievementList | null>(null)
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
|
||||
const other = computed(() => !!data.value && data.value.username !== userStore.user?.username)
|
||||
const groups = computed(() => (data.value ? buildGroups(data.value.achievements) : []))
|
||||
const near = computed(() => (other.value ? [] : nearest(groups.value)))
|
||||
const got = computed(() => data.value?.achievements.filter((item) => item.unlocked) ?? [])
|
||||
const rarities: AchievementRarity[] = ["platinum", "gold", "silver", "bronze"]
|
||||
const countOf = (rarity: AchievementRarity) =>
|
||||
got.value.filter((item) => item.rarity === rarity).length
|
||||
|
||||
// 桌面两栏:左边做题、其他、彩蛋,右边坚持、题单和比赛 —— 两边差不多高
|
||||
const LEFT: GroupName[] = ["做题", "其他", "彩蛋"]
|
||||
const RIGHT: GroupName[] = ["坚持", "题单和比赛"]
|
||||
const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
notFound.value = false
|
||||
try {
|
||||
const [list, sum, badgeRes] = await Promise.all([
|
||||
const [list, badgeRes] = await Promise.all([
|
||||
getAchievements(name.value),
|
||||
getAchievementSummary(name.value),
|
||||
getUserBadges(name.value),
|
||||
getUserBadges(name.value).catch(() => []),
|
||||
])
|
||||
achievements.value = list.achievements
|
||||
summary.value = sum
|
||||
data.value = list
|
||||
badges.value = badgeRes ?? []
|
||||
} catch {
|
||||
data.value = null
|
||||
notFound.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -58,90 +65,49 @@ watch(name, load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hall oj-page">
|
||||
<h2 class="title">成就</h2>
|
||||
<div class="oj-page achievement-page">
|
||||
<!-- delay 50ms:缓存命中时数据几乎立刻回来,不闪一下转圈 -->
|
||||
<n-spin :show="loading" :delay="50" style="min-height: 240px">
|
||||
<n-card v-if="summary" class="summary">
|
||||
<n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
|
||||
<n-flex vertical align="center" :size="6">
|
||||
<n-progress type="circle" :percentage="summary.percent" :stroke-width="8">
|
||||
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
|
||||
{{ summary.percent }}%
|
||||
</n-text>
|
||||
</n-progress>
|
||||
<n-text depth="3" class="nowrap">
|
||||
已获得 {{ summary.unlocked }} / {{ summary.total }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
<n-empty v-if="notFound" description="没有这个用户" class="blank" />
|
||||
<template v-else-if="data">
|
||||
<div class="title">
|
||||
<h2 v-if="other"><UserName :username="data.username" /> 的成就</h2>
|
||||
<h2 v-else>我的成就</h2>
|
||||
<span class="count">
|
||||
拿到 <b>{{ got.length }}</b> 个
|
||||
</span>
|
||||
<span v-if="isDesktop" class="rarities">
|
||||
<span v-for="r in rarities" :key="r" class="rarity">
|
||||
<i :style="{ background: RARITY_COLOR[r] }"></i>{{ RARITY_LABEL[r] }}
|
||||
<b>{{ countOf(r) }}</b>
|
||||
</span>
|
||||
</span>
|
||||
<router-link v-if="other" to="/achievement" class="mine">看我的 ›</router-link>
|
||||
</div>
|
||||
|
||||
<n-flex vertical :size="8" class="rarity">
|
||||
<n-flex v-for="r in rarities" :key="r.rarity" align="center" :wrap="false" :size="10">
|
||||
<n-text strong :style="{ color: rarityColor[r.rarity] }">
|
||||
{{ r.label }}
|
||||
</n-text>
|
||||
<n-progress
|
||||
style="flex: 1"
|
||||
type="line"
|
||||
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
|
||||
:height="6"
|
||||
:border-radius="3"
|
||||
:fill-border-radius="3"
|
||||
:color="rarityColor[r.rarity]"
|
||||
:show-indicator="false"
|
||||
<!-- 只有一张(老师没有班级、或看别人)时也只占半边,别把进度条拉得一屏宽 -->
|
||||
<div v-if="near.length || data.classBoard" class="top">
|
||||
<NearCard v-if="near.length" :items="near" :compact="!isDesktop" />
|
||||
<ClassCard
|
||||
v-if="data.classBoard"
|
||||
:board="data.classBoard"
|
||||
:achievements="data.achievements"
|
||||
:me="data.username"
|
||||
:compact="!isDesktop"
|
||||
/>
|
||||
<n-text depth="3" class="nowrap"> {{ r.unlocked }} / {{ r.total }} </n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<n-tabs v-model:value="tab" type="line" class="tabs">
|
||||
<n-tab name="all">全部</n-tab>
|
||||
<n-tab name="unlocked">已获得</n-tab>
|
||||
<n-tab name="locked">未获得</n-tab>
|
||||
<n-tab name="badges">题单奖章</n-tab>
|
||||
</n-tabs>
|
||||
|
||||
<template v-if="tab !== 'badges'">
|
||||
<n-grid v-if="filtered.length" responsive="screen" cols="1 s:2 l:3" :x-gap="16" :y-gap="16">
|
||||
<n-gi v-for="a in filtered" :key="a.id">
|
||||
<AchievementCard :achievement="a" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<!-- 加载中不显示空态,不然首屏会闪一下"什么都没有" -->
|
||||
<n-empty v-else-if="!loading" description="这里还什么都没有" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<n-grid v-if="badges.length" responsive="screen" cols="1 s:2 l:3" :x-gap="16" :y-gap="16">
|
||||
<n-gi v-for="b in badges" :key="b.id">
|
||||
<n-card class="badge-card">
|
||||
<n-thing :title="b.badge?.name" :description="b.badge?.description">
|
||||
<template #avatar v-if="b.badge?.icon">
|
||||
<n-avatar
|
||||
:size="40"
|
||||
:src="b.badge.icon"
|
||||
color="transparent"
|
||||
object-fit="contain"
|
||||
/>
|
||||
</template>
|
||||
<n-text v-if="b.problemSet" depth="3" class="source">
|
||||
来自题单
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'problemset',
|
||||
params: { problemSetId: b.problemSet.id },
|
||||
}"
|
||||
>
|
||||
{{ b.problemSet.title }}
|
||||
</router-link>
|
||||
</n-text>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
|
||||
<div v-if="isDesktop" class="columns">
|
||||
<div class="column">
|
||||
<GroupCard v-for="g in pick(LEFT)" :key="g.name" :group="g" :badges="badges" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<GroupCard v-for="g in pick(RIGHT)" :key="g.name" :group="g" :badges="badges" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="column">
|
||||
<GroupCard v-for="g in groups" :key="g.name" :group="g" :badges="badges" compact />
|
||||
</div>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
@@ -149,36 +115,94 @@ watch(name, load)
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin: 0 0 var(--oj-gap);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
margin-bottom: var(--oj-gap);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--oj-fs-title);
|
||||
}
|
||||
|
||||
.summary,
|
||||
.badge-card {
|
||||
border-radius: var(--oj-radius);
|
||||
.count {
|
||||
font-size: var(--oj-fs-body);
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.badge-card {
|
||||
height: 100%;
|
||||
.count b {
|
||||
font-size: 18px;
|
||||
color: v-bind("theme.textColor1");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rarities {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.rarity {
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
|
||||
.rarity i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tabs {
|
||||
margin: var(--oj-gap) 0;
|
||||
|
||||
.rarity b {
|
||||
color: v-bind("theme.textColor1");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.source {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
.mine {
|
||||
margin-left: auto;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.primaryColor");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--oj-gap);
|
||||
margin-bottom: var(--oj-gap);
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--oj-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.blank {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.top {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.column {
|
||||
gap: 14px;
|
||||
}
|
||||
.source a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { Achievement } from "utils/types"
|
||||
|
||||
/**
|
||||
* 成就页按「一条条线」摆:同一个指标的几档是一条线(做对 1 / 10 / 50 / 100 / 200 道),
|
||||
* 几条线归一组。设计稿「我的成就重设计」定稿(画布 page ach)。
|
||||
*
|
||||
* 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看,
|
||||
* 在后台加了新指标记得回来补一行。
|
||||
*/
|
||||
export const GROUPS = ["做题", "坚持", "题单和比赛", "其他", "彩蛋"] as const
|
||||
export type GroupName = (typeof GROUPS)[number]
|
||||
|
||||
interface MetricInfo {
|
||||
group: GroupName
|
||||
label: string
|
||||
unit: string
|
||||
/** 还差 r 的时候怎么说 */
|
||||
remain: (r: number) => string
|
||||
/** 进度是「现在连着几」,标签前面带「现在」 */
|
||||
streak?: boolean
|
||||
}
|
||||
|
||||
const METRICS: Record<string, MetricInfo> = {
|
||||
accepted_count: { group: "做题", label: "做对的题", unit: "道", remain: (r) => `再做对 ${r} 道` },
|
||||
mid_ac_count: {
|
||||
group: "做题",
|
||||
label: "中等题",
|
||||
unit: "道",
|
||||
remain: (r) => `再做对 ${r} 道中等题`,
|
||||
},
|
||||
hard_ac_count: {
|
||||
group: "做题",
|
||||
label: "困难题",
|
||||
unit: "道",
|
||||
remain: (r) => `再做对 ${r} 道困难题`,
|
||||
},
|
||||
max_ac_week_streak: {
|
||||
group: "坚持",
|
||||
label: "连着几周有做对",
|
||||
unit: "周",
|
||||
remain: (r) => `再连着 ${r} 周都有做对`,
|
||||
streak: true,
|
||||
},
|
||||
max_ac_streak_days: {
|
||||
group: "坚持",
|
||||
label: "连着几天有做对",
|
||||
unit: "天",
|
||||
remain: (r) => `再连着 ${r} 天都有做对`,
|
||||
},
|
||||
active_days: {
|
||||
group: "坚持",
|
||||
label: "交过题的日子",
|
||||
unit: "天",
|
||||
remain: (r) => `再有 ${r} 天交题`,
|
||||
},
|
||||
submission_count: { group: "坚持", label: "交了几次", unit: "次", remain: (r) => `再交 ${r} 次` },
|
||||
problemset_completed: {
|
||||
group: "题单和比赛",
|
||||
label: "做完的题单",
|
||||
unit: "个",
|
||||
remain: (r) => `再做完 ${r} 个题单`,
|
||||
},
|
||||
badge_count: {
|
||||
group: "题单和比赛",
|
||||
label: "题单奖章",
|
||||
unit: "枚",
|
||||
remain: (r) => `再拿 ${r} 枚题单奖章`,
|
||||
},
|
||||
contest_joined: {
|
||||
group: "题单和比赛",
|
||||
label: "参加的比赛",
|
||||
unit: "场",
|
||||
remain: (r) => `再参加 ${r} 场比赛`,
|
||||
},
|
||||
languages_used: {
|
||||
group: "其他",
|
||||
label: "用过的语言",
|
||||
unit: "种",
|
||||
remain: (r) => `再用 ${r} 种语言`,
|
||||
},
|
||||
achievement_unlocked_count: {
|
||||
group: "其他",
|
||||
label: "拿到的成就",
|
||||
unit: "个",
|
||||
remain: (r) => `再拿 ${r} 个成就`,
|
||||
},
|
||||
first_try_ac_count: {
|
||||
group: "彩蛋",
|
||||
label: "一交就对",
|
||||
unit: "道",
|
||||
remain: (r) => `再有 ${r} 道一交就对`,
|
||||
},
|
||||
max_ac_in_one_day: {
|
||||
group: "彩蛋",
|
||||
label: "一天做对",
|
||||
unit: "道",
|
||||
remain: (r) => `一天做对 ${r} 道`,
|
||||
},
|
||||
midnight_submissions: {
|
||||
group: "彩蛋",
|
||||
label: "凌晨交题",
|
||||
unit: "次",
|
||||
remain: (r) => `再交 ${r} 次`,
|
||||
},
|
||||
early_bird_submissions: {
|
||||
group: "彩蛋",
|
||||
label: "早上交题",
|
||||
unit: "次",
|
||||
remain: (r) => `再交 ${r} 次`,
|
||||
},
|
||||
compile_error_count: {
|
||||
group: "彩蛋",
|
||||
label: "编译失败",
|
||||
unit: "次",
|
||||
remain: (r) => `还差 ${r} 次`,
|
||||
},
|
||||
max_wa_before_ac: {
|
||||
group: "彩蛋",
|
||||
label: "错了好多次才做对",
|
||||
unit: "次",
|
||||
remain: (r) => `还差 ${r} 次`,
|
||||
},
|
||||
max_code_lines: {
|
||||
group: "彩蛋",
|
||||
label: "做对的代码最长",
|
||||
unit: "行",
|
||||
remain: (r) => `还差 ${r} 行`,
|
||||
},
|
||||
}
|
||||
|
||||
const METRIC_ORDER = Object.keys(METRICS)
|
||||
|
||||
export interface Series {
|
||||
metric: string
|
||||
group: GroupName
|
||||
label: string
|
||||
unit: string
|
||||
streak: boolean
|
||||
/** 现在的数 */
|
||||
value: number
|
||||
/** 按门槛从低到高 */
|
||||
items: Achievement[]
|
||||
/** 下一档(还没拿到的门槛最低的一个),全拿到了是 null */
|
||||
next: Achievement | null
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
name: GroupName
|
||||
series: Series[]
|
||||
/** 还没找到的隐藏成就(后端已经遮成问号,连属于哪条线都不知道) */
|
||||
masked: number
|
||||
got: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function infoOf(metric: string): MetricInfo | null {
|
||||
return METRICS[metric] ?? null
|
||||
}
|
||||
|
||||
export function buildGroups(achievements: Achievement[]): Group[] {
|
||||
const byMetric = new Map<string, Achievement[]>()
|
||||
let masked = 0
|
||||
let maskedGot = 0
|
||||
for (const item of achievements) {
|
||||
if (!item.metric) {
|
||||
masked += 1
|
||||
if (item.unlocked) maskedGot += 1
|
||||
continue
|
||||
}
|
||||
const list = byMetric.get(item.metric) ?? []
|
||||
list.push(item)
|
||||
byMetric.set(item.metric, list)
|
||||
}
|
||||
const series: Series[] = [...byMetric.entries()]
|
||||
.sort(([a], [b]) => rankOf(a) - rankOf(b))
|
||||
.map(([metric, items]) => {
|
||||
items.sort((a, b) => (a.threshold ?? 0) - (b.threshold ?? 0))
|
||||
const info = METRICS[metric]
|
||||
return {
|
||||
metric,
|
||||
group: info?.group ?? "其他",
|
||||
label: info?.label ?? items[0]!.name,
|
||||
unit: info?.unit ?? "",
|
||||
streak: info?.streak ?? false,
|
||||
value: items[0]!.progress ?? 0,
|
||||
items,
|
||||
next: items.find((item) => !item.unlocked) ?? null,
|
||||
}
|
||||
})
|
||||
return GROUPS.map((name) => {
|
||||
const own = series.filter((item) => item.group === name)
|
||||
const extra = name === "彩蛋" ? masked : 0
|
||||
const items = own.flatMap((item) => item.items)
|
||||
return {
|
||||
name,
|
||||
series: own,
|
||||
masked: extra,
|
||||
got: items.filter((item) => item.unlocked).length + (name === "彩蛋" ? maskedGot : 0),
|
||||
total: items.length + extra,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function rankOf(metric: string) {
|
||||
const index = METRIC_ORDER.indexOf(metric)
|
||||
return index < 0 ? METRIC_ORDER.length : index
|
||||
}
|
||||
|
||||
export interface Near {
|
||||
achievement: Achievement
|
||||
series: Series
|
||||
remain: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 「快拿到了」:每条线只看下一档,按完成了几成排,取前三。
|
||||
* 一个都没做的新同学全是 0,就按指标先后排 —— 第一个是「做对 1 道」。
|
||||
*/
|
||||
export function nearest(groups: Group[], limit = 3): Near[] {
|
||||
const candidates: Near[] = []
|
||||
for (const group of groups) {
|
||||
for (const series of group.series) {
|
||||
const next = series.next
|
||||
if (!next || next.operator !== "gte" || !next.threshold) continue
|
||||
const r = next.threshold - series.value
|
||||
if (r <= 0) continue
|
||||
const info = METRICS[series.metric]
|
||||
candidates.push({
|
||||
achievement: next,
|
||||
series,
|
||||
remain: info ? info.remain(r) : `还差 ${r}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const ratio = (item: Near) => item.series.value / item.achievement.threshold!
|
||||
return candidates
|
||||
.sort((a, b) => ratio(b) - ratio(a) || rankOf(a.series.metric) - rankOf(b.series.metric))
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -474,8 +474,7 @@ export type {
|
||||
Achievement,
|
||||
AchievementList,
|
||||
AchievementRarity,
|
||||
AchievementRarityStat,
|
||||
AchievementSummary,
|
||||
AchievementClassBoard,
|
||||
AchievementNotification,
|
||||
PendingAchievement,
|
||||
} from "@oj2/contract"
|
||||
|
||||
@@ -15,13 +15,41 @@ export const achievementSchema = z.object({
|
||||
unlocked: z.boolean(),
|
||||
unlockTime: z.string().nullable(),
|
||||
backfilled: z.boolean(),
|
||||
/** 连续几周那一串给的是「现在连着几周」,不是历史最长 —— 要拿下一档得从现在数起 */
|
||||
progress: z.number().nullable(),
|
||||
unlockRate: z.number(),
|
||||
/** 全站几人有 */
|
||||
holders: z.number().int(),
|
||||
/** 本班(被看的人的班)几人有;没有班级是 null */
|
||||
classHolders: z.number().int().nullable(),
|
||||
})
|
||||
|
||||
/** 「本班」那张卡:只在看自己、而且有班级时给 */
|
||||
export const achievementClassBoardSchema = z.object({
|
||||
className: z.string(),
|
||||
/** 本班最近拿到的(补发的不算),新的在前 */
|
||||
recent: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
achievementId: z.number().int(),
|
||||
unlockTime: z.string(),
|
||||
}),
|
||||
),
|
||||
/** 本班成就最多的前几名;一样多的名次相同,先拿到的排前面 */
|
||||
most: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
count: z.number().int(),
|
||||
rank: z.number().int(),
|
||||
}),
|
||||
),
|
||||
/** 自己不在 most 里时的名次;不计入排名的人是 null */
|
||||
me: z.object({ rank: z.number().int(), count: z.number().int() }).nullable(),
|
||||
})
|
||||
|
||||
export const achievementListSchema = z.object({
|
||||
username: z.string(),
|
||||
achievements: z.array(achievementSchema),
|
||||
classBoard: achievementClassBoardSchema.nullable(),
|
||||
})
|
||||
|
||||
export const pendingAchievementSchema = z.object({
|
||||
@@ -32,13 +60,6 @@ export const pendingAchievementSchema = z.object({
|
||||
rarity: achievementRaritySchema,
|
||||
})
|
||||
|
||||
export const achievementRarityStatSchema = z.object({
|
||||
rarity: achievementRaritySchema,
|
||||
label: z.string(),
|
||||
total: z.number().int(),
|
||||
unlocked: z.number().int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WebSocket 推来的解锁通知。比 `/achievements/pending` 多一个 `kind` ——
|
||||
* 题单奖章和成就来自两张表、id 会重叠,前端靠它区分(见 events.ts 的 publishAchievementNotification)。
|
||||
@@ -47,15 +68,6 @@ export const achievementNotificationSchema = pendingAchievementSchema.extend({
|
||||
kind: z.enum(["achievement", "badge"]),
|
||||
})
|
||||
|
||||
export const achievementSummarySchema = z.object({
|
||||
username: z.string(),
|
||||
total: z.number().int(),
|
||||
unlocked: z.number().int(),
|
||||
percent: z.number(),
|
||||
rarity: z.array(achievementRarityStatSchema),
|
||||
recent: z.array(pendingAchievementSchema),
|
||||
})
|
||||
|
||||
export const markAchievementsReadSchema = z.object({
|
||||
ids: z.array(z.number().int()),
|
||||
})
|
||||
@@ -63,9 +75,8 @@ export const markAchievementsReadSchema = z.object({
|
||||
export type Achievement = z.infer<typeof achievementSchema>
|
||||
export type AchievementList = z.infer<typeof achievementListSchema>
|
||||
export type PendingAchievement = z.infer<typeof pendingAchievementSchema>
|
||||
export type AchievementSummary = z.infer<typeof achievementSummarySchema>
|
||||
export type AchievementClassBoard = z.infer<typeof achievementClassBoardSchema>
|
||||
export type AchievementRarity = z.infer<typeof achievementRaritySchema>
|
||||
export type AchievementRarityStat = z.infer<typeof achievementRarityStatSchema>
|
||||
export type AchievementNotification = z.infer<typeof achievementNotificationSchema>
|
||||
|
||||
export type MarkAchievementsRead = z.infer<typeof markAchievementsReadSchema>
|
||||
Reference in new issue
Block a user