Compare commits

...
14 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 6b37a4de80 style(提交详情): 答错时的例子对比换成新样子,三格横排、不一样的那行两边标浅黄
Deploy / deploy (push) Successful in 1m0s
- 和题目页结果页签同一套:黄色小节标题「例子 N 没对上」、正文大小的说明、输入灰 / 正确输出绿 / 学生的输出红
- 这一页一栏 1200 宽,三格横着并排,正确输出和学生的输出一行对一行,不一样的那行两边都垫浅黄
- 窄于 600(手机、课堂任务的弹窗)自动退回竖排

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:17:08 +08:00
xuyueandClaude Opus 5.5 566599614a style(结果页签): 分段、说明、对比改成和题面同一套样子;做对后「接着做」直接给下一题
Deploy / deploy (push) Successful in 1m0s
- 提交结果 / 运行例子 / 自己输入改成浅灰底的分段按钮;状态大字和测试点方块放大一档
- 哪里不对一律是黄色小节标题(例子 1 没对上、错在没给你看的测试点、第 2 行出错),说明用正文大小
- 答错的对比竖着排成灰 / 绿 / 红三格,和题面的例子一个样子;运行时错误把出错那一行放进红格
- 做对了去掉浅绿卡片,改成「接着做」一句话加写着下一题题号题名的大按钮
- 提交详情页的答错对比不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:09:31 +08:00
xuyueandClaude Opus 5.5 472ec69328 style(题目页): 顶上三层条合成一条、课堂和比赛改成左边竖栏;题面小标题和例子重新设计
Deploy / deploy (push) Successful in 58s
- 课堂、比赛在电脑上挪到左栏最左一条竖栏,只写题号(题名鼠标停上去看),顶上从 148px 降到 104px,
  页签行和编辑器工具栏对齐成一根线;专题条去掉蓝底、改成 40px 细横条
- 工具栏按钮只剩三种分量:实心提交、浅绿运行例子、其余不带框;统计/点评/我的提交改成图标 + 字
- 题面:题号题名难度状态一行;小标题改成线条图标 + 绿字 + 细线(语法要求、提示用黄色);
  去掉空段落、老师写的大标题降成粗体;输入输出上下排;来源缩成底部一行灰字;SQL 去掉图例
- 例子改成「输入 → 输出」:「无」写成「不用输入」、行首空格画成点、长句折行缩进两格写全,
  运行例子后序号上标 ✓ / ✗

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:52:58 +08:00
xuyueandClaude Opus 5.5 388f864aec style(题目页): 相似题目推荐的颜色太浅太淡,题目名绿得发虚、第二行灰得看不清
Deploy / deploy (push) Successful in 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:12:08 +08:00
xuyueandClaude Opus 5.5 6a1a36049e fix(看板娘): 老师进出题页时那张图带着绿边和两块被切断的碎片,画风也和别的对不上
Deploy / deploy (push) Successful in 59s
上游 state-tool 原图本身就是坏的(另一套画风、绿幕抠图残边、左右硬切),删掉不用,
出题页那组台词改配戴眼镜看电脑那张。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:50:22 +08:00
xuyueandClaude Opus 5.5 95049b6e4c feat(看板娘): 课堂任务页上她会告诉老师谁卡住了、哪种错扎堆、谁在线没动,卡住的人多起来和全班做完时自己开口
Deploy / deploy (push) Successful in 1m0s
- 点她按全班这一刻挑最要紧的一件:全班做完(「改题」)> 同一种错 ≥3 人(「看这堆」「讲第一个」)
  > 有人卡住(「看 xxx 的」直接打开讲错误)> 布置 10 分钟后在线却一道没交 > 做完的在做挑战。
  卡住、错法归堆和任务页同一套口径(cells.ts 的 isStuck、页面上的按错法归堆)。
- 主动开口:某道题卡住的人涨到 3 个、全班刚做完,每件事一节课说一次;刚打开页面那次只记数不说。
  老师电脑不投影,和学生一样至少隔 3 分钟。
- 老师首页说这节课做完几个人,不再是固定的「这节课的提交,筛好了」。
- 这几句也配了图:扎堆拿放大镜、卡住冒汗、做完拉礼花……

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:43:01 +08:00
xuyueandClaude Opus 5.5 b99f619c0a fix(看板娘): 深色模式下气泡一直是白的;睡着时进比赛会一直睡、点不醒;第一次缩到边上时图会被挤变形
- 深色气泡从 11-03 起就没生效过:scoped 样式把 `:global(html.dark) .bubble` 编译成了只剩 `html.dark`。
- 睡着时进比赛先叫醒;她主动开口也算醒;有人举手、卡住了这类要紧的事,点醒时直接说正事,不先说「没睡」。
- 三张探头图先下好;图没到之前按比例缩着放,不拉伸。
- 立绘没下到(断网、部署后老页面拿旧地址)就接着显示上一张,不再变成空白。
- 跨天的旧账被生闷气、越点越烦顶掉时不算说过,下次点开还会说。
- 左右探头时气泡尖角对准她的头;没有 requestIdleCallback 的浏览器(iPad Safari)不再报错。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:32:25 +08:00
xuyueandClaude Opus 5.5 bcd328a196 feat(看板娘): 换成一整套全身立绘,说什么话摆什么表情;闲着会做自己的事、久了打盹,还能藏到屏幕边上只探个头
立绘换成 Sutera-Diffusus/dsh-whale-musume 的 92 张(MIT,许可原文放在图旁边),全部用上:
- 每组台词配一两张:做对了笑、做错了垮脸、编译错误慌、炸毛、背过身冒黑气、被摸完头脸红冒爱心……
- 被拎着上半身拖和揪着尾巴拖是两张;一直连着摸会被摸得笑出来。
- 5 分钟没人碰换一张日常(做饭、钓鱼、画画、听歌……,冬天下雪、春夏打伞,晚上穿睡衣),
  20 分钟睡着,点她先被吵醒说一句。比赛页不换。
- 往屏幕外拖过一截再松手,就缩进下、左、右那条边只探个头,往外一拖又出来。
- 节日当天换节日装(春节、中秋、圣诞、万圣、情人节)。
只有平时那张跟页面一起下,别的用到才下、下好再换,不闪白。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:24:00 +08:00
xuyueandClaude Opus 5.5 aa8a3b8bfc feat(看板娘): 她会记得前几天被你惹生气、被摸过头,今天第一次点她时翻一次旧账;好感升档那天说一句「熟了一点」
- 只看 7 天内、今天以前的事,同一件事只翻一次(之后说过那句就不再提);顺序是升档 > 惹生气 > 摸头。
- 只在闲聊类情况(空白题目页、题目列表、打招呼、夸人)翻,错了、卡住时照常说正事。
- 好感档位照样不显示,「熟了一点」是唯一的暗示。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:00:40 +08:00
xuyueandClaude Opus 5.5 47c0b5464d feat(看板娘): 做对的题越多,她说话越软、也越耐烦;点她多少次不算
Deploy / deploy (push) Successful in 57s
好感按一共做对几道分五档(不到 10 / 10 / 25 / 40 / 100 道,含比赛题,和成就同一个数),
档位不显示。门槛按最近 60 天交过题的 378 个学生定的:前四档大约各占四分之一,最高一档是前一成。
- 每档多一组自己的话,越往上越软,高档也抽得到低档的;只掺进闲聊、夸人、空白题目页、
  题目列表这几类情况,现有台词不改。
- 越熟越耐烦:最低档点到第 7 次背过身,最高档到第 10 次。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:26:56 +08:00
xuyueandClaude Opus 5.5 3987b83220 feat(看板娘): 连着刷她会越来越不耐烦,刷多了背过身生闷气,摸摸头才哄好;台词一组听完才重复
上线第一天六成的点开离上一次不到 15 秒,学生是在一句一句地刷台词。
- 两次点开隔不到 20 秒算连着:第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去生 30 秒闷气,
  期间点她只蹦不说话、也不主动开口;连点摸到头就哄好。有人举手、卡住了这类要紧的照说。
- 台词按副牌抽,一组听完才会重复(原来只是不连着重复,第一天就有三成是听过的)。
- 空白题目页去掉「运行例子」只留「举手求助」,「做过了」去掉「下一道」:第一天都没人点,
  没按钮的气泡 5 秒自己收起,不一直挡着。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:21:43 +08:00
xuyueandClaude Opus 5.5 f6f7a0c3af fix(看板娘): 把看板娘拖到右上角时,成就弹框会压在她头上,现在弹框让到她左边
Deploy / deploy (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:52:00 +08:00
xuyueandClaude Opus 5.5 cccf7a37be feat(成就): 多了 9 个好拿的档和「挑战成功」一条线,比赛里做的题也算,这学期八成学生多拿到成就
- 补档:做对 20 道、第一道中等题、交满 30 次、5 天交过题、一次就对 5 道、错 3 次后做对、一天做对 5 道;
  新线「挑战成功」1 次 / 5 次
- 比赛里的提交也计入成就(比赛其实是单班测验,2612 班整班在比赛里做题,原来一个成就都没有);
  参加过几场比赛改成判题时就更新,以前只在后台重扫时刷新
- 奖杯收藏家从 15 个提到 20 个
- 0044 迁移:按新口径从提交记录重数指标,给达标的人补发(已经拿到的不收回)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:52:00 +08:00
xuyueandClaude Opus 5.5 1fdee94812 docs(部署): 记下 runner 会「假在线」以及看门狗兜底
gitea-runner 5.2.4 拉任务超时一次后就不再拉,Gitea 显示没有在线的运行器。开发机上
fetch_timeout 放到 30s,另加用户级 gitea-runner-watchdog.timer 每 5 分钟检查、卡住就重启。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:36:48 +08:00
134 changed files with 6855 additions and 755 deletions

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
+7
View File
@@ -309,6 +309,13 @@
"when": 1793693911394, "when": 1793693911394,
"tag": "0043_mascot_event", "tag": "0043_mascot_event",
"breakpoints": true "breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1793695621303,
"tag": "0044_achievement_more_tiers",
"breakpoints": true
} }
] ]
} }
+64 -2
View File
@@ -5,7 +5,8 @@ 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 { parseBody, success } from '../http' import { parseBody, success } from '../http'
import { dayStart, weekStart } from '../time' import { JudgeStatus } from '../judge/status'
import { calendarDay, dayNumber, dayStart, weekStart } from '../time'
export const mascotRoutes = new Hono<AppEnv>() export const mascotRoutes = new Hono<AppEnv>()
@@ -41,6 +42,59 @@ mascotRoutes.post('/mascot/events', requireAuth, async (c) => {
return success(c, null) return success(c, null)
}) })
/** 跨天记得的事只翻这么多天以内的 */
const MEMORY_DAYS = 7
/** 几个东八区日历日以前;不在 MEMORY_DAYS 以内、或之后已经说过那句了,就是 null */
function daysAgo(at: string | null, saidAt: string | null) {
// 按时刻比,别按字符串:库里出来的带微秒、位数不一定一样
if (!at || (saidAt && Date.parse(saidAt) > Date.parse(at))) return null
const days = dayNumber(calendarDay()) - dayNumber(calendarDay(at))
return days >= 1 && days <= MEMORY_DAYS ? days : null
}
/** 跨天记得的事(契约 mascotProfileSchema.memory),一条语句查完 */
async function memoryOf(userId: number) {
const today = dayStart()
const since = new Date(Date.parse(today) - MEMORY_DAYS * 86_400_000).toISOString()
const e = schema.mascotEvent
/** 7 天内最后一次。`beforeToday`:今天的事不算「记得」,只取今天以前的 */
const last = (kind: string, situation: string | null, beforeToday: boolean) => sql`(
select max(${e.createdAt}) from ${e}
where ${e.userId} = ${userId} and ${e.kind} = ${kind}
${situation ? sql`and ${e.situation} = ${situation}` : sql``}
and ${e.createdAt} >= ${since}
${beforeToday ? sql`and ${e.createdAt} < ${today}` : sql``}
)`
const s = schema.submission
const [row] = await db.execute<{
sulk_at: string | null
grudge_at: string | null
pat_at: string | null
pat_said_at: string | null
closer_at: string | null
solved_before: number
}>(sql`
select
${last('open', 'sulk', true)} as sulk_at,
${last('open', 'memory.grudge', false)} as grudge_at,
${last('pat', null, true)} as pat_at,
${last('open', 'memory.pat', false)} as pat_said_at,
${last('open', 'memory.closer', false)} as closer_at,
(
select count(distinct ${s.problemId})::int from ${s}
where ${s.userId} = ${userId} and ${s.result} in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED})
and ${s.createTime} < ${today}
) as solved_before
`)
return {
grudgeDaysAgo: daysAgo(row?.sulk_at ?? null, row?.grudge_at ?? null),
patDaysAgo: daysAgo(row?.pat_at ?? null, row?.pat_said_at ?? null),
solvedBeforeToday: row?.solved_before ?? 0,
closerSaidToday: !!row?.closer_at && Date.parse(row.closer_at) >= Date.parse(today),
}
}
/** 最近多少次题库提交拿来看学生常犯哪类错 */ /** 最近多少次题库提交拿来看学生常犯哪类错 */
const RECENT_SUBMISSIONS = 20 const RECENT_SUBMISSIONS = 20
@@ -51,11 +105,17 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
const week = weekStart() const week = weekStart()
const e = schema.mascotEvent const e = schema.mascotEvent
const [pokes, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([ const [pokes, solved, memory, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([
db db
.select({ value: count() }) .select({ value: count() })
.from(schema.mascotEvent) .from(schema.mascotEvent)
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))), .where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
// 成就每次判完都重数一遍存进 user_stat,直接读,不再按提交现数
db
.select({ value: sql<number>`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` })
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId)),
memoryOf(userId),
db db
.select({ value: countDistinct(s.problemId) }) .select({ value: countDistinct(s.problemId) })
.from(s) .from(s)
@@ -94,6 +154,8 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
return success(c, { return success(c, {
pokes: pokes[0]?.value ?? 0, pokes: pokes[0]?.value ?? 0,
solved: solved[0]?.value ?? 0,
memory,
todaySolved: today[0]?.value ?? 0, todaySolved: today[0]?.value ?? 0,
tooHard: tooHard[0]?.value ?? 0, tooHard: tooHard[0]?.value ?? 0,
week: { week: {
+12 -7
View File
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'accepted_count', key: 'accepted_count',
name: '做对的题数', name: '做对的题数',
helpText: '去重后通过的题目数量(不含比赛)', helpText: '去重后通过的题目数量(含比赛题)',
}, },
{ {
key: 'mid_ac_count', key: 'mid_ac_count',
name: '做对的中等题', name: '做对的中等题',
helpText: '去重后通过的中等难度题目数(不含比赛)', helpText: '去重后通过的中等难度题目数(含比赛题)',
}, },
{ {
key: 'hard_ac_count', key: 'hard_ac_count',
name: '做对的困难题', name: '做对的困难题',
helpText: '去重后通过的困难题目数(不含比赛)', helpText: '去重后通过的困难题目数(含比赛题)',
}, },
{ {
key: 'submission_count', key: 'submission_count',
name: '提交次数', name: '提交次数',
helpText: '提交次数(不含比赛)', helpText: '提交次数(含比赛)',
}, },
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' }, { key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
{ {
@@ -45,13 +45,13 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'max_ac_week_streak', key: 'max_ac_week_streak',
name: '连续有新做对的周数', name: '连续有新做对的周数',
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)', helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
}, },
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' }, { key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
{ {
key: 'contest_joined', key: 'contest_joined',
name: '参加比赛', name: '参加比赛',
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)', helpText: '交过题的比赛数量',
}, },
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' }, { key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
{ {
@@ -59,6 +59,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
name: '做完的专题', name: '做完的专题',
helpText: '完成的专题数量', helpText: '完成的专题数量',
}, },
{
key: 'challenge_count',
name: '挑战成功',
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
},
{ {
key: 'first_try_ac_count', key: 'first_try_ac_count',
name: '一次就对的题', name: '一次就对的题',
@@ -92,7 +97,7 @@ 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',
+20 -10
View File
@@ -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 { db, schema, type DbOrTx } from '../db'
import { publishAchievementNotification } from '../events' import { publishAchievementNotification } from '../events'
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
/** /**
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。 * 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
* 参加过几场比赛、挑战成功几次也在这里一起数。
* *
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题 * 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条 * 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。 * 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx, * 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
* 一个人的提交也就几百条。 * 一个人的提交也就几百条。
*
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
*/ */
async function solvedCounts(tx: DbOrTx, userId: number) { async function solvedCounts(tx: DbOrTx, userId: number) {
const [row] = await tx.execute<{ const [row] = await tx.execute<{
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
mid: number mid: number
hard: number hard: number
first_try: number first_try: number
contests: number
challenges: number
}>( }>(
sql` sql`
with mine as ( 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 from ${schema.submission} s
join ${schema.problem} p on p.id = s.problem_id 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 select
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted, 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 count(*)::int from (
select distinct on (problem_id) result from mine order by problem_id, create_time, id select distinct on (problem_id) result from mine order by problem_id, create_time, id
) first where result in (${ACCEPTED_RESULTS}) ) 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 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)) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId)) .where(eq(schema.submission.id, submissionId))
.limit(1) .limit(1)
if (!row || row.submission.contestId !== null) return [] if (!row) return []
const rejudged = countedAs !== null const rejudged = countedAs !== null
const accepted = isAccepted(row.submission.result) const accepted = isAccepted(row.submission.result)
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
value.mid_ac_count = solved.mid value.mid_ac_count = solved.mid
value.hard_ac_count = solved.hard value.hard_ac_count = solved.hard
value.first_try_ac_count = solved.first_try 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 (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
if (firstAc) { if (firstAc) {
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」 // 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
and( and(
eq(schema.submission.userId, row.submission.userId), eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId), eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
lt(schema.submission.createTime, row.submission.createTime), lt(schema.submission.createTime, row.submission.createTime),
), ),
) )
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
const metric = findMetric(achievement.metric) const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 } if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值) // contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll() if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算 // max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll() 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 浅合并, * `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
* 其余指标不动,写法同 refreshContestJoinedForAll)。 * 其余指标不动,写法同 refreshContestJoinedForAll)。
* *
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径; * 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。 * 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
*/ */
async function refreshAcWeekStreakForAll() { async function refreshAcWeekStreakForAll() {
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有 // 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败 // 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .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) .groupBy(schema.submission.userId, schema.submission.problemId)
.as('first_accepted') .as('first_accepted')
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')` const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
+6
View File
@@ -34,6 +34,12 @@ const METRICS: Record<string, MetricInfo> = {
unit: '道', unit: '道',
remain: (r) => `再做对 ${r} 道困难题`, remain: (r) => `再做对 ${r} 道困难题`,
}, },
challenge_count: {
group: '做题',
label: '挑战成功',
unit: '次',
remain: (r) => `再挑战成功 ${r} 次`,
},
max_ac_week_streak: { max_ac_week_streak: {
group: '坚持', group: '坚持',
label: '连着几周有做对', label: '连着几周有做对',
+8 -1
View File
@@ -77,8 +77,15 @@ useMascotGuide(() => {
const row = assigned.find((it) => stateOf(it).live) ?? assigned[0] const row = assigned.find((it) => stateOf(it).live) ?? assigned[0]
if (!row) return { situation: 'teacher.home.unassigned', actions: [goAction(router, '布置题目', '/task')] } if (!row) return { situation: 'teacher.home.unassigned', actions: [goAction(router, '布置题目', '/task')] }
const query = { className: row.className, problem: row.problems.map((problem) => problem.problemDisplayId).join(','), today: '1' } const query = { className: row.className, problem: row.problems.map((problem) => problem.problemDisplayId).join(','), today: '1' }
// 有人交过了就说这节课做完几个,还没人交就照旧那几句
const progress = row.activeCount
? {
situation: row.allDoneCount >= row.rosterCount ? ('teacher.home.progressDone' as const) : ('teacher.home.progress' as const),
vars: { cls: classLabel(row.className), done: row.allDoneCount, total: row.rosterCount, left: row.rosterCount - row.allDoneCount },
}
: { situation: 'teacher.home.assigned' as const }
return { return {
situation: 'teacher.home.assigned', ...progress,
actions: [ actions: [
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) }, { label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
{ label: '课堂任务', run: () => openTask(row) }, { label: '课堂任务', run: () => openTask(row) },
@@ -71,8 +71,8 @@ const label = computed(() => {
} }
.block { .block {
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box; box-sizing: border-box;
display: inline-flex; display: inline-flex;
@@ -6,6 +6,7 @@ import { applyFixes, clearErrorMark, currentCode, showErrorMark, showPunctuation
import { explainCCompileError } from 'oj/problem/utils/cError' import { explainCCompileError } from 'oj/problem/utils/cError'
import { explainPythonCompileError } from 'utils/pythonError' import { explainPythonCompileError } from 'utils/pythonError'
import { stripJudgePath } from 'utils/judge' import { stripJudgePath } from 'utils/judge'
import SectionHead from './SectionHead.vue'
/** /**
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走 * 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
@@ -80,17 +81,17 @@ onUnmounted(clearErrorMark)
</script> </script>
<template> <template>
<div class="explain-card"> <!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明;那一行编辑器里已经标红了 -->
<div class="explain-section">
<template v-if="explanation"> <template v-if="explanation">
<p class="explain"> <SectionHead icon="problem" tone="amber">{{ explanation.line ? `第 ${explanation.line} 行` : '代码的格式写错了' }}</SectionHead>
<b v-if="explanation.line">第 {{ explanation.line }} 行:</b>{{ explanation.message }} <p class="explain">{{ explanation.message }}</p>
</p>
<p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p> <p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p>
<div v-if="explanation.punctuation"> <div v-if="explanation.punctuation" class="fix">
<n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button> <n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button>
<n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text> <n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text>
</div> </div>
<n-collapse> <n-collapse class="raw-collapse">
<n-collapse-item title="原始报错(英文)" name="raw"> <n-collapse-item title="原始报错(英文)" name="raw">
<div class="raw">{{ rawError }}</div> <div class="raw">{{ rawError }}</div>
</n-collapse-item> </n-collapse-item>
@@ -98,6 +99,7 @@ onUnmounted(clearErrorMark)
</template> </template>
<!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 --> <!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 -->
<template v-else> <template v-else>
<SectionHead icon="problem" tone="amber">代码的格式写错了</SectionHead>
<p class="explain">请仔细检查,看看代码的格式是不是写错了!</p> <p class="explain">请仔细检查,看看代码的格式是不是写错了!</p>
<div class="raw">{{ rawError }}</div> <div class="raw">{{ rawError }}</div>
</template> </template>
@@ -105,20 +107,15 @@ onUnmounted(clearErrorMark)
</template> </template>
<style scoped> <style scoped>
/* 和答错的说明同一种浅灰卡片(设计稿「语法没过」) */ .explain-section {
.explain-card {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind('theme.dividerColor');
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
} }
.explain { .explain {
margin: 0; margin: 0 0 10px;
line-height: 1.7; font-size: 16px;
line-height: 1.75;
} }
.explain.other { .explain.other {
@@ -126,6 +123,10 @@ onUnmounted(clearErrorMark)
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
} }
.fix {
margin-bottom: 10px;
}
.raw { .raw {
white-space: pre; white-space: pre;
overflow-x: auto; overflow-x: auto;
+232 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api' import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api'
@@ -33,15 +34,16 @@ const ctx = useProblemPageContext()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
const theme = useThemeVars() const theme = useThemeVars()
const isDark = useDark() const isDark = useDark()
/** 做完的实心绿圈里的勾:暗色下主题绿很亮,白勾看不清 */
const doneDotText = computed(() => (isDark.value ? 'rgba(0, 0, 0, 0.85)' : '#fff'))
/** /**
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、专题浅蓝、比赛浅黄。 * 课堂、比赛在电脑上是左边的竖栏(课堂浅绿底、比赛浅黄底),专题是一条不带底色的细横条;
* 手机上三种都是横条(课堂浅绿、比赛浅黄)。
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清 * 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
*/ */
const tone = computed(() => ({ const tone = computed(() => ({
lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed, lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
setText: isDark.value ? '#8fb4ee' : '#1d4f9c',
setBar: isDark.value ? '#6f9be0' : '#2f6bc4',
contestText: isDark.value ? '#f2c97d' : '#8a5200', contestText: isDark.value ? '#f2c97d' : '#8a5200',
})) }))
const userStore = useUserStore() const userStore = useUserStore()
@@ -241,8 +243,35 @@ onSolvedHere(() => {
</script> </script>
<template> <template>
<!-- 课堂 --> <!--
<div v-if="lesson" class="context-bar lesson"> 课堂(电脑):左栏最左边一条竖栏,只写题号,题名鼠标停上去看(学生认题靠题号)。
不占高度:机房 1366×768 那种屏,顶上每省一行,题面和编辑器就多一行(设计稿「题目页观感」合一版)
-->
<nav v-if="lesson && !isMobile" class="lesson-rail" aria-label="这节课的题">
<div class="rail-head">
<span>这节课</span>
<b class="rail-done">{{ lesson.done }}/{{ lesson.total }}</b>
<span v-if="lessonStore.languageText" class="rail-lang">{{ lessonStore.languageText }}</span>
</div>
<router-link
v-for="(chip, index) in lesson.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.note ? `${chip.title}(${chip.note})` : chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<span class="rail-dot">
<Icon v-if="chip.status === 'done'" icon="ph:check-bold" :width="14" />
<template v-else>{{ index + 1 }}</template>
</span>
<span class="rail-id">{{ chip.displayId }}</span>
</router-link>
</nav>
<!-- 课堂(手机):一条横的 -->
<div v-else-if="lesson" class="context-bar lesson">
<span class="lead lesson-lead" <span class="lead lesson-lead"
>这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span >这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span
> >
@@ -356,7 +385,37 @@ onSolvedHere(() => {
</component> </component>
</div> </div>
<!-- 比赛 --> <!--
比赛(电脑):和课堂一样是左边一条竖栏 —— 比赛一般 3 到 10 道题,题号一列摆得下。
顶上「‹ 比赛」回比赛页(比赛名鼠标停上去看)、还剩多久;底下「排名」(期中期末进行中不给)
-->
<nav v-else-if="contestBar && !isMobile" class="lesson-rail contest-rail" aria-label="比赛的题">
<router-link :to="contestBar.home" class="rail-back" :title="`回到比赛:${contestBar.title}`">
<Icon icon="ph:caret-left-bold" :width="12" aria-hidden="true" />比赛
</router-link>
<div class="rail-head">
<template v-if="contestBar.remaining">
<span>还剩</span>
<b class="rail-time">{{ contestBar.remaining }}</b>
</template>
<span v-else>{{ contestBar.notStarted ? '还没开始' : '已结束' }}</span>
</div>
<router-link
v-for="chip in contestBar.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<!-- 比赛题号本来就短(1、2、A),直接写在圈里;做对了圈涂绿、号还在,认得出是哪道 -->
<span class="rail-dot">{{ chip.displayId }}</span>
</router-link>
<router-link v-if="contestBar.rank" :to="contestBar.rank" class="rail-rank"> <Icon icon="ph:ranking" :width="18" aria-hidden="true" />排名 </router-link>
</nav>
<!-- 比赛(手机) -->
<div v-else-if="contestBar" class="context-bar contest"> <div v-else-if="contestBar" class="context-bar contest">
<router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link> <router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link>
<ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" /> <ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" />
@@ -371,6 +430,165 @@ onSolvedHere(() => {
</template> </template>
<style scoped> <style scoped>
/* 比赛竖栏:浅黄底,和课堂分得开 */
.contest-rail {
background-color: rgba(208, 138, 28, 0.07);
border-right-color: rgba(208, 138, 28, 0.2);
}
.contest-rail .rail-item:hover {
background-color: rgba(208, 138, 28, 0.1);
}
.contest-rail .rail-dot {
font-size: 12px;
overflow: hidden;
}
.rail-back {
display: flex;
align-items: center;
justify-content: center;
gap: 1px;
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: v-bind('tone.contestText');
text-decoration: none;
}
.rail-back:hover {
text-decoration: underline;
}
.rail-time {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.contestText');
}
.rail-rank {
margin-top: auto;
padding: 8px 0 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 12px;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-rank:hover {
color: v-bind('tone.contestText');
}
/* ==================== 课堂竖栏(电脑) ==================== */
.lesson-rail {
width: 56px;
flex: none;
box-sizing: border-box;
padding: 8px 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
overflow-y: auto;
scrollbar-width: none;
background-color: rgba(24, 160, 88, 0.06);
border-right: 1px solid rgba(24, 160, 88, 0.14);
}
.rail-head {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
line-height: 1.35;
color: v-bind('theme.textColor3');
}
.rail-done {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.lessonText');
}
.rail-lang {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 7px 0;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-item:hover {
background-color: rgba(24, 160, 88, 0.08);
}
.rail-item.current {
font-weight: 700;
color: v-bind('tone.lessonText');
background-color: v-bind('theme.bodyColor');
box-shadow: inset 3px 0 v-bind('theme.primaryColor');
}
.rail-id {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-dot {
width: 28px;
height: 28px;
box-sizing: border-box;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
border: 1.5px solid rgba(128, 128, 128, 0.4);
background-color: v-bind('theme.bodyColor');
color: v-bind('theme.textColor2');
}
.done .rail-dot {
border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: v-bind('doneDotText');
}
/* 做过没对:红圈,和题目列表的红「−」一个意思 */
.tried .rail-dot {
border-color: v-bind('theme.errorColor');
color: v-bind('theme.errorColor');
}
.current .rail-dot {
border-width: 2px;
border-color: v-bind('theme.primaryColor');
color: v-bind('tone.lessonText');
}
.current.done .rail-dot {
color: v-bind('doneDotText');
}
.context-bar { .context-bar {
height: 44px; height: 44px;
flex: none; flex: none;
@@ -390,10 +608,12 @@ onSolvedHere(() => {
border-bottom-color: rgba(24, 160, 88, 0.18); border-bottom-color: rgba(24, 160, 88, 0.18);
} }
/* 专题:不带底色的一条细横条(设计稿「题目页观感」合一版)。专题可以有几十道题、还有「加入专题」,
竖栏放不下,留在页签行上面 */
.problemset { .problemset {
height: 40px;
gap: 10px; gap: 10px;
background-color: rgba(47, 107, 196, 0.06); border-bottom-color: v-bind('theme.dividerColor');
border-bottom-color: rgba(47, 107, 196, 0.16);
} }
.contest { .contest {
@@ -437,7 +657,7 @@ onSolvedHere(() => {
} }
.set-back { .set-back {
color: v-bind('tone.setText'); color: v-bind('theme.textColor1');
} }
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */ /* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
@@ -452,14 +672,14 @@ onSolvedHere(() => {
width: 110px; width: 110px;
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
background-color: rgba(47, 107, 196, 0.16); background-color: rgba(128, 128, 128, 0.16);
overflow: hidden; overflow: hidden;
} }
.set-progress-fill { .set-progress-fill {
height: 100%; height: 100%;
border-radius: 3px; border-radius: 3px;
background-color: v-bind('tone.setBar'); background-color: v-bind('theme.primaryColor');
} }
.countdown { .countdown {
@@ -503,7 +723,7 @@ onSolvedHere(() => {
} }
.problemset .step.forward { .problemset .step.forward {
color: v-bind('tone.setText'); color: v-bind('theme.textColor1');
} }
.step:not(.disabled):hover { .step:not(.disabled):hover {
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useCodeStore } from 'oj/store/code' import { useCodeStore } from 'oj/store/code'
@@ -29,7 +31,7 @@ function fillSample(input: string) {
<template> <template>
<!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) --> <!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) -->
<div class="custom"> <div class="custom">
<span class="label">输入</span> <SectionHead icon="input">输入</SectionHead>
<n-input <n-input
v-model:value="customInput" v-model:value="customInput"
type="textarea" type="textarea"
@@ -38,15 +40,16 @@ function fillSample(input: string) {
class="mono" class="mono"
/> />
<div class="actions"> <div class="actions">
<n-button type="primary" ghost :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()"> <n-button type="primary" secondary :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()">
<template #icon><Icon icon="ph:play-fill" /></template>
用这组数据运行 用这组数据运行
</n-button> </n-button>
<n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text class="fill" @click="fillSample(sample.input)"> <n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text type="primary" class="fill" @click="fillSample(sample.input)">
填入例子 {{ index + 1 }} 填入例子 {{ index + 1 }}
</n-button> </n-button>
</div> </div>
<span class="label">输出</span> <SectionHead icon="output" class="output-head">输出</SectionHead>
<TrialErrorNote <TrialErrorNote
v-if="customResult !== null && customResult !== SubmissionStatus.accepted" v-if="customResult !== null && customResult !== SubmissionStatus.accepted"
:result="customResult" :result="customResult"
@@ -68,13 +71,14 @@ function fillSample(input: string) {
gap: 8px; gap: 8px;
} }
.label { .output-head {
font-size: 13px; margin-top: 14px;
font-weight: 600;
} }
.mono :deep(textarea) { .mono :deep(textarea) {
font-family: Consolas, Monaco, monospace; font-family: Consolas, Monaco, monospace;
font-size: 16px;
line-height: 24px;
} }
.actions { .actions {
@@ -85,13 +89,12 @@ function fillSample(input: string) {
} }
.fill { .fill {
font-size: 13px; font-size: 14px;
color: v-bind('theme.textColor2');
} }
.output { .output {
margin: 0; margin: 0;
min-height: 88px; min-height: 60px;
box-sizing: border-box; box-sizing: border-box;
padding: 10px 10px; padding: 10px 10px;
border-radius: 6px; border-radius: 6px;
@@ -99,7 +102,9 @@ function fillSample(input: string) {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
font-family: Consolas, Monaco, monospace; font-family: Consolas, Monaco, monospace;
font-size: 14px; font-size: 16px;
line-height: 24px;
border: 1px solid rgba(128, 128, 128, 0.14);
} }
.output.empty { .output.empty {
@@ -182,7 +182,9 @@ const changeLanguage = (v: LANGUAGE) => {
<n-select <n-select
v-if="!drawing" v-if="!drawing"
v-model:value="codeStore.code.language" v-model:value="codeStore.code.language"
class="language"
style="width: 120px" style="width: 120px"
:bordered="false"
:size="buttonSize" :size="buttonSize"
:options="languageOptions" :options="languageOptions"
:disabled="showCollabBar" :disabled="showCollabBar"
@@ -190,15 +192,19 @@ const changeLanguage = (v: LANGUAGE) => {
/> />
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 --> <!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
<span v-if="!drawing" class="divider" aria-hidden="true" />
<!-- 浅绿底:比实心的「提交」轻一档,又比旁边不带框的按钮重(设计稿「题目页观感」合一版) -->
<n-button <n-button
v-if="canRunSamples && isDesktop" v-if="canRunSamples && isDesktop"
:size="buttonSize" :size="buttonSize"
type="primary" type="primary"
ghost secondary
:loading="samplesRunning" :loading="samplesRunning"
:disabled="!codeStore.code.value.trim()" :disabled="!codeStore.code.value.trim()"
@click="submissionStore.runSamples()" @click="submissionStore.runSamples()"
> >
<template #icon><Icon icon="ph:play-fill" /></template>
运行例子 运行例子
</n-button> </n-button>
@@ -215,22 +221,26 @@ const changeLanguage = (v: LANGUAGE) => {
:size="buttonSize" :size="buttonSize"
:type="helpButtonType" :type="helpButtonType"
:secondary="teacherIn || collabStore.helpStatus !== 'idle'" :secondary="teacherIn || collabStore.helpStatus !== 'idle'"
:quaternary="!teacherIn && collabStore.helpStatus === 'idle'"
:title="helpButtonTitle" :title="helpButtonTitle"
class="help" class="help"
:class="{ busy: teacherIn }" :class="{ busy: teacherIn }"
@click="toggleHelp" @click="toggleHelp"
> >
<span v-if="teacherIn" class="dot" aria-hidden="true" /> <span v-if="teacherIn" class="dot" aria-hidden="true" />
<Icon v-else-if="collabStore.helpStatus === 'idle'" icon="ph:hand" :width="17" class="help-icon" aria-hidden="true" />
{{ helpButtonText }} {{ helpButtonText }}
</n-button> </n-button>
<n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" @click="openStatistics(problem!._id)"> <n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" quaternary @click="openStatistics(problem!._id)">
课堂统计 课堂统计
</n-button> </n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 --> <!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 -->
<n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect"> <n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect">
<n-button :size="buttonSize" class="more" aria-label="更多:去自测猫、复制代码、重置代码……"> ⋯ </n-button> <n-button :size="buttonSize" quaternary class="more" aria-label="更多:去自测猫、复制代码、重置代码……">
<Icon icon="ph:dots-three-bold" :width="18" />
</n-button>
</n-dropdown> </n-dropdown>
</div> </div>
</template> </template>
@@ -288,6 +298,23 @@ const changeLanguage = (v: LANGUAGE) => {
padding: 0; padding: 0;
} }
/* 语言选择不带框,字加粗:它不是一个要常去点的按钮 */
.language :deep(.n-base-selection-label) {
font-weight: 600;
}
.divider {
flex: none;
width: 1px;
height: 20px;
margin: 0 2px;
background-color: v-bind('theme.dividerColor');
}
.help-icon {
margin-right: 4px;
}
.help.busy { .help.busy {
cursor: default; cursor: default;
} }
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { useLessonStore } from 'shared/store/lesson' import { useLessonStore } from 'shared/store/lesson'
import ChallengeList from './ChallengeList.vue' import ChallengeList from './ChallengeList.vue'
@@ -44,16 +45,17 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</script> </script>
<template> <template>
<!-- 设计稿「答案正确:下一题在结果页签里」:浅绿卡片,一个 44px 的大按钮 --> <!--
<div v-if="lesson" class="lesson-next"> 设计稿「题目页观感」结果 X:「接着做」一节,一句话说还剩几道,一个 44px 的大按钮写下一题。
原来是一张浅绿卡片:左边竖栏已经有这节课的进度,不用再框一块
-->
<section v-if="lesson" class="lesson-next">
<SectionHead icon="next">接着做</SectionHead>
<template v-if="lesson.next"> <template v-if="lesson.next">
<div class="text"> <p class="text">
<span class="headline">{{ label }}还剩 {{ lesson.remaining }} 道</span> {{ label }}还剩 {{ lesson.remaining }} 道,下一道 {{ lesson.next.problemDisplayId }}
<span class="detail"> {{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}。
{{ lesson.next.problemDisplayId }} </p>
{{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}
</span>
</div>
<router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next"> <router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next">
<span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span> <span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span>
<svg <svg
@@ -72,15 +74,12 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</router-link> </router-link>
</template> </template>
<template v-else> <template v-else>
<div class="text"> <p class="text">{{ label }}都做完了,一共 {{ lesson.total }} 道。<template v-if="!challenges">想接着练,可以去专题里挑一个。</template></p>
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span v-if="!challenges" class="detail">想接着练,可以去专题里挑一个</span>
</div>
<router-link v-if="!challenges" to="/problemset" class="next secondary"> <router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去专题继续练</span> <span class="next-label">去专题继续练</span>
</router-link> </router-link>
</template> </template>
</div> </section>
<!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 --> <!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 -->
<template v-if="lesson && !lesson.next && challenges"> <template v-if="lesson && !lesson.next && challenges">
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" /> <ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
@@ -90,30 +89,15 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
<style scoped> <style scoped>
.lesson-next { .lesson-next {
margin-top: 12px; margin-top: 4px;
padding: 16px 18px;
border-radius: 8px;
background-color: rgba(24, 160, 88, 0.07);
border: 1px solid rgba(24, 160, 88, 0.25);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
} }
.text { .text {
display: flex; margin: 0 0 12px;
flex-direction: column; font-size: 16px;
gap: 4px; line-height: 1.75;
}
.headline {
font-size: 15px;
font-weight: 600;
}
.detail {
font-size: 13px;
color: v-bind('theme.textColor2');
} }
.next { .next {
@@ -125,10 +109,10 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
gap: 8px; gap: 8px;
height: 44px; height: 44px;
padding: 0 20px; padding: 0 20px;
border-radius: 6px; border-radius: 8px;
background-color: v-bind('theme.primaryColor'); background-color: v-bind('theme.primaryColor');
color: v-bind('buttonText'); color: v-bind('buttonText');
font-size: 15px; font-size: 16px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
} }
@@ -1,14 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { isSqlProblem, parseFlowDoc } from '@oj2/contract' import { isSqlProblem, parseFlowDoc } from '@oj2/contract'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useProblemStore } from 'oj/store/problem' import { useProblemStore } from 'oj/store/problem'
import { DIFFICULTY } from 'utils/constants' import { DIFFICULTY } from 'utils/constants'
import { copyToClipboard, getTagColor } from 'utils/functions' import { getTagColor } from 'utils/functions'
import { useSubmissionStore } from 'oj/store/submission' import { useSubmissionStore } from 'oj/store/submission'
import Markdown from 'shared/components/Markdown.vue' import Markdown from 'shared/components/Markdown.vue'
import SqlProblemData from './SqlProblemData.vue' import SqlProblemData from './SqlProblemData.vue'
import SimilarProblems from './SimilarProblems.vue' import SimilarProblems from './SimilarProblems.vue'
import SampleList from './SampleList.vue'
import SectionHead from './SectionHead.vue'
import { tidyStatement } from '../utils/statement'
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart' import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
import { useMyFlowchartStore } from 'shared/store/myFlowchart' import { useMyFlowchartStore } from 'shared/store/myFlowchart'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
@@ -83,21 +87,6 @@ const flowchartGrade = computed(() => {
}) })
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value)) const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 例子旁的「复制」:点完变成「已复制」一秒
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, {
immediate: false,
})
async function copySample(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。 // 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来 // 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
watch( watch(
@@ -108,141 +97,96 @@ watch(
{ immediate: true }, { immediate: true },
) )
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位), // 例子只是摆出来(SampleList)。试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
const samples = computed(() => problem.value?.samples ?? []) const samples = computed(() => problem.value?.samples ?? [])
const submissionStore = useSubmissionStore() const submissionStore = useSubmissionStore()
const SAMPLE_SIDES = ['input', 'output'] as const /** 描述、输入、输出、提示:去掉老师为了隔开敲的空段落(见 utils/statement) */
const SAMPLE_SIDE_LABEL = { input: '输入', output: '输出' } as const const statement = computed(() => ({
description: tidyStatement(problem.value?.description),
/* input: tidyStatement(problem.value?.inputDescription),
* 例子默认摆成「例子 N | 输入 | 输出」的表,可机房那两种屏上左栏只有 550px 左右,每格只放得下 output: tidyStatement(problem.value?.outputDescription),
* 二十来个字符,而例子输出最长那行的 p90 就有 28 个字符宽(中文按两个算)—— 一成的题会在 hint: tidyStatement(problem.value?.hint),
* 半格里横着滚。所以按这道题最长那行量一下:半格放得下就摆表,放不下这道题的例子就整体改成 }))
* 上下排(输入一行、输出一行,各占满宽)。整宽还放不下的二十来道才横着滚。不自动折行:折出来
* 的行学生分不清是不是真换了行,照着它输出就错了。
* 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数。
*/
const SAMPLE_FONT = '14px Consolas, Monaco, monospace'
const SAMPLE_NO_W = 56
const SAMPLE_GAP = 8
const SAMPLE_PAD_L = 10
const SAMPLE_PAD_R = 4
const SAMPLE_COPY_W = 44
const sampleCss = {
font: SAMPLE_FONT,
noW: `${SAMPLE_NO_W}px`,
gap: `${SAMPLE_GAP}px`,
pad: `2px ${SAMPLE_PAD_R}px 2px ${SAMPLE_PAD_L}px`,
copyW: `${SAMPLE_COPY_W}px`,
}
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const sampleTextWidth = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = SAMPLE_FONT
let widest = 0
for (const sample of samples.value) {
for (const side of SAMPLE_SIDES) {
for (const line of sample[side].split('\n')) {
// <pre> 里 tab 按 8 列排
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > widest) widest = width
}
}
}
return widest
})
const sampleTable = useTemplateRef<HTMLElement>('sampleTable')
const { width: sampleTableWidth } = useElementSize(sampleTable)
/**
* 窄(手机,或桌面左栏拖到最窄):「例子 N」不占一列,改成每个例子头上一行小字。
* 390 宽的手机上那一列连同间距要吃掉 64px,框只剩一半多
*/
const sampleNarrow = computed(() => sampleTableWidth.value > 0 && sampleTableWidth.value < 480)
/** 摆成表时,半格里留给文字的宽度 */
const sampleHalfWidth = computed(
() => (sampleTableWidth.value - (sampleNarrow.value ? 0 : SAMPLE_NO_W + SAMPLE_GAP) - SAMPLE_GAP) / 2 - SAMPLE_PAD_L - SAMPLE_PAD_R - SAMPLE_COPY_W,
)
const samplesStacked = computed(() => sampleTableWidth.value > 0 && sampleTextWidth.value > sampleHalfWidth.value)
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS + // 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约 // ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
// astRequirementSchema。规则原文(engine / target)不下发给学生。 // astRequirementSchema。规则原文(engine / target)不下发给学生。
const KIND_TAG_TYPE = { // 每条规则前面一个小图标说是哪一类,规则的字照后端给的原样写(里面已经有「必须 / 不能」)
require: 'success', const KIND_ICON = {
forbid: 'error', require: 'ph:check-circle-fill',
count: 'info', forbid: 'ph:prohibit-fill',
count: 'ph:hash-bold',
} as const } as const
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {})) const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
</script> </script>
<template> <template>
<div v-if="problem" class="problem-content"> <div v-if="problem" class="problem-content">
<!-- 题号、题名、难度和状态一行(设计稿「题目页观感」定稿)。题名太长折到下一行,标签留在右边 -->
<header class="problem-head"> <header class="problem-head">
<div class="title-row"> <span class="pid">{{ problem._id }}</span>
<span class="pid">{{ problem._id }}</span> <h1 class="problem-title">{{ problem.title }}</h1>
<h1 class="problem-title">{{ problem.title }}</h1>
</div>
<div class="meta"> <div class="meta">
<n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)"> <n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)">
{{ DIFFICULTY[problem.difficulty] }} {{ DIFFICULTY[problem.difficulty] }}
</n-tag> </n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag> <n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" class="solved"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」 <!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写 「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
「还没交过」,旁边的「流程图 C 级」已经说了交过什么 --> 「还没交过」,旁边的「流程图 C 级」已经说了交过什么。
「做过,还没对」用红色:橙色和「中等」撞色,两个标签挨着分不出来 -->
<template v-else-if="!flowchartPassed"> <template v-else-if="!flowchartPassed">
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag> <n-tag v-if="myStatus === 'tried'" size="small" type="error" :bordered="false"> 做过,还没对 </n-tag>
<n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag> <n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
</template> </template>
<n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag> <n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
</div> </div>
</header> </header>
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 --> <!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了。
<div v-if="astRequirements.length > 0" class="requirements"> 黄色,和提示同色;每条一行,前面的小图标说是「必须 / 不能 / 次数」哪一类 -->
<span class="requirements-head">语法要求</span> <section v-if="astRequirements.length > 0" class="block">
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<template v-for="[lang, rules] in astRequirements" :key="lang"> <template v-for="[lang, rules] in astRequirements" :key="lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span> <div v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</div>
<n-tag v-for="(rule, i) in rules" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false"> <ul class="rules">
{{ rule.description }} <li v-for="(rule, i) in rules" :key="`${lang}-${i}`" :class="rule.kind">
</n-tag> <Icon :icon="KIND_ICON[rule.kind]" :width="16" class="rule-icon" aria-hidden="true" />
<span>{{ rule.description }}</span>
</li>
</ul>
</template> </template>
<span class="requirements-note">提交时会逐条检查</span>
</div>
<section class="block">
<h3 class="title">描述</h3>
<Markdown html :content="problem.description" />
</section> </section>
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 --> <section class="block">
<div v-if="!isSQL" class="io"> <SectionHead icon="description">描述</SectionHead>
<Markdown html :content="statement.description" />
</section>
<!-- 输入、输出上下排:并排的时候多一两句就挤成窄条(2020 的输入折成三行),旁边一栏空着 -->
<template v-if="!isSQL">
<section class="block"> <section class="block">
<h3 class="title">输入</h3> <SectionHead icon="input">输入</SectionHead>
<Markdown html :content="problem.inputDescription" /> <Markdown html :content="statement.input" />
</section> </section>
<section class="block"> <section class="block">
<h3 class="title">输出</h3> <SectionHead icon="output">输出</SectionHead>
<Markdown html :content="problem.outputDescription" /> <Markdown html :content="statement.output" />
</section> </section>
</div> </template>
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 --> <!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block"> <section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
<div class="block-head"> <SectionHead icon="flowchart">
<h3 class="title">你画的流程图</h3> 你画的流程图
<n-button text type="primary" size="small" @click="myFlowchartZoom = true"> 放大看 </n-button> <template #extra>
<n-button text type="primary" size="small" @click="myFlowchartOpen = !myFlowchartOpen"> <n-button text type="primary" @click="myFlowchartZoom = true">放大看 ›</n-button>
{{ myFlowchartOpen ? '收起' : '展开' }} <n-button text type="primary" @click="myFlowchartOpen = !myFlowchartOpen">
</n-button> {{ myFlowchartOpen ? '收起' : '展开' }}
</div> </n-button>
</template>
</SectionHead>
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true"> <div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
<MyFlowchart compact /> <MyFlowchart compact />
</div> </div>
@@ -251,49 +195,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal> </n-modal>
</section> </section>
<template v-if="!isSQL"> <section v-if="!isSQL && samples.length" class="block">
<!-- <SectionHead icon="samples">
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子 例子
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」 <template #extra>
--> <n-button text type="primary" @click="submissionStore.revealResult('custom')">自己输入数据试试 ›</n-button>
<section v-if="samples.length" class="block"> </template>
<div class="block-head"> </SectionHead>
<h3 class="title">例子</h3> <SampleList :samples="samples" />
<n-button text type="primary" size="small" class="block-link" @click="submissionStore.revealResult('custom')"> 自己输入数据试试 › </n-button> </section>
</div>
<!-- 半格放不下最长那行就整体改成上下排,见 samplesStacked -->
<div ref="sampleTable" class="sample-table" :class="{ stacked: samplesStacked, narrow: sampleNarrow }" role="table" aria-label="例子">
<div v-if="!samplesStacked" class="sample-row" role="row">
<span v-if="!sampleNarrow" role="columnheader"></span>
<span role="columnheader" class="sample-label">输入</span>
<span role="columnheader" class="sample-label">输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="sample-row" role="row">
<span class="sample-no" role="rowheader">例子 {{ index + 1 }}</span>
<template v-for="side in SAMPLE_SIDES" :key="side">
<span v-if="samplesStacked" class="sample-label sample-side">
{{ SAMPLE_SIDE_LABEL[side] }}
</span>
<div class="sample-cell" role="cell">
<pre class="testcase">{{ sample[side] }}</pre>
<button
type="button"
class="sample-copy"
:aria-label="`复制例子 ${index + 1} 的${SAMPLE_SIDE_LABEL[side]}`"
@click="copySample(`${index}-${side}`, sample[side])"
>
{{ copiedKey === `${index}-${side}` ? '已复制' : '复制' }}
</button>
</div>
</template>
</div>
</div>
</section>
</template>
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive"> <SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
<template #title="{ text }"> <template #title="{ text }">
<h3 class="title sql-title">{{ text }}</h3> <SectionHead :icon="text === '数据表' ? 'table' : 'expected'" class="sql-head">{{ text }}</SectionHead>
</template> </template>
</SqlProblemData> </SqlProblemData>
@@ -302,10 +216,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏 折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
--> -->
<section v-if="canShowReferenceFlowchart" class="block"> <section v-if="canShowReferenceFlowchart" class="block">
<div class="block-head"> <SectionHead icon="flowchart">
<h3 class="title">参考流程图</h3> 参考流程图
<n-button text type="primary" size="small" class="block-link" @click="referenceZoom = true"> 放大看 </n-button> <template #extra>
</div> <n-button text type="primary" @click="referenceZoom = true">放大看 ›</n-button>
</template>
</SectionHead>
<div <div
class="reference-flowchart" class="reference-flowchart"
role="button" role="button"
@@ -323,268 +239,158 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal> </n-modal>
</section> </section>
<section v-if="problem.hint" class="block"> <section v-if="statement.hint" class="block">
<h3 class="title">提示</h3> <SectionHead icon="hint" tone="amber">提示</SectionHead>
<Markdown html :content="problem.hint" /> <Markdown html :content="statement.hint" />
</section>
<section v-if="problem.source" class="block">
<h3 class="title">来源</h3>
<Markdown html :content="problem.source" />
</section> </section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" /> <SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
<!-- 来源不单起一节:学生做题用不上它,缩成最底下一行灰字 -->
<footer v-if="problem.source" class="source">
<span class="source-label">来源</span>
<Markdown html :content="problem.source" class="source-text" />
</footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* 设计稿「题目页签:还没运行过」 */
.problem-head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.title-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.pid {
flex: none;
padding: 2px 8px;
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.12);
font-size: 13px;
color: v-bind('theme.textColor2');
}
.problem-title {
margin: 0;
font-size: 22px;
line-height: 1.3;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
}
.block {
margin-top: 18px;
}
.block-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.block-head .title {
margin: 0;
}
.problem-content { .problem-content {
container: problem-content / inline-size; container: problem-content / inline-size;
} }
.block-link { /* 题号 | 题名 | ……… | 难度 状态 */
margin-left: auto; .problem-head {
}
/*
* 小节标题(描述 / 输入 / 输出 / 例子 / 提示……)。画板上是 15px 半粗,比 14px 的正文只大一号,
* 一屏题面扫下来分不出哪是标题(用户 2026-09-29 提出):加粗、大一号,左边一道主色竖条
*/
.title {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
margin: 0 0 6px;
padding-left: 9px;
border-left: 3px solid v-bind('theme.primaryColor');
}
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
/* 窄的时候输入、输出上下排:并排的话长一点的说明挤成一字两行的窄条,旁边一栏空着 */
@container problem-content (max-width: 480px) {
.io {
grid-template-columns: minmax(0, 1fr);
}
}
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
.requirements {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
display: flex; display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px 8px; align-items: baseline;
font-size: 13px; gap: 4px 10px;
} }
.requirements-head { .pid {
font-weight: 600; flex: none;
} font-size: 16px;
font-variant-numeric: tabular-nums;
.requirements-note {
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.lang-label { .problem-title {
margin: 0;
min-width: 0;
font-size: 22px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
align-self: center;
}
/* 「已解决」实心绿:做完了要一眼看得出 */
.meta .solved {
background-color: v-bind('theme.successColor');
color: #fff;
font-weight: 600; font-weight: 600;
} }
/* 参考流程图:和例子的格子同一种浅灰底 */ .block {
margin-top: 20px;
}
/* 题面里老师自己写的标题(2020 开头的「鸣潮公式」是 h1):降成和正文一样大的粗体,
不然比题名还大,顶在「描述」下面像另起了一道题 */
.problem-content :deep(.oj-markdown :is(h1, h2, h3, h4, h5, h6)) {
margin: 0.8em 0 0.3em;
font-size: 16px;
font-weight: 700;
line-height: 1.5;
}
/* 语法要求:每条一行 */
.lang-label {
margin: 6px 0 2px;
font-size: 14px;
font-weight: 600;
}
.rules {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 16px;
line-height: 1.6;
}
.rules li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.rule-icon {
flex: none;
margin-top: 0.3em;
}
.rules .require .rule-icon {
color: v-bind('theme.successColor');
}
.rules .forbid .rule-icon {
color: v-bind('theme.errorColor');
}
.rules .count .rule-icon {
color: v-bind('theme.infoColor');
}
/* SQL 的「数据表 / 期望结果」和别的小节一样隔开 */
.sql-head {
margin-top: 20px;
}
/* 参考流程图:和例子的输入格同一种浅灰底 */
.reference-flowchart { .reference-flowchart {
cursor: zoom-in; cursor: zoom-in;
border-radius: 6px; border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05); background-color: rgba(128, 128, 128, 0.05);
padding: 16px 12px; padding: 16px 12px;
} }
.my-flowchart { .my-flowchart {
cursor: zoom-in; cursor: zoom-in;
border-radius: 6px; border-radius: 8px;
background-color: rgba(128, 128, 128, 0.06); border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05);
padding: 8px; padding: 8px;
} }
/* .source {
* 例子:例子 N | 输入 | 输出,每格右边一个「复制」。 margin-top: 24px;
* 放不下时(.stacked)每个例子两行:例子 N | 输入 | 那格,下一行 输出 | 那格 padding-top: 10px;
*/ border-top: 1px solid v-bind('theme.dividerColor');
.sample-table {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 6px; gap: 8px;
} font-size: 13px;
.sample-table.stacked {
gap: 12px;
}
.sample-row {
display: grid;
grid-template-columns: v-bind('sampleCss.noW') minmax(0, 1fr) minmax(0, 1fr);
gap: 6px v-bind('sampleCss.gap');
align-items: stretch;
}
.stacked .sample-row {
grid-template-columns: v-bind('sampleCss.noW') 32px minmax(0, 1fr);
}
.sample-label,
.sample-no {
font-size: 12px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.sample-no { .source-label {
display: flex;
align-items: center;
}
/* 上下排时「例子 N」竖跨输入、输出两行,和「输入」对齐在顶上 */
.stacked .sample-no {
grid-row: span 2;
align-items: flex-start;
}
.stacked .sample-no,
.sample-side {
padding-top: 8px;
line-height: 1.5;
}
/* 窄:「例子 N」占满一行当小标题,下面输入、输出(并排或上下排) */
.narrow .sample-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.narrow.stacked .sample-row {
grid-template-columns: 32px minmax(0, 1fr);
}
.narrow .sample-no,
.narrow.stacked .sample-no {
grid-column: 1 / -1;
grid-row: auto;
padding-top: 4px;
}
.sample-table.narrow {
gap: 10px;
}
.sample-cell {
min-width: 0;
display: flex;
align-items: flex-start;
padding: v-bind('sampleCss.pad');
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
}
.testcase {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 0;
font: v-bind('sampleCss.font');
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
/* 整宽也放不下、只好横着滚的那二十来道:Windows 上默认那根带箭头的粗滚动条比例子本身还显眼 */
.testcase::-webkit-scrollbar {
height: 6px;
}
.testcase::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.35);
}
.testcase::-webkit-scrollbar-track {
background: transparent;
}
.sample-copy {
flex: none; flex: none;
width: v-bind('sampleCss.copyW');
height: 24px;
margin-top: 2px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind('theme.primaryColorPressed');
cursor: pointer;
} }
.sample-copy:hover { .source-text {
background-color: rgba(24, 160, 88, 0.1); min-width: 0;
} font-size: 13px;
line-height: 1.6;
.sql-title {
margin-top: 14px;
} }
</style> </style>
@@ -101,8 +101,8 @@ const color = computed(() => {
.result-header { .result-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
margin-bottom: 12px; margin-bottom: 18px;
} }
.line { .line {
@@ -114,8 +114,8 @@ const color = computed(() => {
.badge { .badge {
flex: none; flex: none;
width: 24px; width: 26px;
height: 24px; height: 26px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -125,12 +125,14 @@ const color = computed(() => {
} }
.title { .title {
font-size: 18px; font-size: 22px;
line-height: 1.3;
font-weight: 700; font-weight: 700;
color: v-bind('color.text'); color: v-bind('color.text');
} }
.sub { .sub {
font-size: 15px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
@@ -74,20 +74,22 @@ function pick(segment: Segment) {
<template> <template>
<FlowchartResult v-if="drawing" /> <FlowchartResult v-if="drawing" />
<div v-show="!drawing" class="result-pane"> <div v-show="!drawing" class="result-pane">
<!-- 设计稿:三个胶囊,选中的深色实心,带上那次结果的时间 --> <!-- 三个分段,带上那次结果的时间 -->
<div v-if="showSegments" class="segments" role="tablist" aria-label="结果"> <div v-if="showSegments" class="segments">
<button <div class="segment-track" role="tablist" aria-label="结果">
v-for="segment in segments" <button
:key="segment.value" v-for="segment in segments"
type="button" :key="segment.value"
role="tab" type="button"
class="segment" role="tab"
:class="{ active: isActive(segment.value) }" class="segment"
:aria-selected="isActive(segment.value)" :class="{ active: isActive(segment.value) }"
@click="pick(segment.value)" :aria-selected="isActive(segment.value)"
> @click="pick(segment.value)"
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template> >
</button> {{ segment.label }}<span v-if="segment.time" class="segment-time">{{ segment.time }}</span>
</button>
</div>
<span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span> <span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span>
</div> </div>
@@ -116,35 +118,53 @@ function pick(segment: Segment) {
padding-bottom: 16px; padding-bottom: 16px;
} }
/* 设计稿「题目页观感」结果 X:浅灰底的分段按钮,选中的白底加粗(和工具栏「写代码 / 画流程图」一个样子) */
.segments { .segments {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; align-items: center;
margin-bottom: 14px; gap: 8px 12px;
margin-bottom: 16px;
}
.segment-track {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 8px;
background-color: rgba(128, 128, 128, 0.12);
} }
.segment { .segment {
height: 28px; height: 28px;
padding: 0 12px; padding: 0 12px;
border: 0; border: 0;
border-radius: 15px; border-radius: 6px;
background-color: rgba(128, 128, 128, 0.12); background: transparent;
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
font: inherit; font: inherit;
font-size: 13px; font-size: 14px;
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.segment-time {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: v-bind('theme.textColor3');
}
.segment-note { .segment-note {
margin-left: auto;
align-self: center;
font-size: 13px; font-size: 13px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.segment.active { .segment.active {
background-color: v-bind('theme.textColor1'); background-color: v-bind('theme.cardColor');
color: v-bind('theme.cardColor'); color: v-bind('theme.textColor1');
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
} }
.formatted { .formatted {
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark' import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark'
import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError' import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError'
import SectionHead from './SectionHead.vue'
/** /**
* 运行时错误的中文说明卡片(诊断由后端 judge/runtime-diagnosis.ts 给)。 * 运行时错误的中文说明(诊断由后端 judge/runtime-diagnosis.ts 给)。
* 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。 * 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。
*/ */
const props = defineProps<{ const props = defineProps<{
@@ -34,34 +35,50 @@ onUnmounted(clearErrorMark)
</script> </script>
<template> <template>
<n-card embedded class="explain-card"> <!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明,再把那一行代码摆出来 -->
<n-flex vertical :size="12"> <div class="explain-section">
<div class="explain"> <SectionHead icon="problem" tone="amber">{{ info.line ? `第 ${info.line} 行出错` : '程序跑到一半出错了' }}</SectionHead>
<b v-if="info.line">第 {{ info.line }} 行运行时出错:</b>{{ message }} <p class="explain">{{ message }}</p>
</div> <div v-if="sourceLine" class="explain-code">
<pre v-if="sourceLine" class="explain-code">{{ sourceLine }}</pre> <span class="line-no">{{ info.line }}</span>
</n-flex> <pre>{{ sourceLine }}</pre>
</n-card> </div>
</div>
</template> </template>
<style scoped> <style scoped>
/* 结果弹窗不限宽,长句子不折行会把弹窗撑出屏幕 */
.explain-card {
max-width: 560px;
}
.explain { .explain {
margin: 0 0 10px;
font-size: 16px; font-size: 16px;
line-height: 1.7; line-height: 1.75;
} }
/* 出错的那一行:红底,前面写行号,和编辑器里标红的那一行对得上 */
.explain-code { .explain-code {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 5px 10px;
border-radius: 6px;
background-color: rgba(208, 48, 80, 0.06);
border: 1px solid rgba(208, 48, 80, 0.24);
font-family: Consolas, Monaco, monospace;
font-size: 16px;
line-height: 24px;
}
.line-no {
flex: none;
font-weight: 700;
color: #d03050;
}
.explain-code pre {
flex: 1 1 auto;
min-width: 0;
margin: 0; margin: 0;
padding: 8px 12px; font: inherit;
border-radius: 4px;
font-size: 15px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
background-color: rgba(208, 48, 80, 0.12);
} }
</style> </style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { copyToClipboard } from 'utils/functions'
import { SubmissionStatus } from 'utils/constants'
import { useCodeStore } from 'oj/store/code'
import { useSubmissionStore } from 'oj/store/submission'
/**
* 题面的例子:每组一行「输入 → 输出」,输出浅绿底(设计稿「题目页观感」ExCard,2026-11-04 定)。
*
* - 输入写的是「无」或者空的(常用题里 12% 的例子)写灰字「不用输入」,不给复制:
* 原来摆成一格可复制的「无」,学生以为要输入一个「无」。
* - 行首的空格画成浅灰点:菱形、三角形那几道靠行首空格对齐,数不清空格就照着排错了。
* 只是画成点,复制出去的还是空格。
* - 半格放不下最长那行,这道题的例子整体改成上下排(输入、↓、输出)。整宽还放不下的那一行
* 才折行,**折下来的部分缩进两格**:原来不折、横着滚,就是怕学生分不清折行和真换行、
* 照着多输出一个换行;缩进之后两者一眼能分开,又不用去找那根细滚动条。
* - 「运行例子」跑过之后,序号上标 ✓ / ✗(还是会跳到「结果」页签,切回来就看得到)。
* 代码改过就不再标:标的是上一份代码的对错,留着会让学生以为改完的还对。
*/
const { samples } = defineProps<{
samples: { input: string; output: string }[]
}>()
const SIDES = ['input', 'output'] as const
const SIDE_LABEL = { input: '输入', output: '输出' } as const
/** 输入写「无」或者空着:这道题不用输入 */
function noInput(text: string) {
const trimmed = text.trim()
return trimmed === '' || trimmed === '无'
}
/** 每一行拆成「行首空格数 + 余下的字」,模板里把行首空格画成点 */
function linesOf(text: string) {
return text.split('\n').map((line) => {
const lead = /^ +/.exec(line)?.[0].length ?? 0
return { lead, rest: line.slice(lead) }
})
}
// ==================== 摆成一行还是上下排 ====================
// 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数
const FONT_SIZE = 16
const FONT = `${FONT_SIZE}px Consolas, Monaco, monospace`
const DOT_W = 26
const ARROW_W = 22
const GAP = 6
/** 格子里除了字以外占掉的:左右内边距、复制按钮、按钮前的间距、边框 */
const PANE_CHROME = 10 + 4 + 26 + 4 + 2
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const widest = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = FONT
let max = 0
for (const sample of samples) {
for (const side of SIDES) {
if (side === 'input' && noInput(sample.input)) continue
for (const line of sample[side].split('\n')) {
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > max) max = width
}
}
}
return max
})
const list = useTemplateRef<HTMLElement>('list')
const { width: listWidth } = useElementSize(list)
const halfText = computed(() => (listWidth.value - DOT_W - ARROW_W - GAP * 3) / 2 - PANE_CHROME)
const stacked = computed(() => listWidth.value > 0 && widest.value > halfText.value)
// ==================== 复制 ====================
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, { immediate: false })
async function copy(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// ==================== 运行例子的 ✓ / ✗ ====================
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const marks = computed<Record<number, 'ok' | 'bad'>>(() => {
const trial = submissionStore.trial
const ran = trial.samplesCode.value
if (trial.samplesRunning.value || !ran) return {}
if (ran.value !== codeStore.code.value || ran.language !== codeStore.code.language) return {}
const out: Record<number, 'ok' | 'bad'> = {}
for (const run of trial.sampleRuns.value) {
out[run.index] = run.result === SubmissionStatus.accepted ? 'ok' : 'bad'
}
return out
})
function markLabel(index: number) {
const mark = marks.value[index]
if (mark === 'ok') return `例子 ${index + 1},运行结果对了`
if (mark === 'bad') return `例子 ${index + 1},运行结果不对`
return `例子 ${index + 1}`
}
const css = {
font: FONT,
dotW: `${DOT_W}px`,
arrowW: `${ARROW_W}px`,
gap: `${GAP}px`,
}
</script>
<template>
<div ref="list" class="sample-list" :class="{ stacked }" role="list" aria-label="例子">
<div v-if="!stacked" class="cols" aria-hidden="true">
<span />
<span>输入</span>
<span />
<span>输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="pair" role="listitem">
<span class="dot" :class="marks[index]" :aria-label="markLabel(index)">
<Icon v-if="marks[index] === 'ok'" icon="ph:check-bold" :width="13" />
<Icon v-else-if="marks[index] === 'bad'" icon="ph:x-bold" :width="12" />
<template v-else>{{ index + 1 }}</template>
</span>
<div class="sides">
<template v-for="side in SIDES" :key="side">
<span v-if="side === 'output'" class="arrow" aria-hidden="true">
<Icon :icon="stacked ? 'ph:arrow-down' : 'ph:arrow-right'" :width="16" />
</span>
<div v-if="side === 'input' && noInput(sample.input)" class="pane in none">不用输入</div>
<div v-else class="pane" :class="side === 'input' ? 'in' : 'out'">
<pre class="text" :aria-label="`例子 ${index + 1} 的${SIDE_LABEL[side]}`"><span
v-for="(line, i) of linesOf(sample[side])"
:key="i"
class="line"
><span v-if="line.lead" class="lead">{{ '·'.repeat(line.lead) }}</span>{{ line.rest }}</span
></pre>
<button type="button" class="copy" :aria-label="`复制例子 ${index + 1} 的${SIDE_LABEL[side]}`" @click="copy(`${index}-${side}`, sample[side])">
<Icon :icon="copiedKey === `${index}-${side}` ? 'ph:check' : 'ph:copy'" :width="15" />
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.sample-list {
display: flex;
flex-direction: column;
gap: 8px;
--pane-in-bg: rgba(128, 128, 128, 0.07);
--pane-in-border: rgba(128, 128, 128, 0.14);
--pane-out-bg: rgba(24, 160, 88, 0.08);
--pane-out-border: rgba(24, 160, 88, 0.26);
--lead: rgba(24, 160, 88, 0.45);
--muted: rgba(128, 128, 128, 0.75);
}
:global(html.dark) .sample-list {
--pane-out-bg: rgba(99, 226, 183, 0.08);
--pane-out-border: rgba(99, 226, 183, 0.26);
--lead: rgba(99, 226, 183, 0.45);
}
.sample-list.stacked {
gap: 12px;
}
.cols,
.pair {
display: grid;
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
}
.cols {
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
margin-bottom: -4px;
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
/* 一行:输入 → 输出;上下排:输入、↓、输出 */
.sides {
display: grid;
grid-template-columns: minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
align-items: stretch;
min-width: 0;
}
.stacked .sides {
grid-template-columns: minmax(0, 1fr);
}
.dot {
width: 22px;
height: 22px;
margin-top: 7px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
background-color: rgba(128, 128, 128, 0.14);
color: var(--muted);
}
.dot.ok {
background-color: #18a058;
color: #fff;
}
.dot.bad {
background-color: #d03050;
color: #fff;
}
.arrow {
display: flex;
justify-content: center;
padding-top: 10px;
color: var(--muted);
}
.stacked .arrow {
justify-content: flex-start;
padding: 2px 0 2px 14px;
}
.pane {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 4px;
padding: 5px 4px 5px 10px;
border-radius: 6px;
}
.pane.in {
background-color: var(--pane-in-bg);
border: 1px solid var(--pane-in-border);
}
.pane.out {
background-color: var(--pane-out-bg);
border: 1px solid var(--pane-out-border);
}
.pane.none {
align-self: start;
background-color: transparent;
border-style: dashed;
padding-right: 10px;
font-size: 14px;
line-height: 24px;
color: var(--muted);
}
.text {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font: v-bind('css.font');
line-height: 24px;
}
/* 一行一块:整宽还放不下才折,折下来的缩进两格,和真换行分得开 */
.line {
display: block;
min-height: 24px;
padding-left: 2ch;
text-indent: -2ch;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.lead {
color: var(--lead);
}
.copy {
flex: none;
width: 26px;
height: 24px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.copy:hover {
background-color: rgba(128, 128, 128, 0.14);
}
</style>
@@ -103,22 +103,14 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
</ResultHeader> </ResultHeader>
<template v-if="current"> <template v-if="current">
<!-- 对上了:三栏摆出来就行 --> <!-- 对上了:输入、正确输出、你的输出竖着摆出来就行,你的输出也是绿的 -->
<div v-if="current.result === SubmissionStatus.accepted" class="passed-card"> <div v-if="current.result === SubmissionStatus.accepted" class="passed-card">
<div class="blocks"> <span class="label">输入</span>
<div class="block"> <pre class="pane in">{{ current.input }}</pre>
<div class="label">输入</div> <span class="label">正确输出</span>
<pre>{{ current.input }}</pre> <pre class="pane ok">{{ current.expected }}</pre>
</div> <span class="label">你的输出</span>
<div class="block"> <pre class="pane ok">{{ current.output }}</pre>
<div class="label">正确输出</div>
<pre>{{ current.expected }}</pre>
</div>
<div class="block">
<div class="label">你的输出</div>
<pre>{{ current.output }}</pre>
</div>
</div>
</div> </div>
<!-- 跑完了、输出对不上:和判完之后同一套说明 --> <!-- 跑完了、输出对不上:和判完之后同一套说明 -->
<WrongAnswerExplain <WrongAnswerExplain
@@ -134,6 +126,7 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
:code="samplesCode?.value ?? ''" :code="samplesCode?.value ?? ''"
:language="samplesCode?.language ?? ''" :language="samplesCode?.language ?? ''"
:plain="inContest" :plain="inContest"
section="stack"
/> />
<!-- 没跑完:编译不过、运行出错、超时 --> <!-- 没跑完:编译不过、运行出错、超时 -->
<TrialErrorNote <TrialErrorNote
@@ -165,15 +158,16 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
} }
.run { .run {
height: 26px; height: 28px;
box-sizing: border-box; box-sizing: border-box;
padding: 0 10px; padding: 0 10px;
border-radius: 13px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
font-weight: 600;
cursor: pointer; cursor: pointer;
} }
@@ -189,56 +183,58 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
color: v-bind('theme.errorColorPressed'); color: v-bind('theme.errorColorPressed');
} }
.run.active { /* 选中的那个涂实心:设计稿「运行例子」 */
font-weight: 600;
border-width: 1.5px;
}
.run.ok.active { .run.ok.active {
border-color: v-bind('theme.successColor'); border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: #fff;
} }
.run.bad.active { .run.bad.active {
border-color: v-bind('theme.errorColor'); border-color: v-bind('theme.errorColor');
background-color: v-bind('theme.errorColor');
color: #fff;
} }
/* 对上了:和答错时同一张两列的网格(标签 | 格子),只是你的输出也是绿的 */
.passed-card { .passed-card {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind('theme.dividerColor');
}
.blocks {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: 64px minmax(0, 1fr);
gap: 8px; gap: 6px 8px;
} align-items: start;
.block {
min-width: 0;
padding: 6px 10px 8px;
border-radius: 5px;
background-color: v-bind('theme.cardColor');
border: 1px solid v-bind('theme.dividerColor');
} }
.label { .label {
font-size: 12px; font-size: 13px;
opacity: 0.7; line-height: 36px;
margin-bottom: 4px; white-space: nowrap;
color: v-bind('theme.textColor3');
} }
.block pre { .pane {
min-width: 0;
margin: 0; margin: 0;
padding: 5px 10px;
border-radius: 6px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
font-family: Monaco, Consolas, monospace; font-family: Consolas, Monaco, monospace;
font-size: 14px; font-size: 16px;
line-height: 24px;
}
.pane.in {
background-color: rgba(128, 128, 128, 0.07);
border: 1px solid rgba(128, 128, 128, 0.14);
}
.pane.ok {
background-color: rgba(24, 160, 88, 0.08);
border: 1px solid rgba(24, 160, 88, 0.26);
} }
.note { .note {
margin: 12px 0 0; margin: 16px 0 0;
font-size: 13px; font-size: 13px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
/**
* 题面的小节标题:线条图标 + 绿字 + 一根细线拖到头,线尾可以挂一个链接
* (「自己输入数据试试 ›」「放大看 ›」)。设计稿「题目页观感」⑦,2026-11-04 定。
*
* 语法要求、提示用黄色:一个是硬性条件、一个是帮忙的话,扫一眼要能和别的节分开。
* 结果页签里「例子 1 没对上」「第 2 行出错」这类说哪里不对的也用黄色(设计稿结果 X)。
* 明暗两套色都在 CSS 里(html.dark),别在这里调 useDark() —— 理由见 ProblemTypeTag。
*/
export type SectionIcon =
| 'description'
| 'input'
| 'output'
| 'samples'
| 'hint'
| 'rules'
| 'table'
| 'expected'
| 'flowchart'
| 'similar'
| 'problem'
| 'next'
| 'challenge'
const ICON: Record<SectionIcon, string> = {
description: 'ph:book-open',
input: 'ph:sign-in',
output: 'ph:sign-out',
samples: 'ph:flask',
hint: 'ph:lightbulb',
rules: 'ph:code',
table: 'ph:table',
expected: 'ph:check-circle',
flowchart: 'ph:flow-arrow',
similar: 'ph:shuffle-angular',
// 结果页签:哪里不对、接着做、还能挑战
problem: 'ph:warning-circle',
next: 'ph:arrow-right',
challenge: 'ph:target',
}
const { icon, tone = 'green' } = defineProps<{
icon: SectionIcon
tone?: 'green' | 'amber'
}>()
</script>
<template>
<div class="section-head" :class="tone">
<h3 class="section-title">
<Icon :icon="ICON[icon]" :width="19" class="section-icon" aria-hidden="true" />
<slot />
</h3>
<span class="section-line" aria-hidden="true" />
<span v-if="$slots.extra" class="section-extra"><slot name="extra" /></span>
</div>
</template>
<style scoped>
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
--head-text: #0c6b3b;
--head-icon: #18a058;
--head-line: rgba(24, 160, 88, 0.22);
}
.section-head.amber {
--head-text: #8a5a00;
--head-icon: #c98a00;
--head-line: rgba(201, 138, 0, 0.25);
}
:global(html.dark) .section-head {
--head-text: #63e2b7;
--head-icon: #63e2b7;
--head-line: rgba(99, 226, 183, 0.22);
}
:global(html.dark) .section-head.amber {
--head-text: #f2c97d;
--head-icon: #f2c97d;
--head-line: rgba(242, 201, 125, 0.25);
}
.section-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
letter-spacing: 2px;
white-space: nowrap;
color: var(--head-text);
}
.section-icon {
flex: none;
color: var(--head-icon);
}
.section-line {
flex: 1 1 auto;
min-width: 12px;
height: 1px;
background-color: var(--head-line);
}
.section-extra {
flex: none;
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 14px;
white-space: nowrap;
}
</style>
@@ -19,14 +19,16 @@ import { useTone } from 'shared/composables/tone'
import { problemTypes } from '../utils/problemType' import { problemTypes } from '../utils/problemType'
import ProblemStatus from './ProblemStatus.vue' import ProblemStatus from './ProblemStatus.vue'
import ProblemTypeTag from './ProblemTypeTag.vue' import ProblemTypeTag from './ProblemTypeTag.vue'
import SectionHead from './SectionHead.vue'
/** /**
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后) * 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。 * 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
* `place` 只管标题的样子:题面里和「提示」「来源」一样带绿竖条,结果页签里和「还能挑战」对齐。 * `place` 只管标题的样子:题面里用题面的小节标题(SectionHead),结果页签里和「还能挑战」对齐。
* *
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表 * 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」) * (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)。整行可点,题目名
* 和题目列表一样用正文色、不用主题绿:绿字细、在白底上发虚
*/ */
const props = defineProps<{ title: string; place: 'content' | 'result' }>() const props = defineProps<{ title: string; place: 'content' | 'result' }>()
@@ -66,7 +68,8 @@ function difficultyStyle(row: SimilarRow) {
<template> <template>
<section v-if="list.length" class="similar" :class="props.place"> <section v-if="list.length" class="similar" :class="props.place">
<h3 class="heading">{{ props.title }}</h3> <SectionHead v-if="props.place === 'content'" icon="similar">{{ props.title }}</SectionHead>
<h3 v-else class="heading">{{ props.title }}</h3>
<div class="rows"> <div class="rows">
<router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }"> <router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
<span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span> <span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
@@ -98,15 +101,6 @@ function difficultyStyle(row: SimilarRow) {
margin: 0 0 8px; margin: 0 0 8px;
} }
/* 和 ProblemContent 的小节标题(提示 / 来源)一样 */
.content .heading {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
padding-left: 9px;
border-left: 3px solid v-bind('theme.primaryColor');
}
/* 和结果页签里的「还能挑战」一样 */ /* 和结果页签里的「还能挑战」一样 */
.result .heading { .result .heading {
font-size: 15px; font-size: 15px;
@@ -165,7 +159,7 @@ function difficultyStyle(row: SimilarRow) {
.id { .id {
grid-area: id; grid-area: id;
min-width: 36px; min-width: 36px;
font-size: 14px; font-size: var(--oj-fs-body);
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -180,8 +174,8 @@ function difficultyStyle(row: SimilarRow) {
.text { .text {
min-width: 0; min-width: 0;
font-size: 15px; font-size: var(--oj-fs-body);
color: v-bind('theme.primaryColor'); font-weight: 500;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -193,7 +187,7 @@ function difficultyStyle(row: SimilarRow) {
height: 22px; height: 22px;
padding: 0 7px; padding: 0 7px;
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: var(--oj-fs-meta);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
@@ -201,8 +195,8 @@ function difficultyStyle(row: SimilarRow) {
.shared { .shared {
grid-area: shared; grid-area: shared;
min-width: 0; min-width: 0;
font-size: 13px; font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor2');
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -211,14 +205,14 @@ function difficultyStyle(row: SimilarRow) {
.people { .people {
grid-area: people; grid-area: people;
justify-self: end; justify-self: end;
font-size: 13px; font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor2');
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
.people b { .people b {
font-weight: 600; font-weight: 600;
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor1');
} }
</style> </style>
@@ -33,8 +33,6 @@ const changedTables = computed(() => {
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */ /** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length) const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length)
const anyChanged = computed(() => changedTables.value.some(({ changes }) => changes && (changes.cells.size > 0 || changes.newRows.size > 0)))
</script> </script>
<template> <template>
@@ -62,7 +60,6 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
</template> </template>
</div> </div>
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p> <p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
<p v-if="anyChanged" class="sqlNote legend"><span class="swatch" aria-hidden="true"></span>执行后变了的格子</p>
</template> </template>
<style scoped> <style scoped>
@@ -77,22 +74,4 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
opacity: 0.65; opacity: 0.65;
margin: 0 0 8px; margin: 0 0 8px;
} }
.legend {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 12px;
height: 12px;
border: 1px solid #f0d48a;
background-color: #fff4d6;
}
html.dark .swatch {
border-color: rgba(240, 180, 40, 0.45);
background-color: rgba(240, 180, 40, 0.18);
}
</style> </style>
@@ -16,6 +16,7 @@ import RuntimeErrorExplain from './RuntimeErrorExplain.vue'
import WrongAnswerExplain from './WrongAnswerExplain.vue' import WrongAnswerExplain from './WrongAnswerExplain.vue'
import LessonNext from './LessonNext.vue' import LessonNext from './LessonNext.vue'
import ResultHeader from './ResultHeader.vue' import ResultHeader from './ResultHeader.vue'
import SectionHead from './SectionHead.vue'
import SimilarProblems from './SimilarProblems.vue' import SimilarProblems from './SimilarProblems.vue'
import ChallengeList from './ChallengeList.vue' import ChallengeList from './ChallengeList.vue'
import ChallengeOutcome from './ChallengeOutcome.vue' import ChallengeOutcome from './ChallengeOutcome.vue'
@@ -361,22 +362,30 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
</div> </div>
</template> </template>
<div v-show="!waitingReveal"> <div v-show="!waitingReveal">
<n-flex vertical v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length"> <n-flex
vertical
:size="20"
v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length"
>
<CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" /> <CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" />
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" /> <RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
<WrongAnswerExplain v-if="sampleCheck" full :check="sampleCheck" :code="submission.code" :language="submission.language" /> <WrongAnswerExplain v-if="sampleCheck" full section="stack" :check="sampleCheck" :code="submission.code" :language="submission.language" />
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded> <!-- 语法要求逐条对没对:和题面的「语法要求」同一个黄色小节标题 -->
<n-flex vertical :size="8"> <section v-if="submission.statisticInfo?.ast_results?.length">
<n-flex v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i" align="center" :size="6"> <SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<n-icon :color="rule.passed ? theme.successColor : theme.errorColor"> <ul class="ast-rules">
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" /> <li v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i">
</n-icon> <Icon
:icon="rule.passed ? 'ph:check-circle-fill' : 'ph:x-circle-fill'"
:width="17"
:color="rule.passed ? theme.successColor : theme.errorColor"
/>
<span>{{ rule.description }}</span> <span>{{ rule.description }}</span>
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 --> <!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
<span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span> <span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span>
</n-flex> </li>
</n-flex> </ul>
</n-card> </section>
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card> <n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" /> <n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
</n-flex> </n-flex>
@@ -386,7 +395,10 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
问过之后,AI 的回答才放进卡片 问过之后,AI 的回答才放进卡片
--> -->
<div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions"> <div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions">
<n-button v-if="showAIHint && !hintTarget" type="primary" ghost :loading="hintLoading" @click="fetchHint(submission.id)"> 让 AI 分析我的代码 </n-button> <n-button v-if="showAIHint && !hintTarget" type="primary" secondary :loading="hintLoading" @click="fetchHint(submission.id)">
<template #icon><Icon icon="ph:sparkle-fill" /></template>
让 AI 分析我的代码
</n-button>
<n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')"> <n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')">
{{ customRunHint }} {{ customRunHint }}
</n-button> </n-button>
@@ -425,7 +437,24 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px 16px; gap: 8px 16px;
margin-top: 12px; margin-top: 16px;
}
.ast-rules {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 16px;
line-height: 1.6;
}
.ast-rules li {
display: flex;
align-items: center;
gap: 8px;
} }
/* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */ /* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */
@@ -6,6 +6,7 @@ import { useProblemStore } from 'oj/store/problem'
import { markdownToText } from 'oj/problem/utils/plainText' import { markdownToText } from 'oj/problem/utils/plainText'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants' import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer' import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer'
import SectionHead from './SectionHead.vue'
/** /**
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。 * 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
@@ -31,6 +32,15 @@ const props = defineProps<{
* 提示句全是对学生说的第二人称,整句换掉人称就读得通 * 提示句全是对学生说的第二人称,整句换掉人称就读得通
*/ */
peer?: boolean peer?: boolean
/**
* 新的样子(设计稿「题目页观感」结果 X):黄色小节标题说哪里不对,说明用正文大小,
* 输入 / 正确输出 / 你的输出是灰、绿、红三格。
* - stack:三格竖着排,和题面的例子一个样子。题目页的结果页签用(左栏只有 500 多宽)
* - row:三格横着并排,正确输出和你的输出一行对一行,不一样的那行两边都垫一层浅黄。
* 提交详情页用(一栏 1200 宽,多行输出对照着看);放不下(窄于 600,比如手机)就退回竖排
* 不给的话是原来那张浅灰卡片
*/
section?: 'stack' | 'row'
}>() }>()
const theme = useThemeVars() const theme = useThemeVars()
@@ -114,39 +124,48 @@ function lines(text: string) {
</script> </script>
<template> <template>
<div class="explain-card"> <div :class="section ? ['explain-section', section] : 'explain-card'">
<p v-if="check.passed" class="explain">题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p> <template v-if="section">
<template v-else> <SectionHead icon="problem" tone="amber">
<p class="explain"> {{ check.passed ? '例子都对了,错在没给你看的测试点' : `例子 ${(check.index ?? 0) + 1} 没对上` }}
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b </SectionHead>
><template v-if="!plain">{{ hint }}</template> <p v-if="check.passed" class="explain">想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p>
</p> <p v-else-if="!plain" class="explain">{{ hint }}</p>
</template>
<p v-else-if="check.passed" class="explain">
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
</p>
<p v-else class="explain">
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b
><template v-if="!plain">{{ hint }}</template>
</p>
<template v-if="!check.passed">
<div class="blocks"> <div class="blocks">
<div class="block"> <div class="block in">
<div class="label">输入</div> <div class="label">输入</div>
<div class="text"> <div class="text">
<div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" /> <div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" />
</div> </div>
</div> </div>
<div class="block"> <div class="block ok">
<div class="label">正确输出</div> <div class="label">正确输出</div>
<div class="text"> <div class="text">
<template v-for="(line, i) in lines(check.expected ?? '')" :key="i"> <template v-for="(line, i) in lines(check.expected ?? '')" :key="i">
<div v-if="i === diffLine && diffParts?.expected" class="line"> <div v-if="i === diffLine && diffParts?.expected" class="line hit">
<span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" /> <span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" />
</div> </div>
<div v-else class="line" v-text="line" /> <div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template> </template>
</div> </div>
</div> </div>
<div class="block"> <div class="block bad">
<div class="label">{{ peer ? '学生' : '你' }}的输出</div> <div class="label">{{ peer ? '学生' : '你' }}的输出</div>
<div v-if="check.output" class="text"> <div v-if="check.output" class="text">
<template v-for="(line, i) in lines(check.output)" :key="i"> <template v-for="(line, i) in lines(check.output)" :key="i">
<div v-if="i === diffLine && diffParts?.output" class="line"> <div v-if="i === diffLine && diffParts?.output" class="line hit">
<span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" /> <span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" />
</div> </div>
<div v-else class="line" v-text="line" /> <div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template> </template>
</div> </div>
<div v-else class="text empty">(什么都没有输出)</div> <div v-else class="text empty">(什么都没有输出)</div>
@@ -241,4 +260,145 @@ function lines(text: string) {
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
line-height: 1.6; line-height: 1.6;
} }
/*
* 题目页结果页签(section):标签一列、格子一列,输入灰、正确输出绿、你的输出红。
* .block 用 display: contents 把「标签 + 格子」直接摊进这张两列的网格
*/
.explain-section {
display: flex;
flex-direction: column;
}
.explain-section .explain {
margin: 0 0 10px;
font-size: 16px;
line-height: 1.75;
}
.explain-section .blocks {
grid-template-columns: 64px minmax(0, 1fr);
gap: 6px 8px;
align-items: start;
}
.explain-section .block {
display: contents;
}
.explain-section .label {
margin: 0;
font-size: 13px;
line-height: 36px;
white-space: nowrap;
}
.explain-section .bad .label {
font-weight: 600;
color: v-bind('theme.errorColor');
}
.explain-section .text {
min-width: 0;
padding: 5px 10px;
border-radius: 6px;
font-size: 16px;
line-height: 24px;
max-height: 260px;
}
.explain-section .text.empty {
font-size: 14px;
}
.explain-section .in .text {
background-color: rgba(128, 128, 128, 0.07);
border: 1px solid rgba(128, 128, 128, 0.14);
}
.explain-section .ok .text {
background-color: rgba(24, 160, 88, 0.08);
border: 1px solid rgba(24, 160, 88, 0.26);
}
.explain-section .bad .text {
background-color: rgba(208, 48, 80, 0.06);
border: 1px solid rgba(208, 48, 80, 0.24);
}
.explain-section .line {
min-height: 24px;
}
.explain-section .cut {
grid-column: 2;
}
.explain-section .requirement {
margin-top: 10px;
font-size: 14px;
color: v-bind('theme.textColor3');
}
/*
* 横排(row,提交详情页):三格并排,标签在格子上面;行的左右留白挪到每一行上,
* 不一样的那行垫的浅黄才能铺满整格宽、两边对齐
*/
.explain-section.row {
container: explain-row / inline-size;
}
.explain-section.row .blocks {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.explain-section.row .block {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: none;
}
.explain-section.row .label {
line-height: 1.5;
}
.explain-section.row .text {
padding: 6px 0;
line-height: 26px;
}
.explain-section.row .line {
min-height: 26px;
padding: 0 12px;
}
.explain-section.row .text.empty {
padding: 6px 12px;
}
.explain-section.row .line.hit {
background-color: rgba(227, 160, 8, 0.14);
}
/* 放不下三格(窄于 600:手机、课堂任务的弹窗)就退回竖排 */
@container explain-row (max-width: 600px) {
.explain-section.row .blocks {
grid-template-columns: 64px minmax(0, 1fr);
gap: 6px 8px;
}
.explain-section.row .block {
display: contents;
}
.explain-section.row .label {
line-height: 36px;
}
}
</style> </style>
@@ -163,8 +163,10 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
return guide(failed, ...actions) return guide(failed, ...actions)
} }
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore', nextAction()) // 这两种只说话、只留举手:上线第一天「做过了」的「下一道」没人点过,空着代码跑例子也没意义,
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', runSamples, help) // 而空白页上「举手求助」是真有人点(不会下手)。没有按钮的气泡 5 秒自己收起,不一直挡着
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore')
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', help)
const classGuide = classSituation(lesson) const classGuide = classSituation(lesson)
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n } if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
return guide('problem.unsubmitted', runSamples, help) return guide('problem.unsubmitted', runSamples, help)
+48 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { errorCode } from 'utils/api' import { errorCode } from 'utils/api'
import { getProblem } from 'oj/api' import { getProblem } from 'oj/api'
@@ -121,6 +122,13 @@ watch(
) )
const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key]) const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key])
/** 抽屉按钮前的小图标:页签行右边三个不带框的按钮,和左边的页签分得开(原来是三个圆角胶囊,看着像另一组页签) */
const DRAWER_ICON: Record<DrawerKey, string> = {
info: 'ph:chart-bar',
reaction: 'ph:chat-circle',
submission: 'ph:clock-counter-clockwise',
}
function toggleDrawer(key: DrawerKey) { function toggleDrawer(key: DrawerKey) {
drawer.value = drawer.value === key ? null : key drawer.value = drawer.value === key ? null : key
} }
@@ -314,7 +322,7 @@ onBeforeUnmount(() => {
</div> </div>
<div v-if="ctx.drawers.length" class="drawer-buttons"> <div v-if="ctx.drawers.length" class="drawer-buttons">
<button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)"> <button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)">
{{ drawerLabel(key) }} <Icon :icon="DRAWER_ICON[key]" :width="16" aria-hidden="true" />{{ drawerLabel(key) }}
</button> </button>
</div> </div>
</div> </div>
@@ -417,26 +425,46 @@ onBeforeUnmount(() => {
cursor: col-resize; cursor: col-resize;
} }
/*
* 左栏:课堂的时候最左边是一条竖栏(ContextBar 的 .lesson-rail),竖栏右边从上到下是
* 专题 / 比赛条、页签行、正文。两个页签的正文叠在同一格里,v-show 只显示一个
*/
.left-pane { .left-pane {
position: relative; position: relative;
height: 100%; height: 100%;
display: flex; display: grid;
flex-direction: column; grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden; overflow: hidden;
} }
.left-pane > :deep(.lesson-rail) {
grid-column: 1;
grid-row: 1 / -1;
}
.left-pane > :deep(.context-bar),
.left-pane > .tab-row {
grid-column: 2;
}
.left-pane > .pane-body {
grid-column: 2;
grid-row: 3;
}
.right-pane { .right-pane {
height: 100%; height: 100%;
} }
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个胶囊按钮。 /* 「题目 / 结果」:下划线式页签,右边是抽屉的三个按钮(图标 + 字,不带框)。
高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排, 高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排,
差 6px 的话两条下边线在分隔线处错成一个台阶 */ 差 6px 的话两条下边线在分隔线处错成一个台阶 */
.tab-row { .tab-row {
flex: none; flex: none;
height: 48px; height: 48px;
box-sizing: border-box; box-sizing: border-box;
padding: 0 20px 0 12px; padding: 0 10px 0 8px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
@@ -468,24 +496,32 @@ onBeforeUnmount(() => {
.drawer-buttons { .drawer-buttons {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
gap: 4px; gap: 2px;
} }
.drawer-button { .drawer-button {
height: 32px; height: 32px;
padding: 0 11px; padding: 0 9px;
border: 1px solid v-bind('theme.borderColor'); border: 0;
border-radius: 16px; border-radius: 6px;
background: transparent; background: transparent;
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit; font: inherit;
font-size: 13px; font-size: 14px;
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.drawer-button svg {
color: v-bind('theme.textColor3');
}
.drawer-button:hover { .drawer-button:hover {
border-color: v-bind('theme.primaryColorHover'); background-color: rgba(128, 128, 128, 0.1);
color: v-bind('theme.primaryColor'); color: v-bind('theme.textColor1');
} }
.pane-body { .pane-body {
@@ -0,0 +1,14 @@
/**
* 题面正文(描述 / 输入 / 输出 / 提示)在渲染前去掉空段落。
*
* 库里的题面多是 wangEditor 时代的 HTML,老师为了「隔开一点」敲的空行存成
* `<p><br></p>`、`<p>&nbsp;</p>`,每个都撑出一整行高的缝(2020 一道题就有两处)。
* 段落之间的间距题面自己会给,这些空段落一律去掉。只动「整段什么字都没有」的,
* 段落里带图片、带字的都不碰。
*/
const EMPTY_PARAGRAPH = /<p(?:\s[^>]*)?>(?:\s|&nbsp;|&#160;| |<br\s*\/?>)*<\/p>/gi
export function tidyStatement(html: string | null | undefined): string {
if (!html) return ''
return html.replace(EMPTY_PARAGRAPH, '')
}
@@ -310,7 +310,15 @@ function handleMore(key: string) {
class="code" class="code"
/> />
<WrongAnswerExplain v-if="sampleCheck" :check="sampleCheck" :code="shown.code" :language="shown.language" :plain="!!shown.contestId" :peer="!own" /> <WrongAnswerExplain
v-if="sampleCheck"
section="row"
:check="sampleCheck"
:code="shown.code"
:language="shown.language"
:plain="!!shown.contestId"
:peer="!own"
/>
<ExplainNote v-else-if="explain" :explain="explain" :tone="explainTone" /> <ExplainNote v-else-if="explain" :explain="explain" :tone="explainTone" />
</template> </template>
+137 -4
View File
@@ -10,7 +10,8 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents'
import { useTeacherHelp } from 'shared/composables/teacherHelp' import { useTeacherHelp } from 'shared/composables/teacherHelp'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { useMascotGuide } from 'shared/mascot/guide' import { requestSpeech } from 'shared/mascot/behavior'
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
@@ -518,7 +519,62 @@ function submissionsHref(
* 还没布置时填题号的框就是这页最大的东西,不给按钮 * 还没布置时填题号的框就是这页最大的东西,不给按钮
*/ */
const startForm = useTemplateRef<{ openPicker: () => void }>('startForm') const startForm = useTemplateRef<{ openPicker: () => void }>('startForm')
useMascotGuide(() => { // ---------- 看板娘:点她时挑全班这一刻最要紧的一件说;卡住的多起来、全班做完时她自己开口 ----------
/** 同一种错有这么多人才算「扎堆」 */
const PILE_MIN = 3
/** 布置这么久了,人在线却一道没交,才算「没动」 */
const IDLE_AFTER_MS = 10 * 60_000
/** 一道题卡住的人涨到这么多,她主动说一声 */
const STUCK_RISE = 3
/** 人最多的那一堆错(灰底那几堆不算:不是今天的错,没得讲) */
function biggestPile() {
let best: { index: number; kind: ErrorKind; people: WrongPerson[] } | null = null
const count = board.value?.problems.length ?? 0
for (let index = 0; index < count; index++) {
const groups = new Map<string, WrongPerson[]>()
for (const person of wrongOf(index)) {
if (QUIET_KINDS.has(person.kind.key)) continue
groups.set(person.kind.key, [...(groups.get(person.kind.key) ?? []), person])
}
for (const people of groups.values()) {
if (!best || people.length > best.people.length) best = { index, kind: people[0]!.kind, people }
}
}
return best && best.people.length >= PILE_MIN ? best : null
}
/** 卡住的人(cells.ts 的 isStuck),交得多的在前 */
function stuckPeople() {
return present.value
.flatMap((student) => student.cells.map((cell, index) => ({ student, cell, index })))
.filter(({ cell, index }) => isStuck(cell, modeAt(index)))
.sort((a, b) => b.cell.attempts - a.cell.attempts)
}
/** 在线、今天一道没交、也不是早就全做完了的人 */
function idlePeople() {
const assignedAt = board.value?.assignedAt
if (!assignedAt || Date.now() - Date.parse(assignedAt) < IDLE_AFTER_MS) return []
return present.value.filter(
(student) => student.online && student.cells.every((cell) => cell.attempts === 0) && student.cells.some((cell) => cell.status !== 'accepted'),
)
}
const allDone = () => present.value.length > 0 && present.value.every((student) => student.cells.every((cell) => cell.status === 'accepted'))
/** 点名字:能讲的打开讲错误的弹层,不能讲的展开那道题的错法 */
function lookAt(index: number, student: TaskBoardStudent): MascotAction {
const cell = student.cells[index]!
const problem = board.value!.problems[index]!
return {
label: `看 ${displayName(student.username)} 的`,
run: () => (explainable(cell, index) ? openExplain(index, student.userId) : (selectedId.value = problem.problemId)),
}
}
useMascotGuide((): MascotGuide | null => {
const b = board.value const b = board.value
if (!b?.className) return null if (!b?.className) return null
if (needLesson.value) if (needLesson.value)
@@ -531,8 +587,85 @@ useMascotGuide(() => {
...(language ? { language } : {}), ...(language ? { language } : {}),
} }
// 新标签打开,和点格子一样:任务页一般开着不关 // 新标签打开,和点格子一样:任务页一般开着不关
const open = () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') const submissions: MascotAction = { label: '这节课的提交', run: () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') }
return { situation: 'teacher.task.assigned', actions: [{ label: '这节课的提交', run: open }] }
if (allDone())
return { situation: 'teacher.task.allDone', actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }] }
const pile = biggestPile()
if (pile) {
const problem = b.problems[pile.index]!
const first = pile.people.find((person) => person.explain)
const actions: MascotAction[] = [{ label: '看这堆', run: () => (selectedId.value = problem.problemId) }]
if (first) actions.push({ label: '讲第一个', run: () => openExplain(pile.index, first.student.userId) })
return {
situation: 'teacher.task.errorPile',
n: pile.people.length,
vars: { pid: problem.problemDisplayId, kind: pile.kind.label },
actions: [...actions, submissions],
}
}
const stuck = stuckPeople()
if (stuck.length) {
const top = stuck[0]!
return {
situation: 'teacher.task.stuck',
n: new Set(stuck.map((it) => it.student.userId)).size,
vars: { name: displayName(top.student.username), k: top.cell.attempts, pid: b.problems[top.index]!.problemDisplayId },
actions: [lookAt(top.index, top.student), submissions],
}
}
const idle = idlePeople()
if (idle.length) {
const name = displayName(idle[0]!.username)
return idle.length === 1 ? { situation: 'teacher.task.idleOne', vars: { name } } : { situation: 'teacher.task.idle', n: idle.length, vars: { name } }
}
const challenging = present.value.filter((student) => student.cells.some((cell) => cell.challenge === 'doing')).length
if (challenging) return { situation: 'teacher.task.challenging', n: challenging, actions: [submissions] }
return { situation: 'teacher.task.assigned', actions: [submissions] }
})
/**
* 主动开口:每 15 秒刷新一次,和上一次比 —— 某道题卡住的人从不到 3 个涨到 3 个、全班刚做完。
* 刚打开页面(或换了班、换了一节)的那一次只记数不说,不然一刷新就把已经知道的事再说一遍
*/
let seen: { key: string; stuck: Map<number, number>; done: boolean } | null = null
watch(board, (b) => {
if (!b?.className || needLesson.value) return
const key = `${b.className}|${b.day}|${b.lessonId ?? ''}|${b.problems.map((problem) => problem.problemId).join(',')}`
const stuck = new Map<number, number>()
for (const { index } of stuckPeople()) {
const id = b.problems[index]!.problemId
stuck.set(id, (stuck.get(id) ?? 0) + 1)
}
const done = allDone()
const before = seen?.key === key ? seen : null
seen = { key, stuck, done }
if (!before) return
if (done && !before.done) {
requestSpeech({
key: `done:${key}`,
situation: 'teacher.task.allDone',
actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }],
})
return
}
for (const [id, n] of stuck) {
if (n < STUCK_RISE || (before.stuck.get(id) ?? 0) >= STUCK_RISE) continue
const problem = b.problems.find((it) => it.problemId === id)!
requestSpeech({
key: `stuck:${key}:${id}`,
situation: 'teacher.task.stuckRise',
n,
vars: { pid: problem.problemDisplayId },
actions: [{ label: '看这道', run: () => (selectedId.value = id) }],
})
return
}
}) })
function lastText(student: TaskBoardStudent) { function lastText(student: TaskBoardStudent) {
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sutera-Diffusus
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more