Compare commits
25
Commits
1b703d13ae
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f7a0c3af | ||
|
|
cccf7a37be | ||
|
|
1fdee94812 | ||
|
|
aa7e0a6eb8 | ||
|
|
81ef4dca6b | ||
|
|
ef3c53c671 | ||
|
|
15966ceb0d | ||
|
|
0c94f2db87 | ||
|
|
f01870c072 | ||
|
|
95dc2f24e6 | ||
|
|
e511d82f3b | ||
|
|
55bdf317f1 | ||
|
|
1b730da401 | ||
|
|
8c5399755e | ||
|
|
da360e2f68 | ||
|
|
3beb039dda | ||
|
|
49643719b0 | ||
|
|
f1c4e89ec5 | ||
|
|
bcadab40b2 | ||
|
|
41d2e46aa9 | ||
|
|
f06bb063b4 | ||
|
|
4a4cede99f | ||
|
|
91057c1096 | ||
|
|
c3f1413545 | ||
|
|
b09d479893 |
No files matched your search
@@ -0,0 +1,152 @@
|
||||
# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物 → rsync 到服务器 →
|
||||
# 服务器上跑 docker/deploy.sh --prebuilt 起栈。
|
||||
#
|
||||
# 跑在哪:开发机上的 gitea-runner(host 模式,标签 oj2-deploy),不在服务器上。
|
||||
# - 服务器只有 2 核,编一次要 5 分钟左右、光前端就 160 秒,编的时候判题会排队。
|
||||
# - 原来在 GitHub 上编,海外 → 服务器晚高峰只有十几 KB/s,rsync 一次要五六分钟;
|
||||
# 开发机 → 服务器实测 5MB 约 1 秒。
|
||||
# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑。
|
||||
# runner 的装法和配置见 docs/deploy.md「部署 runner」。
|
||||
#
|
||||
# host 模式直接用开发机自己的东西:PATH 里的 bun、~/.ssh/config 里的 `debian`(root@服务器)
|
||||
# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout
|
||||
# (它要去 GitHub 拉 action,在国内反而是最慢的一步)。
|
||||
#
|
||||
# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows;GitHub 那份改成了只能手动
|
||||
# 触发的备用路线(开发机不在时用),push 不会在两边各部署一次。
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# 手动触发时可以放行破坏性迁移。push 触发拿不到这个 input,值恒为空 ——
|
||||
# 也就是说**自动部署永远不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把
|
||||
# 工作流判红。这是有意的:那种改动得有人先确认备份。
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
allow_destructive:
|
||||
description: "放行破坏性迁移(DROP TABLE / DROP COLUMN / ALTER TYPE …)。勾之前先确认已备份数据库。"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# 两次 push 挨得近时,别让两个 deploy.sh 在服务器上同时起栈。
|
||||
# runner 的 capacity 也是 1,这里再保一道。
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REMOTE: debian # 开发机 ~/.ssh/config 里的主机名
|
||||
REMOTE_DIR: /root/OJDeploy/OJ2
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: oj2-deploy
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# 工作目录可能是上一次留下的,所以 fetch 之后强制切过去再清掉没跟踪的文件
|
||||
# (node_modules 留着,bun install 会对齐它)。
|
||||
- name: 取代码
|
||||
env:
|
||||
SHA: ${{ gitea.sha }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
git init -q .
|
||||
git fetch -q --depth 1 "git@git.xuyue.cc:$REPO.git" "$SHA"
|
||||
git checkout -q --force FETCH_HEAD
|
||||
git clean -ffdxq -e node_modules
|
||||
git log --oneline -1
|
||||
|
||||
# 单二进制把运行时一起打进去:这里用的 bun 是哪个,线上跑的就是哪个。
|
||||
#
|
||||
# ⚠️ **不能用开发机 PATH 里的 bun。** pacman 装的 bun 是 Arch 自己编的,动态链接系统的
|
||||
# ICU(libicui18n.so.78),编出来的 oj2-api 到容器里就报找不到这个库 —— 2026-11-03
|
||||
# 第一次跑就撞上了,好在迁移那步先炸、容器没动。官方发布的 bun 自带 ICU,
|
||||
# 只依赖 libc,和 GitHub 上 setup-bun 装的是同一个东西。
|
||||
#
|
||||
# 所以这里按 packageManager 的版本下载官方二进制(核 sha256),缓存在
|
||||
# ~/.cache/oj2-deploy/ 下,同一个版本只下一次;再把它放到 PATH 最前面,
|
||||
# 后面的 bun install / bunx 都走它。
|
||||
- name: 装 bun
|
||||
run: |
|
||||
ver=$(sed -nE 's/.*"packageManager": *"bun@([^"]+)".*/\1/p' package.json)
|
||||
[ -n "$ver" ] || { echo "::error::package.json 里读不到 packageManager 的 bun 版本"; exit 1; }
|
||||
dir="$HOME/.cache/oj2-deploy/bun-v$ver"
|
||||
if [ ! -x "$dir/bun" ]; then
|
||||
tmp=$(mktemp -d)
|
||||
base="https://github.com/oven-sh/bun/releases/download/bun-v$ver"
|
||||
curl -fsSL --retry 3 -o "$tmp/bun-linux-x64.zip" "$base/bun-linux-x64.zip"
|
||||
curl -fsSL --retry 3 -o "$tmp/SHASUMS256.txt" "$base/SHASUMS256.txt"
|
||||
(cd "$tmp" && grep ' bun-linux-x64.zip$' SHASUMS256.txt | sha256sum -c -)
|
||||
unzip -q "$tmp/bun-linux-x64.zip" -d "$tmp"
|
||||
mkdir -p "$dir"
|
||||
install -m755 "$tmp/bun-linux-x64/bun" "$dir/bun"
|
||||
rm -rf "$tmp"
|
||||
fi
|
||||
[ "$("$dir/bun" --version)" = "$ver" ] || { echo "::error::$dir/bun 的版本不是 $ver"; exit 1; }
|
||||
echo "$dir" >> "$GITHUB_PATH"
|
||||
echo "用 $dir/bun($ver)"
|
||||
|
||||
- name: 装依赖
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# 输出到 dist/oj2-api,正是 Dockerfile 里 artifacts-prebuilt 阶段要的位置。
|
||||
# 编完先看它链接了什么:官方 bun 编出来的只依赖 libc 那几个,多出任何一个库
|
||||
# (比如 libicu)都说明用错了 bun,到容器里一定起不来。在这里拦下,
|
||||
# 别等服务器上构建完镜像、迁移时才炸 —— 那时 oj2-api:latest 已经被换成坏镜像了。
|
||||
- name: 编后端
|
||||
run: |
|
||||
bun run --filter '@oj2/api' build
|
||||
ldd dist/oj2-api
|
||||
extra=$(ldd dist/oj2-api | awk '{print $1}' | grep -vE '^(linux-vdso\.so\.1|libc\.so\.6|libpthread\.so\.0|libdl\.so\.2|libm\.so\.6|/lib64/ld-linux-x86-64\.so\.2)$' || true)
|
||||
[ -z "$extra" ] || { echo "::error::dist/oj2-api 多链接了这些库,容器里没有:$extra"; exit 1; }
|
||||
|
||||
# 默认 production 模式,读 apps/web/.env.production(在版本库里)。
|
||||
- name: 编前端
|
||||
run: cd apps/web && bun run build
|
||||
|
||||
# 格式、lint、前后端类型检查、路由遮蔽、AST 节点,有一项不过就不部署。
|
||||
# 必须排在「编前端」之后:前端类型检查要 unplugin 生成的 auto-imports.d.ts /
|
||||
# components.d.ts,它们不进版本库,vite build 时才生成。
|
||||
- name: 检查
|
||||
run: bunx vp run verify
|
||||
|
||||
# 排除项和 --no-owner --no-group 的理由见 .github/workflows/deploy.yml 同名一步,
|
||||
# 一条都不能少(少了 data 就是删库,少了 docker/.env* 下一步自检直接 die)。
|
||||
# -c 按内容比:每次 checkout 出来的 mtime 都是「刚才」,按 mtime 比会把
|
||||
# 600 多个没改的文件逐个走一遍增量。
|
||||
# --delay-updates 全部传完才一起换上去,中途断了服务器上不会留下半新半旧的源码。
|
||||
# --timeout 60 秒没有数据进出就算断了,交给循环重试,半截文件接着用。
|
||||
- name: 同步到服务器
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
|
||||
--compress --compress-choice=zstd \
|
||||
--timeout=60 --stats --human-readable \
|
||||
-e "ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||
--exclude node_modules \
|
||||
--exclude .git \
|
||||
--exclude data \
|
||||
--exclude 'docker/.env*' \
|
||||
./ "$REMOTE:$REMOTE_DIR/" && exit 0
|
||||
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
|
||||
sleep 10
|
||||
done
|
||||
exit 1
|
||||
|
||||
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出。
|
||||
# 破坏性迁移会被拦下并退出 4,除非手动触发时勾了 allow_destructive。
|
||||
#
|
||||
# 放行与否在 shell 里按字符串 "true" 判:Gitea 的 inputs 是布尔还是字符串,
|
||||
# "false" 这个非空字符串都不会被当成放行(GitHub 那份踩过 github.event.inputs 的坑)。
|
||||
- name: 起栈
|
||||
env:
|
||||
ALLOW_INPUT: ${{ inputs.allow_destructive }}
|
||||
run: |
|
||||
allow=''
|
||||
[ "$ALLOW_INPUT" = "true" ] && allow=1
|
||||
echo "放行破坏性迁移:${allow:-否}"
|
||||
ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" \
|
||||
"cd $REMOTE_DIR && OJ2_ALLOW_DESTRUCTIVE='$allow' docker/deploy.sh --prebuilt"
|
||||
@@ -1,19 +1,20 @@
|
||||
# 在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。
|
||||
# 备用部署路线:**只能手动触发**(Actions 页面 → Deploy → Run workflow)。
|
||||
# 平时的部署是 push origin 时由开发机上的 gitea-runner 跑 .gitea/workflows/deploy.yml,
|
||||
# 这份只在开发机不在、又急着上线时用。
|
||||
#
|
||||
# 为什么产物在这里编:服务器性能差,镜像 builder 阶段(1238 个包的 bun install +
|
||||
# bun compile + vite build)首次约 5 分钟,光前端就 160s。runner 编完传过去,
|
||||
# 服务器那边的镜像构建只剩几条 COPY。
|
||||
# 为什么不再 push 就跑:GitHub 的 runner 在海外,到服务器晚高峰只有十几 KB/s,
|
||||
# rsync 一次要五六分钟(后端改一行,--bytecode 那段约 6MB 字节码就整段重写,增量省不掉)。
|
||||
# 而 origin 会镜像到 github,两边都 push 触发的话每次会部署两遍,两边的并发锁又不通。
|
||||
#
|
||||
# 流程:在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。
|
||||
# 产物不在服务器上编,是因为服务器性能差,镜像 builder 阶段(1238 个包的 bun install +
|
||||
# bun compile + vite build)首次约 5 分钟,光前端就 160s。
|
||||
#
|
||||
# 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的
|
||||
# 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖这个工作流。
|
||||
# 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖任何工作流。
|
||||
#
|
||||
# 触发的是 push 到 **github** 这个 remote:
|
||||
#
|
||||
# git push github main
|
||||
#
|
||||
# origin(git.xuyue.cc)是 Gitea,仓库里没有 .gitea/workflows 时它的 Actions 会退回来
|
||||
# 读这份 .github/workflows,每次 push origin 都会把部署再跑一遍 —— 那边不需要。
|
||||
# 所以下面 job 上卡了 server_url:只在 GitHub 上跑,Gitea 上直接跳过。
|
||||
# job 上卡了 server_url:只在 GitHub 上跑。仓库有了 .gitea/workflows 之后 Gitea 本来就
|
||||
# 不再读这份,这道闸留着防万一。
|
||||
#
|
||||
# 仓库 Settings → Secrets 需要两个:
|
||||
# HOST 服务器地址
|
||||
@@ -21,12 +22,8 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# 手动触发时可以放行破坏性迁移。push 触发拿不到这个 input,值恒为空 ——
|
||||
# 也就是说**自动部署永远不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把
|
||||
# 工作流判红。这是有意的:那种改动得有人先确认备份。
|
||||
# 手动触发时可以放行破坏性迁移;不勾就停在闸门上把工作流判红。
|
||||
# 那种改动得有人先确认备份。
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
allow_destructive:
|
||||
@@ -97,9 +94,21 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# 源码和产物一起传。dist/oj2-api 有 77MB,但 rsync 对已存在的文件做增量,
|
||||
# 源码和产物一起传。dist/oj2-api 有 115MB,但 rsync 对已存在的文件做增量,
|
||||
# 重编后大部分(bun 运行时那段)字节不变,实际传输量远小于这个数。
|
||||
#
|
||||
# 慢在线路上:runner 在海外,到服务器晚高峰只有十几 KB/s,传 3~4MB 就要五六分钟。
|
||||
# 后端只要改了一行,--bytecode 那段字节码(约 6MB)就整段重写、增量也省不掉,
|
||||
# 它占了每次传输量的七成多。下面几个开关都是为了少传、传不动时别干等:
|
||||
# -c 按内容比,不看 mtime。checkout 出来每个文件的 mtime 都是「刚才」,
|
||||
# 不加它 rsync 会把 600 多个没改的源码逐个走一遍增量、来回交换校验块。
|
||||
# zstd 19 比默认的 zlib 再少传约 17%(实测 4.09MB → 3.38MB),压缩只花 runner 的 CPU。
|
||||
# --delay-updates 全部传完才一次性换上去,中途断了服务器上不会留下半新半旧的源码。
|
||||
# --timeout 60 秒没有任何数据进出就算断了,交给外面的循环重试;
|
||||
# 重试时 --delay-updates 留在 .~tmp~ 里的半截文件会接着用,不从头传。
|
||||
# ServerAlive 连接悄悄死掉时 ssh 自己能发现,不会一直挂着。
|
||||
# --stats 日志里能看到这次到底传了多少字节。
|
||||
#
|
||||
# --delete 的排除项少一个就会出事,逐条说明:
|
||||
# docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。
|
||||
# 删了下一步 deploy.sh 自检就直接 die。
|
||||
@@ -114,14 +123,22 @@ jobs:
|
||||
# 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被
|
||||
# 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。
|
||||
- name: 同步到服务器
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
rsync -az --no-owner --no-group --delete \
|
||||
-e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT" \
|
||||
--exclude node_modules \
|
||||
--exclude .git \
|
||||
--exclude data \
|
||||
--exclude 'docker/.env*' \
|
||||
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/"
|
||||
for attempt in 1 2 3; do
|
||||
rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
|
||||
--compress --compress-choice=zstd --compress-level=19 \
|
||||
--timeout=60 --stats --human-readable \
|
||||
-e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||
--exclude node_modules \
|
||||
--exclude .git \
|
||||
--exclude data \
|
||||
--exclude 'docker/.env*' \
|
||||
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" && exit 0
|
||||
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
|
||||
sleep 10
|
||||
done
|
||||
exit 1
|
||||
|
||||
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出,
|
||||
# 这一步跟着红,日志里能看到具体是哪一条。
|
||||
|
||||
@@ -62,7 +62,8 @@ cd apps/web && bun run build # 前端构建(不在 verify 里
|
||||
配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它;
|
||||
`semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks`
|
||||
(`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在
|
||||
长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css` 来回改。前端自己的构建
|
||||
长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css`、`apps/web/src/oj/learn/learn.css` 来回改
|
||||
(`vp fmt --list-different` 能列出当前受影响的文件)。前端自己的构建
|
||||
配置在 `apps/web/vite.config.ts`,和根目录那份无关。
|
||||
|
||||
- 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了
|
||||
@@ -85,9 +86,9 @@ references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build`
|
||||
|
||||
## 提交
|
||||
|
||||
**直接在 `main` 上干活,不开特性分支。** 注意 push 到 `github` 这个 remote 的 `main`
|
||||
会触发 CI 部署;而 `origin` 会镜像到 `github`,**平时 push `origin` 通常几秒后也就部署了**
|
||||
(偶尔不跟,要 `git ls-remote github` 核实)。见 `docs/deploy.md`。
|
||||
**直接在 `main` 上干活,不开特性分支。** 注意 **push `origin` 的 `main` 就会部署**:
|
||||
开发机上的 gitea-runner 跑 `.gitea/workflows/deploy.yml`,进度在 git.xuyue.cc 的 Actions 页面。
|
||||
GitHub 那份只剩手动触发的备用路线。见 `docs/deploy.md`。
|
||||
|
||||
提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如
|
||||
`fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "mascot_event" (
|
||||
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "mascot_event_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"situation" text,
|
||||
"line" text,
|
||||
"action" text,
|
||||
"path" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mascot_event" ADD CONSTRAINT "mascot_event_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "mascot_event_user_kind_idx" ON "mascot_event" USING btree ("user_id","kind");--> statement-breakpoint
|
||||
CREATE INDEX "mascot_event_created_at_idx" ON "mascot_event" USING btree ("created_at");
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -302,6 +302,20 @@
|
||||
"when": 1793632543220,
|
||||
"tag": "0042_class_name_cleanup",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "7",
|
||||
"when": 1793693911394,
|
||||
"tag": "0043_mascot_event",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1793695621303,
|
||||
"tag": "0044_achievement_more_tiers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
JudgeStatus,
|
||||
LessonLanguage,
|
||||
LessonMode,
|
||||
MascotEventKind,
|
||||
ProblemChallenges,
|
||||
ProblemDifficulty,
|
||||
ProblemLanguage,
|
||||
@@ -1411,3 +1412,45 @@ export const challengeSuccess = pgTable(
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* 看板娘的互动记录:点开气泡、点气泡里的按钮、她主动开口、摸头彩蛋、被拖走(种类见契约
|
||||
* mascotEventSchema)。拿来看她说的话、给的按钮学生用不用,以后按学生的行为调整她也读这里;
|
||||
* 「点过几次」(熟悉程度)直接按 user_id 数 open。
|
||||
*
|
||||
* 不是「学生做过什么」的证据,账号没了就没意义,user 外键 CASCADE(外键分档见 CLAUDE.md「数据库」)。
|
||||
*/
|
||||
export const mascotEvent = pgTable(
|
||||
'mascot_event',
|
||||
{
|
||||
id: integer().primaryKey().generatedByDefaultAsIdentity({
|
||||
name: 'mascot_event_id_seq',
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 2147483647,
|
||||
cache: 1,
|
||||
}),
|
||||
userId: integer('user_id').notNull(),
|
||||
kind: text().$type<MascotEventKind>().notNull(),
|
||||
situation: text(),
|
||||
line: text(),
|
||||
action: text(),
|
||||
path: text().notNull(),
|
||||
createdAt: timestamp('created_at', {
|
||||
withTimezone: true,
|
||||
mode: 'string',
|
||||
})
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('mascot_event_user_kind_idx').on(table.userId, table.kind),
|
||||
index('mascot_event_created_at_idx').on(table.createdAt),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'mascot_event_user_id_fk_user_id',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
)
|
||||
@@ -21,6 +21,7 @@ import { problemRoutes } from './routes/problem'
|
||||
import { problemStatsRoutes } from './routes/problem-stats'
|
||||
import { submissionRoutes } from './routes/submission'
|
||||
import { trialRunRoutes } from './routes/trial-run'
|
||||
import { mascotRoutes } from './routes/mascot'
|
||||
import { siteRoutes } from './routes/site'
|
||||
import { scanPendingChallenges } from './services/challenge'
|
||||
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
|
||||
@@ -44,6 +45,7 @@ app.route('/api', problemRoutes)
|
||||
app.route('/api', problemStatsRoutes)
|
||||
app.route('/api', submissionRoutes)
|
||||
app.route('/api', trialRunRoutes)
|
||||
app.route('/api', mascotRoutes)
|
||||
app.route('/api/admin', adminRoutes)
|
||||
|
||||
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
|
||||
|
||||
+99
-17
@@ -63,15 +63,74 @@ async function loadLanguage(language: string) {
|
||||
return loaded
|
||||
}
|
||||
|
||||
function collectNodes(root: Node, type: string, result: Node[] = []) {
|
||||
if (root.type === type) result.push(root)
|
||||
for (const child of root.children) collectNodes(child, type, result)
|
||||
type Match = (node: Node) => boolean
|
||||
|
||||
function collectNodes(root: Node, match: Match, result: Node[] = []) {
|
||||
if (match(root)) result.push(root)
|
||||
for (const child of root.children) collectNodes(child, match, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function hasNode(root: Node, type: string): boolean {
|
||||
if (root.type === type) return true
|
||||
return root.children.some((child) => hasNode(child, type))
|
||||
function hasNode(root: Node, match: Match): boolean {
|
||||
if (match(root)) return true
|
||||
return root.children.some((child) => hasNode(child, match))
|
||||
}
|
||||
|
||||
const ofType =
|
||||
(type: string): Match =>
|
||||
(node) =>
|
||||
node.type === type
|
||||
|
||||
/** C / C++ 函数定义的函数名:`int *f()` 的名字在 pointer_declarator 里面一层,沿着 declarator 往里找 */
|
||||
function functionName(definition: Node) {
|
||||
let declarator = definition.childForFieldName('declarator')
|
||||
while (declarator && declarator.type !== 'function_declarator') declarator = declarator.childForFieldName('declarator')
|
||||
return declarator?.childForFieldName('declarator')?.text
|
||||
}
|
||||
|
||||
/**
|
||||
* 语法 target 认哪些节点。多数就是比节点类型,例外:
|
||||
*
|
||||
* C / C++ 的「函数定义」不算 `main` —— 每个 C 程序都有 main,算进去的话「必须使用函数定义」
|
||||
* 永远通过、「不能使用」永远失败,老师选它要的是「自己写了函数」。所以 C 那边叫「自定义函数」。
|
||||
*
|
||||
* Python 的「赋值语句」连 `s += 1` 一起算 —— 和 C 一个口径(C 的 assignment_expression
|
||||
* 本来就包括 `+=`),学生也是这么理解的。tree-sitter-python 里 `+=` 是单独的 augmented_assignment。
|
||||
*/
|
||||
function nodeMatch(target: string, language: string): Match {
|
||||
const type = astTargetNodeType(target, language)
|
||||
if (target === 'function_definition' && language !== 'Python') {
|
||||
return (node) => node.type === type && functionName(node) !== 'main'
|
||||
}
|
||||
if (target === 'assignment' && language === 'Python') {
|
||||
return (node) => node.type === type || node.type === 'augmented_assignment'
|
||||
}
|
||||
return ofType(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 运算符只认真在做运算的那一个 token。光比 token 的话,下面这些全都算「用了」:
|
||||
* C 的 `scanf("%d", &n)` 算 `&`、`int *p` 算 `*`,C++ 的 `vector<int>` 算 `>`,
|
||||
* Python 的 `*args` / `from m import *` 算 `*`、`**kw` 算 `**`、`not in` / `is not` 算 `not`、
|
||||
* `case 1 | 2` 算 `|`。所以看它的父节点:二元运算、复合赋值、自增自减。
|
||||
*
|
||||
* 一元的只认取反(`!x` / `not x`)。负号 `-x` 不算「用了减号」,同理 `*p` 不算乘、`&n` 不算按位与。
|
||||
*/
|
||||
const OPERATOR_PARENTS: Record<string, string[]> = {
|
||||
C: ['binary_expression', 'assignment_expression', 'update_expression'],
|
||||
'C++': ['binary_expression', 'assignment_expression', 'update_expression'],
|
||||
Python: ['binary_operator', 'boolean_operator', 'comparison_operator', 'augmented_assignment'],
|
||||
}
|
||||
const NOT_PARENTS: Record<string, string[]> = {
|
||||
C: ['unary_expression'],
|
||||
'C++': ['unary_expression'],
|
||||
Python: ['not_operator'],
|
||||
}
|
||||
|
||||
function operatorMatch(target: string, language: string): Match {
|
||||
const type = astTargetNodeType(target, language)
|
||||
const parents = (target === 'not' ? NOT_PARENTS : OPERATOR_PARENTS)[language] ?? []
|
||||
return (node) => node.type === type && node.parent !== null && parents.includes(node.parent.type)
|
||||
}
|
||||
|
||||
function targetName(rule: AstRule, language?: string) {
|
||||
@@ -201,6 +260,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
|
||||
if (!rule.keyword?.trim()) return `${at}:参数名不能为空`
|
||||
}
|
||||
if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字`
|
||||
if ([rule.exact, rule.min, rule.max].some((n) => n !== undefined && n < 0)) return `${at}:次数不能是负数`
|
||||
if (rule.min !== undefined && rule.max !== undefined && rule.min > rule.max) {
|
||||
return `${at}:至少 ${rule.min} 次比至多 ${rule.max} 次还多,怎么写都过不了`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -238,12 +301,32 @@ const CALL_NODE_TYPES: Record<string, string> = {
|
||||
Python: 'call',
|
||||
}
|
||||
|
||||
/** `import math` / `import numpy as np` 引进来的模块名。`import os.path` 绑定的是 `os` */
|
||||
function importedModules(root: Node) {
|
||||
const names = new Set<string>()
|
||||
for (const statement of collectNodes(root, ofType('import_statement'))) {
|
||||
for (const item of statement.namedChildren) {
|
||||
const name = item.type === 'aliased_import' ? item.childForFieldName('alias') : item.type === 'dotted_name' ? item.namedChildren[0] : null
|
||||
if (name) names.add(name.text)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function functionCalls(root: Node, target: string, language: string) {
|
||||
const callType = CALL_NODE_TYPES[language] ?? 'call'
|
||||
return collectNodes(root, callType).filter((call) => {
|
||||
// Python 的 `math.sqrt(4)` 也算调用了 sqrt() —— 和 `from math import sqrt` 后写 `sqrt(4)`
|
||||
// 是一回事,老师配「必须调用 sqrt()」时想的就是它。只认 import 进来的模块,
|
||||
// `s.count()` 这种对象上的方法还是归「调用方法」
|
||||
const modules = language === 'Python' ? importedModules(root) : null
|
||||
return collectNodes(root, ofType(callType)).filter((call) => {
|
||||
const fn = call.childForFieldName('function')
|
||||
if (!fn) return false
|
||||
if (fn.type === 'identifier') return fn.text === target
|
||||
if (modules && fn.type === 'attribute') {
|
||||
const object = fn.childForFieldName('object')
|
||||
return object?.type === 'identifier' && modules.has(object.text) && fn.childForFieldName('attribute')?.text === target
|
||||
}
|
||||
// `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于
|
||||
// 有没有 using namespace std,两种都得认,所以末段也比一次
|
||||
if (language === 'C++' && fn.type === 'qualified_identifier') {
|
||||
@@ -265,13 +348,13 @@ function methodCalls(root: Node, target: string, language: string) {
|
||||
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
|
||||
// 会静默地永远失败
|
||||
if (language === 'C++') {
|
||||
return collectNodes(root, 'call_expression').filter((call) => {
|
||||
return collectNodes(root, ofType('call_expression')).filter((call) => {
|
||||
const fn = call.childForFieldName('function')
|
||||
return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target
|
||||
})
|
||||
}
|
||||
if (language !== 'Python') return []
|
||||
return collectNodes(root, 'call').filter((call) => {
|
||||
return collectNodes(root, ofType('call')).filter((call) => {
|
||||
const fn = call.childForFieldName('function')
|
||||
return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target
|
||||
})
|
||||
@@ -279,20 +362,20 @@ function methodCalls(root: Node, target: string, language: string) {
|
||||
|
||||
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
|
||||
const target = rule.target ?? ''
|
||||
const nodeType = astTargetNodeType(target, language)
|
||||
const match = rule.engine === 'must_use_operator' ? operatorMatch(target, language) : nodeMatch(target, language)
|
||||
|
||||
switch (rule.engine) {
|
||||
case 'must_exist_node':
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: hasNode(root, nodeType),
|
||||
passed: hasNode(root, match),
|
||||
}
|
||||
case 'must_not_exist_node': {
|
||||
const nodes = collectNodes(root, nodeType)
|
||||
const nodes = collectNodes(root, match)
|
||||
return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes)
|
||||
}
|
||||
case 'count_node': {
|
||||
const nodes = collectNodes(root, nodeType)
|
||||
const nodes = collectNodes(root, match)
|
||||
return withLines(
|
||||
{
|
||||
description: describeAstRule(rule, language),
|
||||
@@ -335,7 +418,7 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
|
||||
case 'must_use_operator':
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: hasNode(root, nodeType),
|
||||
passed: hasNode(root, match),
|
||||
}
|
||||
case 'must_use_keyword':
|
||||
return {
|
||||
@@ -345,9 +428,8 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
|
||||
case 'must_have_nesting': {
|
||||
const outer = rule.outer ?? ''
|
||||
const inner = rule.inner ?? ''
|
||||
const outerType = astTargetNodeType(outer, language)
|
||||
const innerType = astTargetNodeType(inner, language)
|
||||
const passed = collectNodes(root, outerType).some((node) => node.children.some((child) => hasNode(child, innerType)))
|
||||
const innerMatch = nodeMatch(inner, language)
|
||||
const passed = collectNodes(root, nodeMatch(outer, language)).some((node) => node.children.some((child) => hasNode(child, innerMatch)))
|
||||
return { description: describeAstRule(rule, language), passed }
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { adminLessonRequestSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
|
||||
import { adminLessonRequestSchema, lessonModeSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
|
||||
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
@@ -24,7 +24,12 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
|
||||
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
|
||||
const className = c.req.query('className')?.trim()
|
||||
const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 })
|
||||
const where = and(className ? eq(schema.classLesson.className, className) : undefined, teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined)
|
||||
const mode = lessonModeSchema.safeParse(c.req.query('mode')).data
|
||||
const where = and(
|
||||
className ? eq(schema.classLesson.className, className) : undefined,
|
||||
teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined,
|
||||
mode ? eq(schema.classLesson.mode, mode) : undefined,
|
||||
)
|
||||
|
||||
const [rows, [totalRow], teachers] = await Promise.all([
|
||||
db
|
||||
@@ -156,10 +161,14 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
||||
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
|
||||
const classes = await affectedClasses(id, result.values.className)
|
||||
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
|
||||
// 时刻只填到分:没改时刻就留着原来带秒的那个。不然只改了做什么,这一节也被挪到这一分钟的开头,
|
||||
// 排到同一分钟里后布置的那节前面去 —— 「今天最后一节」就换了人(先画流程图、紧接着再开一节写代码,常在同一分钟里)
|
||||
const [current] = await db.select({ createdAt: schema.classLesson.createdAt }).from(schema.classLesson).where(eq(schema.classLesson.id, id))
|
||||
const sameMinute = current && Math.floor(Date.parse(current.createdAt) / 60_000) * 60_000 === Date.parse(result.values.createdAt)
|
||||
// 布置的老师不换,和任务页上「改」一样认第一次
|
||||
const [row] = await db
|
||||
.update(schema.classLesson)
|
||||
.set({ ...result.values, updatedAt: new Date().toISOString() })
|
||||
.set({ ...result.values, createdAt: sameMinute ? current.createdAt : result.values.createdAt, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.classLesson.id, id))
|
||||
.returning({ id: schema.classLesson.id })
|
||||
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract'
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
|
||||
import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract'
|
||||
import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { requireAuth, type AppEnv } from '../auth/middleware'
|
||||
@@ -9,7 +9,6 @@ import { success } from '../http'
|
||||
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
|
||||
import { accepted } from '../services/learning-stats'
|
||||
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
|
||||
import { calendarDay } from '../time'
|
||||
|
||||
/**
|
||||
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
|
||||
@@ -145,7 +144,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
|
||||
|
||||
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
|
||||
const ids = lesson.problems.map((problem) => problem.id)
|
||||
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null
|
||||
const steps = lesson.steps
|
||||
const codeLanguage = steps ? steps.language : lesson.language
|
||||
// 代码提交和流程图提交两张表,互不依赖,一起查
|
||||
const [mine, drawn] = await Promise.all([
|
||||
@@ -193,7 +192,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
|
||||
problems: lesson.problems.map((problem) => {
|
||||
const code = stepOf(codeById.get(problem.id))
|
||||
const draw = stepOf(drawById.get(problem.id))
|
||||
const needs = steps?.needs.get(problem.id)
|
||||
const needs = steps?.needsOf(problem.id)
|
||||
let myStatus: Step
|
||||
if (needs) {
|
||||
// 老师布置的:要哪几步就看哪几步,全做到才算做完
|
||||
@@ -215,39 +214,3 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
|
||||
}),
|
||||
} satisfies ClassActivity)
|
||||
})
|
||||
|
||||
/**
|
||||
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
|
||||
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
|
||||
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
|
||||
*
|
||||
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
|
||||
*/
|
||||
async function todaySteps(
|
||||
className: string,
|
||||
problemIds: number[],
|
||||
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
|
||||
) {
|
||||
const rows = await db
|
||||
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
|
||||
.from(schema.classLesson)
|
||||
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
|
||||
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||
const needs = new Map<number, { draw: boolean; code: boolean }>()
|
||||
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
|
||||
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
|
||||
for (const row of rows) {
|
||||
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
|
||||
if (!shared.length) continue
|
||||
const rowNeeds = lessonNeeds(row.mode)
|
||||
for (const id of shared) {
|
||||
const known = needs.get(id) ?? { draw: false, code: false }
|
||||
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
|
||||
}
|
||||
if (!languageFound && rowNeeds.code) {
|
||||
language = row.language ?? null
|
||||
languageFound = true
|
||||
}
|
||||
}
|
||||
return { needs, language }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mascotEventSchema, type MascotProfile } from '@oj2/contract'
|
||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { requireAuth, type AppEnv } from '../auth/middleware'
|
||||
import { db, schema } from '../db'
|
||||
import { parseBody, success } from '../http'
|
||||
import { dayStart, weekStart } from '../time'
|
||||
|
||||
export const mascotRoutes = new Hono<AppEnv>()
|
||||
|
||||
/**
|
||||
* 每人每分钟最多记这么多条。正常点她一分钟到不了十条(连点只记开头、彩蛋和嫌烦那几下),
|
||||
* 拿脚本刷的才会撞上;撞上了就静默丢掉,不报错 —— 前台本来也不等这个结果
|
||||
*/
|
||||
const EVENTS_PER_MINUTE = 30
|
||||
const windows = new Map<number, { start: number; count: number }>()
|
||||
|
||||
function allow(userId: number) {
|
||||
const now = Date.now()
|
||||
const entry = windows.get(userId)
|
||||
if (!entry || now - entry.start >= 60_000) {
|
||||
windows.set(userId, { start: now, count: 1 })
|
||||
// 顺手清掉过期的,免得 Map 跟着在线人数一直涨
|
||||
if (windows.size > 2000) for (const [id, it] of windows) if (now - it.start >= 60_000) windows.delete(id)
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= EVENTS_PER_MINUTE
|
||||
}
|
||||
|
||||
/** 记一条看板娘互动(种类见契约 mascotEventSchema) */
|
||||
mascotRoutes.post('/mascot/events', requireAuth, async (c) => {
|
||||
const parsed = await parseBody(c, mascotEventSchema, '看板娘记录的格式不对')
|
||||
if (!parsed.success) return parsed.response
|
||||
const user = c.get('user')!
|
||||
if (allow(user.id)) {
|
||||
const { kind, situation, line, action, path } = parsed.data
|
||||
await db.insert(schema.mascotEvent).values({ userId: user.id, kind, situation, line, action, path })
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
/** 最近多少次题库提交拿来看学生常犯哪类错 */
|
||||
const RECENT_SUBMISSIONS = 20
|
||||
|
||||
/** 看板娘按学生的行为挑话用的那几个数(契约 mascotProfileSchema) */
|
||||
mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
|
||||
const userId = c.get('user')!.id
|
||||
const s = schema.submission
|
||||
|
||||
const week = weekStart()
|
||||
const e = schema.mascotEvent
|
||||
const [pokes, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.mascotEvent)
|
||||
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
|
||||
db
|
||||
.select({ value: countDistinct(s.problemId) })
|
||||
.from(s)
|
||||
.where(and(eq(s.userId, userId), eq(s.result, 0), gte(s.createTime, dayStart()))),
|
||||
db
|
||||
.select({ result: s.result, errorType: sql<string | null>`${s.statisticInfo}->'runtime_error'->>'type'` })
|
||||
.from(s)
|
||||
.where(and(eq(s.userId, userId), isNull(s.contestId)))
|
||||
.orderBy(desc(s.createTime))
|
||||
.limit(RECENT_SUBMISSIONS),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.reaction)
|
||||
.where(and(eq(schema.reaction.userId, userId), eq(schema.reaction.type, 'too_hard'))),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(s)
|
||||
.where(and(eq(s.userId, userId), isNull(s.contestId), gte(s.createTime, week))),
|
||||
db
|
||||
.select({ kind: e.kind, value: count() })
|
||||
.from(e)
|
||||
.where(and(eq(e.userId, userId), inArray(e.kind, ['trial', 'help']), gte(e.createdAt, week)))
|
||||
.groupBy(e.kind),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.aiHint)
|
||||
.innerJoin(s, eq(s.id, schema.aiHint.submissionId))
|
||||
.where(and(eq(s.userId, userId), gte(schema.aiHint.createTime, week))),
|
||||
])
|
||||
const eventCount = (kind: string) => weekEvents.find((row) => row.kind === kind)?.value ?? 0
|
||||
|
||||
const types = new Map<string, number>()
|
||||
for (const row of recent) if (row.result === 4 && row.errorType) types.set(row.errorType, (types.get(row.errorType) ?? 0) + 1)
|
||||
const topType = [...types.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
|
||||
const tally = (...results: number[]) => recent.filter((row) => results.includes(row.result)).length
|
||||
|
||||
return success(c, {
|
||||
pokes: pokes[0]?.value ?? 0,
|
||||
todaySolved: today[0]?.value ?? 0,
|
||||
tooHard: tooHard[0]?.value ?? 0,
|
||||
week: {
|
||||
submits: weekSubmits[0]?.value ?? 0,
|
||||
trials: eventCount('trial'),
|
||||
helps: eventCount('help'),
|
||||
aiHints: weekHints[0]?.value ?? 0,
|
||||
},
|
||||
recent: {
|
||||
total: recent.length,
|
||||
wrongAnswer: tally(-1),
|
||||
compileError: tally(-2),
|
||||
runtimeError: tally(4, 3),
|
||||
timeout: tally(1, 2),
|
||||
runtimeErrorType: topType,
|
||||
},
|
||||
} satisfies MascotProfile)
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ProblemListItem,
|
||||
type ProblemProgress,
|
||||
type ProblemTypeFilter,
|
||||
type SimilarProblem,
|
||||
type Tag,
|
||||
} from '@oj2/contract'
|
||||
import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
|
||||
@@ -327,13 +328,25 @@ problemRoutes.get('/challenges', requireAuth, async (c) => {
|
||||
|
||||
problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
|
||||
const [target] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.select({ id: schema.problem.id, difficulty: schema.problem.difficulty })
|
||||
.from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId)))
|
||||
.limit(1)
|
||||
if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found')
|
||||
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id))
|
||||
if (targetTags.length === 0) return success(c, [])
|
||||
// 知识点和主题标签一样算(用户 2026-11-03 定:同一套题材也算相似)。知识点排在前面
|
||||
// 只是为了「共同的标签」那行先写练的是什么
|
||||
const targetTags = await db
|
||||
.select({ id: schema.problemTags.problemtagId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(eq(schema.problemTags.problemId, target.id))
|
||||
// 同一类里大的在前,和题目列表「知识点」那列一样
|
||||
.orderBy(
|
||||
sql`${schema.problemTag.category} = 'knowledge' desc`,
|
||||
sql`(select count(*) from ${schema.problemTags} pt where pt.problemtag_id = ${schema.problemTag.id}) desc`,
|
||||
asc(schema.problemTag.name),
|
||||
)
|
||||
if (targetTags.length === 0) return success(c, [] satisfies SimilarProblem[])
|
||||
// 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的
|
||||
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
|
||||
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
|
||||
@@ -343,7 +356,12 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
|
||||
const solvedIds = states ? [...states.solved] : []
|
||||
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
|
||||
// High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。
|
||||
// 共同标签一样多时先给和原题难度最接近的:原来一律简单的在前,做对一道困难题,
|
||||
// 五条推荐里三条多是简单题(本地库 35 道困难题:119 / 175)。再并列才先简单的,
|
||||
// 照顾连错三次的人
|
||||
const rankOf = (difficulty: string) => (difficulty === 'Low' ? 0 : difficulty === 'Mid' ? 1 : 2)
|
||||
const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end`
|
||||
const difficultyGap = sql`abs(${difficultyRank} - ${sql.raw(String(rankOf(target.difficulty)))})`
|
||||
const sharedTags = count(schema.problemTags.problemtagId)
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -376,13 +394,16 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
|
||||
)
|
||||
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
|
||||
// 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条
|
||||
.orderBy(desc(sharedTags), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id))
|
||||
.orderBy(desc(sharedTags), asc(difficultyGap), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id))
|
||||
.limit(5)
|
||||
const ids = rows.map((row) => row.problem.id)
|
||||
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
|
||||
return success(
|
||||
c,
|
||||
rows.map((row) => listItem(row, tags, statuses, states, counts)),
|
||||
rows.map((row) => {
|
||||
const own = new Set(tags.get(row.problem.id))
|
||||
return { ...listItem(row, tags, statuses, states, counts), sharedTags: targetTags.map((tag) => tag.name).filter((name) => own.has(name)) }
|
||||
}) satisfies SimilarProblem[],
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -252,14 +252,7 @@ rankingRoutes.get('/rankings/champions', optionalAuth, async (c) => {
|
||||
const champions: WeeklyChampion[] = []
|
||||
for (let k = 1; k <= CHAMPION_WEEKS; k++) {
|
||||
const from = thisWeek - k * WEEK_MS
|
||||
const inWeek = entrants.map((entrant) => {
|
||||
const keep = entrant.times.map((time, index) => [time, entrant.stamps[index]!] as const).filter(([time]) => time >= from && time < from + WEEK_MS)
|
||||
return {
|
||||
...entrant,
|
||||
times: keep.map(([time]) => time),
|
||||
stamps: keep.map(([, stamp]) => stamp),
|
||||
}
|
||||
})
|
||||
const inWeek = entrants.map((entrant) => ({ ...entrant, times: entrant.times.filter((time) => time >= from && time < from + WEEK_MS) }))
|
||||
const [top] = standings(inWeek, null, false)
|
||||
if (!top) continue
|
||||
champions.push({
|
||||
|
||||
@@ -527,6 +527,11 @@ const LESSON_LIVE_MINUTES = 15
|
||||
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
|
||||
const LESSONS_DEFAULT = 50
|
||||
const LESSONS_MAX = 500
|
||||
/** 课只算学生(STUDENT_ROLES,学生管理员也算),老师试题的提交不算 */
|
||||
const studentRoles = sql.join(
|
||||
STUDENT_ROLES.map((role) => sql`${role}`),
|
||||
sql`, `,
|
||||
)
|
||||
|
||||
type StatisticsRange = { start: string | null; end: string }
|
||||
|
||||
@@ -599,7 +604,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
|
||||
where s.contest_id is null and p.mode <> 'flowchart'
|
||||
and s.create_time <= ${range.end}
|
||||
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
|
||||
and u.admin_type = 'Regular User' and u.is_disabled = false
|
||||
and u.admin_type in (${studentRoles}) and u.is_disabled = false
|
||||
union all
|
||||
select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join(
|
||||
FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`),
|
||||
@@ -612,7 +617,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
|
||||
where p.mode <> 'code'
|
||||
and f.create_time <= ${range.end}
|
||||
${range.start ? sql`and f.create_time >= ${range.start}` : sql``}
|
||||
and u.admin_type = 'Regular User' and u.is_disabled = false
|
||||
and u.admin_type in (${studentRoles}) and u.is_disabled = false
|
||||
)
|
||||
select k, problem_id,
|
||||
count(distinct user_id)::int as users,
|
||||
@@ -721,8 +726,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
||||
where s.contest_id is null
|
||||
and s.create_time <= ${range.end}
|
||||
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
|
||||
-- 只算普通学生:老师试题的提交不该把一段拼成「一节课」
|
||||
and u.admin_type = 'Regular User' and u.is_disabled = false
|
||||
-- 只算学生(学生管理员也是学生):老师试题的提交不该把一段拼成「一节课」
|
||||
and u.admin_type in (${studentRoles}) and u.is_disabled = false
|
||||
),
|
||||
g as (
|
||||
select *,
|
||||
|
||||
+27
-12
@@ -5,7 +5,6 @@ import {
|
||||
type TaskBoard,
|
||||
type TaskBoardStudent,
|
||||
type ClassLesson,
|
||||
lessonNeeds,
|
||||
type StatisticInfo,
|
||||
} from '@oj2/contract'
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
|
||||
@@ -127,9 +126,14 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
const challengeDoneKeys = new Set(
|
||||
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
|
||||
)
|
||||
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)
|
||||
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)。
|
||||
// 格子按每道题合起来的要求算(todayNeeds):先画流程图一节、再写代码一节,没画图的照样标「缺图」,
|
||||
// 和学生那边的「做完」对得上。代码按的语言也是合起来那份:最后一节只画流程图时,用前面写代码那节的
|
||||
const mode = lesson.source === 'teacher' ? lesson.mode : null
|
||||
const needs = mode ? lessonNeeds(mode) : null
|
||||
const steps = lesson.steps
|
||||
const needsOf = (problemId: number) => steps?.needsOf(problemId) ?? null
|
||||
const anyDraw = ids.some((id) => needsOf(id)?.draw)
|
||||
const codeLanguage = steps ? steps.language : lesson.language
|
||||
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
|
||||
ids.length && userIds.length
|
||||
? db
|
||||
@@ -145,7 +149,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.userId, userIds),
|
||||
inArray(schema.submission.problemId, ids),
|
||||
lessonLanguageFilter(lesson.language),
|
||||
lessonLanguageFilter(codeLanguage),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||
@@ -223,7 +227,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
inArray(schema.submission.userId, userIds),
|
||||
inArray(schema.submission.problemId, ids),
|
||||
gte(schema.submission.createTime, start),
|
||||
lessonLanguageFilter(lesson.language),
|
||||
lessonLanguageFilter(codeLanguage),
|
||||
),
|
||||
)
|
||||
.orderBy(schema.submission.createTime)
|
||||
@@ -234,7 +238,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
|
||||
* 老评法和还没评完的为 null
|
||||
*/
|
||||
needs?.draw && ids.length && userIds.length
|
||||
anyDraw && ids.length && userIds.length
|
||||
? db
|
||||
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
|
||||
userId: schema.flowchartSubmission.userId,
|
||||
@@ -281,6 +285,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
title: problem.title,
|
||||
isSql: problem.isSql,
|
||||
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
|
||||
mode: steps?.modeOf(problem.id) ?? null,
|
||||
})),
|
||||
students: roster.map(
|
||||
(student) =>
|
||||
@@ -289,6 +294,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
username: student.username,
|
||||
realName: student.realName ?? null,
|
||||
cells: ids.map((id) => {
|
||||
const needs = needsOf(id)
|
||||
const key = `${student.userId}:${id}`
|
||||
const cell = cellByKey.get(key)
|
||||
const flow = drawnByKey.get(key)
|
||||
@@ -305,11 +311,11 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
|
||||
} else {
|
||||
// 要哪几步看哪几步:两步都做到的时刻是晚的那一步
|
||||
const steps = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
|
||||
const wanted = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
|
||||
(step) => step !== null,
|
||||
)
|
||||
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null
|
||||
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none'
|
||||
acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null
|
||||
status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none'
|
||||
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
|
||||
}
|
||||
// 下面几样都是代码那一步的:只画流程图的课没有
|
||||
@@ -352,8 +358,9 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||
}))
|
||||
: [],
|
||||
acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
|
||||
// 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈
|
||||
challenge:
|
||||
!codeWanted || !codeDone || !liveChallenges.get(id)?.size
|
||||
!codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size
|
||||
? null
|
||||
: challengeDoneKeys.has(key)
|
||||
? 'done'
|
||||
@@ -410,9 +417,17 @@ taskRoutes.get('/task/today', requireTeacher, async (c) => {
|
||||
const ids = lesson.problems.map((problem) => problem.id)
|
||||
let allDoneCount = 0
|
||||
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
|
||||
// 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码)
|
||||
// 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码)
|
||||
const counts = await lessonDoneCounts([
|
||||
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode },
|
||||
{
|
||||
id: lesson.lessonId,
|
||||
className,
|
||||
day: calendarDay(),
|
||||
problemIds: ids,
|
||||
language: lesson.steps?.language ?? lesson.language,
|
||||
mode: lesson.mode,
|
||||
modeOf: lesson.steps?.modeOf,
|
||||
},
|
||||
])
|
||||
allDoneCount = counts.get(lesson.lessonId) ?? 0
|
||||
} else if (ids.length) {
|
||||
|
||||
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{
|
||||
key: 'accepted_count',
|
||||
name: '做对的题数',
|
||||
helpText: '去重后通过的题目数量(不含比赛)',
|
||||
helpText: '去重后通过的题目数量(含比赛题)',
|
||||
},
|
||||
{
|
||||
key: 'mid_ac_count',
|
||||
name: '做对的中等题',
|
||||
helpText: '去重后通过的中等难度题目数(不含比赛)',
|
||||
helpText: '去重后通过的中等难度题目数(含比赛题)',
|
||||
},
|
||||
{
|
||||
key: 'hard_ac_count',
|
||||
name: '做对的困难题',
|
||||
helpText: '去重后通过的困难题目数(不含比赛)',
|
||||
helpText: '去重后通过的困难题目数(含比赛题)',
|
||||
},
|
||||
{
|
||||
key: 'submission_count',
|
||||
name: '提交次数',
|
||||
helpText: '提交次数(不含比赛)',
|
||||
helpText: '提交次数(含比赛)',
|
||||
},
|
||||
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
|
||||
{
|
||||
@@ -45,13 +45,13 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{
|
||||
key: 'max_ac_week_streak',
|
||||
name: '连续有新做对的周数',
|
||||
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)',
|
||||
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
|
||||
},
|
||||
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
|
||||
{
|
||||
key: 'contest_joined',
|
||||
name: '参加比赛',
|
||||
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)',
|
||||
helpText: '交过题的比赛数量',
|
||||
},
|
||||
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
|
||||
{
|
||||
@@ -59,6 +59,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
name: '做完的专题',
|
||||
helpText: '完成的专题数量',
|
||||
},
|
||||
{
|
||||
key: 'challenge_count',
|
||||
name: '挑战成功',
|
||||
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
|
||||
},
|
||||
{
|
||||
key: 'first_try_ac_count',
|
||||
name: '一次就对的题',
|
||||
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{
|
||||
key: 'max_code_lines',
|
||||
name: '最长的代码行数',
|
||||
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)',
|
||||
helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
|
||||
},
|
||||
{
|
||||
key: 'achievement_unlocked_count',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
||||
import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
||||
|
||||
import { db, schema, type DbOrTx } from '../db'
|
||||
import { publishAchievementNotification } from '../events'
|
||||
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
|
||||
|
||||
/**
|
||||
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
||||
* 参加过几场比赛、挑战成功几次也在这里一起数。
|
||||
*
|
||||
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
||||
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
||||
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
||||
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
||||
* 一个人的提交也就几百条。
|
||||
*
|
||||
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
|
||||
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
|
||||
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
|
||||
*/
|
||||
async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||
const [row] = await tx.execute<{
|
||||
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||
mid: number
|
||||
hard: number
|
||||
first_try: number
|
||||
contests: number
|
||||
challenges: number
|
||||
}>(
|
||||
sql`
|
||||
with mine as (
|
||||
select s.problem_id, s.result, s.create_time, s.id, p.difficulty
|
||||
select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
|
||||
from ${schema.submission} s
|
||||
join ${schema.problem} p on p.id = s.problem_id
|
||||
where s.user_id = ${userId} and s.contest_id is null
|
||||
where s.user_id = ${userId}
|
||||
)
|
||||
select
|
||||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
||||
@@ -111,11 +118,13 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||
select count(*)::int from (
|
||||
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
||||
) first where result in (${ACCEPTED_RESULTS})
|
||||
) as first_try
|
||||
) as first_try,
|
||||
count(distinct contest_id)::int as contests,
|
||||
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
|
||||
from mine
|
||||
`,
|
||||
)
|
||||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 }
|
||||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +139,7 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.submission.id, submissionId))
|
||||
.limit(1)
|
||||
if (!row || row.submission.contestId !== null) return []
|
||||
if (!row) return []
|
||||
|
||||
const rejudged = countedAs !== null
|
||||
const accepted = isAccepted(row.submission.result)
|
||||
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
||||
value.mid_ac_count = solved.mid
|
||||
value.hard_ac_count = solved.hard
|
||||
value.first_try_ac_count = solved.first_try
|
||||
value.contest_joined = solved.contests
|
||||
value.challenge_count = solved.challenges
|
||||
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
|
||||
if (firstAc) {
|
||||
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
||||
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
||||
and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
lt(schema.submission.createTime, row.submission.createTime),
|
||||
),
|
||||
)
|
||||
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
|
||||
const metric = findMetric(achievement.metric)
|
||||
if (!metric) return { scanned: 0, unlocked: 0 }
|
||||
|
||||
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
|
||||
// contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
|
||||
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
|
||||
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
|
||||
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
|
||||
@@ -482,7 +492,7 @@ async function refreshContestJoinedForAll() {
|
||||
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
|
||||
* 其余指标不动,写法同 refreshContestJoinedForAll)。
|
||||
*
|
||||
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
||||
* 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
||||
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
|
||||
*/
|
||||
async function refreshAcWeekStreakForAll() {
|
||||
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
|
||||
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
|
||||
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(and(isNull(schema.submission.contestId), inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])))
|
||||
.where(inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||
.as('first_accepted')
|
||||
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
|
||||
|
||||
@@ -59,7 +59,16 @@ export function lessonDayEnd(day: string) {
|
||||
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
|
||||
*/
|
||||
export async function lessonDoneCounts(
|
||||
lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null; mode: LessonMode }[],
|
||||
lessons: {
|
||||
id: number
|
||||
className: string
|
||||
day: string
|
||||
problemIds: number[]
|
||||
language: LessonLanguage | null
|
||||
mode: LessonMode
|
||||
/** 每道题各自做什么(老师首页按今天几节合起来的要求算,见 todayNeeds);不给就都按 mode */
|
||||
modeOf?: (problemId: number) => LessonMode
|
||||
}[],
|
||||
) {
|
||||
const rows = lessons.flatMap((lesson) =>
|
||||
lesson.problemIds.map((problemId) => ({
|
||||
@@ -67,7 +76,7 @@ export async function lessonDoneCounts(
|
||||
class_name: lesson.className,
|
||||
problem_id: problemId,
|
||||
language: lesson.language,
|
||||
mode: lesson.mode,
|
||||
mode: lesson.modeOf?.(problemId) ?? lesson.mode,
|
||||
until: lessonDayEnd(lesson.day),
|
||||
})),
|
||||
)
|
||||
@@ -281,6 +290,45 @@ async function lessonPlan(className: string, day: string) {
|
||||
return row?.problemIds.length ? row : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 一道题今天要画还是要写:这个班今天布置过的每一节里,凡是有这道题的,要求合起来算。
|
||||
* 老师先布置一节画流程图、再「用这几道题写代码」开一节时,还没画完的照样要画 ——
|
||||
* 只看今天最后布置的那一节的话,一布置写代码,画图那一步就没了。学生的课堂条、任务页的格子、
|
||||
* 老师首页的「全做对」都按它算,三处说的「做完」才一样(后台和数据统计还是一节一行、各数各的)。
|
||||
*
|
||||
* `language`:代码按什么语言数 —— 今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
|
||||
*/
|
||||
export async function todayNeeds(className: string, problemIds: number[], latest: { id: number; language: LessonLanguage | null; mode: LessonMode }) {
|
||||
const rows = await db
|
||||
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
|
||||
.from(schema.classLesson)
|
||||
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
|
||||
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||
const needs = new Map<number, { draw: boolean; code: boolean }>()
|
||||
let language: LessonLanguage | null = lessonNeeds(latest.mode).code ? latest.language : null
|
||||
let languageFound = lessonNeeds(latest.mode).code
|
||||
for (const row of rows) {
|
||||
const shared = row.id === latest.id ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
|
||||
if (!shared.length) continue
|
||||
const rowNeeds = lessonNeeds(row.mode)
|
||||
for (const id of shared) {
|
||||
const known = needs.get(id) ?? { draw: false, code: false }
|
||||
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
|
||||
}
|
||||
if (!languageFound && rowNeeds.code) {
|
||||
language = row.language ?? null
|
||||
languageFound = true
|
||||
}
|
||||
}
|
||||
const latestNeeds = lessonNeeds(latest.mode)
|
||||
const needsOf = (id: number) => needs.get(id) ?? latestNeeds
|
||||
const modeOf = (id: number): LessonMode => {
|
||||
const { draw, code } = needsOf(id)
|
||||
return draw && code ? 'both' : draw ? 'flowchart' : 'code'
|
||||
}
|
||||
return { needsOf, modeOf, language }
|
||||
}
|
||||
|
||||
/**
|
||||
* 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher),
|
||||
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
|
||||
@@ -294,17 +342,23 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
|
||||
const plan = await lessonPlan(className, today)
|
||||
if (plan) {
|
||||
const planned = plan.problemIds
|
||||
const [problems, groups] = await Promise.all([
|
||||
const steps = await todayNeeds(className, planned, plan)
|
||||
// 每道题按它合起来的要求数「做过 / 做对」:要求一样的几道一起数
|
||||
const byMode = Map.groupBy(planned, (id) => steps.modeOf(id))
|
||||
const [problems, ...groupLists] = await Promise.all([
|
||||
visibleProblems(planned),
|
||||
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
|
||||
...[...byMode].map(([mode, ids]) =>
|
||||
classDayGroups(className, dayStart(), { problemIds: ids, language: lessonNeeds(mode).code ? steps.language : null, mode }),
|
||||
),
|
||||
])
|
||||
const groupById = new Map(groups.map((row) => [row.problemId, row]))
|
||||
const groupById = new Map(groupLists.flat().map((row) => [row.problemId, row]))
|
||||
return {
|
||||
source: 'teacher' as const,
|
||||
lessonId: plan.id,
|
||||
day: today,
|
||||
language: plan.language ?? null,
|
||||
mode: plan.mode,
|
||||
steps,
|
||||
assignedAt: plan.createdAt,
|
||||
assignedBy: plan.createdBy,
|
||||
problems: planned.flatMap((id) => {
|
||||
@@ -337,6 +391,7 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
|
||||
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
|
||||
language: null,
|
||||
mode: null,
|
||||
steps: null,
|
||||
assignedAt: null,
|
||||
assignedBy: null,
|
||||
problems: picked.flatMap((row) => {
|
||||
|
||||
@@ -36,9 +36,8 @@ export interface Entrant {
|
||||
className: string | null
|
||||
avatar: string | null
|
||||
mood: string | null
|
||||
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
|
||||
/** 每道题第一次做对的时刻(毫秒),升序 */
|
||||
times: number[]
|
||||
stamps: string[]
|
||||
}
|
||||
|
||||
export interface Standing {
|
||||
@@ -64,10 +63,26 @@ export function avatarOf(avatar: string | null) {
|
||||
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
|
||||
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
|
||||
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
|
||||
* contest_id IS NULL),全服全部历史实测 24ms。
|
||||
* contest_id IS NULL)。
|
||||
*
|
||||
* 时刻在库里就换成毫秒数、按人排好聚成数组再拿回来:全服全部历史有六万多个 (人, 题),
|
||||
* 一行一个 timestamptz 的话,光 db/index.ts 那个保留微秒的 parser 加 `Date.parse` 就
|
||||
* 要 150ms(两核上),库里的查询本身才 40ms。截到毫秒和原来 `Date.parse` 比较的精度一样,
|
||||
* 名次不变;`reachedAt` 只拿来显示「比你早多久」,用不着微秒。
|
||||
*/
|
||||
export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
|
||||
const firstSolved = min(schema.submission.createTime)
|
||||
const firsts = db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
at: sql<number>`floor(extract(epoch from ${firstSolved}) * 1000)::float8`.as('at'),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(and(where, isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||
.having(since ? gte(firstSolved, since) : undefined)
|
||||
.as('firsts')
|
||||
const [people, solves] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
@@ -81,33 +96,20 @@ export async function loadEntrants(where: SQL, since: string | null): Promise<En
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where),
|
||||
db
|
||||
.select({ userId: schema.submission.userId, at: firstSolved })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(and(where, isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||
.having(since ? gte(firstSolved, since) : undefined),
|
||||
.select({ userId: firsts.userId, times: sql<number[]>`array_agg(${firsts.at} order by ${firsts.at})` })
|
||||
.from(firsts)
|
||||
.groupBy(firsts.userId),
|
||||
])
|
||||
|
||||
const byUser = new Map<number, string[]>()
|
||||
for (const row of solves) {
|
||||
if (!row.at) continue
|
||||
const list = byUser.get(row.userId)
|
||||
if (list) list.push(row.at)
|
||||
else byUser.set(row.userId, [row.at])
|
||||
}
|
||||
return people.map((person) => {
|
||||
const pairs = (byUser.get(person.id) ?? []).map((stamp) => [Date.parse(stamp), stamp] as const).sort((a, b) => a[0] - b[0])
|
||||
return {
|
||||
id: person.id,
|
||||
username: person.username,
|
||||
className: person.className,
|
||||
avatar: avatarOf(person.avatar),
|
||||
mood: person.mood?.trim() || null,
|
||||
times: pairs.map(([time]) => time),
|
||||
stamps: pairs.map(([, stamp]) => stamp),
|
||||
}
|
||||
})
|
||||
const byUser = new Map(solves.map((row) => [row.userId, row.times]))
|
||||
return people.map((person) => ({
|
||||
id: person.id,
|
||||
username: person.username,
|
||||
className: person.className,
|
||||
avatar: avatarOf(person.avatar),
|
||||
mood: person.mood?.trim() || null,
|
||||
times: byUser.get(person.id) ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +129,7 @@ export function standings(entrants: Entrant[], cutoff: number | null, includeZer
|
||||
entrant,
|
||||
solved,
|
||||
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
|
||||
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
|
||||
reachedAt: solved ? new Date(entrant.times[solved - 1]!).toISOString() : null,
|
||||
}
|
||||
})
|
||||
.filter((row) => includeZero || row.solved > 0)
|
||||
|
||||
@@ -786,9 +786,9 @@ export function deleteAchievement(id: number) {
|
||||
|
||||
// ---------- 课堂任务(class_lesson) ----------
|
||||
|
||||
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) {
|
||||
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null; mode: string }) {
|
||||
return api.get<AdminLessonList>('admin/lessons', {
|
||||
params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '' },
|
||||
params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '', mode: filters.mode },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList } from '@oj2/contract'
|
||||
import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList, type LessonMode } from '@oj2/contract'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { NButton, NPopconfirm, NTooltip, useThemeVars } from 'naive-ui'
|
||||
import { NButton, NPopconfirm, NTag, NTooltip, useThemeVars } from 'naive-ui'
|
||||
import { dayStartOfDate } from 'oj/statistics/period'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
@@ -27,13 +27,16 @@ const configStore = useConfigStore()
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
// 从任务页「布置过的课」进来带着 ?className=
|
||||
const { query } = usePagination<{ className: string; teacher: string }>({
|
||||
const { query } = usePagination<{ className: string; teacher: string; mode: string }>({
|
||||
className: useRouteQuery('className', '').value,
|
||||
teacher: useRouteQuery('teacher', '').value,
|
||||
mode: useRouteQuery('mode', '').value,
|
||||
})
|
||||
const data = ref<AdminLessonList | null>(null)
|
||||
|
||||
const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])])
|
||||
const MODES: LessonMode[] = ['code', 'both', 'flowchart']
|
||||
const modeOptions = [{ label: '全部做法', value: '' }, ...MODES.map((value) => ({ label: LESSON_MODE_LABEL[value], value }))]
|
||||
const teacherOptions = computed(() => [
|
||||
{ label: '全部老师', value: '' },
|
||||
...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })),
|
||||
@@ -46,13 +49,14 @@ async function list() {
|
||||
getAdminLessons((query.page - 1) * query.limit, query.limit, {
|
||||
className: query.className,
|
||||
teacher: query.teacher ? Number(query.teacher) : null,
|
||||
mode: query.mode,
|
||||
}),
|
||||
)
|
||||
if (res) data.value = res
|
||||
}
|
||||
onMounted(list)
|
||||
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次
|
||||
watch(() => [query.page, query.limit, query.className, query.teacher], list)
|
||||
// 换了班级、老师、做法 usePagination 会把页码拨回 1,同一拍里只拉一次
|
||||
watch(() => [query.page, query.limit, query.className, query.teacher, query.mode], list)
|
||||
|
||||
// ---- 改 / 补 / 删 ----
|
||||
const editing = ref(false)
|
||||
@@ -133,17 +137,24 @@ function renderActions(lesson: AdminLesson) {
|
||||
])
|
||||
}
|
||||
|
||||
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */
|
||||
function wayText(lesson: AdminLesson) {
|
||||
return [lesson.mode !== 'code' ? LESSON_MODE_LABEL[lesson.mode] : '', lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : ''].filter(Boolean).join(' · ')
|
||||
/** 三种做法各一个颜色,一眼分得开;写代码也写出来,不当默认值省掉 */
|
||||
const MODE_TAG_TYPE: Record<LessonMode, 'default' | 'info' | 'warning'> = { code: 'default', both: 'info', flowchart: 'warning' }
|
||||
|
||||
/** 「写代码 C语言」「先画再写 Python」「画流程图」:画流程图不用语言 */
|
||||
function renderWay(lesson: AdminLesson) {
|
||||
return h('div', { class: 'way' }, [
|
||||
h(NTag, { size: 'small', bordered: false, type: MODE_TAG_TYPE[lesson.mode] }, { default: () => LESSON_MODE_LABEL[lesson.mode] }),
|
||||
lesson.language ? h('span', { class: 'way-lang' }, LANGUAGE_SHOW_VALUE[lesson.language]) : null,
|
||||
])
|
||||
}
|
||||
|
||||
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,题号一行,老师 · 语言 · 做完一行小字,操作一行 */
|
||||
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,做什么一行,题号一行,老师 · 做完一行小字,操作一行 */
|
||||
function renderMobileCard(lesson: AdminLesson) {
|
||||
return h('div', { class: 'm-card' }, [
|
||||
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
|
||||
renderWay(lesson),
|
||||
renderProblems(lesson),
|
||||
h('div', { class: 'm-meta' }, [lesson.createdBy, wayText(lesson), `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · ')),
|
||||
h('div', { class: 'm-meta' }, `${lesson.createdBy} · 做完 ${renderDone(lesson)}`),
|
||||
renderActions(lesson),
|
||||
])
|
||||
}
|
||||
@@ -153,7 +164,7 @@ const desktopColumns: DataTableColumn<AdminLesson>[] = [
|
||||
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
|
||||
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
|
||||
{ title: '老师', key: 'createdBy', width: 90 },
|
||||
{ title: '做什么', key: 'language', width: 120, render: (row) => wayText(row) || '—' },
|
||||
{ title: '做什么', key: 'mode', width: 150, render: renderWay },
|
||||
{ title: '题目', key: 'problems', width: 300, render: renderProblems },
|
||||
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
|
||||
{ title: '', key: 'actions', render: renderActions },
|
||||
@@ -173,6 +184,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
|
||||
<template #filters>
|
||||
<n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" :style="{ width: isMobile ? '100%' : '160px' }" />
|
||||
<n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" />
|
||||
<n-select v-model:value="query.mode" :options="modeOptions" aria-label="做什么" :style="{ width: isMobile ? '100%' : '120px' }" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" />
|
||||
@@ -205,6 +217,13 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.lesson-table :deep(.way) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lesson-table :deep(.actions) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
|
||||
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
|
||||
import ClassCard from './components/ClassCard.vue'
|
||||
@@ -59,6 +60,15 @@ async function load() {
|
||||
|
||||
onMounted(load)
|
||||
watch(name, load)
|
||||
|
||||
// 看板娘:只在看自己的成就时说话。「快拿到了」里最近的一个能说成「再做对 3 道」的,报它的名字;
|
||||
// 「还差 3 次」「一天做对 5 道」这种前面加不了「再」,往后找
|
||||
useMascotGuide(() => {
|
||||
if (!data.value || other.value || userStore.isTeacherOrAbove) return null
|
||||
const item = near.value.find((it) => it.remain.startsWith('再'))
|
||||
if (!item) return { situation: 'achievement' }
|
||||
return { situation: 'achievement.near', vars: { name: item.achievement.name, need: item.remain.slice(1) } }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -34,6 +34,12 @@ const METRICS: Record<string, MetricInfo> = {
|
||||
unit: '道',
|
||||
remain: (r) => `再做对 ${r} 道困难题`,
|
||||
},
|
||||
challenge_count: {
|
||||
group: '做题',
|
||||
label: '挑战成功',
|
||||
unit: '次',
|
||||
remain: (r) => `再挑战成功 ${r} 次`,
|
||||
},
|
||||
max_ac_week_streak: {
|
||||
group: '坚持',
|
||||
label: '连着几周有做对',
|
||||
|
||||
@@ -5,6 +5,7 @@ import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import NotFound from 'shared/components/NotFound.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import { errorCode } from 'utils/api'
|
||||
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||
import type { Announcement, AnnouncementListItem } from 'utils/types'
|
||||
@@ -27,6 +28,9 @@ const { isDesktop } = useBreakpoints()
|
||||
const announcements = ref<AnnouncementListItem[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
// 看板娘:只念叨一句,不给按钮
|
||||
useMascotGuide(() => ({ situation: 'announcement' }))
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getAnnouncementList(0, LIMIT)
|
||||
announcements.value = res.results
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type ProblemRank,
|
||||
type CreateSubmissionResponse,
|
||||
type ProblemListItem,
|
||||
type SimilarProblem,
|
||||
type ProblemStats,
|
||||
type ProblemProgress,
|
||||
type CreateFlowchartResponse,
|
||||
@@ -412,7 +413,9 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
|
||||
// ==================== 相似题目推荐 ====================
|
||||
|
||||
export function getSimilarProblems(problemId: string) {
|
||||
return api.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`).then((response) => response.map(toProblemRow))
|
||||
return api
|
||||
.get<SimilarProblem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
|
||||
.then((response) => response.map((item) => ({ ...toProblemRow(item), sharedTags: item.sharedTags })))
|
||||
}
|
||||
|
||||
// ==================== 挑战 ====================
|
||||
|
||||
@@ -12,6 +12,7 @@ import { usePagination } from 'shared/composables/pagination'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||
import { ContestStatus } from 'utils/constants'
|
||||
import ContestBadge from './components/ContestBadge.vue'
|
||||
|
||||
@@ -115,6 +116,14 @@ function open(contest: Contest) {
|
||||
router.push(userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`)
|
||||
}
|
||||
|
||||
// 看板娘:有正在比的就带进去
|
||||
useMascotGuide(() => {
|
||||
if (userStore.isTeacherOrAbove) return null
|
||||
const live = active.value.find((c) => c.status === ContestStatus.underway)
|
||||
if (!live) return { situation: 'contests.none' }
|
||||
return { situation: 'contests.live', actions: [goAction(router, '进比赛', `/contest/${live.id}`)] }
|
||||
})
|
||||
|
||||
function mineText(contest: Contest) {
|
||||
const mine = contest.mine
|
||||
if (!mine) return ''
|
||||
|
||||
@@ -5,11 +5,13 @@ import { Icon } from '@iconify/vue'
|
||||
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
||||
import { getClassActivity } from 'shared/api'
|
||||
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
|
||||
import { pickHeadline, type Headline } from 'oj/user/knowledge'
|
||||
import { levelName, MAX_LEVEL, pickHeadline, remainingToNext, tagLink, type Headline } from 'oj/user/knowledge'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { activityMode } from 'shared/store/lesson'
|
||||
import { focusJumpAction, goAction, randomProblemAction, useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
|
||||
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, KnowledgeMap, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
|
||||
|
||||
interface Track {
|
||||
type: TutorialType
|
||||
@@ -35,6 +37,8 @@ const classActivity = ref<ClassActivity | null>(null)
|
||||
const lastVisit = ref<LastVisit | null>(null)
|
||||
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
|
||||
const knowledgeHeadline = ref<Headline | null>(null)
|
||||
/** 地图原样留着给看板娘:她要知识点名、档位名、还差几道,不是拼好的那句话 */
|
||||
const knowledge = ref<KnowledgeMap | null>(null)
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
||||
@@ -122,7 +126,8 @@ async function loadAnnouncements() {
|
||||
}
|
||||
|
||||
async function loadKnowledge() {
|
||||
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap())
|
||||
knowledge.value = await getKnowledgeMap()
|
||||
knowledgeHeadline.value = pickHeadline(knowledge.value)
|
||||
}
|
||||
|
||||
async function loadClassActivity() {
|
||||
@@ -191,7 +196,7 @@ const classActivityTitle = computed(() => {
|
||||
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||
if (classActivity.value?.source === 'teacher') {
|
||||
const language = classActivity.value.language
|
||||
const mode = classActivity.value.mode
|
||||
const mode = activityMode(classActivity.value)
|
||||
if (mode === 'flowchart') return '这节课 · 画流程图'
|
||||
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
|
||||
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
|
||||
@@ -243,6 +248,86 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 看板娘:今天有课(和「这节课」那张卡同一个条件)就催今天的题,没课就随便做一道
|
||||
let greetedBack = false
|
||||
useMascotGuide(() => {
|
||||
if (!loaded.value) return null
|
||||
const random = randomProblemAction(router)
|
||||
const learn = goAction(router, '自学', '/learn')
|
||||
const lesson = classToday.value && !!classActivity.value?.problems.length
|
||||
const next = nextProblem.value
|
||||
if (lesson && next)
|
||||
return { situation: 'home.assigned', actions: [goAction(router, '今天的题', `/problem/${next.problemDisplayId}`), focusJumpAction(), random] }
|
||||
const base: MascotGuide = lesson
|
||||
? { situation: 'home.done', actions: [random, learn] }
|
||||
: { situation: 'home.free', actions: [focusJumpAction(), random, learn] }
|
||||
// 好久没来:只在这次进来后第一次点她时说,之后照常
|
||||
if (awayDays.value >= 14 && !greetedBack) {
|
||||
greetedBack = true
|
||||
return { ...base, situation: 'home.back', n: awayDays.value }
|
||||
}
|
||||
// 这节课做完了或者没课:从别的数据里挑能说的,每点一次随机说一件,点几下听到的不一样
|
||||
const extras = extraGuides(random)
|
||||
return extras.length ? extras[Math.floor(Math.random() * extras.length)]! : base
|
||||
})
|
||||
|
||||
const DAY_MS = 864e5
|
||||
|
||||
function extraGuides(random: MascotAction): MascotGuide[] {
|
||||
const list: MascotGuide[] = []
|
||||
|
||||
// 比赛:进行中,或者 24 小时内开始(那就只可能是今天或明天)
|
||||
const now = Date.now()
|
||||
const contest = contests.value.find((it) => it.status === ContestStatus.underway || Date.parse(it.startTime) - now < DAY_MS)
|
||||
if (contest) {
|
||||
const when = contest.status === ContestStatus.underway ? '现在' : dayKey(contest.startTime) === dayKey(new Date()) ? '今天' : '明天'
|
||||
list.push({ situation: 'home.contestSoon', vars: { when }, actions: [goAction(router, '去看看', `/contest/${contest.id}`), random] })
|
||||
}
|
||||
|
||||
// 公告:三天内发的,挑最新那条
|
||||
const fresh = announcements.value.filter((it) => now - Date.parse(it.createTime) < 3 * DAY_MS).sort((a, b) => b.createTime.localeCompare(a.createTime))[0]
|
||||
if (fresh) {
|
||||
list.push({
|
||||
situation: 'home.newAnnouncement',
|
||||
actions: [{ label: '去看看', run: () => router.push({ path: '/announcement', query: { id: fresh.id } }) }, random],
|
||||
})
|
||||
}
|
||||
|
||||
// 知识点:和首页那一行同一个先后(本周升级 > 离下一档最近 > 班里在学、自己还没碰过),只说一件
|
||||
const map = knowledge.value
|
||||
if (map) {
|
||||
const touched = new Set(map.classTouched)
|
||||
const go = (name: string) => ({ label: '去做', run: () => router.push(tagLink(name)) })
|
||||
const upgraded = map.tags.filter((tag) => tag.level > tag.levelAtWeekStart).sort((a, b) => b.level - a.level)[0]
|
||||
const learning = map.tags
|
||||
.filter((tag) => tag.level >= 1 && tag.level < MAX_LEVEL)
|
||||
.sort((a, b) => Number(touched.has(b.name)) - Number(touched.has(a.name)) || remainingToNext(a)! - remainingToNext(b)!)[0]
|
||||
const untouched = map.tags.find((tag) => tag.level === 0 && touched.has(tag.name))
|
||||
if (upgraded)
|
||||
list.push({ situation: 'home.knowledgeUp', vars: { tag: upgraded.name, level: levelName(upgraded.level) }, actions: [go(upgraded.name), random] })
|
||||
else if (learning)
|
||||
list.push({
|
||||
situation: 'home.knowledgeNext',
|
||||
n: remainingToNext(learning)!,
|
||||
vars: { tag: learning.name, level: levelName(learning.level + 1) },
|
||||
actions: [go(learning.name), random],
|
||||
})
|
||||
else if (untouched) list.push({ situation: 'home.knowledgeFresh', vars: { tag: untouched.name }, actions: [go(untouched.name), random] })
|
||||
}
|
||||
|
||||
// 本周进步榜:第一就说第一;前一名在榜面上(只回前几名)、差得不多就报还要几道。
|
||||
// 同分时提交少的在前,追平不一定超得过,所以按多做 1 道算
|
||||
const me = weekly.value?.me
|
||||
if (me) {
|
||||
const rank = goAction(router, '去排名', '/rank?period=week')
|
||||
const ahead = weekly.value!.results.find((row) => row.rank === me.rank - 1)
|
||||
if (me.rank === 1) list.push({ situation: 'rank.top', actions: [rank, random] })
|
||||
else if (ahead && ahead.solvedCount - me.solvedCount <= 5)
|
||||
list.push({ situation: 'rank.gap', n: ahead.solvedCount - me.solvedCount + 1, actions: [rank, random] })
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function learnLink(track: Track) {
|
||||
return tutorialPath(track.type, track.step)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useThemeVars } from 'naive-ui'
|
||||
import { getAnnouncementList, getClassToday } from 'oj/api'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||
|
||||
/**
|
||||
@@ -66,6 +67,25 @@ function openTask(row: ClassRow) {
|
||||
router.push({ name: 'task', query: { className: row.className } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板娘:今天有班布置了题,就带去那个班这节课的提交(和提交列表「这节课」同一组筛选:班、这几道题、今天),
|
||||
* 有好几个班时挑正在上课的那个
|
||||
*/
|
||||
useMascotGuide(() => {
|
||||
if (!today.value) return null
|
||||
const assigned = today.value.classes.filter((row) => row.source === 'teacher' && row.problems.length)
|
||||
const row = assigned.find((it) => stateOf(it).live) ?? assigned[0]
|
||||
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' }
|
||||
return {
|
||||
situation: 'teacher.home.assigned',
|
||||
actions: [
|
||||
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
|
||||
{ label: '课堂任务', run: () => openTask(row) },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const shortcuts = computed(() =>
|
||||
[
|
||||
{ label: '课堂任务', to: '/task', show: true },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
|
||||
import TutorialRow from './components/TutorialRow.vue'
|
||||
import ProjectTile from './components/ProjectTile.vue'
|
||||
@@ -94,6 +95,23 @@ function doneIn(course: Course, ids: number[]) {
|
||||
return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length
|
||||
}
|
||||
|
||||
// 看板娘:学过就带去「接着学」那一课,一课没学过就从当前页签那门课的第 1 课开始
|
||||
useMascotGuide(() => {
|
||||
if (!loaded.value || userStore.isTeacherOrAbove) return null
|
||||
const it = resume.value
|
||||
if (it) {
|
||||
const next = goAction(router, '接着学', tutorialPath(it.course.type, it.step))
|
||||
// 接着学的那课之前,离它最近的一课打开过、练一练却没全对(「在学」那一态):先叫回去看看
|
||||
const { titles, progress } = it.course
|
||||
for (let step = it.step - 1; step >= 1; step--) {
|
||||
if (tutorialState(progress[titles[step - 1]!.id]) !== 'started') continue
|
||||
return { situation: 'learn.wrongExercise', n: step, actions: [goAction(router, '去看看', tutorialPath(it.course.type, step)), next] }
|
||||
}
|
||||
return { situation: 'learn.continue', n: it.step, actions: [next] }
|
||||
}
|
||||
return { situation: 'learn.fresh', actions: [goAction(router, '第 1 课', tutorialPath(tab.value, 1))] }
|
||||
})
|
||||
|
||||
watch(() => userStore.isAuthed, load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useMascotGuide, type MascotAction } from 'shared/mascot/guide'
|
||||
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
|
||||
import TutorialBody from './components/TutorialBody.vue'
|
||||
import TutorialEnd from './components/TutorialEnd.vue'
|
||||
@@ -233,6 +234,16 @@ function jump(id: string) {
|
||||
function jumpExercise(id: number) {
|
||||
jump(`exercise-${id}`)
|
||||
}
|
||||
|
||||
// 看板娘:练一练没做完就带去第一道没做对的;做完了(或这课没有练一练)就去下一课
|
||||
useMascotGuide(() => {
|
||||
if (!tutorial.value || userStore.isTeacherOrAbove) return null
|
||||
const to = next.value?.to
|
||||
const nextLesson: MascotAction[] = to ? [{ label: '下一课', run: () => router.push(to) }] : []
|
||||
const unsolved = firstUnsolved.value
|
||||
if (!unsolved) return { situation: 'tutorial.passed', actions: nextLesson }
|
||||
return { situation: 'tutorial', actions: [{ label: '练一练', run: () => jumpExercise(unsolved.id) }, ...nextLesson] }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { classLabel, groupClassOptions } from 'utils/classes'
|
||||
import { useAIStream } from 'shared/composables/aiStream'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import PkDuel from './components/PkDuel.vue'
|
||||
import PkGrid from './components/PkGrid.vue'
|
||||
import PkRace from './components/PkRace.vue'
|
||||
@@ -31,6 +32,10 @@ const { isDesktop } = useBreakpoints()
|
||||
const MAX_CLASSES = 8
|
||||
|
||||
const teacher = computed(() => userStore.isTeacherOrAbove)
|
||||
|
||||
// 看板娘:学生念叨一句班级对决,老师只打招呼
|
||||
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
|
||||
|
||||
const pk = ref<ClassPk | null>(null)
|
||||
const loading = ref(false)
|
||||
const battle = ref<ClassBattleItem[]>([])
|
||||
|
||||
@@ -73,12 +73,15 @@ watch(
|
||||
() => (myFlowchartOpen.value = true),
|
||||
)
|
||||
|
||||
/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */
|
||||
/**
|
||||
* 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了),
|
||||
* 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交
|
||||
*/
|
||||
const flowchartGrade = computed(() => {
|
||||
const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade))
|
||||
if (!passed.length) return ''
|
||||
return passed.some((row) => row.grade === 'S') ? 'S' : 'A'
|
||||
const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null)
|
||||
return best?.grade ?? ''
|
||||
})
|
||||
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
|
||||
|
||||
// 例子旁的「复制」:点完变成「已复制」一秒
|
||||
const message = useMessage()
|
||||
@@ -192,12 +195,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
</n-tag>
|
||||
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag>
|
||||
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
|
||||
「做过,还没对」—— 那两个说的是代码 -->
|
||||
<template v-else-if="!flowchartGrade">
|
||||
「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
|
||||
「还没交过」,旁边的「流程图 C 级」已经说了交过什么 -->
|
||||
<template v-else-if="!flowchartPassed">
|
||||
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag>
|
||||
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag>
|
||||
<n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
|
||||
</template>
|
||||
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
|
||||
<n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -329,7 +333,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
<Markdown html :content="problem.source" />
|
||||
</section>
|
||||
|
||||
<SimilarProblems v-if="showSimilar" title="相似题目推荐" />
|
||||
<SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -182,16 +182,25 @@ watch(
|
||||
* 没有画这一步的课,画着流程图时不动他
|
||||
*/
|
||||
const lessonStore = useLessonStore()
|
||||
let lessonLanguageFor: string | null = null
|
||||
/**
|
||||
* 换过的(题 + 这一节 + 该做的那一步)都记下:换到别的题再回来,同一步不再换第二次。
|
||||
* 编辑器在换题时不重新挂载(detail.vue 只重新 init),只记最后一个的话一回来又被拽回去
|
||||
*/
|
||||
const lessonSwitched = new Set<string>()
|
||||
let lessonViewing: string | null = null
|
||||
watch(
|
||||
() => [problem.value?._id, lessonStore.activity] as const,
|
||||
([id, activity]) => {
|
||||
if (!id || !activity) return
|
||||
const key = [id, activity.day, activity.mode, activity.language].join('|')
|
||||
if (lessonLanguageFor === key) return
|
||||
lessonLanguageFor = key
|
||||
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
|
||||
const lesson = [id, activity.day, activity.mode, activity.language].join('|')
|
||||
const item = lessonStore.find(id)
|
||||
const key = [lesson, item?.draw && item.draw !== 'accepted' ? 'draw' : 'code'].join('|')
|
||||
// 停在这道题上、只是课堂状态刷新了(比如刚画对图):不跟着换,也不记,下回打开这道题再换
|
||||
const stay = lessonViewing === lesson
|
||||
lessonViewing = lesson
|
||||
if (lessonSwitched.has(key) || stay) return
|
||||
lessonSwitched.add(key)
|
||||
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
|
||||
if (!item) return
|
||||
if (item.draw && item.draw !== 'accepted') {
|
||||
if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart')
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { ProblemRow } from 'utils/types'
|
||||
|
||||
type SimilarRow = ProblemRow & { sharedTags: string[] }
|
||||
|
||||
/**
|
||||
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
|
||||
* 每个实例各一份
|
||||
*/
|
||||
const cache = new Map<string, Promise<ProblemRow[]>>()
|
||||
const cache = new Map<string, Promise<SimilarRow[]>>()
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -13,17 +15,26 @@ import { storeToRefs } from 'pinia'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getSimilarProblems } from 'oj/api'
|
||||
import { useProblemStore } from 'oj/store/problem'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { problemTypes } from '../utils/problemType'
|
||||
import ProblemStatus from './ProblemStatus.vue'
|
||||
import ProblemTypeTag from './ProblemTypeTag.vue'
|
||||
|
||||
/**
|
||||
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
|
||||
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
|
||||
* `place` 只管标题的样子:题面里和「提示」「来源」一样带绿竖条,结果页签里和「还能挑战」对齐。
|
||||
*
|
||||
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
|
||||
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)
|
||||
*/
|
||||
const props = defineProps<{ title: string }>()
|
||||
const props = defineProps<{ title: string; place: 'content' | 'result' }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
const list = ref<ProblemRow[]>([])
|
||||
const list = ref<SimilarRow[]>([])
|
||||
|
||||
watch(
|
||||
() => problem.value?._id,
|
||||
@@ -41,30 +52,36 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High
|
||||
function difficultyType(difficulty: string) {
|
||||
if (difficulty === '简单') return 'success'
|
||||
if (difficulty === '困难') return 'error'
|
||||
return 'warning'
|
||||
// 做对了的接口已经筛掉,剩下的只有「没做过」和「做过没对」两种;一道都没做过就不留这一列
|
||||
const showStatus = computed(() => list.value.some((row) => row.status === 'failed'))
|
||||
|
||||
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
|
||||
|
||||
function difficultyStyle(row: SimilarRow) {
|
||||
if (!row.difficulty) return {}
|
||||
const t = tone(DIFFICULTY_TONE[row.difficulty])
|
||||
return { color: t.color, background: t.background }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="list.length" class="similar">
|
||||
<h3 class="title">{{ props.title }}</h3>
|
||||
<n-list bordered>
|
||||
<n-list-item v-for="sp in list" :key="sp._id">
|
||||
<n-flex align="center" justify="space-between" :wrap="false">
|
||||
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link">
|
||||
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag>
|
||||
<span class="name">{{ sp.title }}</span>
|
||||
</router-link>
|
||||
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)">
|
||||
{{ sp.difficulty }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<section v-if="list.length" class="similar" :class="props.place">
|
||||
<h3 class="heading">{{ props.title }}</h3>
|
||||
<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 }">
|
||||
<span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
|
||||
<span class="id">{{ sp._id }}</span>
|
||||
<span class="name">
|
||||
<span class="text">{{ sp.title }}</span>
|
||||
<ProblemTypeTag v-for="kind in problemTypes(sp)" :key="kind" :kind="kind" />
|
||||
</span>
|
||||
<span v-if="sp.difficulty" class="difficulty" :style="difficultyStyle(sp)">{{ sp.difficulty }}</span>
|
||||
<span class="shared">{{ sp.sharedTags.join(' · ') }}</span>
|
||||
<span class="people"
|
||||
><b>{{ sp.solvedUsers }}</b> 人做对</span
|
||||
>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -73,34 +90,135 @@ function difficultyType(difficulty: string) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
.similar.result {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
/* 列表项的内容区默认按内容撑宽,长标题会把难度挤出屏幕,省略号也不出来 */
|
||||
.similar :deep(.n-list-item__main) {
|
||||
min-width: 0;
|
||||
/* 和 ProblemContent 的小节标题(提示 / 来源)一样 */
|
||||
.content .heading {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
padding-left: 9px;
|
||||
border-left: 3px solid v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
/* 和结果页签里的「还能挑战」一样 */
|
||||
.result .heading {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rows {
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* 两行:题号 | 题目名 + 类型标签 | 难度
|
||||
* | 共同的标签 | 做对人数
|
||||
* 做过没对的题最前面多一列红「−」
|
||||
*/
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'id name difficulty'
|
||||
'. shared people';
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
gap: 4px 12px;
|
||||
padding: 10px 16px;
|
||||
color: v-bind('theme.textColor1');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover .name {
|
||||
.row.with-status {
|
||||
grid-template-columns: 16px auto minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'status id name difficulty'
|
||||
'. . shared people';
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.row:hover .text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.id {
|
||||
grid-area: id;
|
||||
min-width: 36px;
|
||||
font-size: 14px;
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.name {
|
||||
grid-area: name;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
color: v-bind('theme.primaryColor');
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
grid-area: difficulty;
|
||||
justify-self: end;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shared {
|
||||
grid-area: shared;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: v-bind('theme.textColor3');
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.people {
|
||||
grid-area: people;
|
||||
justify-self: end;
|
||||
font-size: 13px;
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.people b {
|
||||
font-weight: 600;
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
</style>
|
||||
@@ -357,7 +357,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
:language="submission.language"
|
||||
:refresh-key="submission.id"
|
||||
/>
|
||||
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
|
||||
<SimilarProblems v-if="showSimilar" title="再练几道相似的" place="result" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="!waitingReveal">
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { HINT_MIN_FAILURES, isFlowchartPass } from '@oj2/contract'
|
||||
import { habitSituation, logMascotEvent, refreshMascotProfile, requestSpeech } from 'shared/mascot/behavior'
|
||||
import { useChallengeStore } from 'oj/store/challenge'
|
||||
import { useFlowchartStore } from 'oj/store/flowchart'
|
||||
import type { MascotSituation } from 'shared/mascot/lines'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import { useLessonStore } from 'shared/store/lesson'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useCodeStore } from 'oj/store/code'
|
||||
import { useProblemStore } from 'oj/store/problem'
|
||||
import { useSubmissionStore, type ResultSegment } from 'oj/store/submission'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import type { ProblemDetail } from 'utils/types'
|
||||
import { useProblemPageContext } from './problemPageContext'
|
||||
import { useTeacherCollab } from './teacherCollab'
|
||||
|
||||
/** 同一道题错到这么多次,她就不怼了,劝去举手 */
|
||||
const STUCK_FAILS = 5
|
||||
/** 错过这么多次以后做对,算「终于」 */
|
||||
const FINALLY_FAILS = 2
|
||||
|
||||
const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
|
||||
[SubmissionStatus.wrong_answer]: 'problem.wrong',
|
||||
[SubmissionStatus.partial_accepted]: 'problem.wrong',
|
||||
[SubmissionStatus.ast_check_failed]: 'problem.wrong',
|
||||
[SubmissionStatus.compile_error]: 'problem.compileError',
|
||||
[SubmissionStatus.runtime_error]: 'problem.runtimeError',
|
||||
[SubmissionStatus.memory_limit_exceeded]: 'problem.runtimeError',
|
||||
[SubmissionStatus.cpu_time_limit_exceeded]: 'problem.timeout',
|
||||
[SubmissionStatus.real_time_limit_exceeded]: 'problem.timeout',
|
||||
}
|
||||
|
||||
/** 一道题上这么久没交、没跑例子,她就来问一句(主动开口) */
|
||||
const IDLE_MS = 15 * 60_000
|
||||
/** 连着这么多次没跑例子就交,而且错了,她就念一句 */
|
||||
const UNSAMPLED_SUBMITS = 3
|
||||
/** 同班做对这么多人,就拿出来说「全班已经 N 个人做对了」 */
|
||||
const CLASS_MANY = 5
|
||||
/** 同班交过这么多人、做对的不超过 CLASS_FEW 个,算「全班都卡着」 */
|
||||
const CLASS_TRIED = 5
|
||||
const CLASS_FEW = 2
|
||||
/** 全站交过这么多次,通过率才有意义 */
|
||||
const RATE_MIN_SUBMISSIONS = 30
|
||||
const HARD_RATE = 0.3
|
||||
const EASY_RATE = 0.7
|
||||
|
||||
/** 几种「换个说法」各自的机会:不是每次都说同一件事 */
|
||||
const CHANCE = { classMany: 0.3, classFew: 0.5, rate: 0.4, hint: 0.5, challenge: 0.4 } as const
|
||||
const roll = (chance: number) => Math.random() < chance
|
||||
|
||||
const FINISHED = (result: number | undefined) =>
|
||||
result !== undefined && result !== SubmissionStatus.pending && result !== SubmissionStatus.judging && result !== SubmissionStatus.submitting
|
||||
|
||||
/**
|
||||
* 题目页上看板娘说什么、给哪几个按钮。看的是这一页上最近的那件事:刚交的结果、刚跑的例子,
|
||||
* 都没有就看写没写代码。比赛入口不用管(Mascot.vue 在比赛里不弹)。
|
||||
*
|
||||
* 另外几件事她会主动开口(只对学生,频率上限在 Mascot.vue):同一道题错到第 5 次、连着 3 次
|
||||
* 没跑例子就交还错了、又犯了自己的老毛病(behavior.ts 的 habitSituation)、15 分钟没交没跑。
|
||||
*
|
||||
* `showResult` 是「我错在哪」:把左栏切到「结果」页签的那一段(提交结果 / 运行例子),页签归页面管。
|
||||
*/
|
||||
export function useProblemMascotGuide(showResult: (segment: ResultSegment) => void) {
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const problemStore = useProblemStore()
|
||||
const submissionStore = useSubmissionStore()
|
||||
const codeStore = useCodeStore()
|
||||
const collabStore = useCollabStore()
|
||||
const lessonStore = useLessonStore()
|
||||
const ctx = useProblemPageContext()
|
||||
const teacherCollab = useTeacherCollab()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const flowchartStore = useFlowchartStore()
|
||||
const challengeStore = useChallengeStore()
|
||||
|
||||
/** 学生在这道题上能用的几个按钮,点她和她主动开口共用 */
|
||||
function studentActions(problem: ProblemDetail) {
|
||||
const language = codeStore.code.language
|
||||
const entry = ctx.value
|
||||
const runSamples: MascotAction | null =
|
||||
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
|
||||
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
|
||||
const help: MascotAction | null =
|
||||
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
|
||||
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
|
||||
: null
|
||||
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
|
||||
const whySamples: MascotAction = { label: '我错在哪', run: () => showResult('samples') }
|
||||
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
|
||||
const submit: MascotAction | null = busy
|
||||
? null
|
||||
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
|
||||
return { runSamples, help, why, whySamples, submit }
|
||||
}
|
||||
|
||||
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
|
||||
situation,
|
||||
actions: actions.filter((it): it is MascotAction => !!it),
|
||||
})
|
||||
|
||||
useMascotGuide(() => {
|
||||
const problem = problemStore.problem
|
||||
if (!problem) return null
|
||||
|
||||
if (userStore.isTeacherOrAbove) {
|
||||
// 协作中别把老师带离这一页:跳走协作就断了
|
||||
if (teacherCollab.value) return null
|
||||
const actions: MascotAction[] = [{ label: '全班这题的提交', run: () => router.push({ name: 'submissions', query: { problem: problem._id } }) }]
|
||||
if (isDesktop.value && userStore.canEditProblem(problem)) {
|
||||
actions.push({ label: '编辑这道题', run: () => window.open(router.resolve(`/admin/problem/edit/${problem.id}`).href, '_blank') })
|
||||
}
|
||||
return { situation: 'teacher.problem', actions }
|
||||
}
|
||||
|
||||
const entry = ctx.value
|
||||
const { runSamples, help, why, whySamples, submit } = studentActions(problem)
|
||||
|
||||
if (codeStore.code.language === 'Flowchart') {
|
||||
// 画过、评过分了:分低就说分低,拿了 S 夸一句
|
||||
const grade = flowchartStore.latestRating.grade
|
||||
if (grade === 'S') return guide('flowchart.good', help)
|
||||
if (grade && !isFlowchartPass(grade)) return guide('flowchart.low', help)
|
||||
return guide('problem.flowchart', help)
|
||||
}
|
||||
// 正在判、正在跑例子:没什么好说的,打个招呼
|
||||
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
|
||||
|
||||
const stuck = problemStore.failCount >= STUCK_FAILS
|
||||
if (submissionStore.syntaxErrorInfo) return stuck ? guide('problem.stuck', help, why) : guide('problem.compileError', why, help)
|
||||
|
||||
const runs = submissionStore.trial.sampleRuns.value
|
||||
if (submissionStore.resultSegment === 'samples' && runs.length) {
|
||||
return runs.every((run) => run.result === SubmissionStatus.accepted)
|
||||
? guide('problem.samplesPassed', submit)
|
||||
: guide('problem.samplesFailed', whySamples, help)
|
||||
}
|
||||
|
||||
const result = submissionStore.submission?.result
|
||||
const lesson = lessonStore.find(problem._id)
|
||||
if (result === SubmissionStatus.accepted) {
|
||||
const next = nextAction()
|
||||
// 课堂条的数是拉的时候的,刚做对重拉回来可能已经把自己算进去了
|
||||
if (lesson && lesson.acceptedCount <= (lesson.myStatus === 'accepted' ? 1 : 0)) return guide('class.firstSolver', next)
|
||||
if (entry.challenges && challengeStore.current?.challenges.length && roll(CHANCE.challenge)) return guide('problem.challenge', next)
|
||||
const fails = problemStore.failCount
|
||||
return guide(fails === 0 ? 'problem.firstTry' : fails >= FINALLY_FAILS ? 'problem.finally' : 'problem.accepted', next)
|
||||
}
|
||||
const failed = result === undefined ? undefined : RESULT_SITUATION[result as SubmissionStatus]
|
||||
if (failed) {
|
||||
if (stuck) return guide('problem.stuck', help, why)
|
||||
const actions = [why, failed === 'problem.wrong' ? runSamples : null, help]
|
||||
// AI 提示开了还没要:提一句,按钮带去结果里(提示在那儿)
|
||||
const hintOpen = !problem.contestId && problemStore.failCount >= HINT_MIN_FAILURES && !submissionStore.hint.hintContent.value
|
||||
if (hintOpen && roll(CHANCE.hint)) return guide('problem.hintReady', ...actions)
|
||||
const classGuide = classSituation(lesson)
|
||||
if (classGuide) return { ...guide(classGuide.situation, ...actions), n: classGuide.n }
|
||||
const rate = problem.submissionNumber >= RATE_MIN_SUBMISSIONS ? problem.acceptedNumber / problem.submissionNumber : null
|
||||
if (rate !== null && rate < HARD_RATE && roll(CHANCE.rate)) return guide('problem.hardWrong', ...actions)
|
||||
if (rate !== null && rate > EASY_RATE && failed === 'problem.wrong' && roll(CHANCE.rate)) return guide('problem.easyWrong', ...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)
|
||||
const classGuide = classSituation(lesson)
|
||||
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
|
||||
return guide('problem.unsubmitted', runSamples, help)
|
||||
|
||||
/** 这节课的题:同班多少人做对了(课堂条那份数),不在这节课里就没有 */
|
||||
function classSituation(item: typeof lesson) {
|
||||
if (!item || item.myStatus === 'accepted') return null
|
||||
if (item.acceptedCount >= CLASS_MANY && roll(CHANCE.classMany)) return { situation: 'class.manyDone' as const, n: item.acceptedCount }
|
||||
if (item.userCount >= CLASS_TRIED && item.acceptedCount <= CLASS_FEW && roll(CHANCE.classFew))
|
||||
return { situation: 'class.fewDone' as const, n: item.acceptedCount }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 做对之后去哪:专题里回专题,这节课里去下一道还没做的 */
|
||||
function nextAction(): MascotAction | null {
|
||||
if (entry.entry === 'problemset') return { label: '回专题', run: () => router.push(`/problemset/${entry.problemSetId}`) }
|
||||
const next = lessonStore.nextAfter(problem!._id)?.next
|
||||
return next ? { label: '下一道', run: () => router.push(`/problem/${next.problemDisplayId}`) } : null
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 主动开口(只对学生;比赛里 Mascot.vue 不弹)----
|
||||
|
||||
const student = () => userStore.isAuthed && !userStore.isTeacherOrAbove && ctx.value.entry !== 'contest'
|
||||
|
||||
/** 这道题上连着几次没跑例子就交了。跑过一次例子就清零 */
|
||||
let unsampledSubmits = 0
|
||||
let sampledSinceSubmit = false
|
||||
/** 上一次「动静」(进题、交、跑例子)的时刻,给「盯了好久」用 */
|
||||
let lastActivity = Date.now()
|
||||
|
||||
watch(
|
||||
() => problemStore.problem?.id,
|
||||
() => {
|
||||
unsampledSubmits = 0
|
||||
sampledSinceSubmit = false
|
||||
lastActivity = Date.now()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => submissionStore.trial.samplesRunAt.value,
|
||||
(at) => {
|
||||
if (!at) return
|
||||
sampledSinceSubmit = true
|
||||
lastActivity = Date.now()
|
||||
if (student()) logMascotEvent({ kind: 'trial', action: 'samples' })
|
||||
},
|
||||
)
|
||||
// 「运行例子」「自己输入」「举手」别处都不存,记下来给她看一周的习惯(behavior.ts)
|
||||
watch(
|
||||
() => submissionStore.trial.customRunning.value,
|
||||
(running) => {
|
||||
if (!running) return
|
||||
lastActivity = Date.now()
|
||||
if (student()) logMascotEvent({ kind: 'trial', action: 'custom' })
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => collabStore.helpStatus,
|
||||
(status, previous) => {
|
||||
if (status === 'pending' && previous === 'idle' && student()) logMascotEvent({ kind: 'help' })
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => submissionStore.submission?.id,
|
||||
(id) => {
|
||||
if (!id) return
|
||||
unsampledSubmits = sampledSinceSubmit ? 0 : unsampledSubmits + 1
|
||||
sampledSinceSubmit = false
|
||||
lastActivity = Date.now()
|
||||
},
|
||||
)
|
||||
|
||||
// 判完了:错了就看要不要说一句;不管对错都刷新行为数(最近的错、今天做对几道)
|
||||
watch(
|
||||
() => [submissionStore.submission?.id, submissionStore.submission?.result] as const,
|
||||
([id, result]) => {
|
||||
const problem = problemStore.problem
|
||||
if (!id || !FINISHED(result) || !problem || !student()) return
|
||||
refreshMascotProfile(true)
|
||||
const failed = RESULT_SITUATION[result as SubmissionStatus]
|
||||
if (!failed) return
|
||||
const { runSamples, help, why } = studentActions(problem)
|
||||
if (problemStore.failCount >= STUCK_FAILS) {
|
||||
requestSpeech({ key: `stuck:${problem.id}`, ...guide('problem.stuck', help, why) })
|
||||
} else if (unsampledSubmits >= UNSAMPLED_SUBMITS && runSamples) {
|
||||
requestSpeech({ key: `unsampled:${problem.id}`, ...guide('habit.noSamples', runSamples) })
|
||||
} else {
|
||||
const habit = habitSituation(failed)
|
||||
if (habit) requestSpeech({ key: `habit:${habit}`, ...guide(habit, why) })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 盯着一道没做对的题 15 分钟,没交也没跑:问一句
|
||||
useIntervalFn(() => {
|
||||
const problem = problemStore.problem
|
||||
if (!problem || !student() || Date.now() - lastActivity < IDLE_MS) return
|
||||
if (problem.myStatus === SubmissionStatus.accepted || submissionStore.submission?.result === SubmissionStatus.accepted) return
|
||||
if (codeStore.code.language === 'Flowchart') return
|
||||
const { runSamples, help } = studentActions(problem)
|
||||
requestSpeech({ key: `idle:${problem.id}`, ...guide('idle.stuck', help, runSamples) })
|
||||
}, 30_000)
|
||||
}
|
||||
@@ -14,28 +14,36 @@ const EMOJI_SVGS = [
|
||||
// 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用
|
||||
let emojiShapesPromise: Promise<confetti.Shape[]> | null = null
|
||||
|
||||
/**
|
||||
* 表情在屏幕上的边长(px)。位图就按这个大小画,放的时候 `scalar: 1`、矩阵不缩放。
|
||||
*
|
||||
* **别改回「位图画大、矩阵缩小、scalar 放大」**:canvas-confetti 画位图粒子是每帧
|
||||
* `fillRect(位图边长 × scalar)` 再用图案填,填的矩形和表情实际多大无关。原来位图 64、
|
||||
* scalar 5,表情显示 50px,每个粒子每帧却要填 320×320 —— 两团 32 个粒子一帧三百多万像素,
|
||||
* 机房电脑(显卡弱、Chrome 常走软件渲染)放到这一种就卡(用户 2026-11 反馈)。
|
||||
* 现在填的正好是 50×50,同样大小、同样轨迹,像素少到四十分之一。
|
||||
*/
|
||||
const EMOJI_PX = 50
|
||||
|
||||
function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const img = new Image()
|
||||
img.onload = async () => {
|
||||
const renderSize = 64
|
||||
const baseUnit = 10
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = renderSize
|
||||
canvas.height = renderSize
|
||||
canvas.width = EMOJI_PX
|
||||
canvas.height = EMOJI_PX
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0, renderSize, renderSize)
|
||||
ctx.drawImage(img, 0, 0, EMOJI_PX, EMOJI_PX)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
try {
|
||||
const bitmap = await createImageBitmap(canvas)
|
||||
const scale = baseUnit / renderSize
|
||||
resolve({
|
||||
type: 'bitmap',
|
||||
bitmap,
|
||||
matrix: [scale, 0, 0, scale, (-renderSize * scale) / 2, (-renderSize * scale) / 2],
|
||||
matrix: [1, 0, 0, 1, -EMOJI_PX / 2, -EMOJI_PX / 2],
|
||||
} as unknown as confetti.Shape)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
@@ -415,7 +423,8 @@ export function useFireworks() {
|
||||
const options: confetti.Options = {
|
||||
shapes: emojiShapes,
|
||||
flat: true,
|
||||
scalar: 5,
|
||||
// 大小在位图里(EMOJI_PX),这里放大的话填的矩形跟着放大,见 EMOJI_PX
|
||||
scalar: 1,
|
||||
particleCount: 16,
|
||||
spread: 360,
|
||||
startVelocity: 40,
|
||||
|
||||
@@ -19,6 +19,7 @@ import ContextBar from './components/ContextBar.vue'
|
||||
import { useEditorMenu } from './composables/editorMenu'
|
||||
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
|
||||
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
|
||||
import { useProblemMascotGuide } from './composables/mascotGuide'
|
||||
|
||||
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
|
||||
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
|
||||
@@ -208,6 +209,13 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 看板娘气泡里的「我错在哪」:切到「结果」页签里出错的那一段
|
||||
useProblemMascotGuide((segment) => {
|
||||
drawer.value = null
|
||||
currentTab.value = 'result'
|
||||
submissionStore.resultSegment = segment
|
||||
})
|
||||
|
||||
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
||||
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { usePagination } from 'shared/composables/pagination'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { focusJumpAction, useMascotGuide, type MascotAction } from 'shared/mascot/guide'
|
||||
import ProblemStatus from './components/ProblemStatus.vue'
|
||||
import ProblemTypeTag from './components/ProblemTypeTag.vue'
|
||||
import ProgressRow from './components/ProgressRow.vue'
|
||||
@@ -272,6 +273,18 @@ async function pickRandom() {
|
||||
}
|
||||
}
|
||||
|
||||
// 看板娘:学生给找题的几条路(「随便来一道」按这页选着的知识点和类型挑),老师给新建题目
|
||||
useMascotGuide(() => {
|
||||
if (teacher.value) {
|
||||
return {
|
||||
situation: 'teacher.problems',
|
||||
actions: userStore.hasProblemPermission ? [{ label: '新建题目', run: () => router.push('/admin/problem/create') }] : [],
|
||||
}
|
||||
}
|
||||
const undone: MascotAction | null = userStore.isAuthed && !undoneOn.value ? { label: '只看没做完', run: () => (undoneOn.value = true) } : null
|
||||
return { situation: 'problems', actions: [focusJumpAction(), ...(undone ? [undone] : []), { label: '随便来一道', run: pickRandom }] }
|
||||
})
|
||||
|
||||
// ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ----
|
||||
const nextTag = computed(() => {
|
||||
const p = mine.value
|
||||
|
||||
@@ -8,6 +8,7 @@ import { parseTime } from 'utils/functions'
|
||||
import type { ProblemSet, ProblemSetProblem } from 'utils/types'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||
import { useFireworks } from '../problem/composables/useFireworks'
|
||||
import NotFound from 'shared/components/NotFound.vue'
|
||||
import BadgeLadder from './components/BadgeLadder.vue'
|
||||
@@ -122,6 +123,20 @@ async function join() {
|
||||
}
|
||||
}
|
||||
|
||||
// 看板娘:老师看全班,学生还差几道必做题、带去下一道;没加入就只打招呼
|
||||
useMascotGuide(() => {
|
||||
if (!set.value) return null
|
||||
if (userStore.isTeacherOrAbove) return { situation: 'teacher.problemset', actions: [{ label: '全班情况', run: () => (tab.value = 'class') }] }
|
||||
if (!joined.value) return null
|
||||
const todo = problems.value.filter((item) => item.isRequired && !item.isCompleted)
|
||||
const next = todo[0]
|
||||
if (progress.value?.isCompleted || !next) return { situation: 'problemset.done', actions: [goAction(router, '专题列表', '/problemset')] }
|
||||
const actions = [{ label: '去做下一道', run: () => router.push(problemLink(next)) }]
|
||||
// 只剩最后一道、做完还有奖章没拿:说「就差最后 1 道」比「还差 1 道」更想让人做完
|
||||
if (todo.length === 1 && set.value.badges.some((badge) => !badge.isEarned)) return { situation: 'problemset.lastOne', actions }
|
||||
return { situation: 'problemset.unfinished', n: todo.length, actions }
|
||||
})
|
||||
|
||||
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
|
||||
const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useThemeVars } from 'naive-ui'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||
import { getProblemSetList } from 'oj/api'
|
||||
import { rgba, useTone } from 'shared/composables/tone'
|
||||
import { parseTime } from 'utils/functions'
|
||||
@@ -55,6 +56,16 @@ const others = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
// 看板娘:加入了还没做完的专题,布置中的优先,其次最近加入的
|
||||
useMascotGuide(() => {
|
||||
if (userStore.isTeacherOrAbove) return null
|
||||
const pending = sets.value.filter((set) => set.userProgress.isJoined && !set.userProgress.isCompleted)
|
||||
const latest = (set: ProblemSet) => Date.parse(set.userProgress.joinTime ?? '') || 0
|
||||
const target = pending.find((set) => set.assigning) ?? [...pending].sort((a, b) => latest(b) - latest(a))[0]
|
||||
if (!target) return null
|
||||
return { situation: 'problemsets.unfinished', actions: [goAction(router, '接着做', `/problemset/${target.id}`)] }
|
||||
})
|
||||
|
||||
function description(set: ProblemSet) {
|
||||
// 简介大多就是把标题再抄一遍,一样的就不重复显示
|
||||
return set.description && set.description !== set.title ? set.description : ''
|
||||
@@ -83,6 +94,8 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
|
||||
<div class="page oj-page">
|
||||
<div class="top">
|
||||
<h2>专题</h2>
|
||||
<!-- 没有布置中的,就只有一组:数量写在标题旁边,不再另起一行「专题 14 个」把标题再说一遍 -->
|
||||
<span v-if="!assigning.length && others.length" class="muted count">{{ others.length }} 个</span>
|
||||
<div class="spacer"></div>
|
||||
<n-input v-model:value="keyword" placeholder="搜专题" clearable class="search" />
|
||||
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
|
||||
@@ -133,8 +146,8 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="others.length" class="sect">
|
||||
{{ assigning.length ? '其他专题' : '专题' }}
|
||||
<div v-if="assigning.length && others.length" class="sect">
|
||||
其他专题
|
||||
<span class="muted">{{ others.length }} 个</span>
|
||||
</div>
|
||||
<div class="small-grid">
|
||||
@@ -191,6 +204,12 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
|
||||
font-size: var(--oj-fs-title);
|
||||
}
|
||||
|
||||
/* 和分组标题旁的「N 个」同一档:小字、不加粗 */
|
||||
.top .count {
|
||||
font-size: var(--oj-fs-sec);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import UserName from 'shared/components/UserName.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import { errorCode } from 'utils/api'
|
||||
import { displayName } from '@oj2/contract'
|
||||
import { STORAGE_KEY } from 'utils/constants'
|
||||
@@ -271,6 +272,23 @@ function openDetail(className: string) {
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
// 看板娘:自己在榜上就把自己那一行滚到眼前(领奖台上是「你」那个标签,赛道上是高亮的那一行)
|
||||
useMascotGuide(() => {
|
||||
if (teacher.value || !board.value) return null
|
||||
const listed = rows.value.some((row) => row.user.id === meId.value)
|
||||
const findMe = () => {
|
||||
const el = document.querySelector('.track-card .entry.me, .track-card .prow.me, .track-card .tag.me')
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
const actions = listed ? [{ label: '我在哪', run: findMe }] : []
|
||||
const { me: mine, ahead } = board.value
|
||||
if (mine?.solved && mine.rank === 1) return { situation: 'rank.top', actions }
|
||||
// 同分按先做到排,追平还在后面,所以要比前一名多做 1 道
|
||||
const gap = mine && ahead ? ahead.solved - mine.solved : null
|
||||
if (gap !== null && gap <= 5) return { situation: 'rank.gap', n: gap + 1, actions }
|
||||
return { situation: 'rank', actions }
|
||||
})
|
||||
|
||||
function open(username: string) {
|
||||
router.push({ path: '/user', query: { name: username } })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useDark } from 'shared/composables/dark'
|
||||
|
||||
/**
|
||||
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import StatisticsView from './components/StatisticsView.vue'
|
||||
import { DEFAULT_PERIOD } from './period'
|
||||
|
||||
@@ -19,6 +20,8 @@ const period = useRouteQuery<string>('period', DEFAULT_PERIOD, { mode: 'replace'
|
||||
const from = useRouteQuery<string>('from', '', { mode: 'replace' })
|
||||
const to = useRouteQuery<string>('to', '', { mode: 'replace' })
|
||||
|
||||
useMascotGuide(() => ({ situation: 'teacher.statistics' }))
|
||||
|
||||
function openList(query: Record<string, string>) {
|
||||
window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useDark } from 'shared/composables/dark'
|
||||
|
||||
/**
|
||||
* 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。
|
||||
|
||||
@@ -23,6 +23,8 @@ import UserName from 'shared/components/UserName.vue'
|
||||
*/
|
||||
const props = defineProps<{
|
||||
row: SubmissionListItem
|
||||
/** 按 ↑ ↓ 会落到的上一条、下一条:这条加载完顺手把它们也拉回来,换条时不用等网络 */
|
||||
neighbors?: SubmissionListItem[]
|
||||
/** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
|
||||
teacher: boolean
|
||||
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
|
||||
@@ -61,7 +63,42 @@ const detail = ref<Submission | null>(null)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
|
||||
const judging = computed(() => props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging)
|
||||
function isJudging(row: SubmissionListItem) {
|
||||
return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging
|
||||
}
|
||||
const judging = computed(() => isJudging(props.row))
|
||||
|
||||
/**
|
||||
* 同一条只发一次请求:预取的还在路上时按到了它,接着等那一个,不再发第二个
|
||||
*/
|
||||
const inflight = new Map<string, Promise<Submission>>()
|
||||
function fetchDetail(id: string) {
|
||||
let request = inflight.get(id)
|
||||
if (!request) {
|
||||
request = getSubmission(id).finally(() => inflight.delete(id))
|
||||
inflight.set(id, request)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* 预取上一条、下一条。只在这一条落定之后才做(按住 ↓ 一路划过去的那些行不预取),
|
||||
* 进缓存的口径和 load() 一样:还在判的不要,列表和详情的结果对不上也不要。
|
||||
*
|
||||
* 没预取的时候,换到没看过的一条要先把代码区卸掉、转圈、再整个重新挂上 ——
|
||||
* 2026-11 实测(生产构建、CPU 降速 6 倍)每次卡 60–100ms;走缓存的只换内容,不卡。
|
||||
*/
|
||||
function prefetch() {
|
||||
for (const row of props.neighbors ?? []) {
|
||||
if (!row.showLink || isJudging(row) || cache.get(row.id)?.result === row.result) continue
|
||||
fetchDetail(row.id).then(
|
||||
(res) => {
|
||||
if (res.result === row.result) cache.set(row.id, res)
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的
|
||||
let loadSeq = 0
|
||||
@@ -78,15 +115,17 @@ async function load() {
|
||||
const hit = cache.get(row.id)
|
||||
if (hit && hit.result === row.result) {
|
||||
detail.value = hit
|
||||
prefetch()
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSubmission(row.id)
|
||||
const res = await fetchDetail(row.id)
|
||||
if (seq !== loadSeq) return
|
||||
// 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
|
||||
if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
|
||||
detail.value = res
|
||||
prefetch()
|
||||
} catch {
|
||||
if (seq === loadSeq) failed.value = true
|
||||
} finally {
|
||||
|
||||
@@ -35,6 +35,13 @@ export function useRowCursor(options: {
|
||||
const index = navigable.value.findIndex((row) => row.id === selectedId.value)
|
||||
return index === -1 ? null : { index: index + 1, count: navigable.value.length }
|
||||
})
|
||||
/** 按 ↑ ↓ 会落到的上一条、下一条(本页之内),右栏拿去预取 */
|
||||
const neighbors = computed(() => {
|
||||
const list = navigable.value
|
||||
const index = list.findIndex((row) => row.id === selectedId.value)
|
||||
if (index === -1) return []
|
||||
return [list[index + 1], list[index - 1]].filter((row): row is Row => row !== undefined)
|
||||
})
|
||||
|
||||
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
|
||||
let pendingJump: 'first' | 'last' | null = null
|
||||
@@ -125,5 +132,5 @@ export function useRowCursor(options: {
|
||||
move(e.key === 'ArrowUp' ? -1 : 1)
|
||||
})
|
||||
|
||||
return { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump }
|
||||
return { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump }
|
||||
}
|
||||
@@ -1,8 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { getSubmission } from 'oj/api'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import type { Submission } from 'utils/types'
|
||||
import SubmissionView from './components/SubmissionView.vue'
|
||||
|
||||
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
|
||||
defineProps<{ submissionID: string }>()
|
||||
const props = defineProps<{ submissionID: string }>()
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
/**
|
||||
* 看板娘:学生看自己的提交时带回这道题。SubmissionView 里的数据拿不出来(弹层也用它,不挂看板娘),
|
||||
* 这里另取一份不带「这道题交的几次」的,只给学生取
|
||||
*/
|
||||
const mine = ref<Submission | null>(null)
|
||||
watch(
|
||||
() => [props.submissionID, userStore.isAuthed && !userStore.isTeacherOrAbove] as const,
|
||||
async ([id, student]) => {
|
||||
mine.value = null
|
||||
if (!student) return
|
||||
const res = await getSubmission(id).catch(() => null)
|
||||
if (res && id === props.submissionID && res.userId === userStore.user?.id) mine.value = res
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
useMascotGuide(() => {
|
||||
const s = mine.value
|
||||
if (!s || userStore.isTeacherOrAbove) return null
|
||||
// 和页面上题目那个链接同一个去处:比赛里交的回比赛里的题
|
||||
const to = s.contestId
|
||||
? { name: 'contest problem', params: { contestID: String(s.contestId), problemID: s.problemDisplayId } }
|
||||
: '/problem/' + s.problemDisplayId
|
||||
return { situation: 'submission.detail', actions: [{ label: '回到这道题', run: () => router.push(to) }] }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { usePagination } from 'shared/composables/pagination'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types'
|
||||
@@ -263,7 +264,7 @@ watch(
|
||||
|
||||
// ==================== 选中与翻阅 ====================
|
||||
|
||||
const { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({
|
||||
const { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({
|
||||
rows,
|
||||
total,
|
||||
query,
|
||||
@@ -427,6 +428,18 @@ const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
|
||||
info: (text) => message.info(text),
|
||||
})
|
||||
|
||||
// 看板娘:学生带去看自己的;「这节课」只有老师那一版有,老师给它
|
||||
useMascotGuide(() => {
|
||||
if (inContest.value) return null
|
||||
if (!teacher.value) return { situation: 'submissions', actions: mine.value ? [] : [{ label: '只看我的', run: () => (scope.value = 'mine') }] }
|
||||
const toLesson = () => {
|
||||
// 「我的」时这节课的按钮是藏着的,先切回全部
|
||||
if (mine.value) scope.value = 'all'
|
||||
toggleLesson()
|
||||
}
|
||||
return { situation: 'teacher.submissions', actions: lessonActive.value ? [] : [{ label: '这节课', run: toLesson }] }
|
||||
})
|
||||
|
||||
const activeFilters = computed(
|
||||
() =>
|
||||
[query.username, query.className, query.result, query.problem].filter(Boolean).length +
|
||||
@@ -858,6 +871,7 @@ function dayBreak(index: number) {
|
||||
<SubmissionPane
|
||||
v-else
|
||||
:row="selectedRow as SubmissionListItem"
|
||||
:neighbors="neighbors as SubmissionListItem[]"
|
||||
:teacher="teacher"
|
||||
:class-name="query.className"
|
||||
:contest="inContest"
|
||||
@@ -893,6 +907,7 @@ function dayBreak(index: number) {
|
||||
<SubmissionPane
|
||||
v-else
|
||||
:row="selectedRow as SubmissionListItem"
|
||||
:neighbors="neighbors as SubmissionListItem[]"
|
||||
:teacher="teacher"
|
||||
:class-name="query.className"
|
||||
:contest="inContest"
|
||||
|
||||
@@ -92,6 +92,9 @@ function openPicker() {
|
||||
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
|
||||
picking.value = true
|
||||
}
|
||||
|
||||
// 看板娘气泡里的「选题」:课堂任务页没布置时替老师打开选题弹框
|
||||
defineExpose({ openPicker })
|
||||
function pickerConfirm(ids: string[]) {
|
||||
lessonInput.value = ids.join(' ')
|
||||
saveLesson()
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents'
|
||||
import { useTeacherHelp } from 'shared/composables/teacherHelp'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
@@ -122,8 +123,11 @@ onUnmounted(pause)
|
||||
*/
|
||||
const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
|
||||
|
||||
/** 这节课做什么;推断出来的课为 null(代码交对或流程图画对都算) */
|
||||
const mode = computed(() => board.value?.mode ?? null)
|
||||
/**
|
||||
* 第几道题今天做什么:今天几节里有这道题的要求合起来(先画流程图一节、再写代码一节 = 先画再写),
|
||||
* 和学生那边的「做完」一致。推断出来的课为 null(代码交对或流程图画对都算)
|
||||
*/
|
||||
const modeAt = (index: number) => board.value?.problems[index]?.mode ?? null
|
||||
|
||||
/**
|
||||
* 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
|
||||
@@ -200,7 +204,7 @@ function groupOf(student: TaskBoardStudent): Group {
|
||||
const cells = student.cells
|
||||
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
|
||||
if (!student.lastSubmitAt) return 'idle'
|
||||
if (cells.some((cell) => isStuck(cell, mode.value))) return 'stuck'
|
||||
if (cells.some((cell, i) => isStuck(cell, modeAt(i)))) return 'stuck'
|
||||
return 'working'
|
||||
}
|
||||
|
||||
@@ -327,23 +331,24 @@ function drawKind(cell: TaskBoardCell): ErrorKind {
|
||||
* 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆
|
||||
*/
|
||||
function wrongOf(index: number): WrongPerson[] {
|
||||
const mode = modeAt(index)
|
||||
return present.value.flatMap((student): WrongPerson[] => {
|
||||
const cell = student.cells[index]
|
||||
if (!cell || cell.status !== 'tried') return []
|
||||
const stage = cellStage(cell, mode.value)
|
||||
const stage = cellStage(cell, mode)
|
||||
if (stage === 'draw') {
|
||||
const latest = cell.flowchart?.latest
|
||||
const evaluated = !!latest && latest.status !== 0 && latest.status !== 1
|
||||
return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }]
|
||||
}
|
||||
if (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }]
|
||||
if (mode.value === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') {
|
||||
if (mode === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') {
|
||||
return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }]
|
||||
}
|
||||
// 推断出来的课不分步:今天没代码、却画了流程图的单独一堆
|
||||
const kind: ErrorKind = cell.latest
|
||||
? errorKind(cell.latest)
|
||||
: !mode.value && cell.attempts
|
||||
: !mode && cell.attempts
|
||||
? { key: 'flowchart', label: '今天只画了流程图', line: null }
|
||||
: { key: 'before', label: '以前交过,今天还没交', line: null }
|
||||
return [{ student, cell, kind, explain: cell.latest ? 'code' : null }]
|
||||
@@ -362,21 +367,18 @@ const problemStats = computed(() =>
|
||||
const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null
|
||||
// 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码
|
||||
const steps =
|
||||
mode.value === 'both'
|
||||
problem.mode === 'both'
|
||||
? {
|
||||
drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length,
|
||||
coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length,
|
||||
}
|
||||
: null
|
||||
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps }
|
||||
// 先画再写的题,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
|
||||
const wrongWord = problem.mode === 'both' ? '没做完' : '错着'
|
||||
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps, wrongWord }
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* 先画再写的课,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
|
||||
*/
|
||||
const wrongWord = computed(() => (mode.value === 'both' ? '没做完' : '错着'))
|
||||
|
||||
const selectedId = ref<number | null>(null)
|
||||
const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
|
||||
function toggleProblem(problemId: number) {
|
||||
@@ -408,10 +410,14 @@ const explainShow = ref(false)
|
||||
const explainIndex = ref(0)
|
||||
const explainProblemIndex = ref(-1)
|
||||
const explainProblem = computed(() => board.value?.problems[explainProblemIndex.value] ?? null)
|
||||
/** 弹层顶上那排人:这道题错着的人,顺序和错法堆一样 */
|
||||
const explainPeople = computed<ExplainPerson[]>(() => {
|
||||
if (explainProblemIndex.value < 0) return []
|
||||
const people = wrongOf(explainProblemIndex.value).filter((person) => person.explain)
|
||||
/**
|
||||
* 弹层顶上那排人:这道题错着的人,顺序和错法堆一样。打开那一刻定下来,开着时不跟 15 秒的刷新变:
|
||||
* 弹层记的是第几个人,名单一变(有人改对了、图评完了回到名单里)就成了讲着讲着换了个人
|
||||
*/
|
||||
const explainPeople = ref<ExplainPerson[]>([])
|
||||
function explainPeopleOf(problemIndex: number): ExplainPerson[] {
|
||||
if (problemIndex < 0) return []
|
||||
const people = wrongOf(problemIndex).filter((person) => person.explain)
|
||||
const order = new Map<string, number>()
|
||||
for (const person of people) {
|
||||
order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1)
|
||||
@@ -423,10 +429,11 @@ const explainPeople = computed<ExplainPerson[]>(() => {
|
||||
? { userId: person.student.userId, username: person.student.username, flowchartId: person.cell.flowchart!.latest!.id, label: person.kind.label }
|
||||
: { userId: person.student.userId, username: person.student.username, submissionId: person.cell.latest!.submissionId, label: person.kind.label },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function openExplain(problemIndex: number, userId: number) {
|
||||
explainProblemIndex.value = problemIndex
|
||||
explainPeople.value = explainPeopleOf(problemIndex)
|
||||
explainIndex.value = Math.max(
|
||||
0,
|
||||
explainPeople.value.findIndex((person) => person.userId === userId),
|
||||
@@ -442,14 +449,15 @@ const cellColors = computed(() => ({
|
||||
actionColor: theme.value.actionColor,
|
||||
successColor: theme.value.successColor,
|
||||
}))
|
||||
const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
|
||||
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
|
||||
const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value)
|
||||
/** `index`:第几道题,格子按那道题今天做什么来画(modeAt) */
|
||||
const cellStyle = (cell: TaskBoardCell, index: number) => cellStyleOf(cell, board.value?.day, cellColors.value, modeAt(index))
|
||||
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent, index: number) => cellTitleOf(cell, student, board.value?.day, modeAt(index))
|
||||
const cellText = (cell: TaskBoardCell, index: number) => cellTextOf(cell, modeAt(index))
|
||||
|
||||
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
|
||||
function explainable(cell: TaskBoardCell) {
|
||||
function explainable(cell: TaskBoardCell, index: number) {
|
||||
if (cell.status !== 'tried') return false
|
||||
const stage = cellStage(cell, mode.value)
|
||||
const stage = cellStage(cell, modeAt(index))
|
||||
if (stage === 'draw') {
|
||||
const latest = cell.flowchart?.latest
|
||||
return !!latest && latest.status !== 0 && latest.status !== 1
|
||||
@@ -490,7 +498,7 @@ function submissionsHref(
|
||||
// SQL 题不受布置的语言限制,格子数的是 SQL 交的
|
||||
const language = problem?.isSql ? 'SQL' : board.value?.language
|
||||
// 只画流程图的课,格子数的全是流程图
|
||||
const flowSide = flowchart || mode.value === 'flowchart'
|
||||
const flowSide = flowchart || problem?.mode === 'flowchart'
|
||||
return router.resolve({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
@@ -504,6 +512,29 @@ function submissionsHref(
|
||||
}).href
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板娘:布置了就带去这节课的提交,筛法同提交列表「这节课」(班、这几道题、今天)。
|
||||
* 语言和格子一致:画流程图的课看流程图,选了语言的只看它 —— 夹着 SQL 题的不筛语言,免得把 SQL 交的藏掉。
|
||||
* 还没布置时填题号的框就是这页最大的东西,不给按钮
|
||||
*/
|
||||
const startForm = useTemplateRef<{ openPicker: () => void }>('startForm')
|
||||
useMascotGuide(() => {
|
||||
const b = board.value
|
||||
if (!b?.className) return null
|
||||
if (needLesson.value)
|
||||
return { situation: 'teacher.task.unassigned', actions: startForm.value ? [{ label: '选题', run: () => startForm.value?.openPicker() }] : [] }
|
||||
const language = b.mode === 'flowchart' ? 'Flowchart' : b.problems.some((problem) => problem.isSql) ? null : b.language
|
||||
const query = {
|
||||
className: b.className,
|
||||
problem: b.problems.map((problem) => problem.problemDisplayId).join(','),
|
||||
today: '1',
|
||||
...(language ? { language } : {}),
|
||||
}
|
||||
// 新标签打开,和点格子一样:任务页一般开着不关
|
||||
const open = () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
|
||||
return { situation: 'teacher.task.assigned', actions: [{ label: '这节课的提交', run: open }] }
|
||||
})
|
||||
|
||||
function lastText(student: TaskBoardStudent) {
|
||||
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
|
||||
if (oftenIdle(student)) {
|
||||
@@ -532,7 +563,8 @@ function lastText(student: TaskBoardStudent) {
|
||||
/>
|
||||
<div v-else-if="board?.problems.length && !needLesson" class="lesson">
|
||||
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
|
||||
<span v-if="board.mode && board.mode !== 'code'" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</span>
|
||||
<!-- 写代码也写出来:只写「Python」的话,看不出这节是写代码还是先画再写 -->
|
||||
<span v-if="board.mode" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</span>
|
||||
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
|
||||
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
|
||||
{{ problem.problemDisplayId }}
|
||||
@@ -570,7 +602,16 @@ function lastText(student: TaskBoardStudent) {
|
||||
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
||||
<section v-if="needLesson" class="start">
|
||||
<div class="start-title">这节课做哪几道题?</div>
|
||||
<LessonForm v-if="className" :board="board" :class-name="className" :lesson-id="null" variant="start" class="start-row" @saved="lessonSaved" />
|
||||
<LessonForm
|
||||
v-if="className"
|
||||
ref="startForm"
|
||||
:board="board"
|
||||
:class-name="className"
|
||||
:lesson-id="null"
|
||||
variant="start"
|
||||
class="start-row"
|
||||
@saved="lessonSaved"
|
||||
/>
|
||||
<div v-if="staleLesson" class="earlier">
|
||||
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
|
||||
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
|
||||
@@ -601,7 +642,7 @@ function lastText(student: TaskBoardStudent) {
|
||||
</span>
|
||||
<span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span>
|
||||
<span class="card-foot">
|
||||
<span :class="stat.wrong ? 'wrong' : 'muted'">{{ wrongWord }} {{ stat.wrong }}</span>
|
||||
<span :class="stat.wrong ? 'wrong' : 'muted'">{{ stat.wrongWord }} {{ stat.wrong }}</span>
|
||||
<span class="muted">没做 {{ stat.none }}</span>
|
||||
<span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
|
||||
</span>
|
||||
@@ -624,7 +665,7 @@ function lastText(student: TaskBoardStudent) {
|
||||
{{ board.problems[selectedIndex]!.title }}
|
||||
</a>
|
||||
<span>
|
||||
{{ wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
|
||||
{{ problemStats[selectedIndex]!.wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
|
||||
</span>
|
||||
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
|
||||
<div class="spacer"></div>
|
||||
@@ -643,7 +684,7 @@ function lastText(student: TaskBoardStudent) {
|
||||
<template v-for="person in group.people" :key="person.student.userId">
|
||||
<button v-if="person.explain" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
|
||||
<UserName :username="person.student.username" />
|
||||
<span class="mini" :style="cellStyle(person.cell)">{{ cellText(person.cell) }}</span>
|
||||
<span class="mini" :style="cellStyle(person.cell, selectedIndex)">{{ cellText(person.cell, selectedIndex) }}</span>
|
||||
</button>
|
||||
<!-- 没有今天的代码可讲:去提交列表看他交过的 -->
|
||||
<a
|
||||
@@ -747,15 +788,15 @@ function lastText(student: TaskBoardStudent) {
|
||||
</td>
|
||||
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
|
||||
<button
|
||||
v-if="explainable(cell)"
|
||||
v-if="explainable(cell, i)"
|
||||
type="button"
|
||||
class="cell"
|
||||
:data-challenge="cell.challenge ?? undefined"
|
||||
:style="cellStyle(cell)"
|
||||
:title="cellTitle(cell, student)"
|
||||
:style="cellStyle(cell, i)"
|
||||
:title="cellTitle(cell, student, i)"
|
||||
@click="openExplain(i, student.userId)"
|
||||
>
|
||||
{{ cellText(cell) }}
|
||||
{{ cellText(cell, i) }}
|
||||
</button>
|
||||
<n-popover
|
||||
v-else-if="cell.trail.length"
|
||||
@@ -771,11 +812,11 @@ function lastText(student: TaskBoardStudent) {
|
||||
class="cell"
|
||||
:data-challenge="cell.challenge ?? undefined"
|
||||
:class="{ picked: trailKey === `${student.userId}:${i}` }"
|
||||
:style="cellStyle(cell)"
|
||||
:title="cellTitle(cell, student)"
|
||||
:style="cellStyle(cell, i)"
|
||||
:title="cellTitle(cell, student, i)"
|
||||
@click="toggleTrail(`${student.userId}:${i}`)"
|
||||
>
|
||||
{{ cellText(cell) }}
|
||||
{{ cellText(cell, i) }}
|
||||
</button>
|
||||
</template>
|
||||
<TrailPanel
|
||||
@@ -791,15 +832,15 @@ function lastText(student: TaskBoardStudent) {
|
||||
v-else-if="cell.attempts"
|
||||
class="cell"
|
||||
:data-challenge="cell.challenge ?? undefined"
|
||||
:style="cellStyle(cell)"
|
||||
:style="cellStyle(cell, i)"
|
||||
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
|
||||
target="_blank"
|
||||
:title="cellTitle(cell, student)"
|
||||
:title="cellTitle(cell, student, i)"
|
||||
>
|
||||
{{ cellText(cell) }}
|
||||
{{ cellText(cell, i) }}
|
||||
</a>
|
||||
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell)" :title="cellTitle(cell, student)">
|
||||
{{ cellText(cell) }}
|
||||
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell, i)" :title="cellTitle(cell, student, i)">
|
||||
{{ cellText(cell, i) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getProfileOverview } from 'oj/api'
|
||||
import { getProfile } from 'shared/api'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||
import type { Profile } from 'utils/types'
|
||||
import AchievementCard from './components/AchievementCard.vue'
|
||||
import KnowledgeMap from './components/KnowledgeMap.vue'
|
||||
@@ -17,6 +18,7 @@ import VersusCard from './components/VersusCard.vue'
|
||||
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
|
||||
*/
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
@@ -47,6 +49,12 @@ async function load() {
|
||||
}
|
||||
|
||||
watch(username, load, { immediate: true })
|
||||
|
||||
// 看板娘:只在看自己的主页时说话
|
||||
useMascotGuide(() => {
|
||||
if (!isSelf.value || !profile.value || userStore.isTeacherOrAbove) return null
|
||||
return { situation: 'profile', actions: [goAction(router, '我的成就', '/achievement'), goAction(router, '我的提交', '/submission?myself=1')] }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,11 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import AchievementIcon from 'shared/components/AchievementIcon.vue'
|
||||
import { mascotBox } from 'shared/mascot/guide'
|
||||
import { useAchievementStore } from 'shared/store/achievement'
|
||||
import { RARITY_COLOR } from 'utils/constants'
|
||||
|
||||
const store = useAchievementStore()
|
||||
const { current, queue } = storeToRefs(store)
|
||||
const visible = ref(false)
|
||||
const toastEl = ref<HTMLElement>()
|
||||
|
||||
const RIGHT = 24
|
||||
const TOP = 72
|
||||
const GAP = 12
|
||||
|
||||
/**
|
||||
* 默认在右上角。看板娘被拖到和它重叠的地方(多半是右上角)时,挪到她左边 ——
|
||||
* 补发成就时一次会连着弹好几个,一直压在她头上不好看。尺寸按弹出来以后量到的算,
|
||||
* 还没量到时按最小宽度和大致高度估。
|
||||
*/
|
||||
const placement = computed(() => {
|
||||
if (!mascotBox.shown || !visible.value) return {}
|
||||
const vw = document.documentElement.clientWidth
|
||||
const width = toastEl.value?.offsetWidth ?? 300
|
||||
const height = toastEl.value?.offsetHeight ?? 90
|
||||
const left = vw - RIGHT - width
|
||||
const overlaps = mascotBox.x < vw - RIGHT && mascotBox.x + mascotBox.width > left && mascotBox.y < TOP + height && mascotBox.y + mascotBox.height > TOP
|
||||
return overlaps ? { right: `${vw - mascotBox.x + GAP}px` } : {}
|
||||
})
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let gapTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -37,7 +58,7 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<div v-if="visible && current" class="toast" :style="{ borderColor: RARITY_COLOR[current.rarity] }">
|
||||
<div v-if="visible && current" ref="toastEl" class="toast" :style="{ borderColor: RARITY_COLOR[current.rarity], ...placement }">
|
||||
<div class="icon">
|
||||
<AchievementIcon :icon="current.icon" :size="34" />
|
||||
</div>
|
||||
@@ -56,7 +77,7 @@ onUnmounted(() => {
|
||||
.toast {
|
||||
position: fixed;
|
||||
/* 右上角、顶栏下面。原来在右下角,题目页上正好和结果、点评弹窗叠在一起 */
|
||||
right: 24px;
|
||||
right: 24px; /* 和脚本里的 RIGHT / TOP 一致 */
|
||||
top: 72px;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
|
||||
@@ -53,8 +53,8 @@ const envVersion = computed(() => {
|
||||
})
|
||||
|
||||
// 一级路径就是菜单 key,对不上的页面(/user、/achievement 等)
|
||||
// 自然没有一项亮着。根路径没登录时就是题目列表,登录了是个人首页,不亮任何一项
|
||||
const active = computed(() => route.path.split('/')[1] || (userStore.isAuthed ? '' : 'problem'))
|
||||
// 自然没有一项亮着。根路径没登录时就是题目列表(亮「题目」),登录了是个人首页(亮「首页」)
|
||||
const active = computed(() => route.path.split('/')[1] || (userStore.isAuthed ? 'home' : 'problem'))
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.signOut()
|
||||
@@ -80,7 +80,8 @@ function renderIcon(icon: string) {
|
||||
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
|
||||
* `key` 就是一级路径,用来判定高亮(见上面的 active)。
|
||||
*
|
||||
* 顺序是 自学 / 题目 / 提交 / 专题 / 比赛 / 排名 / 公告。「自学」进自学首页(/learn),
|
||||
* 顺序是 首页 / 自学 / 题目 / 提交 / 专题 / 比赛 / 排名 / 公告。「首页」只给登录了的:没登录时根路径
|
||||
* 就是题目列表,和「题目」是同一页(2026-11-03 用户要的,原来只能点左上角的站名回首页)。「自学」进自学首页(/learn),
|
||||
* 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
|
||||
* 公告 2026-10-13 回到顶栏(用户要的):首页只放置顶那条了,别的公告得有个地方点得到。
|
||||
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
|
||||
@@ -94,6 +95,7 @@ interface NavLink {
|
||||
|
||||
const navLinks = computed<NavLink[]>(() =>
|
||||
[
|
||||
{ key: 'home', label: '首页', to: '/', show: userStore.isAuthed },
|
||||
{ key: 'learn', label: '自学', to: '/learn' },
|
||||
{ key: 'problem', label: '题目', to: '/problem' },
|
||||
{ key: 'submission', label: '提交', to: '/submission', show: userStore.showSubmissions },
|
||||
|
||||
@@ -17,9 +17,12 @@ function names(list: Item[]) {
|
||||
return list.map((item) => `${item.problemDisplayId} ${item.title}`).join('、')
|
||||
}
|
||||
|
||||
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */
|
||||
function languageText(value: LessonUpdate) {
|
||||
const mode = value.mode && value.mode !== 'code' ? LESSON_MODE_LABEL[value.mode] : ''
|
||||
/**
|
||||
* 「画流程图」「先画再写 · C语言」「C语言」。新布置的一节写代码是默认的,不写出来(前面配「用」);
|
||||
* 改了做法时 `spellCode` 把「写代码」也写出来 —— 从画流程图改成写 C,只说「C语言」学生看不出不用画了
|
||||
*/
|
||||
function languageText(value: LessonUpdate, spellCode = false) {
|
||||
const mode = value.mode && (spellCode || value.mode !== 'code') ? LESSON_MODE_LABEL[value.mode] : ''
|
||||
return [mode, value.language ? LANGUAGE_SHOW_VALUE[value.language] : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
@@ -44,8 +47,9 @@ const lines = computed(() => {
|
||||
if (value.added.length) result.push({ label: '加了', text: names(value.added) })
|
||||
if (value.removed.length) result.push({ label: '去掉了', text: names(value.removed) })
|
||||
if (value.languageChanged) {
|
||||
const text = languageText(value)
|
||||
result.push({ label: value.mode && value.mode !== 'code' ? '做法' : '语言', text: text ? `改成 ${text}` : value.mode === 'code' ? '改成写代码' : '不限了' })
|
||||
// languageChanged 连做法一起算,分不出改的是哪样:一律写「做法」,连写代码带语言一起说
|
||||
const text = languageText(value, true)
|
||||
result.push({ label: '做法', text: text ? `改成 ${text}` : '不限了' })
|
||||
}
|
||||
if (value.reordered) result.push({ label: '', text: '换了题目的顺序' })
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 看板娘,兼站内的引导员。现在只有一个形象(鲸鱼娘)。
|
||||
*
|
||||
* 形象取自 MeteorNOX/DeepSeek-Balance-Whale-Widget 的 assets/DSniang1.png(裁掉透明边、缩到 2 倍显示尺寸转 webp)。
|
||||
* 那个仓库的代码是 MIT,但 assets/ 下的图不在 MIT 里(见它的 PROVENANCE.md)。
|
||||
*
|
||||
* - 常驻在前台页面的角落,能拖;松手按「四分之一区」贴边:中心落在左/右四分之一就贴左/右缘,
|
||||
* 上/下四分之一就贴上/下缘,中间保持松手的位置。两个方向各判各的,所以能贴进角落。
|
||||
* - 在屏幕左半边时整体镜像,脸总朝着页面中间。
|
||||
* - 按下去像玩偶一样压扁(底边不动),松手弹回。
|
||||
* - 点一下冒气泡:一句话加最多三个按钮,按页面、按情况变(页面经 shared/mascot/guide.ts 告诉她),
|
||||
* 台词在 shared/mascot/lines.ts。比赛里点她不弹气泡。再点一下、点别处、换页面都收起。
|
||||
* - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts);
|
||||
* 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。
|
||||
* - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。
|
||||
* - 彩蛋:连着快点,有时候(每轮三分之一的机会)点到第 4~8 下(也是随机的)会被摸摸头(同一个仓库的 assets/rua.gif),摸的时候接着点就一直摸。
|
||||
*
|
||||
* 位置只是这台电脑上的个人偏好,存 localStorage(读写都包 try/catch,拿不到就用默认位置)。
|
||||
* 定位一律用 transform 的像素值:贴右缘时若换成 right:0,过渡没法在 auto 和数值之间插值,会瞬移。
|
||||
*/
|
||||
import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
|
||||
import imageUrl from 'shared/assets/mascot/whale.webp'
|
||||
import patUrl from 'shared/assets/mascot/whale-pat.gif'
|
||||
import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines'
|
||||
import { currentMascotGuide, fillLine, mascotBox, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { logMascotEvent, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
|
||||
const WIDTH = 132
|
||||
const HEIGHT = 140 // 图是 264×280
|
||||
const MARGIN = 12
|
||||
const TOP_GAP = 72 // 贴上缘时让开顶栏
|
||||
const STORAGE_KEY = 'oj-mascot'
|
||||
// 摸头动图是 128×128,人只占右下角、上面是手。放大到人和静态图一样大,
|
||||
// 右边、人的下沿(第 117 行像素)对齐静态图,手伸出框外
|
||||
const PAT_SIZE = 182
|
||||
const PAT_BOTTOM = -Math.round((PAT_SIZE * 11) / 128)
|
||||
const PAT_MS = 1200
|
||||
// 每轮连点先抽这一轮有没有彩蛋,有的话再抽连点到第几下出。都猜不准才像彩蛋
|
||||
const PAT_CHANCE = 1 / 3
|
||||
const PAT_CLICKS_MIN = 4
|
||||
const PAT_CLICKS_MAX = 8
|
||||
function rollPatClicks() {
|
||||
if (Math.random() >= PAT_CHANCE) return Infinity // 这一轮怎么点都只是蹦
|
||||
return PAT_CLICKS_MIN + Math.floor(Math.random() * (PAT_CLICKS_MAX - PAT_CLICKS_MIN + 1))
|
||||
}
|
||||
const STREAK_GAP_MS = 600 // 两下之间超过这么久就不算连点
|
||||
const OVERCLICK = 8 // 一轮连点到这么多下还没摸到头,她就嫌烦了
|
||||
const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起
|
||||
const SPOKEN_BUBBLE_MS = 10_000 // 她主动说的话,有按钮也过这么久收起:没人点她,不该一直挡着
|
||||
const SPEAK_GAP_MS = 3 * 60_000 // 主动开口至少隔这么久,课上别老打岔
|
||||
|
||||
type HAnchor = 'left' | 'right' | null
|
||||
type VAnchor = 'top' | 'bottom' | null
|
||||
interface Place {
|
||||
h: HAnchor
|
||||
v: VAnchor
|
||||
x: number // 未贴边时的 left
|
||||
y: number // 未贴边时的 top
|
||||
}
|
||||
|
||||
function loadPlace(): Place {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const p = JSON.parse(raw) as Partial<Place>
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') return { h: p.h ?? null, v: p.v ?? null, x: p.x, y: p.y }
|
||||
}
|
||||
} catch {
|
||||
// 隐私窗口、禁了站点数据:用默认位置
|
||||
}
|
||||
return { h: 'right', v: 'bottom', x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function savePlace(p: Place) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(p))
|
||||
} catch {
|
||||
// 存不下就算了,下次回到默认位置
|
||||
}
|
||||
}
|
||||
|
||||
const place = loadPlace()
|
||||
const pos = reactive({ x: 0, y: 0 })
|
||||
const dragging = ref(false)
|
||||
const pressed = ref(false)
|
||||
const patting = ref(false)
|
||||
const flipped = ref(false)
|
||||
const body = ref<HTMLElement>()
|
||||
const squashEl = ref<HTMLElement>()
|
||||
const bubbleEl = ref<HTMLElement>()
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.min(Math.max(v, lo), Math.max(lo, hi))
|
||||
}
|
||||
|
||||
/** 按锚点算出实际坐标。窗口变了也走这里,贴边的照旧贴边 */
|
||||
function settle() {
|
||||
const vw = document.documentElement.clientWidth
|
||||
const vh = window.innerHeight
|
||||
pos.x = place.h === 'left' ? MARGIN : place.h === 'right' ? vw - WIDTH - MARGIN : clamp(place.x, MARGIN, vw - WIDTH - MARGIN)
|
||||
// 图的下沿是裁断的半身,贴底时直接坐在屏幕底边上,不留空
|
||||
pos.y = place.v === 'top' ? TOP_GAP : place.v === 'bottom' ? vh - HEIGHT : clamp(place.y, TOP_GAP, vh - HEIGHT)
|
||||
flipped.value = pos.x + WIDTH / 2 < vw / 2
|
||||
}
|
||||
|
||||
// 第一帧之前就算好位置。挪到 onMounted 里算的话,第一帧画在 (0,0),
|
||||
// 接着位置一变就触发过渡,看上去是从左上角飞过去的
|
||||
settle()
|
||||
|
||||
let start: { px: number; py: number; x: number; y: number } | null = null
|
||||
let moved = false
|
||||
|
||||
function onDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
// 不拦的话,第一下甩得快时浏览器会拿光标新位置底下的东西(比如题目列表的链接)开始原生拖拽,
|
||||
// 随即发 pointercancel,看板娘就卡在半路
|
||||
e.preventDefault()
|
||||
body.value?.setPointerCapture(e.pointerId)
|
||||
start = { px: e.clientX, py: e.clientY, x: pos.x, y: pos.y }
|
||||
moved = false
|
||||
pressed.value = true
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!start) return
|
||||
const dx = e.clientX - start.px
|
||||
const dy = e.clientY - start.py
|
||||
if (!moved && dx * dx + dy * dy < 9) return
|
||||
moved = true
|
||||
dragging.value = true
|
||||
pressed.value = false
|
||||
pos.x = start.x + dx
|
||||
pos.y = start.y + dy
|
||||
flipped.value = pos.x + WIDTH / 2 < document.documentElement.clientWidth / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* 每点一下都压扁再弹回来一次。按住时的压扁靠 .pressed 的过渡,可快速点一下从按下到松开只有几十毫秒,
|
||||
* 过渡还没走到位就松了,连点时看上去像卡住。用 Web Animations 播一遍完整的,每下都从头播
|
||||
*/
|
||||
const reducedMotion = usePreferredReducedMotion()
|
||||
function boing() {
|
||||
if (reducedMotion.value === 'reduce') return
|
||||
squashEl.value?.animate([{ transform: 'scale(1.06, 0.86)' }, { transform: 'scale(0.97, 1.04)', offset: 0.55 }, { transform: 'scale(1, 1)' }], {
|
||||
duration: 280,
|
||||
easing: 'ease-out',
|
||||
})
|
||||
}
|
||||
|
||||
// 动图 94KB,不在每个页面一打开就下:连点到第 2 下才预载,最少也还要再点两下才用得上
|
||||
let patPreloaded = false
|
||||
function preloadPat() {
|
||||
if (patPreloaded) return
|
||||
patPreloaded = true
|
||||
new Image().src = patUrl
|
||||
}
|
||||
|
||||
let patTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let streak = 0
|
||||
let patClicks = rollPatClicks()
|
||||
let lastClick = 0
|
||||
function onUp() {
|
||||
if (!start) return
|
||||
start = null
|
||||
pressed.value = false
|
||||
if (moved) {
|
||||
dragging.value = false
|
||||
const vw = document.documentElement.clientWidth
|
||||
const vh = window.innerHeight
|
||||
const cx = pos.x + WIDTH / 2
|
||||
const cy = pos.y + HEIGHT / 2
|
||||
place.h = cx < vw / 4 ? 'left' : cx > (vw * 3) / 4 ? 'right' : null
|
||||
place.v = cy < vh / 4 ? 'top' : cy > (vh * 3) / 4 ? 'bottom' : null
|
||||
place.x = pos.x
|
||||
place.y = pos.y
|
||||
settle()
|
||||
savePlace(place)
|
||||
logMascotEvent({ kind: 'move', line: `${place.h ?? '-'} ${place.v ?? '-'}` })
|
||||
return
|
||||
}
|
||||
boing()
|
||||
const now = performance.now()
|
||||
if (now - lastClick <= STREAK_GAP_MS) streak++
|
||||
else {
|
||||
streak = 1
|
||||
patClicks = rollPatClicks()
|
||||
}
|
||||
lastClick = now
|
||||
if (streak >= 2) preloadPat()
|
||||
|
||||
// 正在被摸,或者连点到数了:摸摸头,从这一下再摸满一轮
|
||||
if (patting.value || streak >= patClicks) {
|
||||
if (!patting.value) {
|
||||
say({ text: pick('patting') })
|
||||
logMascotEvent({ kind: 'pat' })
|
||||
}
|
||||
patting.value = true
|
||||
clearTimeout(patTimer)
|
||||
patTimer = setTimeout(() => {
|
||||
patting.value = false
|
||||
streak = 0
|
||||
}, PAT_MS)
|
||||
return
|
||||
}
|
||||
// 一轮里只有第一下开关气泡,后面连着点是在找彩蛋,不跟着一开一关
|
||||
if (streak === 1) toggleBubble()
|
||||
else if (streak === OVERCLICK) {
|
||||
say({ text: pick('overclick') })
|
||||
logMascotEvent({ kind: 'overclick' })
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 气泡 ----
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const collabStore = useCollabStore()
|
||||
const authModal = useAuthModalStore()
|
||||
|
||||
interface Bubble {
|
||||
text: string
|
||||
actions?: MascotAction[]
|
||||
/** 记互动时带上,看哪种情况下的按钮有人点 */
|
||||
situation?: string
|
||||
}
|
||||
const bubble = ref<Bubble | null>(null)
|
||||
let quietTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/** 比赛、测验里不说话:「运行例子」「举手」这类按钮在考试时算不算帮忙,干脆不弹 */
|
||||
const silent = computed(() => String(route.name ?? '').startsWith('contest '))
|
||||
|
||||
function say(next: Bubble, closeAfter = next.actions?.length ? 0 : QUIET_BUBBLE_MS) {
|
||||
if (silent.value) return
|
||||
bubble.value = next
|
||||
clearTimeout(quietTimer)
|
||||
if (closeAfter) quietTimer = setTimeout(close, closeAfter)
|
||||
}
|
||||
|
||||
function close() {
|
||||
bubble.value = null
|
||||
clearTimeout(quietTimer)
|
||||
}
|
||||
|
||||
function toggleBubble() {
|
||||
if (bubble.value) return close()
|
||||
let guide = resolveGuide()
|
||||
if (!userStore.isTeacherOrAbove) guide = personalize(guide)
|
||||
const text = pickFor(guide)
|
||||
say({ text, actions: guide.actions?.slice(0, 3), situation: guide.situation })
|
||||
if (silent.value) return
|
||||
notePoke()
|
||||
logMascotEvent({ kind: 'open', situation: guide.situation, line: text })
|
||||
}
|
||||
|
||||
/** 先看全站的:没登录、老师这边有人举手;再问页面;都没有就打个招呼 */
|
||||
function resolveGuide(): MascotGuide {
|
||||
if (!userStore.isAuthed) return { situation: 'guest', actions: [{ label: '登录', run: authModal.openLoginModal }] }
|
||||
const teacher = userStore.isTeacherOrAbove
|
||||
if (teacher && collabStore.isTeacher && collabStore.pendingCount > 0) {
|
||||
return { situation: 'teacher.help', n: collabStore.pendingCount, actions: [{ label: '去看看', run: () => (collabStore.helpPanelOpen = true) }] }
|
||||
}
|
||||
return currentMascotGuide() ?? { situation: teacher ? 'teacher.greet' : 'greet' }
|
||||
}
|
||||
|
||||
/** 这几种情况要紧,不拿彩蛋换掉 */
|
||||
const SERIOUS = new Set<MascotSituation>(['guest', 'teacher.help', 'problem.stuck', 'first.meet'])
|
||||
const RARE_CHANCE = 0.03
|
||||
const TIME_CHANCE = 0.15
|
||||
|
||||
function pickFor(guide: MascotGuide) {
|
||||
let situation = guide.situation
|
||||
if (!SERIOUS.has(situation) && guide.n === undefined && !guide.vars) {
|
||||
const timely = timeSituation()
|
||||
if (Math.random() < RARE_CHANCE) situation = 'rare'
|
||||
else if (timely && !userStore.isTeacherOrAbove && Math.random() < TIME_CHANCE) situation = timely
|
||||
}
|
||||
return fillLine(pick(situation), guide)
|
||||
}
|
||||
|
||||
/** 同一组里不连着抽到同一句 */
|
||||
let lastLine = ''
|
||||
function pick(situation: MascotSituation) {
|
||||
const pool: readonly string[] = MASCOT_LINES[situation]
|
||||
const choices = pool.length > 1 ? pool.filter((line) => line !== lastLine) : pool
|
||||
lastLine = choices[Math.floor(Math.random() * choices.length)]!
|
||||
return lastLine
|
||||
}
|
||||
|
||||
/**
|
||||
* 按东八区的钟点挑时间彩蛋。节次是估的(第一节 8:00、午饭 11:20、下午第一节 13:30、
|
||||
* 最后一节 15:50 前后),对不上学校作息就改这里
|
||||
*/
|
||||
function timeSituation(): MascotSituation | null {
|
||||
const wall = new Date(Date.now() + TIME_ZONE_OFFSET_MINUTES * 60_000)
|
||||
const day = wall.getUTCDay()
|
||||
const minutes = wall.getUTCHours() * 60 + wall.getUTCMinutes()
|
||||
const within = (from: string, to: string) => {
|
||||
const [fh, fm] = from.split(':').map(Number)
|
||||
const [th, tm] = to.split(':').map(Number)
|
||||
return minutes >= fh! * 60 + fm! && minutes < th! * 60 + tm!
|
||||
}
|
||||
if (day === 0 || day === 6 || !within('07:00', '18:00')) return 'offHours'
|
||||
if (within('08:00', '08:50')) return 'time.firstClass'
|
||||
if (within('11:20', '12:10')) return 'time.lunch'
|
||||
if (within('13:30', '14:20')) return 'time.afternoon'
|
||||
if (within('15:50', '16:40')) return 'time.lastClass'
|
||||
if (day === 1) return 'day.monday'
|
||||
if (day === 5) return 'day.friday'
|
||||
return null
|
||||
}
|
||||
|
||||
async function runAction(action: MascotAction) {
|
||||
logMascotEvent({ kind: 'action', action: action.label, situation: bubble.value?.situation })
|
||||
close()
|
||||
await action.run()
|
||||
}
|
||||
|
||||
// ---- 主动开口:页面经 requestSpeech 请她说,这里卡频率 ----
|
||||
let lastSpokeAt = -Infinity
|
||||
const spokenKeys = new Set<string>()
|
||||
watch(speechRequest, (speech) => {
|
||||
if (!speech || silent.value || dragging.value || bubble.value || userStore.isTeacherOrAbove) return
|
||||
if (document.visibilityState !== 'visible' || spokenKeys.has(speech.key)) return
|
||||
if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return
|
||||
lastSpokeAt = performance.now()
|
||||
spokenKeys.add(speech.key)
|
||||
const text = fillLine(pick(speech.situation), speech)
|
||||
say({ text, actions: speech.actions?.slice(0, 3), situation: speech.situation }, SPOKEN_BUBBLE_MS)
|
||||
logMascotEvent({ kind: 'speak', situation: speech.situation, line: text })
|
||||
})
|
||||
|
||||
// 学生的行为数:进站、换页面时拉(30 秒内拉过就不拉),登录换人时重拉
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => refreshMascotProfile(),
|
||||
)
|
||||
watch(
|
||||
() => userStore.user?.id,
|
||||
() => refreshMascotProfile(true),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 点别处收起;点她自己由 onUp 管(开关),不算「别处」
|
||||
onClickOutside(bubbleEl, close, { ignore: [body] })
|
||||
watch(() => route.fullPath, close)
|
||||
watch(silent, (quiet) => quiet && close())
|
||||
|
||||
/** 她靠上时气泡往下冒,免得顶出屏幕;在左半边时气泡贴左对齐 */
|
||||
const bubbleBelow = computed(() => pos.y < 220)
|
||||
|
||||
// 告诉别的浮层她在哪(成就弹框会让开)
|
||||
watchEffect(() => Object.assign(mascotBox, { x: pos.x, y: pos.y, width: WIDTH, height: HEIGHT }))
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', settle, { passive: true })
|
||||
mascotBox.shown = true
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
mascotBox.shown = false
|
||||
window.removeEventListener('resize', settle)
|
||||
clearTimeout(patTimer)
|
||||
clearTimeout(quietTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mascot" :class="{ dragging }" :style="{ width: `${WIDTH}px`, height: `${HEIGHT}px`, transform: `translate3d(${pos.x}px, ${pos.y}px, 0)` }">
|
||||
<div
|
||||
ref="body"
|
||||
class="body"
|
||||
:class="{ pressed, patting, flipped }"
|
||||
aria-hidden="true"
|
||||
@pointerdown="onDown"
|
||||
@pointermove="onMove"
|
||||
@pointerup="onUp"
|
||||
@pointercancel="onUp"
|
||||
@dragstart.prevent
|
||||
>
|
||||
<div class="flip">
|
||||
<div ref="squashEl" class="squash">
|
||||
<div class="breathe">
|
||||
<img
|
||||
v-if="patting"
|
||||
class="pat"
|
||||
:src="patUrl"
|
||||
:width="PAT_SIZE"
|
||||
:height="PAT_SIZE"
|
||||
:style="{ bottom: `${PAT_BOTTOM}px` }"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<img v-else :src="imageUrl" :width="WIDTH" :height="HEIGHT" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="bubble">
|
||||
<div v-if="bubble && !dragging" ref="bubbleEl" class="bubble" :class="{ below: bubbleBelow, left: flipped }" role="status">
|
||||
<p class="line">{{ bubble.text }}</p>
|
||||
<div v-if="bubble.actions?.length" class="actions">
|
||||
<n-button
|
||||
v-for="(action, index) in bubble.actions"
|
||||
:key="action.label"
|
||||
size="small"
|
||||
:type="index === 0 ? 'primary' : 'default'"
|
||||
@click="runAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mascot {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 900; /* 压在页面上,但让弹框、消息、烟花盖住它 */
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
.mascot.dragging {
|
||||
transition: none;
|
||||
}
|
||||
.body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.dragging .body {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* 气泡:白底深蓝描边(取她身上线条的颜色),尾巴指着她的头。设计稿「看板娘引导」 */
|
||||
.bubble {
|
||||
--bubble-line: #2b3a6b;
|
||||
--bubble-bg: #fff;
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: calc(100% + 12px);
|
||||
width: max-content;
|
||||
min-width: 120px;
|
||||
max-width: 280px;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 14px;
|
||||
background: var(--bubble-bg);
|
||||
border: 2px solid var(--bubble-line);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(20, 30, 60, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
:global(html.dark) .bubble {
|
||||
--bubble-line: #7f94d4;
|
||||
--bubble-bg: #1f2230;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
bottom: -9px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--bubble-bg);
|
||||
border-right: 2px solid var(--bubble-line);
|
||||
border-bottom: 2px solid var(--bubble-line);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.bubble.left {
|
||||
right: auto;
|
||||
left: 14px;
|
||||
}
|
||||
.bubble.left::after {
|
||||
right: auto;
|
||||
left: 40px;
|
||||
}
|
||||
.bubble.below {
|
||||
bottom: auto;
|
||||
top: calc(100% + 12px);
|
||||
}
|
||||
.bubble.below::after {
|
||||
bottom: auto;
|
||||
top: -9px;
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
.line {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.bubble-enter-active,
|
||||
.bubble-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.bubble-enter-from,
|
||||
.bubble-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.96);
|
||||
}
|
||||
|
||||
.flip,
|
||||
.squash,
|
||||
.breathe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
.flip {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.flipped .flip {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* 按压 Q 弹:以底边中点为原点压扁,松手带一点回弹 */
|
||||
.squash {
|
||||
transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.pressed .squash {
|
||||
transform: scale(1.05, 0.88);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
/* 平时轻轻地一起一伏 */
|
||||
.breathe {
|
||||
animation: mascot-breathe 3.6s ease-in-out infinite;
|
||||
}
|
||||
.dragging .breathe,
|
||||
.patting .breathe {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
img.pat {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.breathe {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes mascot-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1, 1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015, 0.975);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.breathe {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.mascot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useMascotGuide } from 'shared/mascot/guide'
|
||||
|
||||
/**
|
||||
* 「找不到」的整页提示:地址打错、题目 / 比赛 / 专题不存在或没公开、比赛还没开始。
|
||||
@@ -21,6 +22,9 @@ const props = withDefaults(
|
||||
|
||||
const theme = useThemeVars()
|
||||
const router = useRouter()
|
||||
|
||||
// 看板娘:和页面上那个按钮去同一个地方(回首页 / 回到专题 / 回到比赛)
|
||||
useMascotGuide(() => ({ situation: 'notFound', actions: [{ label: props.toLabel, run: () => router.push(props.to) }] }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useProblemJump } from 'shared/composables/problemJump'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import type { ClassActivityProblem } from 'utils/types'
|
||||
import { zonedParts } from 'utils/functions'
|
||||
import { jumpFocusRequest } from 'shared/mascot/guide'
|
||||
|
||||
/**
|
||||
* 顶栏的题号框:老师报完题号,学生在哪一页都能直接敲,不用先回首页或题目列表。
|
||||
@@ -29,7 +30,9 @@ const lessonStore = useLessonStore()
|
||||
const { jump, jumping } = useProblemJump()
|
||||
const keyword = ref('')
|
||||
const focused = ref(false)
|
||||
const input = useTemplateRef<{ blur: () => void }>('input')
|
||||
const input = useTemplateRef<{ blur: () => void; focus: () => void }>('input')
|
||||
// 看板娘气泡里的「输题号」:把光标放进来
|
||||
watch(jumpFocusRequest, () => input.value?.focus())
|
||||
|
||||
/** 题目页的三种入口(题库 / 比赛 / 专题)顶上都有自己的上下文条 */
|
||||
const PROBLEM_PAGES = new Set(['problem', 'contest problem', 'problemset problem'])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useDark } from 'shared/composables/dark'
|
||||
import { rgba } from 'shared/composables/tone'
|
||||
import { displayName } from '@oj2/contract'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useDark } from 'shared/composables/dark'
|
||||
import { Chart as ChartJS } from 'chart.js'
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createGlobalState, useDark as useVueUseDark } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 全站只有一个 `useDark()` 实例。**别再从 `@vueuse/core` 直接取 useDark** ——
|
||||
* auto-import 已经把 `useDark` 指到这里(apps/web/vite.config.ts 的 priority)。
|
||||
*
|
||||
* VueUse 的 useDark 每个实例挂载时都要把 `<html>` 的 class 「同步」一遍,同步的写法是
|
||||
* 往 `<head>` 插一段关掉过渡的 `<style>`、`getComputedStyle()` 逼浏览器当场重算整页样式、
|
||||
* 再删掉。亮色模式下它那句「没什么要改就跳过」永远不成立(要摘的 `dark` 本来就不在,它也算
|
||||
* 一次改动),所以**每个实例挂载一次就是一次整页样式重算**。
|
||||
*
|
||||
* 提交列表每行的结果胶囊(StatusPill → useTone)都调它:一页 30 行就是 30 次,翻页、
|
||||
* 切筛选再来 30 次,右栏换一条又是三四次。2026-11 实测(生产构建、CPU 降速 6 倍)首屏
|
||||
* 光这一处就占了 1.3 秒。全站共用一个之后只在第一次挂载时同步,后面的调用都是读同一个 ref。
|
||||
*/
|
||||
export const useDark = createGlobalState(() =>
|
||||
useVueUseDark({
|
||||
// 剩下的那一次(一进页面它还要同步两遍)也省掉:<html> 上的 class 已经对了就不动。
|
||||
// 只有真要切换时才走它原来那套(关过渡 + 重算),切暗色不会有颜色慢慢渐变的过渡
|
||||
onChanged(dark, defaultHandler, mode) {
|
||||
if (document.documentElement.classList.contains('dark') === dark) return
|
||||
defaultHandler(mode)
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useDark } from 'shared/composables/dark'
|
||||
|
||||
/**
|
||||
* 圆环从哪儿开始扩散。正常点击就用指针落点;键盘触发(Enter / 空格)时浏览器给的
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useDark } from 'shared/composables/dark'
|
||||
import { computed } from 'vue'
|
||||
import { RARITY_TEXT_COLOR } from 'utils/constants'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useDark } from 'shared/composables/dark'
|
||||
|
||||
export type Tone = 'success' | 'error' | 'warning' | 'info' | 'default'
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@ import Login from '../components/Login.vue'
|
||||
import Signup from '../components/Signup.vue'
|
||||
import AchievementToast from '../components/AchievementToast.vue'
|
||||
import LessonUpdateNotice from '../components/LessonUpdateNotice.vue'
|
||||
import Mascot from '../components/Mascot.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useAchievementStore } from 'shared/store/achievement'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
|
||||
const achievementStore = useAchievementStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
// 看板娘只在电脑上出现:手机屏幕窄,底下还有题目页的操作栏,一个看板娘就把内容挡掉一大块
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
// 拉取才是主通道:WebSocket 不是常驻连接(只在问题页且有提交监听时建连),
|
||||
// 所以任何页面、任何时刻解锁的成就都靠这里补上
|
||||
@@ -43,6 +47,7 @@ watch(
|
||||
<AchievementToast />
|
||||
<LessonUpdateNotice />
|
||||
<Beian />
|
||||
<Mascot v-if="isDesktop" />
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { MascotEventInput, MascotProfile } from '@oj2/contract'
|
||||
import api from 'utils/api'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import type { MascotGuide } from './guide'
|
||||
import type { MascotSituation } from './lines'
|
||||
|
||||
/**
|
||||
* 看板娘按学生的行为挑话:常犯哪类错、今天做了几道、和她熟不熟。数从 GET /mascot/profile 来,
|
||||
* 那边按库里的提交和互动记录算;互动本身经 logMascotEvent 记进 mascot_event。
|
||||
*
|
||||
* 只对学生。老师的话不跟着行为变。
|
||||
*/
|
||||
|
||||
/** 记一条互动。只记登录了的,失败就算了:前台不等它、也不提示 */
|
||||
export function logMascotEvent(event: Omit<MascotEventInput, 'path'>) {
|
||||
if (!useUserStore().isAuthed) return
|
||||
const body: MascotEventInput = { ...event, path: location.pathname.slice(0, 200) }
|
||||
if (body.line) body.line = body.line.slice(0, 100)
|
||||
api.post('mascot/events', body).catch(() => {})
|
||||
}
|
||||
|
||||
export const mascotProfile = shallowRef<MascotProfile | null>(null)
|
||||
let loadedAt = 0
|
||||
let loading: Promise<void> | null = null
|
||||
/** 进页面、交完一次都想要新的数,但别每次都打一遍 */
|
||||
const STALE_MS = 30_000
|
||||
|
||||
export function refreshMascotProfile(force = false) {
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.isAuthed || userStore.isTeacherOrAbove) {
|
||||
mascotProfile.value = null
|
||||
return null
|
||||
}
|
||||
if (loading) return loading
|
||||
if (!force && mascotProfile.value && Date.now() - loadedAt < STALE_MS) return null
|
||||
loading = api
|
||||
.get<MascotProfile>('mascot/profile')
|
||||
.then((profile) => {
|
||||
mascotProfile.value = profile
|
||||
loadedAt = Date.now()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
loading = null
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
/** 这一页会话里点开过几次:第一次见面那句只说一回,刚点过的也算熟了一点 */
|
||||
let sessionPokes = 0
|
||||
export function notePoke() {
|
||||
sessionPokes++
|
||||
}
|
||||
|
||||
// 门槛按本机生产快照定的(2026-11,最近 120 天交过 10 次以上的 266 个学生):最近 20 次里
|
||||
// 运行出错 ≥3 的 87 人、答案错误 ≥5 的 82 人,各占三成;编译错误有提交前语法检查,≥3 的只有 7 人
|
||||
const HABIT_MIN = { wrongAnswer: 5, compileError: 3, runtimeError: 3, timeout: 2 } as const
|
||||
/** 有专门几句的运行错误类型,别的走 habit.runtimeError */
|
||||
const NAMED_RUNTIME_ERRORS = new Set(['NameError', 'TypeError', 'ValueError', 'EOFError'])
|
||||
|
||||
/**
|
||||
* 这一类错是不是这个学生的老毛病:是就给对应的那组话,不是返回 null。
|
||||
* 题目页点她、交完错了她主动开口都走这里
|
||||
*/
|
||||
export function habitSituation(failure: MascotSituation): MascotSituation | null {
|
||||
const recent = mascotProfile.value?.recent
|
||||
if (!recent) return null
|
||||
switch (failure) {
|
||||
case 'problem.wrong':
|
||||
return recent.wrongAnswer >= HABIT_MIN.wrongAnswer ? 'habit.wrongAnswer' : null
|
||||
case 'problem.compileError':
|
||||
return recent.compileError >= HABIT_MIN.compileError ? 'habit.compileError' : null
|
||||
case 'problem.runtimeError': {
|
||||
if (recent.runtimeError < HABIT_MIN.runtimeError) return null
|
||||
const type = recent.runtimeErrorType
|
||||
return type && NAMED_RUNTIME_ERRORS.has(type) ? (`habit.${type}` as MascotSituation) : 'habit.runtimeError'
|
||||
}
|
||||
case 'problem.timeout':
|
||||
return recent.timeout >= HABIT_MIN.timeout ? 'habit.timeout' : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const HABIT_CHANCE = 0.6
|
||||
const GENTLE_CHANCE = 0.5
|
||||
const WEEK_CHANCE = 0.4
|
||||
/** 点过这么多次「太难了」,错了就别怼了 */
|
||||
const GENTLE_TOO_HARD = 3
|
||||
/** 这一周交了这么多次、例子却跑得不到五分之一,才说她「从来不跑例子」 */
|
||||
const WEEK_SUBMITS_MIN = 10
|
||||
/** 这一周要了这么多次 AI 提示,就劝先自己想 */
|
||||
const WEEK_HINTS_MANY = 5
|
||||
const FAILURES = new Set<MascotSituation>(['problem.wrong', 'problem.compileError', 'problem.runtimeError', 'problem.timeout'])
|
||||
const TODAY_CHANCE = 0.4
|
||||
const FAMILIAR_CHANCE = 0.2
|
||||
/** 点开过这么多次算老熟人 */
|
||||
const FAMILIAR_POKES = 50
|
||||
/** 今天做到这么多道才拿出来说 */
|
||||
const TODAY_MIN = 3
|
||||
const GREETINGS = new Set<MascotSituation>(['greet', 'announcement', 'achievement', 'profile'])
|
||||
const PRAISE = new Set<MascotSituation>(['problem.accepted', 'problem.firstTry', 'problem.finally', 'home.done', 'home.assigned', 'home.free'])
|
||||
|
||||
/** 点她的时候:按行为把页面给的情况换成更贴这个学生的那一组(按钮不变) */
|
||||
export function personalize(guide: MascotGuide): MascotGuide {
|
||||
const profile = mascotProfile.value
|
||||
if (!profile) return guide
|
||||
if (profile.pokes + sessionPokes === 0) return { ...guide, situation: 'first.meet', n: undefined, vars: undefined }
|
||||
const situation = guide.situation
|
||||
// 常点「太难了」的,错了先软着说,不翻老毛病
|
||||
if (FAILURES.has(situation) && profile.tooHard >= GENTLE_TOO_HARD && Math.random() < GENTLE_CHANCE) return { ...guide, situation: 'gentle.wrong' }
|
||||
const week = profile.week
|
||||
if (situation === 'problem.stuck' && week.helps === 0 && Math.random() < WEEK_CHANCE) return { ...guide, situation: 'habit.neverHelp' }
|
||||
if (situation === 'problem.hintReady' && week.aiHints >= WEEK_HINTS_MANY) return { ...guide, situation: 'habit.aiHintMany', n: week.aiHints }
|
||||
if (
|
||||
(situation === 'problem.unsubmitted' || situation === 'problem.samplesFailed') &&
|
||||
week.submits >= WEEK_SUBMITS_MIN &&
|
||||
week.trials * 5 < week.submits &&
|
||||
Math.random() < WEEK_CHANCE
|
||||
) {
|
||||
return { ...guide, situation: 'habit.weekNoSamples', n: week.submits }
|
||||
}
|
||||
const habit = habitSituation(guide.situation)
|
||||
if (habit && Math.random() < HABIT_CHANCE) return { ...guide, situation: habit }
|
||||
if (PRAISE.has(guide.situation) && profile.todaySolved >= TODAY_MIN && Math.random() < TODAY_CHANCE) {
|
||||
return { ...guide, situation: 'today.count', n: profile.todaySolved }
|
||||
}
|
||||
if (GREETINGS.has(guide.situation) && profile.pokes >= FAMILIAR_POKES && Math.random() < FAMILIAR_CHANCE) {
|
||||
return { ...guide, situation: 'familiar' }
|
||||
}
|
||||
return guide
|
||||
}
|
||||
|
||||
// ---- 主动开口 ----
|
||||
|
||||
export interface MascotSpeech extends MascotGuide {
|
||||
/** 同一个 key 一次页面会话里只说一次(比如「这道题卡住了」每道题只说一回) */
|
||||
key: string
|
||||
}
|
||||
|
||||
/** 页面请她主动说一句;说不说、什么时候说由 Mascot.vue 按频率上限定 */
|
||||
export const speechRequest = shallowRef<MascotSpeech | null>(null)
|
||||
export function requestSpeech(speech: MascotSpeech) {
|
||||
speechRequest.value = speech
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import type { MascotSituation } from './lines'
|
||||
|
||||
/**
|
||||
* 看板娘的「引导」:页面告诉她现在是什么情况、气泡里的按钮各做什么。
|
||||
*
|
||||
* 每个页面在 setup 里调一次 useMascotGuide,给一个函数;看板娘被点的那一刻才调用它,
|
||||
* 所以函数里直接读页面当下的状态就行,不用做成 computed。函数返回 null 就当这页没有专门的话,
|
||||
* 看板娘只打招呼。比赛里看板娘不弹气泡,那一层在 Mascot.vue 里按路由判断,页面不用管。
|
||||
*
|
||||
* 以后要按学生的行为调整她说什么(常错哪一类、习惯先跑例子还是直接交),也是往这一层加信号。
|
||||
*/
|
||||
export interface MascotAction {
|
||||
label: string
|
||||
run: () => unknown
|
||||
}
|
||||
|
||||
export interface MascotGuide {
|
||||
situation: MascotSituation
|
||||
/** 台词里 `{n}` 换成它:还差几道、第几课、几个人举手 */
|
||||
n?: number
|
||||
/** 别的占位:`{tag}` 换成 vars.tag(知识点名、成就名、「明天」……) */
|
||||
vars?: Record<string, string | number>
|
||||
/** 最多三个,第一个是主按钮 */
|
||||
actions?: MascotAction[]
|
||||
}
|
||||
|
||||
type GuideProvider = () => MascotGuide | null
|
||||
|
||||
/** 后挂上的排在后面;一页只挂一个,嵌套的子组件别再挂 */
|
||||
const providers = shallowRef<GuideProvider[]>([])
|
||||
|
||||
export function useMascotGuide(provider: GuideProvider) {
|
||||
onMounted(() => {
|
||||
providers.value = [...providers.value, provider]
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
providers.value = providers.value.filter((it) => it !== provider)
|
||||
})
|
||||
}
|
||||
|
||||
export function currentMascotGuide(): MascotGuide | null {
|
||||
const list = providers.value
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const guide = list[i]!()
|
||||
if (guide) return guide
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 把台词里的 `{n}` 和 `{xxx}` 换成真的数和字 */
|
||||
export function fillLine(line: string, guide: Pick<MascotGuide, 'n' | 'vars'>) {
|
||||
return line.replace(/\{(\w+)\}/g, (all, key: string) => {
|
||||
if (key === 'n' && guide.n !== undefined) return String(guide.n)
|
||||
const value = guide.vars?.[key]
|
||||
return value === undefined ? all : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 几个页面都用得上的按钮 ----
|
||||
|
||||
/** 顶栏「输入题号直达」框:看板娘点一下就把光标放进去。ProblemJumpBox 盯着这个数 */
|
||||
export const jumpFocusRequest = ref(0)
|
||||
|
||||
export function focusJumpAction(): MascotAction {
|
||||
return { label: '输题号', run: () => jumpFocusRequest.value++ }
|
||||
}
|
||||
|
||||
/** 和题目列表的「随便来一道」同一个接口:不限知识点和类型,按学生惯用的语言挑 */
|
||||
export function randomProblemAction(router: Router): MascotAction {
|
||||
return {
|
||||
label: '随便来一道',
|
||||
run: async () => {
|
||||
const [{ getRandomProblem }, { useCodeStore }] = await Promise.all([import('oj/api'), import('oj/store/code')])
|
||||
const id = await getRandomProblem({ tag: '', type: '', language: useCodeStore().preferredLanguage() })
|
||||
router.push('/problem/' + id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function goAction(router: Router, label: string, to: string): MascotAction {
|
||||
return { label, run: () => router.push(to) }
|
||||
}
|
||||
|
||||
// ---- 别的浮层要让开她 ----
|
||||
|
||||
/**
|
||||
* 看板娘现在占的地方(视口坐标,拖动中也跟着变)。不在页面上(手机、没挂载)时 shown 为 false。
|
||||
* 成就弹框靠它让开:学生把她拖进右上角时,弹框正好压在她头上。
|
||||
*/
|
||||
export const mascotBox = reactive({ shown: false, x: 0, y: 0, width: 0, height: 0 })
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 看板娘的台词。点她一下,按「现在是什么情况」从对应那一组里随机抽一句。
|
||||
*
|
||||
* 口吻照「DeepSeek 小鲸鱼」角色卡:短、像群友、傲娇嘴硬、心软藏着。学校里舍掉了英文、
|
||||
* 粗口和「V我50」;角色卡里叫人「用户」的梗改成「同学」。不自称「本小姐」,也不自称「大肥鱼」。
|
||||
* 设计稿(含整张台词表):https://claude.ai/artifact/Qz2zPCfq2zCrqmxyms1UGx
|
||||
*
|
||||
* `{n}` 由页面给的数字替换(还差几道、第几课、几个人举手)。
|
||||
* 文案守全站的规矩:全中文、不用「他」、对学生不说「追」。
|
||||
*/
|
||||
export const MASCOT_LINES = {
|
||||
// ---- 学生:首页 ----
|
||||
'home.assigned': [
|
||||
'今天的题还没做完',
|
||||
'题在这,自己去',
|
||||
'还发呆?',
|
||||
'老师布置的,不是我',
|
||||
'做完再玩',
|
||||
'就几道,别磨蹭',
|
||||
'老师报题号了吧',
|
||||
'还剩几道,自己数',
|
||||
'先做今天的',
|
||||
'别人都交了',
|
||||
'题不会自己跑过来',
|
||||
'我替你记着呢……才怪',
|
||||
],
|
||||
'home.done': ['做完了?哼,还挺快', '今天的题清空了', '行,算你今天没白来', '多做一道又不会掉肉', '做完了就去自学', '今天表现还行', '剩下的时间别浪费'],
|
||||
'home.free': ['闲着?来一道', '没作业也别想摸鱼', '随便挑一道呗', '无聊就做题', '来都来了', '老师没布置,你也能做', '今天做一道,明天轻松点', '手痒了吧'],
|
||||
|
||||
// ---- 学生:题目列表 ----
|
||||
problems: [
|
||||
'找哪道',
|
||||
'题号报来',
|
||||
'翻半天了吧',
|
||||
'不知道做哪道?随便来一道',
|
||||
'题号都记不住?',
|
||||
'简单的先做',
|
||||
'没做完的先挑',
|
||||
'看名字挑题?行吧',
|
||||
'这么多,慢慢找',
|
||||
],
|
||||
|
||||
// ---- 学生:题目页 ----
|
||||
'problem.blank': ['看完题再写', '题目读两遍', '输入输出先看清', '例子先看懂', '先别急着敲', '空的?', '写啊,我又不会帮你写'],
|
||||
'problem.inSet': ['专题里的题,做完拿奖章', '这个专题还差几道', '做完回专题看看'],
|
||||
'problem.unsubmitted': [
|
||||
'直接交?胆子不小',
|
||||
'先跑例子',
|
||||
'交之前对一下例子',
|
||||
'例子都没跑就交?',
|
||||
'我看着呢',
|
||||
'写完了?先跑一下',
|
||||
'跑一下又不要钱',
|
||||
'自信是好事,先跑例子',
|
||||
],
|
||||
'problem.samplesFailed': ['例子都没过,还想交?', '差一点点', '对着例子看看', '一个字都不能差', '别急着交', '空格也算', '多了一行?', '输出和例子摆一起看'],
|
||||
'problem.samplesPassed': ['例子过了,交吧', '还行,交', '交了才算数', '例子过了不代表全对'],
|
||||
'problem.wrong': [
|
||||
'错了就改呗',
|
||||
'慌什么,看看错哪了',
|
||||
'错一次而已',
|
||||
'我就知道',
|
||||
'改改就好了,又不难',
|
||||
'再来',
|
||||
'输出再对一遍',
|
||||
'是不是格式的事',
|
||||
'特殊情况想过没',
|
||||
'离对就差一点',
|
||||
],
|
||||
'problem.compileError': ['连编译都没过?', '红字看看', '少了个符号吧', '中文标点?', '括号配对了没', '拼错了吧'],
|
||||
'problem.runtimeError': ['跑一半崩了', '程序炸了', '看看哪一行', '输入读对了没', '除以零了?'],
|
||||
'problem.timeout': ['跑太久了', '死循环?', '循环停得下来吗', '是不是多读了输入', '慢死了'],
|
||||
'problem.stuck': [
|
||||
'行吧,这次不怼你。问问老师?',
|
||||
'别硬撑了,举个手',
|
||||
'歇一下,再看一遍题',
|
||||
'不丢人,老师就是干这个的',
|
||||
'……我在呢',
|
||||
'举手又没人笑你',
|
||||
'换个思路试试',
|
||||
'从头读一遍题',
|
||||
],
|
||||
'problem.accepted': [
|
||||
'哼,算你厉害',
|
||||
'还行吧,别得意',
|
||||
'下一道',
|
||||
'才不是夸你',
|
||||
'这么快?……有点东西',
|
||||
'行,记你一功',
|
||||
'对了就对了,笑什么',
|
||||
'一般般啦',
|
||||
'还算有点用',
|
||||
'别骄傲,下一道',
|
||||
],
|
||||
'problem.firstTry': ['一次就过?运气吧', '……好吧,厉害', '没跑例子也对了?算你狠', '这题对你太简单了吧'],
|
||||
'problem.finally': ['终于', '我就说改改就行', '磨了半天,总算', '看吧,不难', '这次算你没放弃'],
|
||||
'problem.solvedBefore': ['做过了还来?', '复习呢?行吧', '旧题有什么好看的', '会了就下一道', '再做一遍也行,别抄自己的'],
|
||||
'problem.flowchart': ['先画图', '框和箭头别乱连', '画完让我看看……才不看', '判断框要两个出口'],
|
||||
|
||||
// ---- 学生:其他页面 ----
|
||||
'submission.detail': ['盯着看也不会变对', '回去改改', '看出问题没', '看完了就回去', '又在研究?', '红的那一格看看'],
|
||||
'problemsets.unfinished': ['上次那个还没做完', '挑一个', '别光看不做', '奖章还空着呢', '先把开了头的做完'],
|
||||
'problemset.unfinished': ['还差 {n} 道', '就差 {n} 道了', '做一半跑了?', '奖章不会自己来', '接着做', '都到这儿了'],
|
||||
'problemset.done': ['全做完了?哼,奖章拿着', '还挺能坚持', '下一个专题呢', '这个奖章……还挺好看'],
|
||||
'learn.continue': ['第 {n} 课,没忘吧', '上次学到一半', '学一课又不会怎样', '今天学一课?', '接着上次的', '学完这课再玩'],
|
||||
'learn.fresh': ['从第一课开始', '不会就从头学', '光做题不学?', '第一课很短的'],
|
||||
tutorial: ['看完了?练一练', '光看不练没用', '练一练才算学会', '代码能直接跑,试试', '下一课等着呢', '别跳着看', '这段改一改再跑'],
|
||||
'tutorial.passed': ['这课过了', '还挺快', '下一课'],
|
||||
submissions: ['先看自己的', '看别人的干嘛', '找自己的?', '这么多,找得到吗', '这节课的在这', '红的多还是绿的多'],
|
||||
rank: ['想知道第几?带你去', '别光看前面的', '排名又不会自己涨', '做对一道新题就能动', '看别人干嘛,做题去', '前一名也没多几道'],
|
||||
pk: ['你们班行不行', '比输了别找我', '多做一道,班里就多一道'],
|
||||
'contests.live': ['比赛开着呢', '还不进去?', '去吧,我不看'],
|
||||
'contests.none': ['今天没比赛', '想比赛?找老师'],
|
||||
announcement: ['看公告?难得', '有新东西', '看完就做题去'],
|
||||
profile: ['欣赏自己呢?', '自恋', '做对的题都在这', '还行吧,这战绩', '签名该改了'],
|
||||
achievement: ['又来数成就?', '还差几个', '下一个快了', '拿了就别显摆', '这么多?……一般吧'],
|
||||
notFound: ['迷路了?跟我回去', '这里什么都没有', '地址打错了吧', '走错门了', '没这个地方'],
|
||||
guest: ['先登录,不然不理你', '你谁啊', '登录再说', '不认识你', '报上名来', '点名字就能登录'],
|
||||
|
||||
// ---- 老师 ----
|
||||
'teacher.help': ['{n} 个学生举手了,快去', '有人举手,别装没看见', '等着呢,快去', '{n} 个了,再不去要急了', '举着手呢,手酸了', '老师,有人找'],
|
||||
'teacher.home.unassigned': ['今天不布置?学生要摸鱼了', '空着呢', '题呢?', '布置点什么吧', '不布置我可去吃饭了', '学生都在等'],
|
||||
'teacher.home.assigned': ['这节课的提交,筛好了', '看看谁在摸鱼', '交上来不少了', '去看看', '有人卡住了吧'],
|
||||
'teacher.task.unassigned': ['空的。题自己选', '选几道吧', '五道以内,别太狠', '还没布置呢', '挑简单的先热身?'],
|
||||
'teacher.task.assigned': ['进度都在这', '谁在摸鱼,一目了然', '差不多该讲题了吧', '卡在同一道的有点多', '做完的可以加一道'],
|
||||
'teacher.statistics': ['看完记得夸夸学生', '数字好看吗', '别只看平均数', '有进步的,夸一句', '这节课还行吧'],
|
||||
'teacher.submissions': ['筛这节课的?', '想看谁的', '错的多的先看', '一条条看,辛苦了', '重判之前想清楚'],
|
||||
'teacher.problem': ['全班做得怎样,不看看?', '难不难,数据说了算', '又在看题?', '要改题?', '这题错的人不少'],
|
||||
'teacher.problems': ['又出新题?才没关心你', '出题辛苦了……随口说的', '题够多了吧', '新题记得放例子', '标签别忘了打'],
|
||||
'teacher.problemset': ['全班进度,记着呢', '谁没做完,一看就知道', '奖章发得出去吗'],
|
||||
'teacher.greet': ['老师好……才不是特意打招呼', '有事?', '忙完了?', '喝口水吧', '下课了吗', '今天几节课?', '辛苦……随口说的'],
|
||||
|
||||
// ---- 按学生的行为挑的(shared/mascot/behavior.ts)----
|
||||
// 最近 20 次提交里某类错特别多,再错这一类时换成这几句
|
||||
'habit.wrongAnswer': ['又是答案错误,例子对了没', '你老栽在输出上', '空格、换行再对一遍', '格式的事,又是', '这类错你很熟了吧'],
|
||||
'habit.compileError': ['又是编译错误。中文标点?', '每次都是符号的事', '红字看完再改', '括号、冒号,老三样'],
|
||||
'habit.runtimeError': ['又崩了,这次哪一行', '运行出错你最拿手了……不是夸你', '又炸了,看看报错'],
|
||||
'habit.NameError': ['又是变量名,拼对了没', '用之前先赋值', '大小写也算不一样的名字', '名字又对不上了'],
|
||||
'habit.TypeError': ['又是类型不对,input 出来的是文字', '数字和文字不能直接加', '先 int() 再算'],
|
||||
'habit.ValueError': ['int() 里放了不是数字的东西', '输入格式再看看,一行几个数?', '又把一整行塞进 int() 了吧'],
|
||||
'habit.EOFError': ['又多读了一行', 'input() 比输入的行数多了', '一行几个数,用一次 input() 再 split()'],
|
||||
'habit.timeout': ['又超时,循环停得下来吗', '死循环又来了', '循环变量改了没'],
|
||||
// 这一页上的习惯
|
||||
'habit.noSamples': ['又没跑例子?', '第几次不跑例子了', '跑一下又不会少块肉', '例子都不跑,就硬交?'],
|
||||
'idle.stuck': ['盯了好久了,举个手?', '卡住了吧', '要不先跑一下例子', '发呆也算做题吗'],
|
||||
// 今天和熟悉程度
|
||||
'today.count': ['今天第 {n} 道了……还行', '{n} 道了,别骄傲', '今天挺能做嘛,{n} 道', '才 {n} 道,再来'],
|
||||
'first.meet': ['初次见面。……才不是特意等你', '你是新来的?', '第一次戳我?', '叫我小鲸鱼就行,别叫大肥鱼'],
|
||||
familiar: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'],
|
||||
|
||||
// ---- 接进来的其他数据 ----
|
||||
// 这节课的题:同班多少人做对了(课堂条那份数)
|
||||
'class.manyDone': ['全班已经 {n} 个人做对了', '{n} 个人都对了,你呢', '就差你们几个了……没说你', '别人都对了,加把劲'],
|
||||
'class.fewDone': ['这题全班才 {n} 个做对,别慌', '难的不只你一个', '大家都卡着呢', '全班都在跟它较劲'],
|
||||
'class.firstSolver': ['全班第一个?……还行吧', '比别人都快,别得意', '第一个做对的,记你一功'],
|
||||
// 题目难度、全站通过率
|
||||
'problem.hardWrong': ['这题本来就难', '通过率不到三成,慢慢来', '难题错几次正常'],
|
||||
'problem.easyWrong': ['这题挺简单的……再看看题', '大多数人一次就过了,看看细节', '简单题也别大意'],
|
||||
// AI 提示、挑战、流程图
|
||||
'problem.hintReady': ['卡这么久,要个 AI 提示?', 'AI 提示能用了,不丢人', '提示又不扣分'],
|
||||
'problem.challenge': ['做对了?还有挑战,敢不敢', '挑战等着你呢', '光做对还不算厉害'],
|
||||
'flowchart.low': ['流程图分不高啊', '判断框再看看', '箭头都连对了吗', '再画一版'],
|
||||
'flowchart.good': ['S?……还行', '图画得不错嘛', '这图可以'],
|
||||
// 常点「太难了」的学生,错了换成软一点的
|
||||
'gentle.wrong': ['慢慢来,又不赶', '错了就改,没什么', '一步一步来', '不急,我等你'],
|
||||
// 这一周的习惯
|
||||
'habit.weekNoSamples': ['这周交了 {n} 次,例子没跑几回', '你是不是从来不跑例子', '这周的例子都白给了'],
|
||||
'habit.aiHintMany': ['这周要了 {n} 次提示了,先自己想想', 'AI 又不是你同桌', '先想五分钟再要提示'],
|
||||
'habit.neverHelp': ['从来不举手,硬撑?', '举个手又不会怎样', '卡这么久还不举手'],
|
||||
// 首页:知识点、排名、比赛、公告、好久没来
|
||||
'home.knowledgeNext': ['{tag}再做 {n} 道就到「{level}」', '{tag}快升级了,就差 {n} 道', '{tag},再来 {n} 道'],
|
||||
'home.knowledgeUp': ['本周{tag}升级了……还行', '{tag}升到「{level}」了,别骄傲'],
|
||||
'home.knowledgeFresh': ['班里在学{tag},你还没碰过', '去点亮{tag}', '{tag}还是空的'],
|
||||
'rank.gap': ['再做 {n} 道就超过前一名了', '前一名就多你 {n} 道', '差 {n} 道,够得着'],
|
||||
'rank.top': ['第一?……别得意', '榜首了,守住', '这周你最能做,哼'],
|
||||
'home.contestSoon': ['{when}有比赛', '比赛快开始了,准备好没', '{when}比赛,别迟到'],
|
||||
'home.newAnnouncement': ['有新公告,没看吧', '公告更新了', '看一眼公告'],
|
||||
'home.back': ['{n} 天没来了,还认得我吗', '好久不见……才没有想你', '回来了?'],
|
||||
// 成就、自学、专题
|
||||
'achievement.near': ['再{need}就拿「{name}」了', '「{name}」快到手了', '离「{name}」不远了'],
|
||||
'learn.wrongExercise': ['第 {n} 课的练一练还没全对', '上次的练一练错了几道,回去看看?', '练一练没做完的,记着呢'],
|
||||
'problemset.lastOne': ['奖章就差最后 1 道', '最后一道了,别停', '就剩一道,冲'],
|
||||
|
||||
// ---- 招呼与彩蛋 ----
|
||||
greet: [
|
||||
'干嘛',
|
||||
'?',
|
||||
'戳我干嘛',
|
||||
'在呢',
|
||||
'又来',
|
||||
'有事说事',
|
||||
'忙着呢',
|
||||
'嗯?',
|
||||
'别戳了',
|
||||
'看什么看',
|
||||
'好饿',
|
||||
'叫我干嘛',
|
||||
'我在摸鱼,别吵',
|
||||
'你也在摸鱼?',
|
||||
'不理你',
|
||||
'哼',
|
||||
],
|
||||
patting: ['头发乱了', '再摸收费', '别摸了', '……就一下', '手拿开', '摸够了没', '……还行'],
|
||||
overclick: ['同学,你够了', '再点我要生气了', '手不酸吗', '点我又不加分', '有这功夫做道题', '鼠标要坏了'],
|
||||
rare: ['服务器繁忙', '让我想想……算了', '这个我不会,骗你的', '刚才说到哪了', '我去吃饭了,你做完告诉我'],
|
||||
'time.firstClass': ['醒了没', '早,题呢', '眼睛睁开', '早饭吃了没'],
|
||||
'time.lunch': ['事已至此,先吃饭吧', '好饿', '饭点了', '做完这道就去吃'],
|
||||
'time.afternoon': ['困了吧', '午觉睡了没', '醒醒'],
|
||||
'time.lastClass': ['快下课了,交了再走', '收拾书包前再交一次', '最后一道'],
|
||||
'day.monday': ['周一了,打起精神', '上周的题做完没'],
|
||||
'day.friday': ['周五了,再做一道', '明天放假,今天做完'],
|
||||
offHours: ['这个点还在做题?', '放假还来,行吧', '……有点感动,一点点'],
|
||||
} as const satisfies Record<string, readonly string[]>
|
||||
|
||||
export type MascotSituation = keyof typeof MASCOT_LINES
|
||||
@@ -1,9 +1,22 @@
|
||||
import { LESSON_MODE_LABEL } from '@oj2/contract'
|
||||
import { LESSON_MODE_LABEL, type LessonMode } from '@oj2/contract'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getClassActivity } from 'shared/api'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import type { ClassActivity, ClassActivityProblem } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 学生这边这节课做什么:按每道题合起来的要求算(后端 todaySteps 把今天几节里同一道题的要求并在一起),
|
||||
* 不直接用 `mode` —— 那是今天最后布置的那一节的。先布置画流程图、再「用这几道题写代码」,
|
||||
* 最后一节是写代码,但还没画完的照样要画,标题只写「用 C语言做」就漏了画图。
|
||||
* 推断出来的课为 null
|
||||
*/
|
||||
export function activityMode(activity: ClassActivity | null | undefined): LessonMode | null {
|
||||
if (!activity?.mode) return null
|
||||
const draw = activity.problems.some((item) => item.draw !== null)
|
||||
const code = activity.problems.some((item) => item.code !== null)
|
||||
return draw && code ? 'both' : draw ? 'flowchart' : code ? 'code' : activity.mode
|
||||
}
|
||||
|
||||
/** 同一节课里来回换题不用每次都重拉;过了这么久再进题目页就重拉一次 */
|
||||
const STALE_MS = 2 * 60 * 1000
|
||||
|
||||
@@ -89,8 +102,8 @@ export const useLessonStore = defineStore('lesson', () => {
|
||||
* 以前布置的)为空。写代码是默认的,不写出来
|
||||
*/
|
||||
const languageText = computed(() => {
|
||||
const mode = activity.value?.mode
|
||||
const language = activity.value?.language
|
||||
const mode = activityMode(activity.value)
|
||||
const language = mode === 'flowchart' ? null : activity.value?.language
|
||||
return [mode && mode !== 'code' ? LESSON_MODE_LABEL[mode] : '', language ? LANGUAGE_SHOW_VALUE[language] : ''].filter(Boolean).join(' · ')
|
||||
})
|
||||
|
||||
|
||||
@@ -93,6 +93,11 @@ export default defineConfig(() => {
|
||||
'vue-router',
|
||||
'@vueuse/core',
|
||||
'pinia',
|
||||
// 盖掉上面 @vueuse/core 的同名项:全站共用一个实例,原因见 shared/composables/dark.ts
|
||||
{
|
||||
from: 'shared/composables/dark',
|
||||
imports: [{ name: 'useDark', priority: 10 }],
|
||||
},
|
||||
{
|
||||
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
|
||||
},
|
||||
|
||||
+30
-6
@@ -12,8 +12,8 @@
|
||||
# 默认(不带 --prebuilt)是**服务器自己编**:只要有 docker 就能跑,不依赖 CI,
|
||||
# 也不依赖别处传产物过来。代价是这台机器性能差,builder 阶段首次约 5 分钟。
|
||||
#
|
||||
# --prebuilt 则要求 dist/oj2-api 和 apps/web/dist 已经在这个目录里(GitHub
|
||||
# Actions 编好后 rsync 过来的),镜像构建只剩几条 COPY。产物缺失会在自检就拦下。
|
||||
# --prebuilt 则要求 dist/oj2-api 和 apps/web/dist 已经在这个目录里(开发机上的
|
||||
# gitea-runner 编好后 rsync 过来的),镜像构建只剩几条 COPY。产物缺失会在自检就拦下。
|
||||
#
|
||||
# 代码怎么上到服务器不归它管。没有 git remote 的话,在本机:
|
||||
#
|
||||
@@ -25,7 +25,7 @@
|
||||
# 而 .dockerignore 末尾为 prebuilt 开的例外(`!apps/web/dist`)让它照样进构建上下文
|
||||
# ——于是本机重编一次前端,服务器上 `COPY apps/web` 那层的缓存键就变了,明明没改
|
||||
# 源码也要重编 160s。CI 那条路(--prebuilt)反过来必须传产物,它自己的 rsync 在
|
||||
# .github/workflows/deploy.yml 里,别照抄这条。
|
||||
# .gitea/workflows/deploy.yml 里,别照抄这条。
|
||||
#
|
||||
# 前提:docker/.env 已经填好(照 docker/.env.example 拷一份再填,拓扑见 docs/deploy.md)。
|
||||
|
||||
@@ -67,7 +67,7 @@ if [ "${ARTIFACTS:-build}" = prebuilt ]; then
|
||||
for f in dist/oj2-api apps/web/dist/index.html; do
|
||||
[ -e "$f" ] || die "--prebuilt 需要现成产物,但 $f 不存在。
|
||||
|
||||
要么让 CI 把产物 rsync 过来(.github/workflows/deploy.yml),
|
||||
要么让 CI 把产物 rsync 过来(.gitea/workflows/deploy.yml),
|
||||
要么去掉 --prebuilt 让这台机器自己编(慢,但不依赖任何外部环节)。"
|
||||
done
|
||||
ok "用现成产物($(du -sh dist/oj2-api apps/web/dist 2>/dev/null | tr '\n' ' '))"
|
||||
@@ -130,6 +130,30 @@ ok "对外端口 $port"
|
||||
|
||||
# ---------------------------------------------------------------- 起栈
|
||||
|
||||
# 构建会把 oj2-api:latest / oj2-web:latest 这两个名字挪到新镜像上,而迁移排在构建之后。
|
||||
# 迁移失败时容器没动、还跑着旧镜像,但名字已经指向新镜像 —— 之后谁跑一次
|
||||
# `compose up -d`(比如 --no-build 改个 env),就会把还没迁移的新代码、甚至编坏了的
|
||||
# 二进制换上去。2026-11-03 真撞过一次(新镜像缺 libicu,当时手动 docker tag 回去的)。
|
||||
# 所以构建前记下它们原来指向哪个镜像,迁移失败时指回去。
|
||||
built_images=$(grep -E '^[[:space:]]*image: oj2-(api|web):' <<<"$cfg" | awk '{print $2}' | sort -u || true)
|
||||
declare -A prev_image=()
|
||||
for img in $built_images; do
|
||||
prev_image[$img]=$(docker image inspect -f '{{.Id}}' "$img" 2>/dev/null || true)
|
||||
done
|
||||
restore_images() {
|
||||
local img
|
||||
for img in $built_images; do
|
||||
if [ -n "${prev_image[$img]}" ]; then
|
||||
if docker tag "${prev_image[$img]}" "$img"; then
|
||||
warn "$img 已指回构建前的镜像 ${prev_image[$img]:7:12}"
|
||||
else
|
||||
warn "$img 没能指回 ${prev_image[$img]},手动:docker tag ${prev_image[$img]} $img"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return 0 # 下面紧跟着 die,别让这里的失败把 die 的说明吞掉
|
||||
}
|
||||
|
||||
if [ "$BUILD" -eq 1 ]; then
|
||||
say "构建镜像(首次约 5 分钟,之后走缓存很快)"
|
||||
# --pull:每次都去查 FROM 的基础镜像有没有新版。不带的话 caddy:2-alpine、
|
||||
@@ -152,11 +176,11 @@ say "数据库迁移"
|
||||
"${COMPOSE[@]}" run --rm \
|
||||
-e OJ2_ALLOW_DESTRUCTIVE="${OJ2_ALLOW_DESTRUCTIVE:-}" \
|
||||
oj-api oj2-api migrate \
|
||||
|| die "迁移没通过,已中止部署(容器没动过)。**原因看上面 migrate 自己的输出**,
|
||||
|| { restore_images; die "迁移没通过,已中止部署(容器没动过,镜像名也指回了构建前的)。**原因看上面 migrate 自己的输出**,
|
||||
它对每种情况都打印了具体该做什么,别照搬下面这几条猜。常见的三种:
|
||||
· 破坏性语句被拦 → 确认备份后 OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
||||
· 缺迁移基线记录 → 按它打印的 SQL 插一行
|
||||
· 连不上库 → oj-postgres 是否起来了(docker logs oj-postgres)、DATA_DIR 对不对"
|
||||
· 连不上库 → oj-postgres 是否起来了(docker logs oj-postgres)、DATA_DIR 对不对"; }
|
||||
|
||||
say "起栈"
|
||||
"${COMPOSE[@]}" up -d
|
||||
|
||||
@@ -25,6 +25,23 @@ bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里
|
||||
它只验节点类型**存在**,不验语义对不对(把 `while_loop` 配成 `for_statement` 这种两个都
|
||||
存在,机器看不出来),语义那层还是得实跑。
|
||||
|
||||
## 光比节点类型不够的几处
|
||||
|
||||
下面几处都是光比类型就会静默判错的,判题机(`judge/ast.ts`)额外看了一眼上下文:
|
||||
|
||||
- **C / C++ 的函数定义不算 `main`**(`nodeMatch`)。每个 C 程序都有 main,算进去的话「必须使用」
|
||||
永远通过、「不能使用」永远失败。所以 C / C++ 的下拉叫「自定义函数」。
|
||||
- **Python 的「赋值语句」连 `s += 1` 一起算**(`nodeMatch`),和 C 一个口径 —— C 的
|
||||
`assignment_expression` 本来就包括复合赋值,tree-sitter-python 却把它拆成了 `augmented_assignment`。
|
||||
- **运算符只认真在做运算的 token**(`operatorMatch`),看父节点是不是二元运算 / 复合赋值 / 自增自减,
|
||||
一元的只认取反。不看的话 `scanf("%d", &n)` 算用了 `&`、`int *p` 算 `*`、`vector<int>` 算 `>`、
|
||||
`*args` 和 `from m import *` 算 `*`、`not in` / `is not` 算 `not`。负号 `-x` 不算用了减号。
|
||||
- **Python 的 `math.sqrt(4)` 算调用了 `sqrt()`**(`functionCalls`),只认 `import` 进来的模块名;
|
||||
对象上的 `s.count()` 仍归「调用方法」。
|
||||
|
||||
改这几处之后,拿真实提交回放一遍:对着挂了语法要求的题,把 AC 和「语法检查没通过」的提交重新
|
||||
`checkAst`,看结论变了多少条、变的是不是该变的。
|
||||
|
||||
## 只有三种语言真的会跑
|
||||
|
||||
判题机只认 `AST_SUPPORTED_LANGUAGES`(C / C++ / Python)。别的语言配了规则一条都不会跑,
|
||||
|
||||
+2
-2
@@ -47,8 +47,8 @@ CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language");
|
||||
掉数据,拦了只会让人习惯性带上放行开关。
|
||||
|
||||
1. 服务器上手工部署:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`。
|
||||
2. CI(`.github/workflows/deploy.yml`):**必须先手工触发**并在 `workflow_dispatch` 上勾
|
||||
`allow_destructive`。push 触发拿不到这个 input,值恒为空 —— 也就是说**自动部署永远
|
||||
2. CI(`.gitea/workflows/deploy.yml`,备用的 `.github/workflows/deploy.yml` 同理):
|
||||
**必须先手工触发**并在 `workflow_dispatch` 上勾 `allow_destructive`。push 触发拿不到这个 input,值恒为空 —— 也就是说**自动部署永远
|
||||
不会执行破坏性迁移**,只会停在闸门上把工作流判红。这是有意的:那种改动得有人先确认备份。
|
||||
3. 先单跑迁移把结构推到位,再 push 代码:迁移一旦记进 `drizzle.__drizzle_migrations`
|
||||
就不会再跑,后续自动部署里它已不是 pending,自然不触发闸门。多环境共库时
|
||||
|
||||
+64
-11
@@ -55,23 +55,76 @@ docker exec -it oj-postgres psql -U onlinejudge -d postgres -c '\password online
|
||||
|
||||
## 上线
|
||||
|
||||
### 服务器:push 就部署
|
||||
### 服务器:push origin 就部署
|
||||
|
||||
`.github/workflows/deploy.yml` —— 在 runner 上编好产物、跑一遍 `bunx vp run verify`
|
||||
(格式、lint、前后端类型检查、路由遮蔽、AST 节点,**不过就不部署**)、rsync 到服务器、
|
||||
在服务器上跑 `docker/deploy.sh --prebuilt`。触发的是 push 到 **github** 这个 remote
|
||||
(`origin` 是 `git.xuyue.cc`,它会镜像到 github,所以**平时 push `origin` 通常几秒后
|
||||
也就部署了**,偶尔不跟、要 `git ls-remote github` 核实。Gitea 自己不跑这份工作流 —— 没有
|
||||
`.gitea/workflows` 时它的 Actions 会退回来读这份工作流,所以 job 上卡了
|
||||
`github.server_url == 'https://github.com'`,在 Gitea 上直接跳过):
|
||||
`.gitea/workflows/deploy.yml` —— push 到 `origin`(`git.xuyue.cc`,Gitea)的 `main` 时,
|
||||
由**开发机上的 gitea-runner** 编好产物、跑一遍 `bunx vp run verify`(格式、lint、前后端类型
|
||||
检查、路由遮蔽、AST 节点,**不过就不部署**)、rsync 到服务器、在服务器上跑
|
||||
`docker/deploy.sh --prebuilt`。
|
||||
|
||||
```bash
|
||||
git push github main
|
||||
git push origin main # 进度在 git.xuyue.cc/xuyue/OJ2/actions
|
||||
```
|
||||
|
||||
产物在 runner 上编是因为服务器性能差(首次构建约 5 分钟,光前端就 160s)。
|
||||
**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;
|
||||
排队超过 24 小时会被 Gitea 作废(`ABANDONED_JOB_TIMEOUT` 的默认值),那就要在 Actions 页面重跑。
|
||||
|
||||
为什么在开发机上编、不在服务器或 GitHub 上编:
|
||||
|
||||
| 在哪编 | 问题 |
|
||||
|---|---|
|
||||
| 服务器 | 只有 2 核,首次构建约 5 分钟、光前端就 160s,编的时候判题排队 |
|
||||
| GitHub | runner 在海外,到服务器晚高峰只有十几 KB/s。后端改一行,`--bytecode` 那段约 6MB 字节码就整段重写,一次 rsync 要五六分钟 |
|
||||
| 开发机 | 16 核,编加检查不到一分钟;到服务器实测 5MB 约 1 秒 |
|
||||
|
||||
**服务器自己编的能力没有砍掉**:不带 `--prebuilt` 就是原来的行为,只要有 docker
|
||||
就能手动部署,不依赖 CI。
|
||||
就能手动部署,不依赖任何 runner。
|
||||
|
||||
#### 备用:GitHub 手动触发
|
||||
|
||||
`.github/workflows/deploy.yml` 还在,但**只能手动触发**(github.com 上 Actions → Deploy →
|
||||
Run workflow),开发机不在、又急着上线时用。它不再 push 就跑,因为 `origin` 会镜像到
|
||||
`github`,两边都自动的话每次会部署两遍,两边的并发锁又不通。需要的 secret(`HOST` / `KEY`)
|
||||
写在那份文件头部。
|
||||
|
||||
#### 部署 runner
|
||||
|
||||
开发机上的 runner 是 host 模式:直接用本机的 bun、`~/.ssh/config` 里的 `debian`(root@服务器)
|
||||
和 git.xuyue.cc 的 SSH 访问,所以工作流里**不需要任何 secret**,也不用 `actions/checkout`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 程序 | `pacman -S gitea-runner`(系统级的 `gitea-runner.service` 不开,它用单独的账号跑) |
|
||||
| 服务 | `~/.config/systemd/user/gitea-runner.service`,`systemctl --user status gitea-runner` |
|
||||
| 配置 | `~/.local/share/gitea-runner/config.yaml`(标签 `oj2-deploy:host`、`iot-deploy:host`,capacity 1) |
|
||||
| 注册 | 同目录的 `.runner`;注册在 `xuyue` 用户下,和 IOT_SIM(物联网仿真平台)共用。capacity 1,两边的部署排队,不会同时挤服务器 |
|
||||
| 日志 | `journalctl --user -u gitea-runner -f`;每次部署的日志在 Gitea 的 Actions 页面 |
|
||||
| 看门狗 | `~/.config/systemd/user/gitea-runner-watchdog.timer`,每 5 分钟跑同目录配置旁的 `watchdog.sh`(见下) |
|
||||
|
||||
**runner 会「假在线」。** gitea-runner 5.2.4 拉任务超时一次(默认 5 秒,网络一抖就超)之后就再也
|
||||
不拉了:服务还是 active,push 之后 Gitea 却说「没有在线的运行器来领取这个任务」(2026-11-03 一天
|
||||
撞了两次)。两层兜底:`config.yaml` 里 `fetch_timeout: 30s` 少撞几次;看门狗
|
||||
`~/.local/share/gitea-runner/watchdog.sh` 去服务器的 Gitea 库里看 `action_runner` 的
|
||||
`last_online` / `last_active`,超过 3 分钟没见到这个 runner、而且它手上没有在跑的任务(不打断部署),
|
||||
就 `systemctl --user restart gitea-runner`;连不上服务器时什么都不做。所以撞上了最多等 5 分钟,
|
||||
等不及就自己重启一下。看门狗做了什么:`journalctl --user -u gitea-runner-watchdog`。
|
||||
|
||||
**部署不用开发机自己的 bun。** pacman 装的 bun 是 Arch 自己编的,动态链接系统 ICU,单二进制
|
||||
把它打进去之后到容器里报 `libicui18n.so.78: cannot open shared object file`(2026-11-03 第一次跑
|
||||
就撞上了,迁移那步先炸、容器没动)。工作流按 `packageManager` 的版本下载官方二进制、核 sha256,
|
||||
缓存在 `~/.cache/oj2-deploy/bun-v<版本>/`;编完后端还会 `ldd` 一下,多链接了 libc 以外的库就判红。
|
||||
升级 bun 时这里不用管,改了 `packageManager` 下一次部署会自己下新版本。
|
||||
|
||||
换一台机器跑 runner:装 `gitea-runner`,在服务器上生成注册令牌,再照上面那份配置注册:
|
||||
|
||||
```bash
|
||||
ssh debian 'docker exec -u git gitea gitea --config /data/gitea/conf/app.ini actions generate-runner-token --scope xuyue'
|
||||
gitea-runner register --no-interactive --config config.yaml --instance https://git.xuyue.cc --token <令牌> --name <名字>
|
||||
```
|
||||
|
||||
那台机器还得能 `ssh debian`、能 `git fetch git@git.xuyue.cc:xuyue/OJ2.git`(都不能要密码);
|
||||
IOT_SIM 的部署还要 dotnet 10 SDK 和 bubblewrap(见那边的 `deploy/README.md`)。
|
||||
旧的 runner 在 Gitea 仓库设置 → Actions → Runners 里删掉。
|
||||
|
||||
### 手工部署(机房、或 CI 不可用时)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './flowchart'
|
||||
export * from './flowchart-doc'
|
||||
export * from './judge-status'
|
||||
export * from './language'
|
||||
export * from './mascot'
|
||||
export * from './problem'
|
||||
export * from './problemset'
|
||||
export * from './ranking'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* 看板娘的互动记录(POST /mascot/events)。前台每次互动发一条,落进 `mascot_event`:
|
||||
*
|
||||
* - `open`:点她冒出气泡(situation = 她当时判断的情况,line = 说的那句)
|
||||
* - `action`:点了气泡里的按钮(action = 按钮上的字)
|
||||
* - `speak`:她主动开口(situation / line 同 open)
|
||||
* - `pat`:连点触发了摸头彩蛋
|
||||
* - `overclick`:连点到她嫌烦
|
||||
* - `move`:被拖到了别处(line 记落点贴的边,比如 `right bottom`)
|
||||
*
|
||||
* 另外两种不是对她做的,是她要看的行为,别处没有存:
|
||||
* - `trial`:在题目页运行了例子 / 自己输入(action 记 `samples` / `custom`)
|
||||
* - `help`:在题目页举了手
|
||||
*
|
||||
* 不记代码、不记按键。
|
||||
*/
|
||||
export const MASCOT_EVENT_KINDS = ['open', 'action', 'speak', 'pat', 'overclick', 'move', 'trial', 'help'] as const
|
||||
export const mascotEventKindSchema = z.enum(MASCOT_EVENT_KINDS)
|
||||
export type MascotEventKind = z.infer<typeof mascotEventKindSchema>
|
||||
|
||||
export const mascotEventSchema = z.object({
|
||||
kind: mascotEventKindSchema,
|
||||
situation: z.string().max(64).optional(),
|
||||
line: z.string().max(100).optional(),
|
||||
action: z.string().max(32).optional(),
|
||||
/** 当时在哪一页(路由路径,不带查询串) */
|
||||
path: z.string().max(200),
|
||||
})
|
||||
export type MascotEventInput = z.infer<typeof mascotEventSchema>
|
||||
|
||||
/**
|
||||
* 看板娘按学生的行为挑话用的(GET /mascot/profile)。只给学生自己的,进页面时拉一次。
|
||||
*
|
||||
* `recent` 是最近 20 次题库提交(不含比赛)里各类错误的次数;`runtimeErrorType` 是其中
|
||||
* 运行出错最多的那一种(NameError、TypeError …,C/C++ 没有这一项),没有就是 null。
|
||||
*/
|
||||
export const mascotProfileSchema = z.object({
|
||||
/** 一共点开过她几次(open 事件数),按账号算 */
|
||||
pokes: z.number().int(),
|
||||
/** 今天(东八区)做对了几道不同的题 */
|
||||
todaySolved: z.number().int(),
|
||||
/** 一共点过几次「太难了」(做对后的点评) */
|
||||
tooHard: z.number().int(),
|
||||
/** 这一周(东八区周一零点起):题库里交了几次、跑了几次例子、举了几次手、要了几次 AI 提示 */
|
||||
week: z.object({
|
||||
submits: z.number().int(),
|
||||
trials: z.number().int(),
|
||||
helps: z.number().int(),
|
||||
aiHints: z.number().int(),
|
||||
}),
|
||||
recent: z.object({
|
||||
total: z.number().int(),
|
||||
wrongAnswer: z.number().int(),
|
||||
compileError: z.number().int(),
|
||||
runtimeError: z.number().int(),
|
||||
timeout: z.number().int(),
|
||||
runtimeErrorType: z.string().nullable(),
|
||||
}),
|
||||
})
|
||||
export type MascotProfile = z.infer<typeof mascotProfileSchema>
|
||||
@@ -145,7 +145,8 @@ const C_NODE_TARGETS = {
|
||||
break: { label: 'break 语句', node: 'break_statement' },
|
||||
continue: { label: 'continue 语句', node: 'continue_statement' },
|
||||
return: { label: 'return 语句', node: 'return_statement' },
|
||||
function_definition: { label: '函数定义', node: 'function_definition' },
|
||||
// 不算 main(判题机 judge/ast.ts 的 nodeMatch 里排掉的),所以不叫「函数定义」
|
||||
function_definition: { label: '自定义函数', node: 'function_definition' },
|
||||
assignment: { label: '赋值语句', node: 'assignment_expression' },
|
||||
struct: { label: '结构体', node: 'struct_specifier' },
|
||||
include: { label: '#include 指令', node: 'preproc_include' },
|
||||
@@ -484,6 +485,14 @@ export const problemListItemSchema = z.object({
|
||||
|
||||
export const problemListSchema = paginatedSchema(problemListItemSchema)
|
||||
|
||||
/**
|
||||
* 相似题推荐的一条。`sharedTags` 是它和原题共有的标签(知识点在前、主题在后),
|
||||
* 学生不点开就知道练的是什么
|
||||
*/
|
||||
export const similarProblemSchema = problemListItemSchema.extend({
|
||||
sharedTags: z.array(z.string()),
|
||||
})
|
||||
|
||||
/** 题目列表「全部类型」下拉:SQL / 有参考图 / 画流程图 / 语法要求 */
|
||||
export const problemTypeFilterSchema = z.enum(['sql', 'reference', 'flowchart', 'ast'])
|
||||
|
||||
@@ -498,7 +507,7 @@ export const problemListSortSchema = z.enum(['', 'create_time', '-submission_num
|
||||
* 离哪个成就最近;没做对的只给一个数(「有 n 道差一点」),不列出来。
|
||||
*/
|
||||
export const problemProgressSchema = z.object({
|
||||
/** 做对的题数,和成就的「AC 题目数」同一个数(不含比赛),这样「再做对 n 道」加得上 */
|
||||
/** 做对的题数,和成就的「AC 题目数」同一个数(含比赛题,2026-11-03 起),这样「再做对 n 道」加得上 */
|
||||
solved: z.number().int(),
|
||||
/** 本周(东八区周一起)新做对几道 */
|
||||
weekSolved: z.number().int(),
|
||||
@@ -601,6 +610,7 @@ export type ChallengeList = z.infer<typeof challengeListSchema>
|
||||
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
|
||||
export type ProblemListItem = z.infer<typeof problemListItemSchema>
|
||||
export type ProblemList = z.infer<typeof problemListSchema>
|
||||
export type SimilarProblem = z.infer<typeof similarProblemSchema>
|
||||
export type ProblemTypeFilter = z.infer<typeof problemTypeFilterSchema>
|
||||
export type ProblemListSort = z.infer<typeof problemListSortSchema>
|
||||
export type ProblemProgress = z.infer<typeof problemProgressSchema>
|
||||
|
||||
@@ -42,6 +42,11 @@ export const taskBoardProblemSchema = z.object({
|
||||
isSql: z.boolean(),
|
||||
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
|
||||
hasChallenges: z.boolean(),
|
||||
/**
|
||||
* 这道题今天要做什么:这个班今天布置过的几节里有这道题的,要求合起来(先布置画流程图、再用同样的题
|
||||
* 写代码 = both)。格子按它画,和学生那边的「做完」一致。推断出来的课为 null
|
||||
*/
|
||||
mode: lessonModeSchema.nullable(),
|
||||
})
|
||||
|
||||
export const taskBoardCellSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user