Compare commits
3
Commits
aa7e0a6eb8
...
f6f7a0c3af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f7a0c3af | ||
|
|
cccf7a37be | ||
|
|
1fdee94812 |
No files matched your search
@@ -0,0 +1,184 @@
|
||||
-- 成就惠及更多学生(2026-11-03)。已经拿到的一个不收回。
|
||||
--
|
||||
-- 这学期 375 个交过题的学生,成就数中位数 5 个、三分之一只有 0–2 个。三件事:
|
||||
--
|
||||
-- 1. 比赛里的提交也算成就(判题结算同步改了,见 services/achievements.ts 的 solvedCounts)。
|
||||
-- 这里的比赛其实是单班测验,2612 班整个班都在比赛里做题,人均做对 17 道却一个成就也没有;
|
||||
-- 「参加过几场比赛」以前判题时也不更新,只在后台重扫时刷新,所以连「初入赛场」都没拿到。
|
||||
-- 2. 几条线补上低一档(10 道和 50 道之间、一次就对 1 次和 20 次之间……),外加一条新线
|
||||
-- 「挑战成功」。括号里是这学期学生已经达到的比例。
|
||||
-- 3. 档多了,「奖杯收藏家」从 15 个提到 20 个,不然人人都有。
|
||||
--
|
||||
-- 然后从提交记录把含比赛的指标重数一遍,按新配置给所有达标的人补发(backfilled,不显示日期)。
|
||||
-- 编译错误次数和按天的连续不重数:前者上线前的历史本来就没有,后者已经下架。
|
||||
|
||||
INSERT INTO "achievement" ("name", "description", "icon", "rarity", "hidden", "metric", "operator", "threshold", "visible", "unlock_count", "order", "create_time") VALUES
|
||||
('稳扎稳打', '通过 20 道题目', 'noto:orange-book', 'bronze', false, 'accepted_count', 'gte', 20, true, 0, 2, now()), -- 44%
|
||||
('初露锋芒', '通过 1 道中等难度题目', 'noto:sunrise-over-mountains', 'bronze', false, 'mid_ac_count', 'gte', 1, true, 0, 6, now()), -- 21%
|
||||
('迎难而上', '挑战成功 1 次', 'noto:puzzle-piece', 'bronze', false, 'challenge_count', 'gte', 1, true, 0, 10, now()), -- 60%
|
||||
('精益求精', '挑战成功 5 次', 'noto:gem-stone', 'silver', false, 'challenge_count', 'gte', 5, true, 0, 10, now()), -- 12%
|
||||
('勤学苦练', '累计提交 30 次', 'noto:pencil', 'bronze', false, 'submission_count', 'gte', 30, true, 0, 11, now()), -- 48%
|
||||
('渐成习惯', '累计 5 天有提交记录', 'noto:spiral-calendar', 'bronze', false, 'active_days', 'gte', 5, true, 0, 15, now()), -- 59%
|
||||
('手感火热', '累计 5 次首次提交即通过', 'noto:glowing-star', 'bronze', true, 'first_try_ac_count', 'gte', 5, true, 0, 25, now()), -- 67%
|
||||
('再试一次', '同一道题失败 3 次后终于通过', 'noto:counterclockwise-arrows-button', 'bronze', true, 'max_wa_before_ac', 'gte', 3, true, 0, 32, now()), -- 54%
|
||||
('一鼓作气', '一天之内通过 5 道题', 'noto:dashing-away', 'bronze', true, 'max_ac_in_one_day', 'gte', 5, true, 0, 34, now()); -- 57%
|
||||
--> statement-breakpoint
|
||||
UPDATE "achievement" SET "threshold" = 20, "description" = '解锁 20 个成就(不含白金)'
|
||||
WHERE "metric" = 'achievement_unlocked_count' AND "threshold" = 15;
|
||||
--> statement-breakpoint
|
||||
-- 交过题、还没有 user_stat 的人先建一行(2612 班大多是这样:比赛提交以前根本不进结算)
|
||||
INSERT INTO "user_stat" ("user_id", "metrics", "update_time")
|
||||
SELECT DISTINCT s."user_id", '{}'::jsonb, now()
|
||||
FROM "submission" s
|
||||
JOIN "user" u ON u."id" = s."user_id"
|
||||
ON CONFLICT ("user_id") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- 按判题结算的口径从提交记录重数(services/achievements.ts):含比赛;在判的(6/7)不算,
|
||||
-- 它们判完时结算会自己加;做对 = ACCEPTED(0) / AST_CHECK_FAILED(10);日历按东八区。
|
||||
-- 「新做对」= 这道题第一次做对的那条提交。
|
||||
UPDATE "user_stat" AS target
|
||||
SET "metrics" = target."metrics" || fresh.metrics, "update_time" = now()
|
||||
FROM (
|
||||
WITH subs AS (
|
||||
SELECT s."id", s."user_id", s."problem_id", s."contest_id", s."result", s."language", s."code", s."create_time",
|
||||
(s."create_time" AT TIME ZONE 'Asia/Shanghai') AS local, p."difficulty",
|
||||
s."result" IN (0, 10) AS ac,
|
||||
-- 这道题在这条之前交过几次(同一时刻的不算「之前」,和结算里的 create_time < 一致)
|
||||
rank() OVER (PARTITION BY s."user_id", s."problem_id" ORDER BY s."create_time") - 1 AS before
|
||||
FROM "submission" s
|
||||
JOIN "user" u ON u."id" = s."user_id"
|
||||
JOIN "problem" p ON p."id" = s."problem_id"
|
||||
WHERE s."result" NOT IN (6, 7)
|
||||
),
|
||||
totals AS (
|
||||
SELECT "user_id",
|
||||
count(DISTINCT "problem_id") FILTER (WHERE ac)::int AS accepted,
|
||||
count(DISTINCT "problem_id") FILTER (WHERE ac AND "difficulty" = 'Mid')::int AS mid,
|
||||
count(DISTINCT "problem_id") FILTER (WHERE ac AND "difficulty" = 'High')::int AS hard,
|
||||
count(*)::int AS submissions,
|
||||
count(DISTINCT "contest_id")::int AS contests,
|
||||
count(*) FILTER (WHERE extract(hour FROM local) < 5)::int AS midnight,
|
||||
count(*) FILTER (WHERE extract(hour FROM local) >= 5 AND extract(hour FROM local) < 7)::int AS early,
|
||||
jsonb_agg(DISTINCT "language") AS languages,
|
||||
jsonb_agg(DISTINCT to_char(local, 'YYYY-MM-DD')) AS dates
|
||||
FROM subs GROUP BY "user_id"
|
||||
),
|
||||
first_try AS (
|
||||
SELECT "user_id", count(*) FILTER (WHERE ac)::int AS n
|
||||
FROM (SELECT DISTINCT ON ("user_id", "problem_id") "user_id", ac FROM subs ORDER BY "user_id", "problem_id", "create_time", "id") f
|
||||
GROUP BY "user_id"
|
||||
),
|
||||
code_lines AS (
|
||||
SELECT "user_id", max((
|
||||
SELECT count(*) FROM regexp_split_to_table("code", E'\n') AS line WHERE btrim(line, E' \t\r') <> ''
|
||||
))::int AS n
|
||||
FROM subs WHERE ac GROUP BY "user_id"
|
||||
),
|
||||
first_ac AS (
|
||||
SELECT DISTINCT ON ("user_id", "problem_id") "user_id", "problem_id", local, before
|
||||
FROM subs WHERE ac ORDER BY "user_id", "problem_id", "create_time", "id"
|
||||
),
|
||||
wa_before AS (
|
||||
SELECT "user_id", max(before)::int AS n FROM first_ac GROUP BY "user_id"
|
||||
),
|
||||
per_day AS (
|
||||
SELECT "user_id", jsonb_object_agg(day, n) AS days, max(n)::int AS best
|
||||
FROM (SELECT "user_id", to_char(local, 'YYYY-MM-DD') AS day, count(*)::int AS n FROM first_ac GROUP BY 1, 2) d
|
||||
GROUP BY "user_id"
|
||||
),
|
||||
weeks AS (
|
||||
SELECT DISTINCT "user_id", date_trunc('week', local)::date AS w FROM first_ac
|
||||
),
|
||||
runs AS (
|
||||
SELECT "user_id", count(*)::int AS len, max(w) AS last_w
|
||||
FROM (SELECT "user_id", w, w - (row_number() OVER (PARTITION BY "user_id" ORDER BY w))::int * 7 AS g FROM weeks) i
|
||||
GROUP BY "user_id", g
|
||||
),
|
||||
streak AS (
|
||||
SELECT "user_id", max(len) AS best, (array_agg(len ORDER BY last_w DESC))[1] AS current, max(last_w) AS last_w
|
||||
FROM runs GROUP BY "user_id"
|
||||
),
|
||||
challenges AS (
|
||||
SELECT "user_id", count(*)::int AS n FROM "challenge_success" GROUP BY "user_id"
|
||||
)
|
||||
SELECT t."user_id",
|
||||
jsonb_build_object(
|
||||
'accepted_count', t.accepted,
|
||||
'mid_ac_count', t.mid,
|
||||
'hard_ac_count', t.hard,
|
||||
'first_try_ac_count', coalesce(ft.n, 0),
|
||||
'submission_count', t.submissions,
|
||||
'contest_joined', t.contests,
|
||||
'midnight_submissions', t.midnight,
|
||||
'early_bird_submissions', t.early,
|
||||
'_languages', t.languages,
|
||||
'languages_used', jsonb_array_length(t.languages),
|
||||
'_active_dates', t.dates,
|
||||
'active_days', jsonb_array_length(t.dates),
|
||||
'max_code_lines', coalesce(cl.n, 0),
|
||||
'max_wa_before_ac', coalesce(wb.n, 0),
|
||||
'_ac_per_day', coalesce(pd.days, '{}'::jsonb),
|
||||
'max_ac_in_one_day', coalesce(pd.best, 0),
|
||||
'challenge_count', coalesce(ch.n, 0)
|
||||
) || CASE WHEN st."user_id" IS NULL THEN '{}'::jsonb ELSE jsonb_build_object(
|
||||
'max_ac_week_streak', st.best,
|
||||
'_current_ac_week_streak', st.current,
|
||||
'_last_ac_week', to_char(st.last_w, 'YYYY-MM-DD')
|
||||
) END AS metrics
|
||||
FROM totals t
|
||||
LEFT JOIN first_try ft ON ft."user_id" = t."user_id"
|
||||
LEFT JOIN code_lines cl ON cl."user_id" = t."user_id"
|
||||
LEFT JOIN wa_before wb ON wb."user_id" = t."user_id"
|
||||
LEFT JOIN per_day pd ON pd."user_id" = t."user_id"
|
||||
LEFT JOIN streak st ON st."user_id" = t."user_id"
|
||||
LEFT JOIN challenges ch ON ch."user_id" = t."user_id"
|
||||
) fresh
|
||||
WHERE target."user_id" = fresh."user_id";
|
||||
--> statement-breakpoint
|
||||
-- 挑战成功了、却一条提交都没有的(不会有,保险)也给个数
|
||||
UPDATE "user_stat" AS target
|
||||
SET "metrics" = jsonb_set(target."metrics", '{challenge_count}', to_jsonb(c.n))
|
||||
FROM (SELECT "user_id", count(*)::int AS n FROM "challenge_success" GROUP BY "user_id") c
|
||||
WHERE target."user_id" = c."user_id" AND (target."metrics" -> 'challenge_count') IS DISTINCT FROM to_jsonb(c.n);
|
||||
--> statement-breakpoint
|
||||
-- 补发:上架的、非元成就,达标没发的都发(口径同 rescanAchievement:backfilled、待通知)
|
||||
INSERT INTO "user_achievement" ("user_id", "achievement_id", "unlock_time", "backfilled", "notified")
|
||||
SELECT st."user_id", a."id", now(), true, false
|
||||
FROM "user_stat" st
|
||||
JOIN "achievement" a ON a."visible" AND a."metric" <> 'achievement_unlocked_count'
|
||||
WHERE jsonb_typeof(st."metrics" -> a."metric") = 'number'
|
||||
AND CASE WHEN a."operator" = 'gte' THEN (st."metrics" ->> a."metric")::numeric >= a."threshold"
|
||||
ELSE (st."metrics" ->> a."metric")::numeric <= a."threshold" END
|
||||
ON CONFLICT ("user_id", "achievement_id") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- 已解锁数(上架的非白金)重数,口径同 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);
|
||||
--> statement-breakpoint
|
||||
-- 第二轮:元成就(奖杯收藏家)
|
||||
INSERT INTO "user_achievement" ("user_id", "achievement_id", "unlock_time", "backfilled", "notified")
|
||||
SELECT st."user_id", a."id", now(), true, false
|
||||
FROM "user_stat" st
|
||||
JOIN "achievement" a ON a."visible" AND a."metric" = 'achievement_unlocked_count'
|
||||
WHERE jsonb_typeof(st."metrics" -> a."metric") = 'number'
|
||||
AND CASE WHEN a."operator" = 'gte' THEN (st."metrics" ->> a."metric")::numeric >= a."threshold"
|
||||
ELSE (st."metrics" ->> a."metric")::numeric <= a."threshold" END
|
||||
ON CONFLICT ("user_id", "achievement_id") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
UPDATE "achievement" AS a
|
||||
SET "unlock_count" = coalesce(c.n, 0)
|
||||
FROM (SELECT "achievement_id", count(*)::int AS n FROM "user_achievement" GROUP BY "achievement_id") c
|
||||
WHERE c."achievement_id" = a."id" AND a."unlock_count" IS DISTINCT FROM c.n;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -309,6 +309,13 @@
|
||||
"when": 1793693911394,
|
||||
"tag": "0043_mascot_event",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1793695621303,
|
||||
"tag": "0044_achievement_more_tiers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{
|
||||
key: 'accepted_count',
|
||||
name: '做对的题数',
|
||||
helpText: '去重后通过的题目数量(不含比赛)',
|
||||
helpText: '去重后通过的题目数量(含比赛题)',
|
||||
},
|
||||
{
|
||||
key: 'mid_ac_count',
|
||||
name: '做对的中等题',
|
||||
helpText: '去重后通过的中等难度题目数(不含比赛)',
|
||||
helpText: '去重后通过的中等难度题目数(含比赛题)',
|
||||
},
|
||||
{
|
||||
key: 'hard_ac_count',
|
||||
name: '做对的困难题',
|
||||
helpText: '去重后通过的困难题目数(不含比赛)',
|
||||
helpText: '去重后通过的困难题目数(含比赛题)',
|
||||
},
|
||||
{
|
||||
key: 'submission_count',
|
||||
name: '提交次数',
|
||||
helpText: '提交次数(不含比赛)',
|
||||
helpText: '提交次数(含比赛)',
|
||||
},
|
||||
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
|
||||
{
|
||||
@@ -45,13 +45,13 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{
|
||||
key: 'max_ac_week_streak',
|
||||
name: '连续有新做对的周数',
|
||||
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)',
|
||||
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
|
||||
},
|
||||
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
|
||||
{
|
||||
key: 'contest_joined',
|
||||
name: '参加比赛',
|
||||
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)',
|
||||
helpText: '交过题的比赛数量',
|
||||
},
|
||||
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
|
||||
{
|
||||
@@ -59,6 +59,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
name: '做完的专题',
|
||||
helpText: '完成的专题数量',
|
||||
},
|
||||
{
|
||||
key: 'challenge_count',
|
||||
name: '挑战成功',
|
||||
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
|
||||
},
|
||||
{
|
||||
key: 'first_try_ac_count',
|
||||
name: '一次就对的题',
|
||||
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{
|
||||
key: 'max_code_lines',
|
||||
name: '最长的代码行数',
|
||||
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)',
|
||||
helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
|
||||
},
|
||||
{
|
||||
key: 'achievement_unlocked_count',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
||||
import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
||||
|
||||
import { db, schema, type DbOrTx } from '../db'
|
||||
import { publishAchievementNotification } from '../events'
|
||||
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
|
||||
|
||||
/**
|
||||
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
||||
* 参加过几场比赛、挑战成功几次也在这里一起数。
|
||||
*
|
||||
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
||||
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
||||
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
||||
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
||||
* 一个人的提交也就几百条。
|
||||
*
|
||||
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
|
||||
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
|
||||
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
|
||||
*/
|
||||
async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||
const [row] = await tx.execute<{
|
||||
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||
mid: number
|
||||
hard: number
|
||||
first_try: number
|
||||
contests: number
|
||||
challenges: number
|
||||
}>(
|
||||
sql`
|
||||
with mine as (
|
||||
select s.problem_id, s.result, s.create_time, s.id, p.difficulty
|
||||
select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
|
||||
from ${schema.submission} s
|
||||
join ${schema.problem} p on p.id = s.problem_id
|
||||
where s.user_id = ${userId} and s.contest_id is null
|
||||
where s.user_id = ${userId}
|
||||
)
|
||||
select
|
||||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
||||
@@ -111,11 +118,13 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||
select count(*)::int from (
|
||||
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
||||
) first where result in (${ACCEPTED_RESULTS})
|
||||
) as first_try
|
||||
) as first_try,
|
||||
count(distinct contest_id)::int as contests,
|
||||
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
|
||||
from mine
|
||||
`,
|
||||
)
|
||||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 }
|
||||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +139,7 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.submission.id, submissionId))
|
||||
.limit(1)
|
||||
if (!row || row.submission.contestId !== null) return []
|
||||
if (!row) return []
|
||||
|
||||
const rejudged = countedAs !== null
|
||||
const accepted = isAccepted(row.submission.result)
|
||||
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
||||
value.mid_ac_count = solved.mid
|
||||
value.hard_ac_count = solved.hard
|
||||
value.first_try_ac_count = solved.first_try
|
||||
value.contest_joined = solved.contests
|
||||
value.challenge_count = solved.challenges
|
||||
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
|
||||
if (firstAc) {
|
||||
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
||||
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
||||
and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
lt(schema.submission.createTime, row.submission.createTime),
|
||||
),
|
||||
)
|
||||
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
|
||||
const metric = findMetric(achievement.metric)
|
||||
if (!metric) return { scanned: 0, unlocked: 0 }
|
||||
|
||||
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
|
||||
// contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
|
||||
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
|
||||
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
|
||||
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
|
||||
@@ -482,7 +492,7 @@ async function refreshContestJoinedForAll() {
|
||||
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
|
||||
* 其余指标不动,写法同 refreshContestJoinedForAll)。
|
||||
*
|
||||
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
||||
* 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
||||
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
|
||||
*/
|
||||
async function refreshAcWeekStreakForAll() {
|
||||
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
|
||||
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
|
||||
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(and(isNull(schema.submission.contestId), inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])))
|
||||
.where(inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||
.as('first_accepted')
|
||||
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
|
||||
|
||||
@@ -34,6 +34,12 @@ const METRICS: Record<string, MetricInfo> = {
|
||||
unit: '道',
|
||||
remain: (r) => `再做对 ${r} 道困难题`,
|
||||
},
|
||||
challenge_count: {
|
||||
group: '做题',
|
||||
label: '挑战成功',
|
||||
unit: '次',
|
||||
remain: (r) => `再挑战成功 ${r} 次`,
|
||||
},
|
||||
max_ac_week_streak: {
|
||||
group: '坚持',
|
||||
label: '连着几周有做对',
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import AchievementIcon from 'shared/components/AchievementIcon.vue'
|
||||
import { mascotBox } from 'shared/mascot/guide'
|
||||
import { useAchievementStore } from 'shared/store/achievement'
|
||||
import { RARITY_COLOR } from 'utils/constants'
|
||||
|
||||
const store = useAchievementStore()
|
||||
const { current, queue } = storeToRefs(store)
|
||||
const visible = ref(false)
|
||||
const toastEl = ref<HTMLElement>()
|
||||
|
||||
const RIGHT = 24
|
||||
const TOP = 72
|
||||
const GAP = 12
|
||||
|
||||
/**
|
||||
* 默认在右上角。看板娘被拖到和它重叠的地方(多半是右上角)时,挪到她左边 ——
|
||||
* 补发成就时一次会连着弹好几个,一直压在她头上不好看。尺寸按弹出来以后量到的算,
|
||||
* 还没量到时按最小宽度和大致高度估。
|
||||
*/
|
||||
const placement = computed(() => {
|
||||
if (!mascotBox.shown || !visible.value) return {}
|
||||
const vw = document.documentElement.clientWidth
|
||||
const width = toastEl.value?.offsetWidth ?? 300
|
||||
const height = toastEl.value?.offsetHeight ?? 90
|
||||
const left = vw - RIGHT - width
|
||||
const overlaps = mascotBox.x < vw - RIGHT && mascotBox.x + mascotBox.width > left && mascotBox.y < TOP + height && mascotBox.y + mascotBox.height > TOP
|
||||
return overlaps ? { right: `${vw - mascotBox.x + GAP}px` } : {}
|
||||
})
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let gapTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -37,7 +58,7 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<div v-if="visible && current" class="toast" :style="{ borderColor: RARITY_COLOR[current.rarity] }">
|
||||
<div v-if="visible && current" ref="toastEl" class="toast" :style="{ borderColor: RARITY_COLOR[current.rarity], ...placement }">
|
||||
<div class="icon">
|
||||
<AchievementIcon :icon="current.icon" :size="34" />
|
||||
</div>
|
||||
@@ -56,7 +77,7 @@ onUnmounted(() => {
|
||||
.toast {
|
||||
position: fixed;
|
||||
/* 右上角、顶栏下面。原来在右下角,题目页上正好和结果、点评弹窗叠在一起 */
|
||||
right: 24px;
|
||||
right: 24px; /* 和脚本里的 RIGHT / TOP 一致 */
|
||||
top: 72px;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
|
||||
import imageUrl from 'shared/assets/mascot/whale.webp'
|
||||
import patUrl from 'shared/assets/mascot/whale-pat.gif'
|
||||
import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines'
|
||||
import { currentMascotGuide, fillLine, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { currentMascotGuide, fillLine, mascotBox, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { logMascotEvent, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
@@ -352,10 +352,15 @@ watch(silent, (quiet) => quiet && close())
|
||||
/** 她靠上时气泡往下冒,免得顶出屏幕;在左半边时气泡贴左对齐 */
|
||||
const bubbleBelow = computed(() => pos.y < 220)
|
||||
|
||||
// 告诉别的浮层她在哪(成就弹框会让开)
|
||||
watchEffect(() => Object.assign(mascotBox, { x: pos.x, y: pos.y, width: WIDTH, height: HEIGHT }))
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', settle, { passive: true })
|
||||
mascotBox.shown = true
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
mascotBox.shown = false
|
||||
window.removeEventListener('resize', settle)
|
||||
clearTimeout(patTimer)
|
||||
clearTimeout(quietTimer)
|
||||
|
||||
@@ -81,3 +81,11 @@ export function randomProblemAction(router: Router): MascotAction {
|
||||
export function goAction(router: Router, label: string, to: string): MascotAction {
|
||||
return { label, run: () => router.push(to) }
|
||||
}
|
||||
|
||||
// ---- 别的浮层要让开她 ----
|
||||
|
||||
/**
|
||||
* 看板娘现在占的地方(视口坐标,拖动中也跟着变)。不在页面上(手机、没挂载)时 shown 为 false。
|
||||
* 成就弹框靠它让开:学生把她拖进右上角时,弹框正好压在她头上。
|
||||
*/
|
||||
export const mascotBox = reactive({ shown: false, x: 0, y: 0, width: 0, height: 0 })
|
||||
@@ -99,6 +99,15 @@ Run workflow),开发机不在、又急着上线时用。它不再 push 就
|
||||
| 配置 | `~/.local/share/gitea-runner/config.yaml`(标签 `oj2-deploy:host`、`iot-deploy:host`,capacity 1) |
|
||||
| 注册 | 同目录的 `.runner`;注册在 `xuyue` 用户下,和 IOT_SIM(物联网仿真平台)共用。capacity 1,两边的部署排队,不会同时挤服务器 |
|
||||
| 日志 | `journalctl --user -u gitea-runner -f`;每次部署的日志在 Gitea 的 Actions 页面 |
|
||||
| 看门狗 | `~/.config/systemd/user/gitea-runner-watchdog.timer`,每 5 分钟跑同目录配置旁的 `watchdog.sh`(见下) |
|
||||
|
||||
**runner 会「假在线」。** gitea-runner 5.2.4 拉任务超时一次(默认 5 秒,网络一抖就超)之后就再也
|
||||
不拉了:服务还是 active,push 之后 Gitea 却说「没有在线的运行器来领取这个任务」(2026-11-03 一天
|
||||
撞了两次)。两层兜底:`config.yaml` 里 `fetch_timeout: 30s` 少撞几次;看门狗
|
||||
`~/.local/share/gitea-runner/watchdog.sh` 去服务器的 Gitea 库里看 `action_runner` 的
|
||||
`last_online` / `last_active`,超过 3 分钟没见到这个 runner、而且它手上没有在跑的任务(不打断部署),
|
||||
就 `systemctl --user restart gitea-runner`;连不上服务器时什么都不做。所以撞上了最多等 5 分钟,
|
||||
等不及就自己重启一下。看门狗做了什么:`journalctl --user -u gitea-runner-watchdog`。
|
||||
|
||||
**部署不用开发机自己的 bun。** pacman 装的 bun 是 Arch 自己编的,动态链接系统 ICU,单二进制
|
||||
把它打进去之后到容器里报 `libicui18n.so.78: cannot open shared object file`(2026-11-03 第一次跑
|
||||
|
||||
@@ -507,7 +507,7 @@ export const problemListSortSchema = z.enum(['', 'create_time', '-submission_num
|
||||
* 离哪个成就最近;没做对的只给一个数(「有 n 道差一点」),不列出来。
|
||||
*/
|
||||
export const problemProgressSchema = z.object({
|
||||
/** 做对的题数,和成就的「AC 题目数」同一个数(不含比赛),这样「再做对 n 道」加得上 */
|
||||
/** 做对的题数,和成就的「AC 题目数」同一个数(含比赛题,2026-11-03 起),这样「再做对 n 道」加得上 */
|
||||
solved: z.number().int(),
|
||||
/** 本周(东八区周一起)新做对几道 */
|
||||
weekSolved: z.number().int(),
|
||||
|
||||
Reference in new issue
Block a user