feat(成就): 下架三个拿不到的成就、理顺稀有度和图标;接口多给本班几人有和「本班」卡片
- 0032:下架六边形战士(判题机只剩 3 种语言)、坚持三天/一周(按天算基本拿不到); 不撞南墙改白银、屡败屡战改黄金;百发百中换 💯、屡败屡战换 💪,不再和别的撞图标; 代码行数只算做对的、空行不算,并按新口径重算所有人;已经拿到的一个不收回 - 奖杯收藏家只数上架的成就(判题结算、补发重算、0032 存量一起改) - GET /achievements:每个成就带全站几人有、本班几人有;看自己时带「本班」(最近拿到、成就最多前五、自己第几); 隐藏成就同一串拿到一档就整串亮出来;连续几周给「现在连着几周」而不是历史最长 - 删掉只有旧成就页在用的 /achievements/summary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
689d40b116
commit
85108df9d8
9 files changed
+4218
-211
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,
|
"when": 1791766110876,
|
||||||
"tag": "0031_tutorial_project_and_problems",
|
"tag": "0031_tutorial_project_and_problems",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791821976519,
|
||||||
|
"tag": "0032_achievement_cleanup",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
markAchievementsReadSchema,
|
markAchievementsReadSchema,
|
||||||
|
STUDENT_ROLES,
|
||||||
type Achievement,
|
type Achievement,
|
||||||
|
type AchievementClassBoard,
|
||||||
type AchievementList,
|
type AchievementList,
|
||||||
type AchievementSummary,
|
|
||||||
type PendingAchievement,
|
type PendingAchievement,
|
||||||
} from "@oj2/contract"
|
} 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 { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, parseBody, success } from "../http"
|
import { failure, parseBody, success } from "../http"
|
||||||
|
import { currentWeekStreak } from "../services/problem-progress"
|
||||||
import { asRecord } from "./helpers"
|
import { asRecord } from "./helpers"
|
||||||
|
|
||||||
export const achievementRoutes = new Hono<AppEnv>()
|
export const achievementRoutes = new Hono<AppEnv>()
|
||||||
@@ -18,14 +20,22 @@ export const achievementRoutes = new Hono<AppEnv>()
|
|||||||
async function resolveUser(requested: string | undefined, currentId: number) {
|
async function resolveUser(requested: string | undefined, currentId: number) {
|
||||||
if (!requested) {
|
if (!requested) {
|
||||||
const [current] = await db
|
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)
|
.from(schema.user)
|
||||||
.where(eq(schema.user.id, currentId))
|
.where(eq(schema.user.id, currentId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
return current ?? null
|
return current ?? null
|
||||||
}
|
}
|
||||||
const [target] = await db
|
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)
|
.from(schema.user)
|
||||||
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false)))
|
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -43,9 +53,10 @@ function pendingData(row: { achievement: typeof schema.achievement.$inferSelect
|
|||||||
}
|
}
|
||||||
|
|
||||||
achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
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", "用户不存在")
|
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
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.achievement)
|
.from(schema.achievement)
|
||||||
@@ -57,15 +68,31 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
|||||||
.from(schema.userStat)
|
.from(schema.userStat)
|
||||||
.where(eq(schema.userStat.userId, target.id))
|
.where(eq(schema.userStat.userId, target.id))
|
||||||
.limit(1),
|
.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 unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
|
||||||
const metrics = asRecord(statRows[0]?.metrics)
|
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 result = achievements.map((achievement) => {
|
||||||
const record = unlocked.get(achievement.id)
|
const record = unlocked.get(achievement.id)
|
||||||
const masked = achievement.hidden && !record
|
const masked = achievement.hidden && !record && !revealedMetrics.has(achievement.metric)
|
||||||
const progress = metrics[achievement.metric]
|
const progress =
|
||||||
|
achievement.metric === "max_ac_week_streak" ? weekStreak : metrics[achievement.metric]
|
||||||
return {
|
return {
|
||||||
id: achievement.id,
|
id: achievement.id,
|
||||||
name: masked ? "???" : achievement.name,
|
name: masked ? "???" : achievement.name,
|
||||||
@@ -80,61 +107,100 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
|||||||
unlockTime: record?.unlockTime ?? null,
|
unlockTime: record?.unlockTime ?? null,
|
||||||
backfilled: record?.backfilled ?? false,
|
backfilled: record?.backfilled ?? false,
|
||||||
progress: masked ? null : typeof progress === "number" ? progress : 0,
|
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
|
} satisfies Achievement
|
||||||
})
|
})
|
||||||
|
const classBoard = target.id === me.id && target.className ? await buildClassBoard(target) : null
|
||||||
return success(c, {
|
return success(c, {
|
||||||
username: target.username,
|
username: target.username,
|
||||||
achievements: result,
|
achievements: result,
|
||||||
|
classBoard,
|
||||||
} satisfies AchievementList)
|
} satisfies AchievementList)
|
||||||
})
|
})
|
||||||
|
|
||||||
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
/** 本班 = 同一个班级的学生(含学生管理员),停用的不算 */
|
||||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
function classMembers(className: string) {
|
||||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
return and(
|
||||||
const [achievements, unlockedRows] = await Promise.all([
|
eq(schema.user.className, className),
|
||||||
db
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
.select({ id: schema.achievement.id, rarity: schema.achievement.rarity })
|
eq(schema.user.isDisabled, false),
|
||||||
.from(schema.achievement)
|
)
|
||||||
.where(eq(schema.achievement.visible, true)),
|
}
|
||||||
|
|
||||||
|
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
|
db
|
||||||
.select({
|
.select({
|
||||||
record: schema.userAchievement,
|
username: schema.user.username,
|
||||||
achievement: schema.achievement,
|
achievementId: schema.userAchievement.achievementId,
|
||||||
|
unlockTime: schema.userAchievement.unlockTime,
|
||||||
})
|
})
|
||||||
.from(schema.userAchievement)
|
.from(schema.userAchievement)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.userAchievement.userId))
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
schema.achievement,
|
schema.achievement,
|
||||||
eq(schema.userAchievement.achievementId, schema.achievement.id),
|
eq(schema.achievement.id, schema.userAchievement.achievementId),
|
||||||
)
|
)
|
||||||
.where(
|
.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)),
|
.orderBy(desc(schema.userAchievement.unlockTime))
|
||||||
])
|
.limit(CLASS_RECENT),
|
||||||
const labels = {
|
// 不计入排名的人(老师在排名里关掉的)也不进「成就最多」
|
||||||
bronze: "青铜",
|
db
|
||||||
silver: "白银",
|
.select({
|
||||||
gold: "黄金",
|
id: schema.user.id,
|
||||||
platinum: "白金",
|
username: schema.user.username,
|
||||||
}
|
count: count(schema.achievement.id),
|
||||||
const rarities = ["bronze", "silver", "gold", "platinum"] as const
|
last: max(schema.userAchievement.unlockTime),
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
.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) => {
|
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
{
|
{
|
||||||
key: "max_code_lines",
|
key: "max_code_lines",
|
||||||
name: "最长代码行数",
|
name: "最长代码行数",
|
||||||
helpText: "提交过的最长代码有多少行",
|
helpText: "做对的提交里最长的代码有多少行(空行不算,不含比赛)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "achievement_unlocked_count",
|
key: "achievement_unlocked_count",
|
||||||
name: "已解锁成就数",
|
name: "已解锁成就数",
|
||||||
helpText: "已解锁的成就数量(不含白金档)",
|
helpText: "已解锁的成就数量(不含白金档和已下架的)",
|
||||||
meta: true,
|
meta: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -192,9 +192,11 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
|||||||
value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
||||||
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
|
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
|
||||||
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
|
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
|
||||||
|
// 只算做对的、空行不算:原来交什么都算,贴一大段就拿到「长篇巨制」(0032 按这个口径重算过存量)
|
||||||
|
if (accepted)
|
||||||
value.max_code_lines = Math.max(
|
value.max_code_lines = Math.max(
|
||||||
numberMetric(value, "max_code_lines"),
|
numberMetric(value, "max_code_lines"),
|
||||||
row.submission.code.split(/\r?\n/).length,
|
row.submission.code.split("\n").filter((line) => line.trim() !== "").length,
|
||||||
)
|
)
|
||||||
await tx
|
await tx
|
||||||
.update(schema.userStat)
|
.update(schema.userStat)
|
||||||
@@ -213,6 +215,7 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
|||||||
and(
|
and(
|
||||||
eq(schema.userAchievement.userId, row.submission.userId),
|
eq(schema.userAchievement.userId, row.submission.userId),
|
||||||
ne(schema.achievement.rarity, "platinum"),
|
ne(schema.achievement.rarity, "platinum"),
|
||||||
|
eq(schema.achievement.visible, true),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||||
@@ -269,7 +272,11 @@ export async function updateAchievementsForProblemSet(userId: number) {
|
|||||||
.from(schema.userAchievement)
|
.from(schema.userAchievement)
|
||||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||||
.where(
|
.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
|
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||||
await db
|
await db
|
||||||
@@ -406,7 +413,7 @@ export function metaAchievements() {
|
|||||||
/**
|
/**
|
||||||
* 按 `user_achievement` 重算 `achievement_unlocked_count`,返回实际改动了的用户 id。
|
* 按 `user_achievement` 重算 `achievement_unlocked_count`,返回实际改动了的用户 id。
|
||||||
*
|
*
|
||||||
* 口径和判题结算一致:已解锁的**非白金**成就数。只 `jsonb_set` 这一个键、只写值变了的行,
|
* 口径和判题结算一致:已解锁的、还上架的**非白金**成就数(下架的不算,见 0032)。只 `jsonb_set` 这一个键、只写值变了的行,
|
||||||
* 不整体覆盖 `metrics` —— 整体写回会和并发判题写的其它指标互相踩。
|
* 不整体覆盖 `metrics` —— 整体写回会和并发判题写的其它指标互相踩。
|
||||||
* 不传 `userIds` 就是全体有 `user_stat` 的用户(`recount` 存量订正用)。
|
* 不传 `userIds` 就是全体有 `user_stat` 的用户(`recount` 存量订正用)。
|
||||||
*/
|
*/
|
||||||
@@ -423,7 +430,7 @@ export async function refreshUnlockedCount(userIds?: number[]) {
|
|||||||
select ua.user_id, count(*)::int as value
|
select ua.user_id, count(*)::int as value
|
||||||
from ${schema.userAchievement} ua
|
from ${schema.userAchievement} ua
|
||||||
join ${schema.achievement} a on a.id = ua.achievement_id
|
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
|
group by ua.user_id
|
||||||
) c on c.user_id = s.user_id
|
) c on c.user_id = s.user_id
|
||||||
where true ${scope}
|
where true ${scope}
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ function numberOf(metrics: Record<string, unknown>, key: string) {
|
|||||||
return typeof value === "number" ? value : 0
|
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> {
|
export async function buildProblemProgress(userId: number): Promise<ProblemProgress> {
|
||||||
const [states, statRows, achievements, unlockedRows] = await Promise.all([
|
const [states, statRows, achievements, unlockedRows] = await Promise.all([
|
||||||
problemStates(userId),
|
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))) {
|
for (const [day, n] of Object.entries(asRecord(metrics._ac_per_day))) {
|
||||||
if (day >= monday && typeof n === "number") weekSolved += n
|
if (day >= monday && typeof n === "number") weekSolved += n
|
||||||
}
|
}
|
||||||
// 当前连续周数只在最近一次新通过落在本周或上周时还作数,再早就已经断了
|
const weekStreak = currentWeekStreak(metrics)
|
||||||
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 unlocked = new Set(unlockedRows.map((row) => row.id))
|
const unlocked = new Set(unlockedRows.map((row) => row.id))
|
||||||
let next: ProblemProgress["next"] = null
|
let next: ProblemProgress["next"] = null
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -15,13 +15,41 @@ export const achievementSchema = z.object({
|
|||||||
unlocked: z.boolean(),
|
unlocked: z.boolean(),
|
||||||
unlockTime: z.string().nullable(),
|
unlockTime: z.string().nullable(),
|
||||||
backfilled: z.boolean(),
|
backfilled: z.boolean(),
|
||||||
|
/** 连续几周那一串给的是「现在连着几周」,不是历史最长 —— 要拿下一档得从现在数起 */
|
||||||
progress: z.number().nullable(),
|
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({
|
export const achievementListSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
achievements: z.array(achievementSchema),
|
achievements: z.array(achievementSchema),
|
||||||
|
classBoard: achievementClassBoardSchema.nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const pendingAchievementSchema = z.object({
|
export const pendingAchievementSchema = z.object({
|
||||||
@@ -32,13 +60,6 @@ export const pendingAchievementSchema = z.object({
|
|||||||
rarity: achievementRaritySchema,
|
rarity: achievementRaritySchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const achievementRarityStatSchema = z.object({
|
|
||||||
rarity: achievementRaritySchema,
|
|
||||||
label: z.string(),
|
|
||||||
total: z.number().int(),
|
|
||||||
unlocked: z.number().int(),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket 推来的解锁通知。比 `/achievements/pending` 多一个 `kind` ——
|
* WebSocket 推来的解锁通知。比 `/achievements/pending` 多一个 `kind` ——
|
||||||
* 题单奖章和成就来自两张表、id 会重叠,前端靠它区分(见 events.ts 的 publishAchievementNotification)。
|
* 题单奖章和成就来自两张表、id 会重叠,前端靠它区分(见 events.ts 的 publishAchievementNotification)。
|
||||||
@@ -47,15 +68,6 @@ export const achievementNotificationSchema = pendingAchievementSchema.extend({
|
|||||||
kind: z.enum(["achievement", "badge"]),
|
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({
|
export const markAchievementsReadSchema = z.object({
|
||||||
ids: z.array(z.number().int()),
|
ids: z.array(z.number().int()),
|
||||||
})
|
})
|
||||||
@@ -63,9 +75,8 @@ export const markAchievementsReadSchema = z.object({
|
|||||||
export type Achievement = z.infer<typeof achievementSchema>
|
export type Achievement = z.infer<typeof achievementSchema>
|
||||||
export type AchievementList = z.infer<typeof achievementListSchema>
|
export type AchievementList = z.infer<typeof achievementListSchema>
|
||||||
export type PendingAchievement = z.infer<typeof pendingAchievementSchema>
|
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 AchievementRarity = z.infer<typeof achievementRaritySchema>
|
||||||
export type AchievementRarityStat = z.infer<typeof achievementRarityStatSchema>
|
|
||||||
export type AchievementNotification = z.infer<typeof achievementNotificationSchema>
|
export type AchievementNotification = z.infer<typeof achievementNotificationSchema>
|
||||||
|
|
||||||
export type MarkAchievementsRead = z.infer<typeof markAchievementsReadSchema>
|
export type MarkAchievementsRead = z.infer<typeof markAchievementsReadSchema>
|
||||||
Reference in new issue
Block a user