Compare commits
45
Commits
38a331c2b7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b37a4de80 | ||
|
|
566599614a | ||
|
|
472ec69328 | ||
|
|
388f864aec | ||
|
|
6a1a36049e | ||
|
|
95049b6e4c | ||
|
|
b99f619c0a | ||
|
|
bcd328a196 | ||
|
|
aa8a3b8bfc | ||
|
|
47c0b5464d | ||
|
|
3987b83220 | ||
|
|
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 | ||
|
|
1b703d13ae | ||
|
|
959c326bc9 | ||
|
|
dbb265b02f | ||
|
|
2bda05a978 | ||
|
|
221d264701 | ||
|
|
48590eee60 | ||
|
|
29c31640b5 | ||
|
|
46d3fe2dbe | ||
|
|
bc42ade705 |
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 +
|
# 为什么不再 push 就跑:GitHub 的 runner 在海外,到服务器晚高峰只有十几 KB/s,
|
||||||
# bun compile + vite build)首次约 5 分钟,光前端就 160s。runner 编完传过去,
|
# rsync 一次要五六分钟(后端改一行,--bytecode 那段约 6MB 字节码就整段重写,增量省不掉)。
|
||||||
# 服务器那边的镜像构建只剩几条 COPY。
|
# 而 origin 会镜像到 github,两边都 push 触发的话每次会部署两遍,两边的并发锁又不通。
|
||||||
|
#
|
||||||
|
# 流程:在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。
|
||||||
|
# 产物不在服务器上编,是因为服务器性能差,镜像 builder 阶段(1238 个包的 bun install +
|
||||||
|
# bun compile + vite build)首次约 5 分钟,光前端就 160s。
|
||||||
#
|
#
|
||||||
# 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的
|
# 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的
|
||||||
# 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖这个工作流。
|
# 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖任何工作流。
|
||||||
#
|
#
|
||||||
# 触发的是 push 到 **github** 这个 remote:
|
# job 上卡了 server_url:只在 GitHub 上跑。仓库有了 .gitea/workflows 之后 Gitea 本来就
|
||||||
#
|
# 不再读这份,这道闸留着防万一。
|
||||||
# git push github main
|
|
||||||
#
|
|
||||||
# origin(git.xuyue.cc)是 Gitea,仓库里没有 .gitea/workflows 时它的 Actions 会退回来
|
|
||||||
# 读这份 .github/workflows,每次 push origin 都会把部署再跑一遍 —— 那边不需要。
|
|
||||||
# 所以下面 job 上卡了 server_url:只在 GitHub 上跑,Gitea 上直接跳过。
|
|
||||||
#
|
#
|
||||||
# 仓库 Settings → Secrets 需要两个:
|
# 仓库 Settings → Secrets 需要两个:
|
||||||
# HOST 服务器地址
|
# HOST 服务器地址
|
||||||
@@ -21,12 +22,8 @@
|
|||||||
name: Deploy
|
name: Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# 手动触发时可以放行破坏性迁移;不勾就停在闸门上把工作流判红。
|
||||||
branches:
|
# 那种改动得有人先确认备份。
|
||||||
- main
|
|
||||||
# 手动触发时可以放行破坏性迁移。push 触发拿不到这个 input,值恒为空 ——
|
|
||||||
# 也就是说**自动部署永远不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把
|
|
||||||
# 工作流判红。这是有意的:那种改动得有人先确认备份。
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
allow_destructive:
|
allow_destructive:
|
||||||
@@ -97,9 +94,21 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/id_deploy
|
chmod 600 ~/.ssh/id_deploy
|
||||||
ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
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 运行时那段)字节不变,实际传输量远小于这个数。
|
# 重编后大部分(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 的排除项少一个就会出事,逐条说明:
|
# --delete 的排除项少一个就会出事,逐条说明:
|
||||||
# docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。
|
# docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。
|
||||||
# 删了下一步 deploy.sh 自检就直接 die。
|
# 删了下一步 deploy.sh 自检就直接 die。
|
||||||
@@ -114,14 +123,22 @@ jobs:
|
|||||||
# 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被
|
# 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被
|
||||||
# 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。
|
# 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。
|
||||||
- name: 同步到服务器
|
- name: 同步到服务器
|
||||||
|
timeout-minutes: 20
|
||||||
run: |
|
run: |
|
||||||
rsync -az --no-owner --no-group --delete \
|
for attempt in 1 2 3; do
|
||||||
-e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT" \
|
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 node_modules \
|
||||||
--exclude .git \
|
--exclude .git \
|
||||||
--exclude data \
|
--exclude data \
|
||||||
--exclude 'docker/.env*' \
|
--exclude 'docker/.env*' \
|
||||||
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/"
|
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" && exit 0
|
||||||
|
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出,
|
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出,
|
||||||
# 这一步跟着红,日志里能看到具体是哪一条。
|
# 这一步跟着红,日志里能看到具体是哪一条。
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ cd apps/web && bun run build # 前端构建(不在 verify 里
|
|||||||
配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它;
|
配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它;
|
||||||
`semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks`
|
`semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks`
|
||||||
(`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在
|
(`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`,和根目录那份无关。
|
配置在 `apps/web/vite.config.ts`,和根目录那份无关。
|
||||||
|
|
||||||
- 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了
|
- 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了
|
||||||
@@ -85,9 +86,9 @@ references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build`
|
|||||||
|
|
||||||
## 提交
|
## 提交
|
||||||
|
|
||||||
**直接在 `main` 上干活,不开特性分支。** 注意 push 到 `github` 这个 remote 的 `main`
|
**直接在 `main` 上干活,不开特性分支。** 注意 **push `origin` 的 `main` 就会部署**:
|
||||||
会触发 CI 部署;而 `origin` 会镜像到 `github`,**平时 push `origin` 通常几秒后也就部署了**
|
开发机上的 gitea-runner 跑 `.gitea/workflows/deploy.yml`,进度在 git.xuyue.cc 的 Actions 页面。
|
||||||
(偶尔不跟,要 `git ls-remote github` 核实)。见 `docs/deploy.md`。
|
GitHub 那份只剩手动触发的备用路线。见 `docs/deploy.md`。
|
||||||
|
|
||||||
提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如
|
提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如
|
||||||
`fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。
|
`fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它
|
|||||||
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION:
|
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION:
|
||||||
|
|
||||||
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
|
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
|
||||||
(problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存
|
(problem_tags)、专题/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存
|
||||||
(user_stat)。
|
(user_stat)。
|
||||||
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
|
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
|
||||||
多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
|
多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
|||||||
/**
|
/**
|
||||||
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
|
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
|
||||||
*
|
*
|
||||||
* 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
|
* 课堂任务拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
|
||||||
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
|
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
|
||||||
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
|
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ function acceptRequest(ws: CollabSocket, request: HelpRequest) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
|
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
|
||||||
* 「帮完了」要回到哪个班的看板
|
* 「帮完了」要回到哪个班的任务页
|
||||||
*/
|
*/
|
||||||
function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) {
|
function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) {
|
||||||
const frame = (peer: Record<string, unknown>) =>
|
const frame = (peer: Record<string, unknown>) =>
|
||||||
@@ -630,7 +630,7 @@ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师在看板上主动打开一个学生正在写的代码(学生没举手)。
|
* 老师在任务页上主动打开一个学生正在写的代码(学生没举手)。
|
||||||
*
|
*
|
||||||
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
|
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
|
||||||
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
|
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状,
|
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂任务的格子同一份形状,
|
||||||
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
|
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
|
||||||
*/
|
*/
|
||||||
export interface CollabLatest {
|
export interface CollabLatest {
|
||||||
@@ -111,7 +111,7 @@ export interface Room {
|
|||||||
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
|
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
|
||||||
studentId: number
|
studentId: number
|
||||||
/**
|
/**
|
||||||
* help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。
|
* help = 学生举了手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
|
||||||
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
|
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
|
||||||
*/
|
*/
|
||||||
mode: 'help' | 'peek'
|
mode: 'help' | 'peek'
|
||||||
@@ -144,7 +144,7 @@ export function roomOf(ws: CollabSocket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、
|
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在任务页上「在写」那一列看它、
|
||||||
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
|
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
|
||||||
* 连接断了就清掉。同一账号开了两个标签页时,后报的算
|
* 连接断了就清掉。同一账号开了两个标签页时,后报的算
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 2026-11 班级不再从用户名的 ks 前缀推出来,改成建号时明确给。两个老号当年被推成了不是班号的班级
|
||||||
|
-- (ksxrm → 'xrm',ks王仁杰2 → '王仁杰'),任何班级下拉都选不到它们,后台一编辑还会被班号校验挡住,
|
||||||
|
-- 改成没填班级。只动不是 3~4 位数字的,正常的班一行不碰
|
||||||
|
UPDATE "user" SET "class_name" = NULL WHERE "class_name" IS NOT NULL AND "class_name" !~ '^[0-9]{3,4}$';
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -295,6 +295,27 @@
|
|||||||
"when": 1793626595966,
|
"when": 1793626595966,
|
||||||
"tag": "0041_lesson_mode",
|
"tag": "0041_lesson_mode",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 42,
|
||||||
|
"version": "7",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+57
-14
@@ -46,6 +46,7 @@ import type {
|
|||||||
JudgeStatus,
|
JudgeStatus,
|
||||||
LessonLanguage,
|
LessonLanguage,
|
||||||
LessonMode,
|
LessonMode,
|
||||||
|
MascotEventKind,
|
||||||
ProblemChallenges,
|
ProblemChallenges,
|
||||||
ProblemDifficulty,
|
ProblemDifficulty,
|
||||||
ProblemLanguage,
|
ProblemLanguage,
|
||||||
@@ -342,12 +343,12 @@ export const problemset = pgTable(
|
|||||||
createdById: integer('created_by_id').notNull(),
|
createdById: integer('created_by_id').notNull(),
|
||||||
/**
|
/**
|
||||||
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
|
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
|
||||||
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
|
* 布置期内,专题里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
|
||||||
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
|
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
|
||||||
* 两个都为空 = 没布置。
|
* 两个都为空 = 没布置。
|
||||||
*
|
*
|
||||||
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
|
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
|
||||||
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字
|
* 不一定同时升级,改列名会让没升级的那边一查专题就报错,所以只改代码里的名字
|
||||||
*/
|
*/
|
||||||
assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }),
|
assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }),
|
||||||
assignedAt: timestamp('assigned_at', { withTimezone: true, mode: 'string' }),
|
assignedAt: timestamp('assigned_at', { withTimezone: true, mode: 'string' }),
|
||||||
@@ -613,9 +614,9 @@ export const submission = pgTable(
|
|||||||
shared: boolean().default(false).notNull(),
|
shared: boolean().default(false).notNull(),
|
||||||
statisticInfo: jsonb('statistic_info').default({}).notNull(),
|
statisticInfo: jsonb('statistic_info').default({}).notNull(),
|
||||||
username: text().notNull(),
|
username: text().notNull(),
|
||||||
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid)提交时记下来,
|
// 来源专题:学生从专题入口(/problemset/:id/problem/:pid)提交时记下来,
|
||||||
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它,
|
// 提交列表据此标出「这条来自专题」。**只是来源标记**,专题进度、奖章一概不看它,
|
||||||
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。
|
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有专题」记账。
|
||||||
// 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。
|
// 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。
|
||||||
problemsetId: bigint('problemset_id', { mode: 'number' }),
|
problemsetId: bigint('problemset_id', { mode: 'number' }),
|
||||||
},
|
},
|
||||||
@@ -745,13 +746,13 @@ export const submission = pgTable(
|
|||||||
foreignColumns: [problem.id],
|
foreignColumns: [problem.id],
|
||||||
name: 'submission_problem_id_76847b55_fk_problem_id',
|
name: 'submission_problem_id_76847b55_fk_problem_id',
|
||||||
}),
|
}),
|
||||||
// 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。
|
// 部分索引:绝大多数提交不来自专题,全列索引等于给 12 万行白建一遍。
|
||||||
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删题单时的外键检查
|
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删专题时的外键检查
|
||||||
// 也能用上它——不然那条检查要顺序扫全表。
|
// 也能用上它——不然那条检查要顺序扫全表。
|
||||||
index('submission_problemset_id_idx')
|
index('submission_problemset_id_idx')
|
||||||
.using('btree', table.problemsetId.asc().nullsLast())
|
.using('btree', table.problemsetId.asc().nullsLast())
|
||||||
.where(sql`${table.problemsetId} is not null`),
|
.where(sql`${table.problemsetId} is not null`),
|
||||||
// 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。
|
// 删掉专题不该带走提交:置空来源标记就行,提交本身照旧存在。
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [table.problemsetId],
|
columns: [table.problemsetId],
|
||||||
foreignColumns: [problemset.id],
|
foreignColumns: [problemset.id],
|
||||||
@@ -1195,7 +1196,7 @@ export const problemsetBadge = pgTable(
|
|||||||
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
||||||
*
|
*
|
||||||
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
|
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
|
||||||
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
|
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isTutorialDone`)。
|
||||||
*
|
*
|
||||||
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
||||||
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
||||||
@@ -1293,10 +1294,10 @@ export const exerciseAttempt = pgTable(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节
|
* 老师给某个班布置的一节课的题(课堂任务里布置的),一次布置一行。一个班一天可以有几节
|
||||||
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
|
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
|
||||||
*
|
*
|
||||||
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
|
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(services/lesson.ts 的 classLessonProblems)。
|
||||||
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
|
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
|
||||||
*
|
*
|
||||||
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
|
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
|
||||||
@@ -1322,7 +1323,7 @@ export const classLesson = pgTable(
|
|||||||
problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
|
problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
|
||||||
/**
|
/**
|
||||||
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
|
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
|
||||||
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
|
* 没有它。写入侧在 PUT /task/lesson 按 lessonLanguageSchema 校验过
|
||||||
*/
|
*/
|
||||||
language: text().$type<LessonLanguage>(),
|
language: text().$type<LessonLanguage>(),
|
||||||
/**
|
/**
|
||||||
@@ -1330,11 +1331,11 @@ export const classLesson = pgTable(
|
|||||||
* 都是写代码。写入侧按 lessonModeSchema 校验过
|
* 都是写代码。写入侧按 lessonModeSchema 校验过
|
||||||
*/
|
*/
|
||||||
mode: text().$type<LessonMode>().notNull().default('code'),
|
mode: text().$type<LessonMode>().notNull().default('code'),
|
||||||
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
|
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。任务页上写「HH:mm 谁 布置」 */
|
||||||
createdBy: integer('created_by').notNull(),
|
createdBy: integer('created_by').notNull(),
|
||||||
/**
|
/**
|
||||||
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
|
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
|
||||||
* 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。
|
* 同一天有几节时,按它分先后:最后布置的那一节是任务页上的「这节课」。
|
||||||
* 加这一列之前布置的用 updated_at 回填(0034)
|
* 加这一列之前布置的用 updated_at 回填(0034)
|
||||||
*/
|
*/
|
||||||
createdAt: timestamp('created_at', {
|
createdAt: timestamp('created_at', {
|
||||||
@@ -1411,3 +1412,45 @@ export const challengeSuccess = pgTable(
|
|||||||
}).onDelete('cascade'),
|
}).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'),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -268,9 +268,9 @@ export async function evaluateFlowchart(job: FlowchartJobData, { isFinalAttempt
|
|||||||
evaluationTime: new Date().toISOString(),
|
evaluationTime: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||||
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的题单 —— 和判题那一路
|
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的专题 —— 和判题那一路
|
||||||
// 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经
|
// 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经
|
||||||
// 落库,1.5 秒后跳回题单页看到的就是新数据。比赛题不进题单
|
// 落库,1.5 秒后跳回专题页看到的就是新数据。比赛题不进专题
|
||||||
if (isFlowchartPass(result.grade) && row.problem.contestId === null) {
|
if (isFlowchartPass(result.grade) && row.problem.contestId === null) {
|
||||||
await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString())
|
await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { authRoutes } from './routes/auth'
|
|||||||
import { accountRoutes } from './routes/account'
|
import { accountRoutes } from './routes/account'
|
||||||
import { contestRoutes } from './routes/contest'
|
import { contestRoutes } from './routes/contest'
|
||||||
import { contentRoutes } from './routes/content'
|
import { contentRoutes } from './routes/content'
|
||||||
import { classroomRoutes } from './routes/classroom'
|
import { homeRoutes } from './routes/home'
|
||||||
|
import { taskRoutes } from './routes/task'
|
||||||
import { rankingRoutes } from './routes/ranking'
|
import { rankingRoutes } from './routes/ranking'
|
||||||
import { problemsetRoutes } from './routes/problemset'
|
import { problemsetRoutes } from './routes/problemset'
|
||||||
import { achievementRoutes } from './routes/achievement'
|
import { achievementRoutes } from './routes/achievement'
|
||||||
@@ -20,6 +21,7 @@ import { problemRoutes } from './routes/problem'
|
|||||||
import { problemStatsRoutes } from './routes/problem-stats'
|
import { problemStatsRoutes } from './routes/problem-stats'
|
||||||
import { submissionRoutes } from './routes/submission'
|
import { submissionRoutes } from './routes/submission'
|
||||||
import { trialRunRoutes } from './routes/trial-run'
|
import { trialRunRoutes } from './routes/trial-run'
|
||||||
|
import { mascotRoutes } from './routes/mascot'
|
||||||
import { siteRoutes } from './routes/site'
|
import { siteRoutes } from './routes/site'
|
||||||
import { scanPendingChallenges } from './services/challenge'
|
import { scanPendingChallenges } from './services/challenge'
|
||||||
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
|
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
|
||||||
@@ -32,7 +34,8 @@ app.route('/api', accountRoutes)
|
|||||||
app.route('/api', siteRoutes)
|
app.route('/api', siteRoutes)
|
||||||
app.route('/api', contestRoutes)
|
app.route('/api', contestRoutes)
|
||||||
app.route('/api', contentRoutes)
|
app.route('/api', contentRoutes)
|
||||||
app.route('/api', classroomRoutes)
|
app.route('/api', homeRoutes)
|
||||||
|
app.route('/api', taskRoutes)
|
||||||
app.route('/api', rankingRoutes)
|
app.route('/api', rankingRoutes)
|
||||||
app.route('/api', problemsetRoutes)
|
app.route('/api', problemsetRoutes)
|
||||||
app.route('/api', achievementRoutes)
|
app.route('/api', achievementRoutes)
|
||||||
@@ -42,6 +45,7 @@ app.route('/api', problemRoutes)
|
|||||||
app.route('/api', problemStatsRoutes)
|
app.route('/api', problemStatsRoutes)
|
||||||
app.route('/api', submissionRoutes)
|
app.route('/api', submissionRoutes)
|
||||||
app.route('/api', trialRunRoutes)
|
app.route('/api', trialRunRoutes)
|
||||||
|
app.route('/api', mascotRoutes)
|
||||||
app.route('/api/admin', adminRoutes)
|
app.route('/api/admin', adminRoutes)
|
||||||
|
|
||||||
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
|
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
|
||||||
|
|||||||
+99
-17
@@ -63,15 +63,74 @@ async function loadLanguage(language: string) {
|
|||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectNodes(root: Node, type: string, result: Node[] = []) {
|
type Match = (node: Node) => boolean
|
||||||
if (root.type === type) result.push(root)
|
|
||||||
for (const child of root.children) collectNodes(child, type, result)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNode(root: Node, type: string): boolean {
|
function hasNode(root: Node, match: Match): boolean {
|
||||||
if (root.type === type) return true
|
if (match(root)) return true
|
||||||
return root.children.some((child) => hasNode(child, type))
|
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) {
|
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 (!rule.keyword?.trim()) return `${at}:参数名不能为空`
|
||||||
}
|
}
|
||||||
if (!astRuleIsMeaningful(rule)) 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
|
return null
|
||||||
@@ -238,12 +301,32 @@ const CALL_NODE_TYPES: Record<string, string> = {
|
|||||||
Python: 'call',
|
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) {
|
function functionCalls(root: Node, target: string, language: string) {
|
||||||
const callType = CALL_NODE_TYPES[language] ?? 'call'
|
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')
|
const fn = call.childForFieldName('function')
|
||||||
if (!fn) return false
|
if (!fn) return false
|
||||||
if (fn.type === 'identifier') return fn.text === target
|
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 取决于
|
// `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于
|
||||||
// 有没有 using namespace std,两种都得认,所以末段也比一次
|
// 有没有 using namespace std,两种都得认,所以末段也比一次
|
||||||
if (language === 'C++' && fn.type === 'qualified_identifier') {
|
if (language === 'C++' && fn.type === 'qualified_identifier') {
|
||||||
@@ -265,13 +348,13 @@ function methodCalls(root: Node, target: string, language: string) {
|
|||||||
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
|
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
|
||||||
// 会静默地永远失败
|
// 会静默地永远失败
|
||||||
if (language === 'C++') {
|
if (language === 'C++') {
|
||||||
return collectNodes(root, 'call_expression').filter((call) => {
|
return collectNodes(root, ofType('call_expression')).filter((call) => {
|
||||||
const fn = call.childForFieldName('function')
|
const fn = call.childForFieldName('function')
|
||||||
return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target
|
return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (language !== 'Python') return []
|
if (language !== 'Python') return []
|
||||||
return collectNodes(root, 'call').filter((call) => {
|
return collectNodes(root, ofType('call')).filter((call) => {
|
||||||
const fn = call.childForFieldName('function')
|
const fn = call.childForFieldName('function')
|
||||||
return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target
|
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 {
|
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
|
||||||
const target = rule.target ?? ''
|
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) {
|
switch (rule.engine) {
|
||||||
case 'must_exist_node':
|
case 'must_exist_node':
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule, language),
|
description: describeAstRule(rule, language),
|
||||||
passed: hasNode(root, nodeType),
|
passed: hasNode(root, match),
|
||||||
}
|
}
|
||||||
case 'must_not_exist_node': {
|
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)
|
return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes)
|
||||||
}
|
}
|
||||||
case 'count_node': {
|
case 'count_node': {
|
||||||
const nodes = collectNodes(root, nodeType)
|
const nodes = collectNodes(root, match)
|
||||||
return withLines(
|
return withLines(
|
||||||
{
|
{
|
||||||
description: describeAstRule(rule, language),
|
description: describeAstRule(rule, language),
|
||||||
@@ -335,7 +418,7 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
|
|||||||
case 'must_use_operator':
|
case 'must_use_operator':
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule, language),
|
description: describeAstRule(rule, language),
|
||||||
passed: hasNode(root, nodeType),
|
passed: hasNode(root, match),
|
||||||
}
|
}
|
||||||
case 'must_use_keyword':
|
case 'must_use_keyword':
|
||||||
return {
|
return {
|
||||||
@@ -345,9 +428,8 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
|
|||||||
case 'must_have_nesting': {
|
case 'must_have_nesting': {
|
||||||
const outer = rule.outer ?? ''
|
const outer = rule.outer ?? ''
|
||||||
const inner = rule.inner ?? ''
|
const inner = rule.inner ?? ''
|
||||||
const outerType = astTargetNodeType(outer, language)
|
const innerMatch = nodeMatch(inner, language)
|
||||||
const innerType = astTargetNodeType(inner, language)
|
const passed = collectNodes(root, nodeMatch(outer, language)).some((node) => node.children.some((child) => hasNode(child, innerMatch)))
|
||||||
const passed = collectNodes(root, outerType).some((node) => node.children.some((child) => hasNode(child, innerType)))
|
|
||||||
return { description: describeAstRule(rule, language), passed }
|
return { description: describeAstRule(rule, language), passed }
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { JudgeJobData } from './job'
|
|||||||
import { isAccepted, JudgeStatus, type JudgeStatusValue } from './status'
|
import { isAccepted, JudgeStatus, type JudgeStatusValue } from './status'
|
||||||
import { parseProblemTemplate, shiftCompileLines } from './template'
|
import { parseProblemTemplate, shiftCompileLines } from './template'
|
||||||
import { withJudgeSlot } from './slots'
|
import { withJudgeSlot } from './slots'
|
||||||
import { runSqlCase } from './sql'
|
import { runSqlCase, sqlCaseBudgetMs } from './sql'
|
||||||
import { nativeRuntimeError, parsePythonTraceback, type RuntimeErrorInfo } from './runtime-diagnosis'
|
import { nativeRuntimeError, parsePythonTraceback, type RuntimeErrorInfo } from './runtime-diagnosis'
|
||||||
import { compileError, judge, JudgeUnreachableError, type JudgeCase, type JudgeResponse } from './sandbox'
|
import { compileError, judge, JudgeUnreachableError, type JudgeCase, type JudgeResponse } from './sandbox'
|
||||||
import { readInfo } from '../services/test-case'
|
import { readInfo } from '../services/test-case'
|
||||||
@@ -549,11 +549,11 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
)
|
)
|
||||||
if (!saved) return
|
if (!saved) return
|
||||||
|
|
||||||
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
|
// 专题记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
|
||||||
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
|
// 只认路由参数里那一个专题:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
|
||||||
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
|
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
|
||||||
// 进度已经落库,跳回题单页看到的就是新数据。
|
// 进度已经落库,跳回专题页看到的就是新数据。
|
||||||
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
|
// 比赛题不进专题(专题加题时卡了 contestId IS NULL),跳过。
|
||||||
if (row.submission.contestId === null && isAccepted(result)) {
|
if (row.submission.contestId === null && isAccepted(result)) {
|
||||||
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
|
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
|
||||||
}
|
}
|
||||||
@@ -625,6 +625,25 @@ async function judgeSqlSubmission(problem: typeof schema.problem.$inferSelect, s
|
|||||||
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
|
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
|
||||||
if (keys.length === 0) throw new Error('题目没有任何测试点')
|
if (keys.length === 0) throw new Error('题目没有任何测试点')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 和沙箱判题占同一个池的名额(judge/slots.ts):SQL 子进程在 worker 里跑,同样吃服务器
|
||||||
|
* 那两个核,原来不排队 —— 两道 SQL 加两道沙箱判题、再加流程图试运行一起压上去,
|
||||||
|
* 按墙钟算的 SQL 时限就会把本来不超时的语句判成超时。一个名额管整份提交,和沙箱那边一样。
|
||||||
|
* 租约按每个测试点最长可能占的时间算,再加 30 秒
|
||||||
|
*/
|
||||||
|
const holdMs = keys.length * sqlCaseBudgetMs(problem.timeLimit) + 30_000
|
||||||
|
return withJudgeSlot('judge', { holdMs }, () => judgeSqlCases(problem, keys, info, refSql, studentSql, mode, sqlConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function judgeSqlCases(
|
||||||
|
problem: typeof schema.problem.$inferSelect,
|
||||||
|
keys: string[],
|
||||||
|
info: NonNullable<Awaited<ReturnType<typeof readInfo>>>,
|
||||||
|
refSql: string,
|
||||||
|
studentSql: string,
|
||||||
|
mode: 'query' | 'modify',
|
||||||
|
sqlConfig: Record<string, unknown>,
|
||||||
|
): Promise<JudgeResponse> {
|
||||||
const cases: JudgeCase[] = []
|
const cases: JudgeCase[] = []
|
||||||
for (const [index, key] of keys.entries()) {
|
for (const [index, key] of keys.entries()) {
|
||||||
const inputName = info.test_cases![key]!.input_name
|
const inputName = info.test_cases![key]!.input_name
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个测试点最长可能占多久:受信阶段的预算 + 启动余量,进了学生阶段换成题目时限 + 余量。
|
||||||
|
* 判题名额的租约(judge/slots.ts)按它算,租约比这短的话跑到一半名额会被别人拿走
|
||||||
|
*/
|
||||||
|
export function sqlCaseBudgetMs(timeLimitMs: number) {
|
||||||
|
return trustedBudgetMs(timeLimitMs) + STARTUP_SLACK_MS + timeLimitMs + STUDENT_SLACK_MS
|
||||||
|
}
|
||||||
|
|
||||||
export function runSqlCase(job: Extract<SqlJob, { kind: 'judge' }>) {
|
export function runSqlCase(job: Extract<SqlJob, { kind: 'judge' }>) {
|
||||||
return runJob<CaseResult>(job, {
|
return runJob<CaseResult>(job, {
|
||||||
trustedMs: trustedBudgetMs(job.timeLimitMs),
|
trustedMs: trustedBudgetMs(job.timeLimitMs),
|
||||||
|
|||||||
@@ -263,10 +263,13 @@ accountRoutes.put('/me/profile', requireAuth, async (c) => {
|
|||||||
const parsed = await parseBody(c, updateProfileRequestSchema, 'Invalid profile payload')
|
const parsed = await parseBody(c, updateProfileRequestSchema, 'Invalid profile payload')
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const values = Object.fromEntries(Object.entries(parsed.data).map(([key, value]) => [key, value === '' ? null : value]))
|
const values = Object.fromEntries(Object.entries(parsed.data).map(([key, value]) => [key, value === '' ? null : value]))
|
||||||
|
// 只带了不收的字段(比如老页面还发 realName):没什么可改的,别让 drizzle 的空 set 报成 500
|
||||||
|
if (Object.keys(values).length) {
|
||||||
await db
|
await db
|
||||||
.update(schema.userProfile)
|
.update(schema.userProfile)
|
||||||
.set(values)
|
.set(values)
|
||||||
.where(eq(schema.userProfile.userId, c.get('user')!.id))
|
.where(eq(schema.userProfile.userId, c.get('user')!.id))
|
||||||
|
}
|
||||||
const profile = await getUserProfileById(c.get('user')!.id, true)
|
const profile = await getUserProfileById(c.get('user')!.id, true)
|
||||||
if (!profile) return failure(c, 404, 'profile-not-found', 'User profile does not exist')
|
if (!profile) return failure(c, 404, 'profile-not-found', 'User profile does not exist')
|
||||||
return success(c, profile)
|
return success(c, profile)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
adminTypeSchema,
|
adminTypeSchema,
|
||||||
checkUsernamesRequestSchema,
|
checkUsernamesRequestSchema,
|
||||||
deleteUsersRequestSchema,
|
deleteUsersRequestSchema,
|
||||||
|
displayName,
|
||||||
|
legacyClassOf,
|
||||||
NO_CLASS,
|
NO_CLASS,
|
||||||
type CheckUsernamesResponse,
|
type CheckUsernamesResponse,
|
||||||
importUsersRequestSchema,
|
importUsersRequestSchema,
|
||||||
@@ -26,36 +28,12 @@ import { revokeUserSessions } from '../../auth/session'
|
|||||||
import { requireSuperAdmin, type AppEnv } from '../../auth/middleware'
|
import { requireSuperAdmin, type AppEnv } from '../../auth/middleware'
|
||||||
import { db, schema } from '../../db'
|
import { db, schema } from '../../db'
|
||||||
import { failure, parseBody, success } from '../../http'
|
import { failure, parseBody, success } from '../../http'
|
||||||
import { classPrefixCondition, queryInteger, sampleUser } from '../helpers'
|
import { classKeywordCondition, queryInteger, sampleUser } from '../helpers'
|
||||||
import { publishConfigUpdate } from '../../events'
|
import { publishConfigUpdate } from '../../events'
|
||||||
import { getWebsiteOptions } from '../../services/options'
|
import { getWebsiteOptions } from '../../services/options'
|
||||||
|
|
||||||
export const adminAccountRoutes = new Hono<AppEnv>()
|
export const adminAccountRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
const CLASS_NAME_MIN_DIGITS = 3
|
|
||||||
const CLASS_NAME_MAX_DIGITS = 4
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `ks251XXX` / `ks2510XX` → `251` / `2510`。不以 `ks+数字` 开头的(管理员、教师账号)返回 null。
|
|
||||||
*
|
|
||||||
* 位数不对**直接报错,不猜** —— 猜错会把 className 存歪,而剥前缀显示姓名、班级下拉、
|
|
||||||
* 统计页都依赖它准确。先用 `\d+` 抓全再判位数,不能直接用固定位数的正则匹配:
|
|
||||||
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
|
|
||||||
* 对齐旧 `account/views/admin.py:get_class_name`。
|
|
||||||
*/
|
|
||||||
function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } {
|
|
||||||
const matched = /^ks(\d+)/.exec(username)
|
|
||||||
if (!matched) return { ok: true, value: null }
|
|
||||||
const digits = matched[1]!
|
|
||||||
if (digits.length < CLASS_NAME_MIN_DIGITS || digits.length > CLASS_NAME_MAX_DIGITS) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message: `用户名 ${username} 的班级号 ${digits} 是 ${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ok: true, value: digits }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission:
|
* 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission:
|
||||||
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
|
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
|
||||||
@@ -120,8 +98,8 @@ adminAccountRoutes.get('/rankings/users', requireSuperAdmin, async (c) => {
|
|||||||
const where = and(
|
const where = and(
|
||||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
// 输入班级前缀(ks251)按班级匹配,否则照旧按用户名包含
|
// 填的是班号(251,或者老习惯 ks251)按班级圈,否则按用户名包含
|
||||||
keyword ? (classPrefixCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined,
|
keyword ? (classKeywordCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
@@ -220,20 +198,62 @@ adminAccountRoutes.get('/users', requireSuperAdmin, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。
|
* 这个班里已经有的、叫这几个名字的人(多半是同一个学生粘了两次)。三种写法都要认:老号 `ks232张三`
|
||||||
* 比较时归一成小写,和导入、登录同一个口径
|
* (剥掉前缀比)、名字被占过加了数字的新号 `张三2`、24 级数媒那批编号制用户名 —— 后两种名字只在真名里,
|
||||||
|
* 所以真名也比。比较归一成小写,和登录、导入同一个口径
|
||||||
|
*/
|
||||||
|
async function classmatesNamed(className: string, names: string[]) {
|
||||||
|
const wanted = new Set(names.map((name) => name.toLowerCase()))
|
||||||
|
const rows = await db
|
||||||
|
.select({ username: schema.user.username, realName: schema.userProfile.realName, createTime: schema.user.createTime })
|
||||||
|
.from(schema.user)
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
|
.where(eq(schema.user.className, className))
|
||||||
|
return rows.flatMap((row) => {
|
||||||
|
const name = [displayName(row.username), row.realName ?? ''].find((candidate) => candidate && wanted.has(candidate.toLowerCase()))
|
||||||
|
return name ? [{ name, username: row.username, createTime: row.createTime }] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全站已经被占的用户名里,和这几个名字撞的:名字本身,以及「名字 + 数字」(给新号加数字时要绕开)。
|
||||||
|
* 名字自己就带数字(王芳1)也照样认 —— 不能先把库里的尾数剥掉再比,那样 `王芳1` 剥成 `王芳` 就漏了
|
||||||
|
*/
|
||||||
|
async function takenUsernames(names: string[]) {
|
||||||
|
const escape = (text: string) => text.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&')
|
||||||
|
const pattern = `^(${[...new Set(names.map((name) => escape(name.toLowerCase())))].join('|')})[0-9]*$`
|
||||||
|
const rows = await db
|
||||||
|
.select({ username: sql<string>`lower(${schema.user.username})` })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(sql`lower(${schema.user.username}) ~ ${pattern}`)
|
||||||
|
return new Set(rows.map((row) => row.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量建号之前查重(批量建号页边粘名单边查):这个班里已经有谁了,以及每个名字会建成什么用户名
|
||||||
|
* (名字被别的班、注册的号占了就加数字)。导入时后端还会再查一遍同班同名,这里只是让老师提前看到
|
||||||
*/
|
*/
|
||||||
adminAccountRoutes.post('/users/check', requireSuperAdmin, async (c) => {
|
adminAccountRoutes.post('/users/check', requireSuperAdmin, async (c) => {
|
||||||
const parsed = await parseBody(c, checkUsernamesRequestSchema)
|
const parsed = await parseBody(c, checkUsernamesRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const names = [...new Set(parsed.data.usernames.map((name) => name.toLowerCase()))]
|
const { className, names } = parsed.data
|
||||||
const rows = await db
|
const [existing, taken] = await Promise.all([classmatesNamed(className, names), takenUsernames(names)])
|
||||||
.select({ username: schema.user.username, createTime: schema.user.createTime })
|
return success(c, { existing, usernames: suggestUsernames(names, taken) } satisfies CheckUsernamesResponse)
|
||||||
.from(schema.user)
|
|
||||||
.where(inArray(sql`lower(${schema.user.username})`, names))
|
|
||||||
return success(c, { existing: rows } satisfies CheckUsernamesResponse)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新号的用户名:名字没被占就是名字本身,占了往后加数字(杨涵宇2、杨涵宇3)。同一批里先到先得。
|
||||||
|
* 比较按小写,和登录、导入同一个口径
|
||||||
|
*/
|
||||||
|
function suggestUsernames(names: string[], taken: Set<string>) {
|
||||||
|
return names.map((name) => {
|
||||||
|
let candidate = name
|
||||||
|
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${name}${n}`
|
||||||
|
taken.add(candidate.toLowerCase())
|
||||||
|
return candidate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
adminAccountRoutes.get('/users/:id', requireSuperAdmin, async (c) => {
|
adminAccountRoutes.get('/users/:id', requireSuperAdmin, async (c) => {
|
||||||
const [row] = await selectUser(queryInteger(c.req.param('id'), 0, { min: 1 }))
|
const [row] = await selectUser(queryInteger(c.req.param('id'), 0, { min: 1 }))
|
||||||
if (!row) return failure(c, 404, 'user-not-found', 'User does not exist')
|
if (!row) return failure(c, 404, 'user-not-found', 'User does not exist')
|
||||||
@@ -250,8 +270,8 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
|
|||||||
|
|
||||||
const username = data.username.trim().toLowerCase()
|
const username = data.username.trim().toLowerCase()
|
||||||
const email = data.email.trim().toLowerCase()
|
const email = data.email.trim().toLowerCase()
|
||||||
const className = classNameOf(username)
|
// 班级明确给了就用;老页面不发这一项,不动原来的班(以前是从用户名的 ks 前缀推,改名就跟着变)
|
||||||
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message)
|
const className = data.className !== undefined ? data.className : existing.user.className
|
||||||
|
|
||||||
const [dupUsername] = await db
|
const [dupUsername] = await db
|
||||||
.select({ id: schema.user.id })
|
.select({ id: schema.user.id })
|
||||||
@@ -270,7 +290,7 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
|
|||||||
const patch: Partial<typeof schema.user.$inferInsert> = {
|
const patch: Partial<typeof schema.user.$inferInsert> = {
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
className: className.value,
|
className,
|
||||||
adminType: data.adminType,
|
adminType: data.adminType,
|
||||||
isDisabled: data.isDisabled,
|
isDisabled: data.isDisabled,
|
||||||
problemPermission: normalizePermission(data.adminType, data.problemPermission),
|
problemPermission: normalizePermission(data.adminType, data.problemPermission),
|
||||||
@@ -337,10 +357,14 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
|||||||
// PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的
|
// PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的
|
||||||
// 查重建出第二个账号,两个人登录时撞成同一条记录。
|
// 查重建出第二个账号,两个人登录时撞成同一条记录。
|
||||||
const prepared: Prepared[] = []
|
const prepared: Prepared[] = []
|
||||||
for (const [username, password, email, realName] of rows) {
|
for (const [username, password, email, realName, explicitClass] of rows) {
|
||||||
const name = username.toLowerCase()
|
const name = username.toLowerCase()
|
||||||
const className = classNameOf(name)
|
// 班级明确给了就用(新号不带 ks 前缀);老写法没给,退回从 ks 前缀推,位数不对报错不猜
|
||||||
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message)
|
const legacy = explicitClass === undefined ? legacyClassOf(name) : null
|
||||||
|
if (legacy === undefined) {
|
||||||
|
return failure(c, 400, 'invalid-class-name', `用户名 ${name} 里 ks 后面的班级号位数不对,班级号要是 3~4 位数字`)
|
||||||
|
}
|
||||||
|
const className = explicitClass ?? legacy
|
||||||
const mail = email.trim().toLowerCase()
|
const mail = email.trim().toLowerCase()
|
||||||
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
|
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
|
||||||
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
|
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
|
||||||
@@ -355,7 +379,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
|||||||
raw: password,
|
raw: password,
|
||||||
email: mail,
|
email: mail,
|
||||||
realName,
|
realName,
|
||||||
className: className.value,
|
className,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +396,19 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
|||||||
return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`)
|
return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同班同名再查一遍:用户名不再带班号以后,「同一个学生粘了两次」撞不上用户名唯一约束了(新号会被加成张三2),
|
||||||
|
// 只靠批量建号页的查重挡 —— 那边有半秒防抖,粘完马上点就绕过去了
|
||||||
|
const byClass = new Map<string, string[]>()
|
||||||
|
for (const item of prepared) {
|
||||||
|
if (item.className) byClass.set(item.className, [...(byClass.get(item.className) ?? []), item.realName || displayName(item.username)])
|
||||||
|
}
|
||||||
|
for (const [className, names] of byClass) {
|
||||||
|
const clash = await classmatesNamed(className, names)
|
||||||
|
if (clash.length) {
|
||||||
|
return failure(c, 409, 'name-exists-in-class', `${className} 班已经有${clash.map((row) => `「${row.name}」`).join('、')}了`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ username: schema.user.username, email: schema.user.email })
|
.select({ username: schema.user.username, email: schema.user.email })
|
||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
@@ -446,7 +483,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
|
|||||||
return users.length
|
return users.length
|
||||||
})
|
})
|
||||||
|
|
||||||
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它,
|
// 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它,
|
||||||
// 原来要再去设置页手动加,经常忘
|
// 原来要再去设置页手动加,经常忘
|
||||||
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
|
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
|
||||||
const options = await getWebsiteOptions()
|
const options = await getWebsiteOptions()
|
||||||
@@ -488,7 +525,7 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
|
|||||||
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
|
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
|
||||||
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。
|
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。
|
||||||
//
|
//
|
||||||
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、
|
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、专题进度、
|
||||||
// AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
|
// AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
|
||||||
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
|
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
|
||||||
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
|
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
|
||||||
@@ -496,10 +533,10 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
|
|||||||
try {
|
try {
|
||||||
const deleted = await db.transaction(async (tx) => {
|
const deleted = await db.transaction(async (tx) => {
|
||||||
/**
|
/**
|
||||||
* 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** ——
|
* 外键拦得住成就、专题进度、比赛排名这些,**唯独提交拦不住** ——
|
||||||
* `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经
|
* `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经
|
||||||
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
|
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
|
||||||
* 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 ——
|
* 只交过题、没拿过成就没进过专题的学生照样删得掉,提交留在库里成了孤儿 ——
|
||||||
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测:
|
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测:
|
||||||
* 28 个已删账号留下 935 条这样的提交。
|
* 28 个已删账号留下 935 条这样的提交。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { randomInt } from 'node:crypto'
|
import { randomInt } from 'node:crypto'
|
||||||
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { count, countDistinct, desc, eq, gte, ilike, inArray, isNull, not, sql } from 'drizzle-orm'
|
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, not, sql } from 'drizzle-orm'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import { requireAdmin, requireSuperAdmin, type AppEnv } from '../../auth/middleware'
|
import { requireAdmin, requireSuperAdmin, type AppEnv } from '../../auth/middleware'
|
||||||
@@ -22,12 +22,12 @@ import { failure, parseBody, success } from '../../http'
|
|||||||
import { sniffImageExtension } from '../../services/image'
|
import { sniffImageExtension } from '../../services/image'
|
||||||
import { getWebsiteOptions } from '../../services/options'
|
import { getWebsiteOptions } from '../../services/options'
|
||||||
import { calendarDay, dayStart, localTime, termStart, weekStart } from '../../time'
|
import { calendarDay, dayStart, localTime, termStart, weekStart } from '../../time'
|
||||||
import { STUDENT_ROLES } from '@oj2/contract'
|
import { displayName, STUDENT_ROLES } from '@oj2/contract'
|
||||||
import { TEACHER_ROLES } from '../helpers'
|
import { TEACHER_ROLES } from '../helpers'
|
||||||
import { onlineUserIds } from '../../auth/presence'
|
import { onlineUserIds } from '../../auth/presence'
|
||||||
import { JudgeStatus } from '../../judge/status'
|
import { JudgeStatus } from '../../judge/status'
|
||||||
import { suggestActiveClass } from '../../services/class-activity'
|
import { suggestActiveClass } from '../../services/class-activity'
|
||||||
import { classPrefixCondition } from '../helpers'
|
import { classKeywordCondition } from '../helpers'
|
||||||
|
|
||||||
export const adminConfRoutes = new Hono<AppEnv>()
|
export const adminConfRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -346,20 +346,20 @@ adminConfRoutes.get('/dashboard', requireSuperAdmin, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
adminConfRoutes.get('/random-usernames', requireSuperAdmin, async (c) => {
|
adminConfRoutes.get('/random-usernames', requireSuperAdmin, async (c) => {
|
||||||
// 传的是**班级前缀**(形如 ks251),不是班级号 —— 前端输入框写的就是「班级前缀」,
|
// 后台首页「抽一个人」:从这个班随机拿 10 个,回的是界面上显示的名字(老号剥掉 ks 前缀)。
|
||||||
// 拿到结果后按这个前缀 split 取姓名。班级前缀走 classPrefixCondition(ks251 不带上
|
// 班号按 class_name 精确匹配;老页面发的是 ks251,classKeywordCondition 两种都认
|
||||||
// ks2511);别的形状的输入照旧 istartswith,与旧接口一致
|
|
||||||
const classroom = c.req.query('classroom')?.trim()
|
const classroom = c.req.query('classroom')?.trim()
|
||||||
if (!classroom) return failure(c, 400, 'invalid-request', '需要班级号')
|
const where = classroom ? classKeywordCondition(classroom) : undefined
|
||||||
|
if (!where) return failure(c, 400, 'invalid-request', '需要班级号')
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ username: schema.user.username })
|
.select({ username: schema.user.username })
|
||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
.where(classPrefixCondition(classroom) ?? ilike(schema.user.username, `${classroom}%`))
|
.where(and(where, eq(schema.user.isDisabled, false)))
|
||||||
.orderBy(sql`random()`)
|
.orderBy(sql`random()`)
|
||||||
.limit(10)
|
.limit(10)
|
||||||
return success(
|
return success(
|
||||||
c,
|
c,
|
||||||
rows.map((row) => row.username),
|
rows.map((row) => displayName(row.username)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
FLOWCHART_PASS_GRADES,
|
FLOWCHART_PASS_GRADES,
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
isLessonDone,
|
isTutorialDone,
|
||||||
type LearnBoard,
|
type LearnBoard,
|
||||||
type LearnBoardStudent,
|
type LearnBoardStudent,
|
||||||
type LearnExerciseAttemptList,
|
type LearnExerciseAttemptList,
|
||||||
@@ -53,8 +53,8 @@ function classCondition(value: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的,
|
* 没有班级的学生**照样统计**(班级列显示为空)。班级是建号时给的(老号当年从用户名的 ks 前缀推),
|
||||||
* 推不出来时就是 null(见 admin/account.ts 的 classNameOf),把这些人过滤掉等于让他们
|
* 没给就是 null,把这些人过滤掉等于让他们
|
||||||
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
|
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
|
||||||
*/
|
*/
|
||||||
function studentCondition(value: string | null) {
|
function studentCondition(value: string | null) {
|
||||||
@@ -64,7 +64,7 @@ function studentCondition(value: string | null) {
|
|||||||
/**
|
/**
|
||||||
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
|
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
|
||||||
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
|
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
|
||||||
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前
|
* 课堂任务(services/class-activity.ts 的 activityRows)同一个口径。不论早晚:读这课之前
|
||||||
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
|
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
|
||||||
*/
|
*/
|
||||||
async function problemActivity(userIds: number[], problemIds: number[]) {
|
async function problemActivity(userIds: number[], problemIds: number[]) {
|
||||||
@@ -198,7 +198,7 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
|
|||||||
students: students.map((student) => {
|
students: students.map((student) => {
|
||||||
const opened: number[] = []
|
const opened: number[] = []
|
||||||
const done: number[] = []
|
const done: number[] = []
|
||||||
const lessons: LearnBoardStudent['lessons'] = []
|
const attempts: LearnBoardStudent['tutorials'] = []
|
||||||
let exerciseSolved = 0
|
let exerciseSolved = 0
|
||||||
let totalSeconds = 0
|
let totalSeconds = 0
|
||||||
let lastViewedAt: string | null = null
|
let lastViewedAt: string | null = null
|
||||||
@@ -210,26 +210,26 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
|
|||||||
exerciseSolved += solvedHere
|
exerciseSolved += solvedHere
|
||||||
if (exercises) {
|
if (exercises) {
|
||||||
const { tried, solved, wrongAttempts } = exercises
|
const { tried, solved, wrongAttempts } = exercises
|
||||||
lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
|
attempts.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
|
||||||
}
|
}
|
||||||
if (row) {
|
if (row) {
|
||||||
opened.push(tutorial.id)
|
opened.push(tutorial.id)
|
||||||
totalSeconds += row.totalSeconds
|
totalSeconds += row.totalSeconds
|
||||||
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
|
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
|
||||||
}
|
}
|
||||||
const lesson = {
|
const here = {
|
||||||
viewCount: row?.viewCount ?? 0,
|
viewCount: row?.viewCount ?? 0,
|
||||||
exerciseTotal: tutorial.exerciseTotal,
|
exerciseTotal: tutorial.exerciseTotal,
|
||||||
exerciseSolved: solvedHere,
|
exerciseSolved: solvedHere,
|
||||||
}
|
}
|
||||||
if (isLessonDone(lesson)) done.push(tutorial.id)
|
if (isTutorialDone(here)) done.push(tutorial.id)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...student,
|
...student,
|
||||||
opened,
|
opened,
|
||||||
done,
|
done,
|
||||||
exerciseSolved,
|
exerciseSolved,
|
||||||
lessons,
|
tutorials: attempts,
|
||||||
problems: problemsOf.get(student.userId) ?? [],
|
problems: problemsOf.get(student.userId) ?? [],
|
||||||
totalSeconds,
|
totalSeconds,
|
||||||
lastViewedAt,
|
lastViewedAt,
|
||||||
@@ -394,27 +394,27 @@ adminLearnRoutes.get('/learn-analytics/students/:id', requireTeacher, async (c)
|
|||||||
).map((row) => [row.problemId, row]),
|
).map((row) => [row.problemId, row]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const byLesson = new Map<number, LearnStudentDetail['lessons'][number]['exercises']>()
|
const byTutorial = new Map<number, LearnStudentDetail['tutorials'][number]['exercises']>()
|
||||||
for (const { tutorialId, ...row } of exercises) {
|
for (const { tutorialId, ...row } of exercises) {
|
||||||
const list = byLesson.get(tutorialId) ?? []
|
const list = byTutorial.get(tutorialId) ?? []
|
||||||
list.push({
|
list.push({
|
||||||
...row,
|
...row,
|
||||||
attempts: row.attempts ?? 0,
|
attempts: row.attempts ?? 0,
|
||||||
solved: row.solved ?? false,
|
solved: row.solved ?? false,
|
||||||
})
|
})
|
||||||
byLesson.set(tutorialId, list)
|
byTutorial.set(tutorialId, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
return success(c, {
|
return success(c, {
|
||||||
...student,
|
...student,
|
||||||
lessons: tutorials
|
tutorials: tutorials
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
tutorialId: row.tutorialId,
|
tutorialId: row.tutorialId,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
viewCount: row.viewCount ?? 0,
|
viewCount: row.viewCount ?? 0,
|
||||||
totalSeconds: row.totalSeconds ?? 0,
|
totalSeconds: row.totalSeconds ?? 0,
|
||||||
lastViewedAt: row.lastViewedAt,
|
lastViewedAt: row.lastViewedAt,
|
||||||
exercises: byLesson.get(row.tutorialId) ?? [],
|
exercises: byTutorial.get(row.tutorialId) ?? [],
|
||||||
problems: related
|
problems: related
|
||||||
.filter((problem) => problem.tutorialId === row.tutorialId)
|
.filter((problem) => problem.tutorialId === row.tutorialId)
|
||||||
.map(({ id: problemId, displayId, title }) => {
|
.map(({ id: problemId, displayId, title }) => {
|
||||||
|
|||||||
@@ -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 { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ import { localDateTime } from '../../time'
|
|||||||
import { queryInteger } from '../helpers'
|
import { queryInteger } from '../helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改,
|
* 后台「课堂任务」:布置过的每一节课(class_lesson)。任务页上布置错了班、错了题可以在这里改,
|
||||||
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
|
* 任务页外上的课(以前的、没在任务页布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
|
||||||
*
|
*
|
||||||
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。
|
* 权限和任务页一样是老师:任务页上任何老师都能改任何班的这节课,这里不另设门槛。
|
||||||
*/
|
*/
|
||||||
export const adminLessonRoutes = new Hono<AppEnv>()
|
export const adminLessonRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -24,7 +24,12 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
|
|||||||
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
|
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
|
||||||
const className = c.req.query('className')?.trim()
|
const className = c.req.query('className')?.trim()
|
||||||
const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 })
|
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([
|
const [rows, [totalRow], teachers] = await Promise.all([
|
||||||
db
|
db
|
||||||
@@ -116,7 +121,7 @@ async function lessonValues(body: AdminLessonRequest): Promise<
|
|||||||
> {
|
> {
|
||||||
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1)
|
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1)
|
||||||
if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
|
if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
|
||||||
// 只画流程图不用语言,存成 null(和看板布置一样)
|
// 只画流程图不用语言,存成 null(和任务页布置一样)
|
||||||
const language = body.mode === 'flowchart' ? null : body.language
|
const language = body.mode === 'flowchart' ? null : body.language
|
||||||
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
|
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
|
||||||
if ('code' in resolved) return resolved
|
if ('code' in resolved) return resolved
|
||||||
@@ -156,10 +161,14 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
|||||||
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
|
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
|
||||||
const classes = await affectedClasses(id, result.values.className)
|
const classes = await affectedClasses(id, result.values.className)
|
||||||
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(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
|
const [row] = await db
|
||||||
.update(schema.classLesson)
|
.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))
|
.where(eq(schema.classLesson.id, id))
|
||||||
.returning({ id: schema.classLesson.id })
|
.returning({ id: schema.classLesson.id })
|
||||||
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const adminProblemSetRoutes = new Hono<AppEnv>()
|
|||||||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
|
* 取出专题并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
|
||||||
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
|
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
|
||||||
*/
|
*/
|
||||||
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) {
|
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) {
|
||||||
@@ -124,7 +124,7 @@ function assignment(requested: string | null, current: { assignedAt: string | nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 题单本体
|
// ---------------------------------------------------------------- 专题本体
|
||||||
|
|
||||||
adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
|
adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
|
||||||
const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 })
|
||||||
@@ -132,8 +132,8 @@ adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
|
|||||||
const user = c.get('user')!
|
const user = c.get('user')!
|
||||||
const filters = []
|
const filters = []
|
||||||
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
|
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
|
||||||
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失,
|
// 「切换可见性」的接口 —— 一旦把专题设成不可见,它就从后台列表里消失,
|
||||||
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
|
// 再也没法在界面上改回来。后台必须能看见自己管的全部专题。
|
||||||
if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id))
|
if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id))
|
||||||
const keyword = c.req.query('keyword')?.trim()
|
const keyword = c.req.query('keyword')?.trim()
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
@@ -174,13 +174,13 @@ adminProblemSetRoutes.post('/problem-sets', requireTeacher, async (c) => {
|
|||||||
|
|
||||||
adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => {
|
adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
return success(c, await serialize(row))
|
return success(c, await serialize(row))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
|
adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const period = assignment(parsed.data.assignedUntil, row)
|
const period = assignment(parsed.data.assignedUntil, row)
|
||||||
@@ -201,7 +201,7 @@ adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
|
|||||||
|
|
||||||
adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async (c) => {
|
adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
|
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(schema.problemset)
|
.update(schema.problemset)
|
||||||
@@ -213,18 +213,18 @@ adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async
|
|||||||
|
|
||||||
adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => {
|
adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
// 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐,
|
// 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐,
|
||||||
// user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。
|
// user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。
|
||||||
await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
|
await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 题单里的题目
|
// ---------------------------------------------------------------- 专题里的题目
|
||||||
|
|
||||||
adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => {
|
adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ item: schema.problemsetProblem, problem: schema.problem })
|
.select({ item: schema.problemsetProblem, problem: schema.problem })
|
||||||
.from(schema.problemsetProblem)
|
.from(schema.problemsetProblem)
|
||||||
@@ -255,7 +255,7 @@ adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c
|
|||||||
*/
|
*/
|
||||||
adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (c) => {
|
adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const parsed = await parseBody(c, addProblemsToSetRequestSchema)
|
const parsed = await parseBody(c, addProblemsToSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))]
|
const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))]
|
||||||
@@ -298,7 +298,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (
|
|||||||
// 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉
|
// 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉
|
||||||
adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => {
|
adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema)
|
const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const links = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id))
|
const links = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||||
@@ -320,7 +320,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, as
|
|||||||
|
|
||||||
adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
|
adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const parsed = await parseBody(c, updateProblemInSetRequestSchema, '参数错误')
|
const parsed = await parseBody(c, updateProblemInSetRequestSchema, '参数错误')
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const updated = await db
|
const updated = await db
|
||||||
@@ -328,7 +328,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher,
|
|||||||
.set(parsed.data)
|
.set(parsed.data)
|
||||||
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
|
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
|
||||||
.returning({ id: schema.problemsetProblem.id })
|
.returning({ id: schema.problemsetProblem.id })
|
||||||
if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中')
|
if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中')
|
||||||
// 必做 / 选做变了,分母和「做完」跟着变
|
// 必做 / 选做变了,分母和「做完」跟着变
|
||||||
await resyncProgress(row.id)
|
await resyncProgress(row.id)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
@@ -336,12 +336,12 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher,
|
|||||||
|
|
||||||
adminProblemSetRoutes.delete('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
|
adminProblemSetRoutes.delete('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const deleted = await db
|
const deleted = await db
|
||||||
.delete(schema.problemsetProblem)
|
.delete(schema.problemsetProblem)
|
||||||
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
|
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
|
||||||
.returning({ id: schema.problemsetProblem.id })
|
.returning({ id: schema.problemsetProblem.id })
|
||||||
if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中')
|
if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中')
|
||||||
await resyncProgress(row.id)
|
await resyncProgress(row.id)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
@@ -383,14 +383,14 @@ async function badgesWithCount(badges: BadgeRow[]) {
|
|||||||
|
|
||||||
adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => {
|
adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
|
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
|
||||||
return success(c, await badgesWithCount(badges))
|
return success(c, await badgesWithCount(badges))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => {
|
adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const parsed = await parseBody(c, createProblemSetBadgeRequestSchema)
|
const parsed = await parseBody(c, createProblemSetBadgeRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
@@ -407,7 +407,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c)
|
|||||||
|
|
||||||
adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
|
adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema)
|
const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -422,11 +422,11 @@ adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, a
|
|||||||
|
|
||||||
adminProblemSetRoutes.delete('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
|
adminProblemSetRoutes.delete('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get('user')!)
|
const row = await loadOwned(c, c.get('user')!)
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const badgeId = queryInteger(c.req.param('badgeId'), 0, { min: 1 })
|
const badgeId = queryInteger(c.req.param('badgeId'), 0, { min: 1 })
|
||||||
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
|
// 必须先确认这枚奖章确实属于本专题,再动 user_badge。
|
||||||
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
|
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定专题,
|
||||||
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
|
// 于是「自己的专题 id + 别人的奖章 id」会真删掉别人的获奖记录,
|
||||||
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
|
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
|
||||||
const [badge] = await db
|
const [badge] = await db
|
||||||
.select({ id: schema.problemsetBadge.id })
|
.select({ id: schema.problemsetBadge.id })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
aiHintRequestSchema,
|
aiHintRequestSchema,
|
||||||
classAnalysisRequestSchema,
|
classAnalysisRequestSchema,
|
||||||
classPkAnalysisRequestSchema,
|
classPkAnalysisRequestSchema,
|
||||||
|
displayName,
|
||||||
HINT_MIN_FAILURES,
|
HINT_MIN_FAILURES,
|
||||||
LEARNING_PERIOD_LABELS,
|
LEARNING_PERIOD_LABELS,
|
||||||
learningPeriodSchema,
|
learningPeriodSchema,
|
||||||
@@ -354,6 +355,8 @@ aiRoutes.post('/ai/class-analysis', requireAuth, async (c) => {
|
|||||||
// 数字和抽屉里看到的是同一份(services/ranking.ts 的 classDetail),不再由前端整包传上来
|
// 数字和抽屉里看到的是同一份(services/ranking.ts 的 classDetail),不再由前端整包传上来
|
||||||
const detail = await classDetail(parsed.data.className, true)
|
const detail = await classDetail(parsed.data.className, true)
|
||||||
if (!detail.members) return failure(c, 404, 'class-not-found', '这个班没有学生')
|
if (!detail.members) return failure(c, 404, 'class-not-found', '这个班没有学生')
|
||||||
|
// 「要多关心的同学」会被点名:老号的用户名带 ks252 前缀,喂给模型之前剥掉,报告里写的才是界面上那个名字
|
||||||
|
const forModel = { ...detail, care: detail.care?.map((row) => ({ ...row, user: { ...row.user, username: displayName(row.user.username) } })) ?? null }
|
||||||
return streamChat(
|
return streamChat(
|
||||||
[
|
[
|
||||||
'你是编程课老师的助教。下面是一个班这学期在 OJ 上的做题数据(JSON,人名是用户名)。',
|
'你是编程课老师的助教。下面是一个班这学期在 OJ 上的做题数据(JSON,人名是用户名)。',
|
||||||
@@ -362,7 +365,7 @@ aiRoutes.post('/ai/class-analysis', requireAuth, async (c) => {
|
|||||||
'**分化**(前后 10%、人最多的那一档同分)、**建议**(点名 care 里的同学,给下节课能直接做的事)。',
|
'**分化**(前后 10%、人最多的那一档同分)、**建议**(点名 care 里的同学,给下节课能直接做的事)。',
|
||||||
'用大白话,不要出现四分位数、标准差、方差这类统计词,不要编数据里没有的数字。',
|
'用大白话,不要出现四分位数、标准差、方差这类统计词,不要编数据里没有的数字。',
|
||||||
].join(''),
|
].join(''),
|
||||||
JSON.stringify(detail),
|
JSON.stringify(forModel),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
displayName,
|
||||||
createFlowchartRequestSchema,
|
createFlowchartRequestSchema,
|
||||||
isFlowchartPass,
|
isFlowchartPass,
|
||||||
type CreateFlowchartResponse,
|
type CreateFlowchartResponse,
|
||||||
@@ -27,18 +28,7 @@ import { consumeToken } from '../services/throttling'
|
|||||||
import { buildWordFrequencies } from '../services/word-frequency'
|
import { buildWordFrequencies } from '../services/word-frequency'
|
||||||
import { dayStart } from '../time'
|
import { dayStart } from '../time'
|
||||||
import { unknownDisplayIds } from '../services/submission-filters'
|
import { unknownDisplayIds } from '../services/submission-filters'
|
||||||
import {
|
import { classCondition, isAdminRole, isTeacherOrAbove, matchedUsers, asRecord, parseDisplayIds, queryInteger, scopedUsers, rounded } from './helpers'
|
||||||
classCondition,
|
|
||||||
isAdminRole,
|
|
||||||
isTeacherOrAbove,
|
|
||||||
matchedUsers,
|
|
||||||
asRecord,
|
|
||||||
parseDisplayIds,
|
|
||||||
queryInteger,
|
|
||||||
scopedUsers,
|
|
||||||
rounded,
|
|
||||||
stripClassPrefix,
|
|
||||||
} from './helpers'
|
|
||||||
|
|
||||||
export const flowchartRoutes = new Hono<AppEnv>()
|
export const flowchartRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -48,7 +38,7 @@ function flowchartThrottleKey(userId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
|
* 专题防抄:专题布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
|
||||||
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
|
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
|
||||||
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
||||||
*/
|
*/
|
||||||
@@ -69,7 +59,7 @@ function canView(
|
|||||||
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
/** 只有学生看自己的提交时才需要查专题;其余情况给一张空表,省一次查询 */
|
||||||
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
||||||
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
||||||
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
||||||
@@ -280,7 +270,7 @@ async function flowchartUserFilter(username: string, exact = false) {
|
|||||||
const flowchartListColumns = {
|
const flowchartListColumns = {
|
||||||
flowchart: {
|
flowchart: {
|
||||||
id: schema.flowchartSubmission.id,
|
id: schema.flowchartSubmission.id,
|
||||||
// showLink 判定要(本人 + 题单闸门),序列化本身用不到
|
// showLink 判定要(本人 + 专题闸门),序列化本身用不到
|
||||||
userId: schema.flowchartSubmission.userId,
|
userId: schema.flowchartSubmission.userId,
|
||||||
problemId: schema.flowchartSubmission.problemId,
|
problemId: schema.flowchartSubmission.problemId,
|
||||||
status: schema.flowchartSubmission.status,
|
status: schema.flowchartSubmission.status,
|
||||||
@@ -293,6 +283,7 @@ const flowchartListColumns = {
|
|||||||
evaluationTime: schema.flowchartSubmission.evaluationTime,
|
evaluationTime: schema.flowchartSubmission.evaluationTime,
|
||||||
},
|
},
|
||||||
username: schema.user.username,
|
username: schema.user.username,
|
||||||
|
className: schema.user.className,
|
||||||
problem: {
|
problem: {
|
||||||
displayId: schema.problem.displayId,
|
displayId: schema.problem.displayId,
|
||||||
title: schema.problem.title,
|
title: schema.problem.title,
|
||||||
@@ -349,10 +340,11 @@ flowchartRoutes.get('/flowcharts', requireAuth, async (c) => {
|
|||||||
)
|
)
|
||||||
return success(c, {
|
return success(c, {
|
||||||
results: rows.map(
|
results: rows.map(
|
||||||
({ flowchart, username, problem }) =>
|
({ flowchart, username, className, problem }) =>
|
||||||
({
|
({
|
||||||
id: flowchart.id,
|
id: flowchart.id,
|
||||||
username,
|
username,
|
||||||
|
className,
|
||||||
problemDisplayId: problem.displayId,
|
problemDisplayId: problem.displayId,
|
||||||
problemTitle: problem.title,
|
problemTitle: problem.title,
|
||||||
status: flowchart.status,
|
status: flowchart.status,
|
||||||
@@ -540,7 +532,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
|
|||||||
const peopleTruncated = peopleRows.length > PEOPLE_LIMIT
|
const peopleTruncated = peopleRows.length > PEOPLE_LIMIT
|
||||||
const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({
|
const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
realName: stripClassPrefix(row.username, row.className),
|
realName: displayName(row.username),
|
||||||
bestScore: row.bestScore === null ? null : Number(row.bestScore),
|
bestScore: row.bestScore === null ? null : Number(row.bestScore),
|
||||||
bestGrade: row.bestGrade,
|
bestGrade: row.bestGrade,
|
||||||
count: row.count,
|
count: row.count,
|
||||||
@@ -561,7 +553,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
|
|||||||
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
|
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
|
||||||
dataUnaccepted: roster.map((row) => ({
|
dataUnaccepted: roster.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
realName: stripClassPrefix(row.username, row.className),
|
realName: displayName(row.username),
|
||||||
})),
|
})),
|
||||||
people: [],
|
people: [],
|
||||||
peopleTruncated: false,
|
peopleTruncated: false,
|
||||||
@@ -639,7 +631,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
|
|||||||
.filter((row) => !submitted.has(row.id))
|
.filter((row) => !submitted.has(row.id))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
realName: stripClassPrefix(row.username, row.className),
|
realName: displayName(row.username),
|
||||||
})),
|
})),
|
||||||
people,
|
people,
|
||||||
peopleTruncated,
|
peopleTruncated,
|
||||||
@@ -719,7 +711,7 @@ flowchartRoutes.post('/flowcharts/:id/retry', requireAuth, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
|
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入专题之前的(专题闸门)。
|
||||||
* 结果页签的历次分数(scores)从这一份出。
|
* 结果页签的历次分数(scores)从这一份出。
|
||||||
*
|
*
|
||||||
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
|
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
|
||||||
@@ -748,7 +740,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条,
|
* 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条,
|
||||||
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。
|
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被专题闸门藏起来的次数,界面上给一句说明。
|
||||||
*/
|
*/
|
||||||
flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => {
|
flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => {
|
||||||
const problemId = queryInteger(c.req.param('id'), 0, { min: 1 })
|
const problemId = queryInteger(c.req.param('id'), 0, { min: 1 })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from '@oj2/contract'
|
import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from '@oj2/contract'
|
||||||
|
|
||||||
import { and, count, eq, ilike, inArray, isNull, notInArray, sql } from 'drizzle-orm'
|
import { and, count, eq, ilike, inArray, isNull, notInArray } from 'drizzle-orm'
|
||||||
|
|
||||||
import type { AuthUser } from '../auth/session'
|
import type { AuthUser } from '../auth/session'
|
||||||
import { db, schema } from '../db'
|
import { db, schema } from '../db'
|
||||||
@@ -29,8 +29,9 @@ export function sampleUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 去掉用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。
|
* 去掉老号用户名里的 `ks<班级号>` 前缀:`ks251张三` + `251` → `张三`。只剩旧版登录名单(site.ts 的
|
||||||
* 对齐旧后端 `utils/shortcuts.py:52` 的 `strip_class_prefix`。
|
* /classes/:className/usernames)在用 —— 旧登录页拿名字拼回「ks + 班号 + 名字」去登录,所以必须严格按这个班的班号剥。
|
||||||
|
* 界面上显示名字一律用契约的 `displayName`。
|
||||||
*
|
*
|
||||||
* 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配,
|
* 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配,
|
||||||
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
|
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
|
||||||
@@ -42,19 +43,15 @@ export function stripClassPrefix(username: string, className: string | null | un
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户名按「班级前缀」匹配:`ks251` 只匹配 251 班,**不能**把 `ks2511张三` 也捞进来。
|
* 搜索框里填的是一个班(`232`,或者老习惯 `ks232`):按 `user.class_name` 精确圈这个班。
|
||||||
|
* 不是这个形状返回 undefined,由调用方退回按用户名包含匹配。
|
||||||
*
|
*
|
||||||
* 班级号 3~4 位并存(见 admin/account.ts 的 classNameOf),裸 `ilike 'ks251%'` 会把
|
* 原来是拿用户名前缀 `ks232` 去 ilike,还得专门挡住 `ks2321…` 被 232 班捞走;
|
||||||
* 2510~2519 班全算进 251 班。所以输入是 `ks` + 3 位以上数字时,要求紧跟着的不是数字;
|
* 2026-11 起新号不带前缀,前缀匹配会把他们全漏掉,所以只认班级这一列
|
||||||
* 1~2 位(`ks25`)是年级前缀,照旧宽松匹配。输入不是 `ks+数字` 形状时返回 undefined,
|
|
||||||
* 由调用方决定退回什么匹配方式。
|
|
||||||
*/
|
*/
|
||||||
export function classPrefixCondition(input: string) {
|
export function classKeywordCondition(input: string) {
|
||||||
const matched = /^ks(\d+)$/i.exec(input)
|
const matched = /^(?:ks)?(\d{3,4})$/i.exec(input)
|
||||||
if (!matched) return undefined
|
return matched ? eq(schema.user.className, matched[1]!) : undefined
|
||||||
const prefix = ilike(schema.user.username, `${input}%`)
|
|
||||||
if (matched[1]!.length < 3) return prefix
|
|
||||||
return and(prefix, sql`${schema.user.username} !~* ${`^${input}[0-9]`}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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'
|
||||||
|
import { getLoginWindow } from '../auth/session'
|
||||||
|
import { db, schema } from '../db'
|
||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
|
||||||
|
* 「这节课是哪几道题」的取法在 services/lesson.ts(课堂任务、后台、统计共用)
|
||||||
|
*/
|
||||||
|
export const homeRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
|
||||||
|
const LAST_VISIT_MAX_DAYS = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
|
||||||
|
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
|
||||||
|
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
|
||||||
|
*/
|
||||||
|
homeRoutes.get('/me/last-visit', requireAuth, async (c) => {
|
||||||
|
const user = c.get('user')!
|
||||||
|
const window = await getLoginWindow(c)
|
||||||
|
const previousLogin = window?.previousLogin ?? null
|
||||||
|
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
|
||||||
|
return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
problemId: schema.submission.problemId,
|
||||||
|
problemDisplayId: schema.problem.displayId,
|
||||||
|
title: schema.problem.title,
|
||||||
|
result: schema.submission.result,
|
||||||
|
createTime: schema.submission.createTime,
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, user.id),
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
gte(schema.submission.createTime, previousLogin),
|
||||||
|
lt(schema.submission.createTime, window.loginAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.submission.createTime))
|
||||||
|
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||||
|
|
||||||
|
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
|
||||||
|
const byProblem = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
problemDisplayId: string
|
||||||
|
title: string
|
||||||
|
attempts: number
|
||||||
|
lastResult: (typeof rows)[number]['result']
|
||||||
|
lastTime: string
|
||||||
|
solved: boolean
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
const solved: { problemDisplayId: string; title: string }[] = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const item = byProblem.get(row.problemId) ?? {
|
||||||
|
problemDisplayId: row.problemDisplayId,
|
||||||
|
title: row.title,
|
||||||
|
attempts: 0,
|
||||||
|
lastResult: row.result,
|
||||||
|
lastTime: row.createTime,
|
||||||
|
solved: false,
|
||||||
|
}
|
||||||
|
item.attempts++
|
||||||
|
item.lastResult = row.result
|
||||||
|
item.lastTime = row.createTime
|
||||||
|
if (!item.solved && accepted.includes(row.result)) {
|
||||||
|
item.solved = true
|
||||||
|
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
|
||||||
|
}
|
||||||
|
byProblem.set(row.problemId, item)
|
||||||
|
}
|
||||||
|
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
|
||||||
|
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
|
||||||
|
const everSolved = tried.length
|
||||||
|
? new Set(
|
||||||
|
(
|
||||||
|
await db
|
||||||
|
.selectDistinct({ problemId: schema.submission.problemId })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, user.id),
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
inArray(schema.submission.problemId, tried),
|
||||||
|
inArray(schema.submission.result, accepted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).map((row) => row.problemId),
|
||||||
|
)
|
||||||
|
: new Set<number>()
|
||||||
|
const unsolved = tried
|
||||||
|
.filter((id) => !everSolved.has(id))
|
||||||
|
.map((id) => byProblem.get(id)!)
|
||||||
|
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
|
||||||
|
problemDisplayId,
|
||||||
|
title,
|
||||||
|
attempts,
|
||||||
|
lastResult,
|
||||||
|
}))
|
||||||
|
return success(c, {
|
||||||
|
previousLogin,
|
||||||
|
summary: {
|
||||||
|
submissionCount: rows.length,
|
||||||
|
solvedCount: solved.length,
|
||||||
|
lastSubmitTime: rows.at(-1)!.createTime,
|
||||||
|
unsolved,
|
||||||
|
solved: solved.slice(0, 12),
|
||||||
|
},
|
||||||
|
} satisfies LastVisit)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
|
||||||
|
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
|
||||||
|
*/
|
||||||
|
homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
|
||||||
|
const user = c.get('user')!
|
||||||
|
if (!user.className) {
|
||||||
|
return success(c, {
|
||||||
|
className: null,
|
||||||
|
day: null,
|
||||||
|
source: null,
|
||||||
|
language: null,
|
||||||
|
mode: null,
|
||||||
|
problems: [],
|
||||||
|
} satisfies ClassActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
|
||||||
|
const ids = lesson.problems.map((problem) => problem.id)
|
||||||
|
const steps = lesson.steps
|
||||||
|
const codeLanguage = steps ? steps.language : lesson.language
|
||||||
|
// 代码提交和流程图提交两张表,互不依赖,一起查
|
||||||
|
const [mine, drawn] = await Promise.all([
|
||||||
|
ids.length
|
||||||
|
? db
|
||||||
|
.select({
|
||||||
|
problemId: schema.submission.problemId,
|
||||||
|
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, user.id),
|
||||||
|
inArray(schema.submission.problemId, ids),
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
// 布置的是 C:以前用 Python 做对过不算这份作业做完
|
||||||
|
lessonLanguageFilter(codeLanguage),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.problemId)
|
||||||
|
: [],
|
||||||
|
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
|
||||||
|
ids.length
|
||||||
|
? db
|
||||||
|
.select({
|
||||||
|
problemId: schema.flowchartSubmission.problemId,
|
||||||
|
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
|
||||||
|
})
|
||||||
|
.from(schema.flowchartSubmission)
|
||||||
|
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
|
||||||
|
.groupBy(schema.flowchartSubmission.problemId)
|
||||||
|
: [],
|
||||||
|
])
|
||||||
|
type Step = 'accepted' | 'tried' | 'none'
|
||||||
|
const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried')
|
||||||
|
const codeById = new Map(mine.map((row) => [row.problemId, row.accepted]))
|
||||||
|
const drawById = new Map(drawn.map((row) => [row.problemId, row.passed]))
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
className: user.className,
|
||||||
|
day: lesson.day,
|
||||||
|
source: lesson.source,
|
||||||
|
language: codeLanguage,
|
||||||
|
mode: lesson.mode,
|
||||||
|
problems: lesson.problems.map((problem) => {
|
||||||
|
const code = stepOf(codeById.get(problem.id))
|
||||||
|
const draw = stepOf(drawById.get(problem.id))
|
||||||
|
const needs = steps?.needsOf(problem.id)
|
||||||
|
let myStatus: Step
|
||||||
|
if (needs) {
|
||||||
|
// 老师布置的:要哪几步就看哪几步,全做到才算做完
|
||||||
|
const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null)
|
||||||
|
myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none'
|
||||||
|
} else {
|
||||||
|
// 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」)
|
||||||
|
myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
problemDisplayId: problem.displayId,
|
||||||
|
title: problem.title,
|
||||||
|
userCount: problem.userCount,
|
||||||
|
acceptedCount: problem.acceptedCount,
|
||||||
|
myStatus,
|
||||||
|
draw: needs?.draw ? draw : null,
|
||||||
|
code: needs?.code ? code : null,
|
||||||
|
} satisfies ClassActivityProblem
|
||||||
|
}),
|
||||||
|
} satisfies ClassActivity)
|
||||||
|
})
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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 { JudgeStatus } from '../judge/status'
|
||||||
|
import { calendarDay, dayNumber, 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 MEMORY_DAYS = 7
|
||||||
|
|
||||||
|
/** 几个东八区日历日以前;不在 MEMORY_DAYS 以内、或之后已经说过那句了,就是 null */
|
||||||
|
function daysAgo(at: string | null, saidAt: string | null) {
|
||||||
|
// 按时刻比,别按字符串:库里出来的带微秒、位数不一定一样
|
||||||
|
if (!at || (saidAt && Date.parse(saidAt) > Date.parse(at))) return null
|
||||||
|
const days = dayNumber(calendarDay()) - dayNumber(calendarDay(at))
|
||||||
|
return days >= 1 && days <= MEMORY_DAYS ? days : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跨天记得的事(契约 mascotProfileSchema.memory),一条语句查完 */
|
||||||
|
async function memoryOf(userId: number) {
|
||||||
|
const today = dayStart()
|
||||||
|
const since = new Date(Date.parse(today) - MEMORY_DAYS * 86_400_000).toISOString()
|
||||||
|
const e = schema.mascotEvent
|
||||||
|
/** 7 天内最后一次。`beforeToday`:今天的事不算「记得」,只取今天以前的 */
|
||||||
|
const last = (kind: string, situation: string | null, beforeToday: boolean) => sql`(
|
||||||
|
select max(${e.createdAt}) from ${e}
|
||||||
|
where ${e.userId} = ${userId} and ${e.kind} = ${kind}
|
||||||
|
${situation ? sql`and ${e.situation} = ${situation}` : sql``}
|
||||||
|
and ${e.createdAt} >= ${since}
|
||||||
|
${beforeToday ? sql`and ${e.createdAt} < ${today}` : sql``}
|
||||||
|
)`
|
||||||
|
const s = schema.submission
|
||||||
|
const [row] = await db.execute<{
|
||||||
|
sulk_at: string | null
|
||||||
|
grudge_at: string | null
|
||||||
|
pat_at: string | null
|
||||||
|
pat_said_at: string | null
|
||||||
|
closer_at: string | null
|
||||||
|
solved_before: number
|
||||||
|
}>(sql`
|
||||||
|
select
|
||||||
|
${last('open', 'sulk', true)} as sulk_at,
|
||||||
|
${last('open', 'memory.grudge', false)} as grudge_at,
|
||||||
|
${last('pat', null, true)} as pat_at,
|
||||||
|
${last('open', 'memory.pat', false)} as pat_said_at,
|
||||||
|
${last('open', 'memory.closer', false)} as closer_at,
|
||||||
|
(
|
||||||
|
select count(distinct ${s.problemId})::int from ${s}
|
||||||
|
where ${s.userId} = ${userId} and ${s.result} in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED})
|
||||||
|
and ${s.createTime} < ${today}
|
||||||
|
) as solved_before
|
||||||
|
`)
|
||||||
|
return {
|
||||||
|
grudgeDaysAgo: daysAgo(row?.sulk_at ?? null, row?.grudge_at ?? null),
|
||||||
|
patDaysAgo: daysAgo(row?.pat_at ?? null, row?.pat_said_at ?? null),
|
||||||
|
solvedBeforeToday: row?.solved_before ?? 0,
|
||||||
|
closerSaidToday: !!row?.closer_at && Date.parse(row.closer_at) >= Date.parse(today),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近多少次题库提交拿来看学生常犯哪类错 */
|
||||||
|
const RECENT_SUBMISSIONS = 20
|
||||||
|
|
||||||
|
/** 看板娘按学生的行为挑话用的那几个数(契约 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, solved, memory, 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'))),
|
||||||
|
// 成就每次判完都重数一遍存进 user_stat,直接读,不再按提交现数
|
||||||
|
db
|
||||||
|
.select({ value: sql<number>`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` })
|
||||||
|
.from(schema.userStat)
|
||||||
|
.where(eq(schema.userStat.userId, userId)),
|
||||||
|
memoryOf(userId),
|
||||||
|
db
|
||||||
|
.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,
|
||||||
|
solved: solved[0]?.value ?? 0,
|
||||||
|
memory,
|
||||||
|
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 ProblemListItem,
|
||||||
type ProblemProgress,
|
type ProblemProgress,
|
||||||
type ProblemTypeFilter,
|
type ProblemTypeFilter,
|
||||||
|
type SimilarProblem,
|
||||||
type Tag,
|
type Tag,
|
||||||
} from '@oj2/contract'
|
} from '@oj2/contract'
|
||||||
import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
|
import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
|
||||||
@@ -73,7 +74,7 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
|
* 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
|
||||||
* 学生的题目列表不带这个参数
|
* 学生的题目列表不带这个参数
|
||||||
*/
|
*/
|
||||||
function lessonLanguageFilter(raw: string | undefined) {
|
function lessonLanguageFilter(raw: string | undefined) {
|
||||||
@@ -327,13 +328,25 @@ problemRoutes.get('/challenges', requireAuth, async (c) => {
|
|||||||
|
|
||||||
problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
|
problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
|
||||||
const [target] = await db
|
const [target] = await db
|
||||||
.select({ id: schema.problem.id })
|
.select({ id: schema.problem.id, difficulty: schema.problem.difficulty })
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId)))
|
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found')
|
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))
|
// 知识点和主题标签一样算(用户 2026-11-03 定:同一套题材也算相似)。知识点排在前面
|
||||||
if (targetTags.length === 0) return success(c, [])
|
// 只是为了「共同的标签」那行先写练的是什么
|
||||||
|
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) 再在内存里筛,刷题多的
|
// 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的
|
||||||
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
|
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
|
||||||
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
|
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
|
||||||
@@ -343,7 +356,12 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
|
|||||||
const solvedIds = states ? [...states.solved] : []
|
const solvedIds = states ? [...states.solved] : []
|
||||||
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
|
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
|
||||||
// High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。
|
// 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 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 sharedTags = count(schema.problemTags.problemtagId)
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -376,13 +394,16 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
|
|||||||
)
|
)
|
||||||
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
|
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
|
||||||
// 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条
|
// 末尾的 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)
|
.limit(5)
|
||||||
const ids = rows.map((row) => row.problem.id)
|
const ids = rows.map((row) => row.problem.id)
|
||||||
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
|
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
|
||||||
return success(
|
return success(
|
||||||
c,
|
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[],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
|||||||
|
|
||||||
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||||
|
|
||||||
/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */
|
/** 必做题的 problemId;一道必做都没标的专题退回全部(和 computeProgress 同一个口径) */
|
||||||
function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
|
function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
|
||||||
const required = links.filter((link) => link.isRequired)
|
const required = links.filter((link) => link.isRequired)
|
||||||
return new Set((required.length ? required : links).map((link) => link.problemId))
|
return new Set((required.length ? required : links).map((link) => link.problemId))
|
||||||
@@ -56,7 +56,7 @@ function progressSummary(progress: ProgressRow | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
|
* 学生能看到的专题:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
|
||||||
* 看到的样子、看全班情况,不该被 404 挡在外面。
|
* 看到的样子、看全班情况,不该被 404 挡在外面。
|
||||||
*/
|
*/
|
||||||
async function loadVisible(id: number, user: AuthUser | null | undefined) {
|
async function loadVisible(id: number, user: AuthUser | null | undefined) {
|
||||||
@@ -99,7 +99,7 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
|
* 一次把整页专题的附属数据全查回来,再在内存里按 problemsetId 分组。
|
||||||
* 固定 6 条查询,与行数无关(按行查就是 N+1)。
|
* 固定 6 条查询,与行数无关(按行查就是 N+1)。
|
||||||
*/
|
*/
|
||||||
async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) {
|
async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) {
|
||||||
@@ -153,7 +153,7 @@ async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boole
|
|||||||
assignedUntil: row.assignedUntil,
|
assignedUntil: row.assignedUntil,
|
||||||
assigning,
|
assigning,
|
||||||
problemsCount: countBySet.get(row.id)?.value ?? 0,
|
problemsCount: countBySet.get(row.id)?.value ?? 0,
|
||||||
// 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径
|
// 一道必做都没标的专题退回「全部都算必做」,和 computeProgress 同一个口径
|
||||||
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
|
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
|
||||||
joinedCount: joinedBySet.get(row.id) ?? 0,
|
joinedCount: joinedBySet.get(row.id) ?? 0,
|
||||||
userProgress: progressSummary(progress),
|
userProgress: progressSummary(progress),
|
||||||
@@ -207,7 +207,7 @@ problemsetRoutes.get('/problem-sets/locks', requireAuth, async (c) => {
|
|||||||
|
|
||||||
problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
|
problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
|
||||||
const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
||||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const [data] = await serializeProblemSets([row], c.get('user')?.id)
|
const [data] = await serializeProblemSets([row], c.get('user')?.id)
|
||||||
return success(c, data)
|
return success(c, data)
|
||||||
})
|
})
|
||||||
@@ -215,10 +215,10 @@ problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
|
|||||||
problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => {
|
problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => {
|
||||||
const user = c.get('user')
|
const user = c.get('user')
|
||||||
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user)
|
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user)
|
||||||
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const id = found.set.id
|
const id = found.set.id
|
||||||
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
|
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
|
||||||
// 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
|
// 编号的(1、2、3),老专题里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
link: schema.problemsetProblem,
|
link: schema.problemsetProblem,
|
||||||
@@ -295,14 +295,14 @@ problemsetRoutes.post('/problem-set-progress', requireAuth, async (c) => {
|
|||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const user = c.get('user')!
|
const user = c.get('user')!
|
||||||
const found = await loadVisible(parsed.data.problemSetId, user)
|
const found = await loadVisible(parsed.data.problemSetId, user)
|
||||||
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const problemSet = found.set
|
const problemSet = found.set
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: schema.problemsetProgress.id })
|
.select({ id: schema.problemsetProgress.id })
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProgress)
|
||||||
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id)))
|
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (existing) return failure(c, 409, 'already-joined', '已经加入该题单')
|
if (existing) return failure(c, 409, 'already-joined', '已经加入该专题')
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const links = await tx
|
const links = await tx
|
||||||
.select({
|
.select({
|
||||||
@@ -359,9 +359,9 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
* 老师看一个班在这个专题里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
||||||
*
|
*
|
||||||
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
|
* 所有老师都能看,不只是专题的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
|
||||||
* 也是任何老师看任何班,这里和它们一致。
|
* 也是任何老师看任何班,这里和它们一致。
|
||||||
*
|
*
|
||||||
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
|
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
|
||||||
@@ -370,7 +370,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
|
|||||||
*/
|
*/
|
||||||
problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => {
|
problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => {
|
||||||
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
||||||
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||||
const id = found.set.id
|
const id = found.set.id
|
||||||
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
|
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
|
||||||
const [links, joinedClasses] = await Promise.all([
|
const [links, joinedClasses] = await Promise.all([
|
||||||
|
|||||||
@@ -252,14 +252,7 @@ rankingRoutes.get('/rankings/champions', optionalAuth, async (c) => {
|
|||||||
const champions: WeeklyChampion[] = []
|
const champions: WeeklyChampion[] = []
|
||||||
for (let k = 1; k <= CHAMPION_WEEKS; k++) {
|
for (let k = 1; k <= CHAMPION_WEEKS; k++) {
|
||||||
const from = thisWeek - k * WEEK_MS
|
const from = thisWeek - k * WEEK_MS
|
||||||
const inWeek = entrants.map((entrant) => {
|
const inWeek = entrants.map((entrant) => ({ ...entrant, times: entrant.times.filter((time) => time >= from && time < from + WEEK_MS) }))
|
||||||
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 [top] = standings(inWeek, null, false)
|
const [top] = standings(inWeek, null, false)
|
||||||
if (!top) continue
|
if (!top) continue
|
||||||
champions.push({
|
champions.push({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Quote, WebsiteConfig } from '@oj2/contract'
|
import { CLASS_NAME_RE, displayName, type ClassMember, type Quote, type WebsiteConfig } from '@oj2/contract'
|
||||||
import { asc, desc, eq } from 'drizzle-orm'
|
import { asc, desc, eq } from 'drizzle-orm'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
@@ -92,9 +92,33 @@ siteRoutes.get('/quotes/random', async (c) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
siteRoutes.get('/classes/:className/usernames', async (c) => {
|
/**
|
||||||
|
* 登录框点名字的名单:名字给人点,用户名拿去登录(老号 ks232张三,新号就是名字)。名字按界面上的显示规则剥,
|
||||||
|
* 和 UserName 一致 —— 不能只剥「ks + 当前班号」:班级现在能在后台改,换过班的老号会露出整串
|
||||||
|
*/
|
||||||
|
siteRoutes.get('/classes/:className/members', async (c) => {
|
||||||
const className = c.req.param('className').trim()
|
const className = c.req.param('className').trim()
|
||||||
if (!/^\d{3,4}$/.test(className)) {
|
if (!CLASS_NAME_RE.test(className)) {
|
||||||
|
return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits')
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select({ username: schema.user.username })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(eq(schema.user.className, className))
|
||||||
|
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||||
|
return success(
|
||||||
|
c,
|
||||||
|
rows.map(({ username }) => ({ name: displayName(username), username }) satisfies ClassMember),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧版登录框的名单:只回名字,前端拼「ks + 班号 + 名字」去登录。2026-11 的新前端改走上面的 /members;
|
||||||
|
* 这条原样留着,是给上线时还开着旧登录页、没刷新的机房电脑用的(老号照样登得上)。过一阵可以删
|
||||||
|
*/
|
||||||
|
siteRoutes.get('/classes/:className/usernames', async (c) => {
|
||||||
|
const className = c.req.param('className').trim()
|
||||||
|
if (!CLASS_NAME_RE.test(className)) {
|
||||||
return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits')
|
return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits')
|
||||||
}
|
}
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -102,7 +126,6 @@ siteRoutes.get('/classes/:className/usernames', async (c) => {
|
|||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
.where(eq(schema.user.className, className))
|
.where(eq(schema.user.className, className))
|
||||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||||
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
|
|
||||||
return success(
|
return success(
|
||||||
c,
|
c,
|
||||||
rows.map(({ username }) => stripClassPrefix(username, className)),
|
rows.map(({ username }) => stripClassPrefix(username, className)),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
displayName,
|
||||||
FLOWCHART_PASS_GRADES,
|
FLOWCHART_PASS_GRADES,
|
||||||
NO_CLASS,
|
NO_CLASS,
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
@@ -27,7 +28,7 @@ import { type ContestEnv } from '../services/contest'
|
|||||||
import { getBooleanOption } from '../services/options'
|
import { getBooleanOption } from '../services/options'
|
||||||
import { lessonDoneCounts } from '../services/lesson'
|
import { lessonDoneCounts } from '../services/lesson'
|
||||||
import { calendarDay, dayStart, localTime } from '../time'
|
import { calendarDay, dayStart, localTime } from '../time'
|
||||||
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from './helpers'
|
import { isAdminRole, parseDisplayIds, rounded, scopedUsers } from './helpers'
|
||||||
|
|
||||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||||
|
|
||||||
@@ -345,7 +346,7 @@ submissionStatisticsRoutes.get('/submissions/statistics', requireTeacher, async
|
|||||||
.filter((row) => !submittedUserIds.has(row.id))
|
.filter((row) => !submittedUserIds.has(row.id))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
realName: stripClassPrefix(row.username, row.className),
|
realName: displayName(row.username),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -374,7 +375,7 @@ submissionStatisticsRoutes.get('/submissions/statistics', requireTeacher, async
|
|||||||
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
|
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
|
||||||
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
|
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
|
||||||
*/
|
*/
|
||||||
realName: scoped ? stripClassPrefix(row.username, row.className) : row.username,
|
realName: scoped ? displayName(row.username) : row.username,
|
||||||
submissionCount: row.submissionCount,
|
submissionCount: row.submissionCount,
|
||||||
solvedCount: row.solvedCount,
|
solvedCount: row.solvedCount,
|
||||||
}))
|
}))
|
||||||
@@ -403,7 +404,7 @@ const GRID_LIMIT = 5000
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
|
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
|
||||||
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。
|
* 班级和学生名的匹配和统计接口同一套(scopedUsers:班级精确、名字包含)。
|
||||||
*/
|
*/
|
||||||
submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, async (c) => {
|
submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, async (c) => {
|
||||||
const scope = await statisticsScope(c)
|
const scope = await statisticsScope(c)
|
||||||
@@ -481,7 +482,7 @@ submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, a
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = {
|
entry = {
|
||||||
username: row.username,
|
username: row.username,
|
||||||
realName: stripClassPrefix(row.username, row.className),
|
realName: displayName(row.username),
|
||||||
className: row.className,
|
className: row.className,
|
||||||
submissions: [],
|
submissions: [],
|
||||||
}
|
}
|
||||||
@@ -526,10 +527,15 @@ const LESSON_LIVE_MINUTES = 15
|
|||||||
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
|
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
|
||||||
const LESSONS_DEFAULT = 50
|
const LESSONS_DEFAULT = 50
|
||||||
const LESSONS_MAX = 500
|
const LESSONS_MAX = 500
|
||||||
|
/** 课只算学生(STUDENT_ROLES,学生管理员也算),老师试题的提交不算 */
|
||||||
|
const studentRoles = sql.join(
|
||||||
|
STUDENT_ROLES.map((role) => sql`${role}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
|
||||||
type StatisticsRange = { start: string | null; end: string }
|
type StatisticsRange = { start: string | null; end: string }
|
||||||
|
|
||||||
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */
|
/** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */
|
||||||
async function lessonPlans(range: StatisticsRange) {
|
async function lessonPlans(range: StatisticsRange) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
@@ -598,7 +604,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
|
|||||||
where s.contest_id is null and p.mode <> 'flowchart'
|
where s.contest_id is null and p.mode <> 'flowchart'
|
||||||
and s.create_time <= ${range.end}
|
and s.create_time <= ${range.end}
|
||||||
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
|
${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
|
union all
|
||||||
select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join(
|
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}`),
|
FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`),
|
||||||
@@ -611,7 +617,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
|
|||||||
where p.mode <> 'code'
|
where p.mode <> 'code'
|
||||||
and f.create_time <= ${range.end}
|
and f.create_time <= ${range.end}
|
||||||
${range.start ? sql`and f.create_time >= ${range.start}` : sql``}
|
${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,
|
select k, problem_id,
|
||||||
count(distinct user_id)::int as users,
|
count(distinct user_id)::int as users,
|
||||||
@@ -720,8 +726,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
|
|||||||
where s.contest_id is null
|
where s.contest_id is null
|
||||||
and s.create_time <= ${range.end}
|
and s.create_time <= ${range.end}
|
||||||
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
|
${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 (
|
g as (
|
||||||
select *,
|
select *,
|
||||||
|
|||||||
@@ -101,10 +101,10 @@ submissionRoutes.post('/submissions', requireAuth, async (c) => {
|
|||||||
return failure(c, 400, 'language-not-allowed', `${parsed.data.language} is not allowed in the problem`)
|
return failure(c, 400, 'language-not-allowed', `${parsed.data.language} is not allowed in the problem`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 来源题单:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了
|
// 来源专题:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了
|
||||||
// 在提交列表里标出「这条是刷题单刷出来的」。校验只确认这道题确实在那个题单里 ——
|
// 在提交列表里标出「这条是刷专题刷出来的」。校验只确认这道题确实在那个专题里 ——
|
||||||
// 不查 visible / status,因为藏起来的题单里还困着已加入的学生(他们照样在做题),
|
// 不查 visible / status,因为藏起来的专题里还困着已加入的学生(他们照样在做题),
|
||||||
// 也不查有没有加入:没加入照样能从题单页点进题目,标记来源不该比入口本身更严。
|
// 也不查有没有加入:没加入照样能从专题页点进题目,标记来源不该比入口本身更严。
|
||||||
// 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。
|
// 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。
|
||||||
let problemsetId: number | null = null
|
let problemsetId: number | null = null
|
||||||
if (contestId === null && parsed.data.problemSetId) {
|
if (contestId === null && parsed.data.problemSetId) {
|
||||||
@@ -241,7 +241,7 @@ function canViewSubmission(
|
|||||||
problemSetCutoffs?: Map<number, string>,
|
problemSetCutoffs?: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
// 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
|
// 专题防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
|
||||||
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
||||||
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
||||||
const cutoff = problemSetCutoffs?.get(row.problemId)
|
const cutoff = problemSetCutoffs?.get(row.problemId)
|
||||||
@@ -273,7 +273,7 @@ const submissionListColumns = {
|
|||||||
id: schema.submission.id,
|
id: schema.submission.id,
|
||||||
createTime: schema.submission.createTime,
|
createTime: schema.submission.createTime,
|
||||||
userId: schema.submission.userId,
|
userId: schema.submission.userId,
|
||||||
// 题单闸门要按题定位,序列化本身用不到它
|
// 专题闸门要按题定位,序列化本身用不到它
|
||||||
problemId: schema.submission.problemId,
|
problemId: schema.submission.problemId,
|
||||||
/**
|
/**
|
||||||
* 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的
|
* 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的
|
||||||
@@ -287,8 +287,8 @@ const submissionListColumns = {
|
|||||||
result: schema.submission.result,
|
result: schema.submission.result,
|
||||||
language: schema.submission.language,
|
language: schema.submission.language,
|
||||||
statisticInfo: schema.submission.statisticInfo,
|
statisticInfo: schema.submission.statisticInfo,
|
||||||
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
// 只取 id,专题标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
||||||
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜
|
// 会动到下面那条调过的分页查询,而每页最多两三个不同的专题,PK 查一次更便宜
|
||||||
problemsetId: schema.submission.problemsetId,
|
problemsetId: schema.submission.problemsetId,
|
||||||
/**
|
/**
|
||||||
* 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。
|
* 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。
|
||||||
@@ -331,7 +331,7 @@ function listCaseSummary(row: { language: string; casesTotal: number | null; cas
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的
|
* 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的
|
||||||
* 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。
|
* 代码在专题布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。
|
||||||
*/
|
*/
|
||||||
async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) {
|
async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -355,7 +355,7 @@ async function submissionDetail(id: string, user: AuthUser, withAttempts: boolea
|
|||||||
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
||||||
const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId])
|
const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId])
|
||||||
const visible = canViewSubmission(user, row.submission, row.problem, cutoffs)
|
const visible = canViewSubmission(user, row.submission, row.problem, cutoffs)
|
||||||
// 走到这里还看不了、又是自己的,只剩题单布置期这一种(见 canViewSubmission)
|
// 走到这里还看不了、又是自己的,只剩专题布置期这一种(见 canViewSubmission)
|
||||||
if (!visible && !own) return { status: 'forbidden' } as const
|
if (!visible && !own) return { status: 'forbidden' } as const
|
||||||
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
||||||
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
||||||
@@ -489,8 +489,8 @@ async function paginateSubmissionRows(where: SQL | undefined, limit: number, off
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这一页里出现过的来源题单,id → 标题。传进来的数组允许带 null 和重复值。
|
* 这一页里出现过的来源专题,id → 标题。传进来的数组允许带 null 和重复值。
|
||||||
* 一页最多 250 行、实际能落到的题单数是个位数,按主键 IN 查一次就完了。
|
* 一页最多 250 行、实际能落到的专题数是个位数,按主键 IN 查一次就完了。
|
||||||
*/
|
*/
|
||||||
async function problemsetTitleMap(ids: Array<number | null>) {
|
async function problemsetTitleMap(ids: Array<number | null>) {
|
||||||
const unique = [...new Set(ids.filter((id): id is number => id !== null))]
|
const unique = [...new Set(ids.filter((id): id is number => id !== null))]
|
||||||
@@ -545,7 +545,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => {
|
|||||||
user && !isTeacherOrAbove(user)
|
user && !isTeacherOrAbove(user)
|
||||||
? problemSetLockCutoffs(user.id, [...new Set(rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId))])
|
? problemSetLockCutoffs(user.id, [...new Set(rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId))])
|
||||||
: undefined,
|
: undefined,
|
||||||
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够
|
// 来源专题的标题。一页里不同专题最多几个,按主键查一次就够
|
||||||
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
|
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
|
||||||
])
|
])
|
||||||
return success(c, {
|
return success(c, {
|
||||||
@@ -563,7 +563,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => {
|
|||||||
result: submission.result,
|
result: submission.result,
|
||||||
language: submission.language,
|
language: submission.language,
|
||||||
statisticInfo: asRecord(submission.statisticInfo),
|
statisticInfo: asRecord(submission.statisticInfo),
|
||||||
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
|
// 专题被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
|
||||||
problemSet:
|
problemSet:
|
||||||
submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
|
submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
|
||||||
? {
|
? {
|
||||||
@@ -609,7 +609,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
|
|||||||
.offset(offset),
|
.offset(offset),
|
||||||
displayId ? unknownDisplayIds(displayId, contest.id) : [],
|
displayId ? unknownDisplayIds(displayId, contest.id) : [],
|
||||||
])
|
])
|
||||||
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
|
// 这里不挂专题防作弊闸门(对比公开列表):专题里的题必定是非比赛题——加题时卡了
|
||||||
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
|
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
|
||||||
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
|
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
|
||||||
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
|
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
|
||||||
@@ -628,7 +628,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
|
|||||||
result: submission.result,
|
result: submission.result,
|
||||||
language: submission.language,
|
language: submission.language,
|
||||||
statisticInfo: asRecord(submission.statisticInfo),
|
statisticInfo: asRecord(submission.statisticInfo),
|
||||||
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
|
// 比赛提交没有来源专题:专题只收非比赛题(admin/problemset.ts 加题时卡了
|
||||||
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
|
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
|
||||||
problemSet: null,
|
problemSet: null,
|
||||||
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
|
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
|
||||||
|
|||||||
@@ -1,440 +1,39 @@
|
|||||||
import {
|
import {
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
classLessonRequestSchema,
|
classLessonRequestSchema,
|
||||||
type ClassActivity,
|
|
||||||
type ClassToday,
|
type ClassToday,
|
||||||
type ClassBoard,
|
type TaskBoard,
|
||||||
type ClassBoardStudent,
|
type TaskBoardStudent,
|
||||||
type ClassLesson,
|
type ClassLesson,
|
||||||
type ClassActivityProblem,
|
|
||||||
type LastVisit,
|
|
||||||
type LessonLanguage,
|
|
||||||
type LessonMode,
|
|
||||||
lessonNeeds,
|
|
||||||
type StatisticInfo,
|
type StatisticInfo,
|
||||||
} from '@oj2/contract'
|
} from '@oj2/contract'
|
||||||
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm'
|
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import { requireAuth, requireTeacher, type AppEnv } from '../auth/middleware'
|
import { requireTeacher, type AppEnv } from '../auth/middleware'
|
||||||
import { seenToday } from '../auth/presence'
|
import { seenToday } from '../auth/presence'
|
||||||
import { getLoginWindow } from '../auth/session'
|
|
||||||
import { presenceOf } from '../collab/state'
|
import { presenceOf } from '../collab/state'
|
||||||
import { db, schema } from '../db'
|
import { db, schema } from '../db'
|
||||||
import { challengeSuccessesOf } from '../services/challenge'
|
import { challengeSuccessesOf } from '../services/challenge'
|
||||||
import { failure, parseBody, success } from '../http'
|
import { failure, parseBody, success } from '../http'
|
||||||
import { isAccepted } from '../judge/status'
|
import { isAccepted } from '../judge/status'
|
||||||
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
|
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
|
||||||
import { accepted } from '../services/learning-stats'
|
import {
|
||||||
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
|
announceLessonChange,
|
||||||
|
CLASS_ACTIVITY_MIN_USERS,
|
||||||
|
classDayGroups,
|
||||||
|
classLessonProblems,
|
||||||
|
lessonDoneCounts,
|
||||||
|
resolveLessonProblems,
|
||||||
|
todayLessonSnapshot,
|
||||||
|
} from '../services/lesson'
|
||||||
import { calendarDay, dayStart, localTime } from '../time'
|
import { calendarDay, dayStart, localTime } from '../time'
|
||||||
|
|
||||||
export const classroomRoutes = new Hono<AppEnv>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
|
* 课堂任务(老师):布置这节课的题、看全班做到哪、老师首页的「今天的课」。
|
||||||
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
|
* 「这节课是哪几道题」的取法在 services/lesson.ts,学生首页的「班里在做」也用它(routes/home.ts)
|
||||||
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
|
|
||||||
*/
|
*/
|
||||||
const CLASS_ACTIVITY_MIN_USERS = 5
|
export const taskRoutes = new Hono<AppEnv>()
|
||||||
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
|
|
||||||
const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
|
|
||||||
const CLASS_ACTIVITY_LIMIT = 8
|
|
||||||
interface ClassDayGroup {
|
|
||||||
day: string
|
|
||||||
problemId: number
|
|
||||||
firstAt: string
|
|
||||||
userCount: number
|
|
||||||
acceptedCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
|
|
||||||
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
|
|
||||||
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
|
|
||||||
*
|
|
||||||
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
|
|
||||||
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
|
|
||||||
*/
|
|
||||||
async function classDayGroups(
|
|
||||||
className: string,
|
|
||||||
since: string,
|
|
||||||
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {},
|
|
||||||
): Promise<ClassDayGroup[]> {
|
|
||||||
const { mode } = options
|
|
||||||
const activity = activityRows({
|
|
||||||
since,
|
|
||||||
users: classMemberIds(className),
|
|
||||||
problemIds: options.problemIds,
|
|
||||||
language: options.language,
|
|
||||||
only: mode === 'code' || mode === 'flowchart' ? mode : undefined,
|
|
||||||
})
|
|
||||||
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
|
|
||||||
const perUser = db
|
|
||||||
.select({
|
|
||||||
day: day.as('day'),
|
|
||||||
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
|
|
||||||
userId: sql<number>`${activity.userId}`.as('user_id'),
|
|
||||||
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
|
|
||||||
solved: (mode === 'both'
|
|
||||||
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
|
|
||||||
: sql<boolean>`bool_or(${activity.solved})`
|
|
||||||
).as('solved'),
|
|
||||||
})
|
|
||||||
.from(activity)
|
|
||||||
.groupBy(day, activity.problemId, activity.userId)
|
|
||||||
.as('per_user')
|
|
||||||
const userCount = sql<number>`count(*)::int`
|
|
||||||
const query = db
|
|
||||||
.select({
|
|
||||||
day: perUser.day,
|
|
||||||
problemId: perUser.problemId,
|
|
||||||
firstAt: sql<string>`min(${perUser.firstAt})`,
|
|
||||||
userCount,
|
|
||||||
acceptedCount: sql<number>`(count(*) filter (where ${perUser.solved}))::int`,
|
|
||||||
})
|
|
||||||
.from(perUser)
|
|
||||||
.groupBy(perUser.day, perUser.problemId)
|
|
||||||
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
|
|
||||||
async function visibleProblems(ids: number[]) {
|
|
||||||
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: schema.problem.id,
|
|
||||||
displayId: schema.problem.displayId,
|
|
||||||
title: schema.problem.title,
|
|
||||||
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
|
|
||||||
})
|
|
||||||
.from(schema.problem)
|
|
||||||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
|
||||||
return new Map(rows.map((row) => [row.id, row]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
|
|
||||||
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
|
|
||||||
*/
|
|
||||||
async function lessonPlan(className: string, day: string) {
|
|
||||||
const [row] = await db
|
|
||||||
.select({
|
|
||||||
id: schema.classLesson.id,
|
|
||||||
problemIds: schema.classLesson.problemIds,
|
|
||||||
language: schema.classLesson.language,
|
|
||||||
mode: schema.classLesson.mode,
|
|
||||||
createdAt: schema.classLesson.createdAt,
|
|
||||||
createdBy: sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`,
|
|
||||||
})
|
|
||||||
.from(schema.classLesson)
|
|
||||||
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
|
||||||
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
|
|
||||||
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
|
||||||
.limit(1)
|
|
||||||
return row?.problemIds.length ? row : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 班里「这节课」的题。老师在课堂看板布置过就用老师的(source = teacher),
|
|
||||||
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
|
|
||||||
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
|
|
||||||
*
|
|
||||||
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
|
|
||||||
* 学生最需要它的时候。
|
|
||||||
*/
|
|
||||||
async function classLessonProblems(className: string, lookbackDays: number) {
|
|
||||||
const today = calendarDay()
|
|
||||||
const plan = await lessonPlan(className, today)
|
|
||||||
if (plan) {
|
|
||||||
const planned = plan.problemIds
|
|
||||||
const [problems, groups] = await Promise.all([
|
|
||||||
visibleProblems(planned),
|
|
||||||
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
|
|
||||||
])
|
|
||||||
const groupById = new Map(groups.map((row) => [row.problemId, row]))
|
|
||||||
return {
|
|
||||||
source: 'teacher' as const,
|
|
||||||
lessonId: plan.id,
|
|
||||||
day: today,
|
|
||||||
language: plan.language ?? null,
|
|
||||||
mode: plan.mode,
|
|
||||||
assignedAt: plan.createdAt,
|
|
||||||
assignedBy: plan.createdBy,
|
|
||||||
problems: planned.flatMap((id) => {
|
|
||||||
const problem = problems.get(id)
|
|
||||||
if (!problem) return []
|
|
||||||
const group = groupById.get(id)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...problem,
|
|
||||||
userCount: group?.userCount ?? 0,
|
|
||||||
acceptedCount: group?.acceptedCount ?? 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
|
|
||||||
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
|
|
||||||
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
|
|
||||||
const picked = groups
|
|
||||||
.filter((row) => row.day === latest)
|
|
||||||
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
|
|
||||||
.slice(0, CLASS_ACTIVITY_LIMIT)
|
|
||||||
const problems = await visibleProblems(picked.map((row) => row.problemId))
|
|
||||||
return {
|
|
||||||
source: picked.length ? ('inferred' as const) : null,
|
|
||||||
lessonId: null,
|
|
||||||
day: picked.length ? latest : null,
|
|
||||||
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
|
|
||||||
language: null,
|
|
||||||
mode: null,
|
|
||||||
assignedAt: null,
|
|
||||||
assignedBy: null,
|
|
||||||
problems: picked.flatMap((row) => {
|
|
||||||
const problem = problems.get(row.problemId)
|
|
||||||
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
|
|
||||||
const LAST_VISIT_MAX_DAYS = 30
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
|
|
||||||
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
|
|
||||||
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
|
|
||||||
*/
|
|
||||||
classroomRoutes.get('/me/last-visit', requireAuth, async (c) => {
|
|
||||||
const user = c.get('user')!
|
|
||||||
const window = await getLoginWindow(c)
|
|
||||||
const previousLogin = window?.previousLogin ?? null
|
|
||||||
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
|
|
||||||
return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
|
||||||
}
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
problemId: schema.submission.problemId,
|
|
||||||
problemDisplayId: schema.problem.displayId,
|
|
||||||
title: schema.problem.title,
|
|
||||||
result: schema.submission.result,
|
|
||||||
createTime: schema.submission.createTime,
|
|
||||||
})
|
|
||||||
.from(schema.submission)
|
|
||||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.submission.userId, user.id),
|
|
||||||
isNull(schema.submission.contestId),
|
|
||||||
gte(schema.submission.createTime, previousLogin),
|
|
||||||
lt(schema.submission.createTime, window.loginAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(schema.submission.createTime))
|
|
||||||
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
|
||||||
|
|
||||||
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
|
|
||||||
const byProblem = new Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
problemDisplayId: string
|
|
||||||
title: string
|
|
||||||
attempts: number
|
|
||||||
lastResult: (typeof rows)[number]['result']
|
|
||||||
lastTime: string
|
|
||||||
solved: boolean
|
|
||||||
}
|
|
||||||
>()
|
|
||||||
const solved: { problemDisplayId: string; title: string }[] = []
|
|
||||||
for (const row of rows) {
|
|
||||||
const item = byProblem.get(row.problemId) ?? {
|
|
||||||
problemDisplayId: row.problemDisplayId,
|
|
||||||
title: row.title,
|
|
||||||
attempts: 0,
|
|
||||||
lastResult: row.result,
|
|
||||||
lastTime: row.createTime,
|
|
||||||
solved: false,
|
|
||||||
}
|
|
||||||
item.attempts++
|
|
||||||
item.lastResult = row.result
|
|
||||||
item.lastTime = row.createTime
|
|
||||||
if (!item.solved && accepted.includes(row.result)) {
|
|
||||||
item.solved = true
|
|
||||||
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
|
|
||||||
}
|
|
||||||
byProblem.set(row.problemId, item)
|
|
||||||
}
|
|
||||||
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
|
|
||||||
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
|
|
||||||
const everSolved = tried.length
|
|
||||||
? new Set(
|
|
||||||
(
|
|
||||||
await db
|
|
||||||
.selectDistinct({ problemId: schema.submission.problemId })
|
|
||||||
.from(schema.submission)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.submission.userId, user.id),
|
|
||||||
isNull(schema.submission.contestId),
|
|
||||||
inArray(schema.submission.problemId, tried),
|
|
||||||
inArray(schema.submission.result, accepted),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).map((row) => row.problemId),
|
|
||||||
)
|
|
||||||
: new Set<number>()
|
|
||||||
const unsolved = tried
|
|
||||||
.filter((id) => !everSolved.has(id))
|
|
||||||
.map((id) => byProblem.get(id)!)
|
|
||||||
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
|
|
||||||
problemDisplayId,
|
|
||||||
title,
|
|
||||||
attempts,
|
|
||||||
lastResult,
|
|
||||||
}))
|
|
||||||
return success(c, {
|
|
||||||
previousLogin,
|
|
||||||
summary: {
|
|
||||||
submissionCount: rows.length,
|
|
||||||
solvedCount: solved.length,
|
|
||||||
lastSubmitTime: rows.at(-1)!.createTime,
|
|
||||||
unsolved,
|
|
||||||
solved: solved.slice(0, 12),
|
|
||||||
},
|
|
||||||
} satisfies LastVisit)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
|
|
||||||
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
|
|
||||||
*/
|
|
||||||
classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
|
|
||||||
const user = c.get('user')!
|
|
||||||
if (!user.className) {
|
|
||||||
return success(c, {
|
|
||||||
className: null,
|
|
||||||
day: null,
|
|
||||||
source: null,
|
|
||||||
language: null,
|
|
||||||
mode: null,
|
|
||||||
problems: [],
|
|
||||||
} satisfies ClassActivity)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 codeLanguage = steps ? steps.language : lesson.language
|
|
||||||
// 代码提交和流程图提交两张表,互不依赖,一起查
|
|
||||||
const [mine, drawn] = await Promise.all([
|
|
||||||
ids.length
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
problemId: schema.submission.problemId,
|
|
||||||
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
|
||||||
})
|
|
||||||
.from(schema.submission)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.submission.userId, user.id),
|
|
||||||
inArray(schema.submission.problemId, ids),
|
|
||||||
isNull(schema.submission.contestId),
|
|
||||||
// 布置的是 C:以前用 Python 做对过不算这份作业做完
|
|
||||||
lessonLanguageFilter(codeLanguage),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(schema.submission.problemId)
|
|
||||||
: [],
|
|
||||||
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
|
|
||||||
ids.length
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
problemId: schema.flowchartSubmission.problemId,
|
|
||||||
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
|
|
||||||
})
|
|
||||||
.from(schema.flowchartSubmission)
|
|
||||||
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
|
|
||||||
.groupBy(schema.flowchartSubmission.problemId)
|
|
||||||
: [],
|
|
||||||
])
|
|
||||||
type Step = 'accepted' | 'tried' | 'none'
|
|
||||||
const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried')
|
|
||||||
const codeById = new Map(mine.map((row) => [row.problemId, row.accepted]))
|
|
||||||
const drawById = new Map(drawn.map((row) => [row.problemId, row.passed]))
|
|
||||||
|
|
||||||
return success(c, {
|
|
||||||
className: user.className,
|
|
||||||
day: lesson.day,
|
|
||||||
source: lesson.source,
|
|
||||||
language: codeLanguage,
|
|
||||||
mode: lesson.mode,
|
|
||||||
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)
|
|
||||||
let myStatus: Step
|
|
||||||
if (needs) {
|
|
||||||
// 老师布置的:要哪几步就看哪几步,全做到才算做完
|
|
||||||
const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null)
|
|
||||||
myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none'
|
|
||||||
} else {
|
|
||||||
// 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」)
|
|
||||||
myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none'
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
problemDisplayId: problem.displayId,
|
|
||||||
title: problem.title,
|
|
||||||
userCount: problem.userCount,
|
|
||||||
acceptedCount: problem.acceptedCount,
|
|
||||||
myStatus,
|
|
||||||
draw: needs?.draw ? draw : null,
|
|
||||||
code: needs?.code ? code : null,
|
|
||||||
} satisfies ClassActivityProblem
|
|
||||||
}),
|
|
||||||
} 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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
|
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
|
||||||
@@ -450,7 +49,7 @@ function flowchartCaseCounts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
|
* 任务页上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
|
||||||
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
|
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
|
||||||
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
|
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
|
||||||
*/
|
*/
|
||||||
@@ -459,11 +58,11 @@ const RECENT_LESSONS = 5
|
|||||||
const RECENT_LESSONS_WINDOW_DAYS = 45
|
const RECENT_LESSONS_WINDOW_DAYS = 45
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
|
* 课堂任务:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
|
||||||
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
|
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
|
||||||
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
|
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
|
||||||
*/
|
*/
|
||||||
classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
taskRoutes.get('/task/board', requireTeacher, async (c) => {
|
||||||
const day = calendarDay()
|
const day = calendarDay()
|
||||||
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
|
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
|
||||||
if (!className) {
|
if (!className) {
|
||||||
@@ -480,10 +79,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
problems: [],
|
problems: [],
|
||||||
students: [],
|
students: [],
|
||||||
recentLessons: 0,
|
recentLessons: 0,
|
||||||
} satisfies ClassBoard)
|
} satisfies TaskBoard)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。
|
// 推断只看今天(回看 1 天):任务页是给这节课用的,昨天的题不该冒出来。
|
||||||
// lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
|
// lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
|
||||||
const [lesson, [last]] = await Promise.all([
|
const [lesson, [last]] = await Promise.all([
|
||||||
classLessonProblems(className, 1),
|
classLessonProblems(className, 1),
|
||||||
@@ -527,9 +126,14 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
const challengeDoneKeys = new Set(
|
const challengeDoneKeys = new Set(
|
||||||
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
|
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 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([
|
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
? db
|
? db
|
||||||
@@ -545,7 +149,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
isNull(schema.submission.contestId),
|
isNull(schema.submission.contestId),
|
||||||
inArray(schema.submission.userId, userIds),
|
inArray(schema.submission.userId, userIds),
|
||||||
inArray(schema.submission.problemId, ids),
|
inArray(schema.submission.problemId, ids),
|
||||||
lessonLanguageFilter(lesson.language),
|
lessonLanguageFilter(codeLanguage),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||||
@@ -562,7 +166,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
.where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start)))
|
.where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start)))
|
||||||
.groupBy(schema.submission.userId)
|
.groupBy(schema.submission.userId)
|
||||||
: [],
|
: [],
|
||||||
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。
|
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在任务页上是一直没动手。
|
||||||
// 和上面代码那条同一个范围:今天、所有题、不论评没评完
|
// 和上面代码那条同一个范围:今天、所有题、不论评没评完
|
||||||
userIds.length
|
userIds.length
|
||||||
? db
|
? db
|
||||||
@@ -586,7 +190,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
: [],
|
: [],
|
||||||
/**
|
/**
|
||||||
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
|
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
|
||||||
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。
|
* 有的题这节课整个班都在画流程图,只数代码提交的话,任务页上满屏「没开始」。
|
||||||
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
|
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
|
||||||
*/
|
*/
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
@@ -623,7 +227,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
inArray(schema.submission.userId, userIds),
|
inArray(schema.submission.userId, userIds),
|
||||||
inArray(schema.submission.problemId, ids),
|
inArray(schema.submission.problemId, ids),
|
||||||
gte(schema.submission.createTime, start),
|
gte(schema.submission.createTime, start),
|
||||||
lessonLanguageFilter(lesson.language),
|
lessonLanguageFilter(codeLanguage),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(schema.submission.createTime)
|
.orderBy(schema.submission.createTime)
|
||||||
@@ -634,7 +238,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
|
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
|
||||||
* 老评法和还没评完的为 null
|
* 老评法和还没评完的为 null
|
||||||
*/
|
*/
|
||||||
needs?.draw && ids.length && userIds.length
|
anyDraw && ids.length && userIds.length
|
||||||
? db
|
? db
|
||||||
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
|
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
|
||||||
userId: schema.flowchartSubmission.userId,
|
userId: schema.flowchartSubmission.userId,
|
||||||
@@ -681,6 +285,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
title: problem.title,
|
title: problem.title,
|
||||||
isSql: problem.isSql,
|
isSql: problem.isSql,
|
||||||
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
|
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
|
||||||
|
mode: steps?.modeOf(problem.id) ?? null,
|
||||||
})),
|
})),
|
||||||
students: roster.map(
|
students: roster.map(
|
||||||
(student) =>
|
(student) =>
|
||||||
@@ -689,6 +294,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
username: student.username,
|
username: student.username,
|
||||||
realName: student.realName ?? null,
|
realName: student.realName ?? null,
|
||||||
cells: ids.map((id) => {
|
cells: ids.map((id) => {
|
||||||
|
const needs = needsOf(id)
|
||||||
const key = `${student.userId}:${id}`
|
const key = `${student.userId}:${id}`
|
||||||
const cell = cellByKey.get(key)
|
const cell = cellByKey.get(key)
|
||||||
const flow = drawnByKey.get(key)
|
const flow = drawnByKey.get(key)
|
||||||
@@ -705,11 +311,11 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
|
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
|
||||||
} else {
|
} 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,
|
(step) => step !== null,
|
||||||
)
|
)
|
||||||
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null
|
acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null
|
||||||
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none'
|
status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none'
|
||||||
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
|
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
|
||||||
}
|
}
|
||||||
// 下面几样都是代码那一步的:只画流程图的课没有
|
// 下面几样都是代码那一步的:只画流程图的课没有
|
||||||
@@ -752,8 +358,9 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
|
acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
|
||||||
|
// 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈
|
||||||
challenge:
|
challenge:
|
||||||
!codeWanted || !codeDone || !liveChallenges.get(id)?.size
|
!codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size
|
||||||
? null
|
? null
|
||||||
: challengeDoneKeys.has(key)
|
: challengeDoneKeys.has(key)
|
||||||
? 'done'
|
? 'done'
|
||||||
@@ -769,18 +376,18 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
|
|||||||
editing: presenceOf(student.userId)?.problemId ?? null,
|
editing: presenceOf(student.userId)?.problemId ?? null,
|
||||||
online: seen.get(student.userId)?.online ?? false,
|
online: seen.get(student.userId)?.online ?? false,
|
||||||
seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null,
|
seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null,
|
||||||
}) satisfies ClassBoardStudent,
|
}) satisfies TaskBoardStudent,
|
||||||
),
|
),
|
||||||
recentLessons: recentDays.length,
|
recentLessons: recentDays.length,
|
||||||
} satisfies ClassBoard)
|
} satisfies TaskBoard)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
|
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
|
||||||
* 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
|
* 同一个门槛),或者老师今天在任务页上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
|
||||||
* 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
|
* 和任务页同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
|
||||||
*/
|
*/
|
||||||
classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
|
taskRoutes.get('/task/today', requireTeacher, async (c) => {
|
||||||
const start = dayStart()
|
const start = dayStart()
|
||||||
const today = calendarDay()
|
const today = calendarDay()
|
||||||
const activity = activityRows({ since: start })
|
const activity = activityRows({ since: start })
|
||||||
@@ -810,13 +417,21 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
|
|||||||
const ids = lesson.problems.map((problem) => problem.id)
|
const ids = lesson.problems.map((problem) => problem.id)
|
||||||
let allDoneCount = 0
|
let allDoneCount = 0
|
||||||
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
|
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
|
||||||
// 老师布置的:和看板、后台课堂作业同一个口径(要画的看图、要写的看代码)
|
// 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码)
|
||||||
const counts = await lessonDoneCounts([
|
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
|
allDoneCount = counts.get(lesson.lessonId) ?? 0
|
||||||
} else if (ids.length) {
|
} else if (ids.length) {
|
||||||
// 推断出来的:做没做对不限今天(和看板的格子一样:以前做对的也算做完),代码或流程图都算
|
// 推断出来的:做没做对不限今天(和任务页的格子一样:以前做对的也算做完),代码或流程图都算
|
||||||
const done = activityRows({
|
const done = activityRows({
|
||||||
since: new Date(0).toISOString(),
|
since: new Date(0).toISOString(),
|
||||||
users: classMemberIds(className),
|
users: classMemberIds(className),
|
||||||
@@ -857,10 +472,10 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先,
|
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂任务(老师布置的优先,
|
||||||
* 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
|
* 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
|
||||||
*/
|
*/
|
||||||
classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
taskRoutes.get('/task/lesson', requireTeacher, async (c) => {
|
||||||
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
|
const className = c.req.query('className')?.trim() || (await suggestActiveClass())
|
||||||
if (!className)
|
if (!className)
|
||||||
return success(c, {
|
return success(c, {
|
||||||
@@ -869,7 +484,7 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
|||||||
language: null,
|
language: null,
|
||||||
problems: [],
|
problems: [],
|
||||||
} satisfies ClassLesson)
|
} satisfies ClassLesson)
|
||||||
// 和看板一样只回看今天:「这节课」不该冒出昨天的题
|
// 和任务页一样只回看今天:「这节课」不该冒出昨天的题
|
||||||
const lesson = await classLessonProblems(className, 1)
|
const lesson = await classLessonProblems(className, 1)
|
||||||
// C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language)
|
// C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language)
|
||||||
const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL'))
|
const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL'))
|
||||||
@@ -889,12 +504,12 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
|
|||||||
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
|
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
|
||||||
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
|
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
|
||||||
*/
|
*/
|
||||||
classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
taskRoutes.put('/task/lesson', requireTeacher, async (c) => {
|
||||||
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
|
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const user = c.get('user')!
|
const user = c.get('user')!
|
||||||
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
|
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
|
||||||
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」
|
// 只画流程图不用语言:存成 null,任务页和学生那边就不会写「C语言」
|
||||||
const language = mode === 'flowchart' ? null : parsed.data.language
|
const language = mode === 'flowchart' ? null : parsed.data.language
|
||||||
const day = calendarDay()
|
const day = calendarDay()
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from '../se
|
|||||||
*
|
*
|
||||||
* **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info
|
* **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info
|
||||||
* 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后
|
* 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后
|
||||||
* user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
|
* user_achievement 随成就级联,漂不了)、专题进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
|
||||||
*
|
*
|
||||||
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate:
|
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
{
|
{
|
||||||
key: 'accepted_count',
|
key: 'accepted_count',
|
||||||
name: '做对的题数',
|
name: '做对的题数',
|
||||||
helpText: '去重后通过的题目数量(不含比赛)',
|
helpText: '去重后通过的题目数量(含比赛题)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mid_ac_count',
|
key: 'mid_ac_count',
|
||||||
name: '做对的中等题',
|
name: '做对的中等题',
|
||||||
helpText: '去重后通过的中等难度题目数(不含比赛)',
|
helpText: '去重后通过的中等难度题目数(含比赛题)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'hard_ac_count',
|
key: 'hard_ac_count',
|
||||||
name: '做对的困难题',
|
name: '做对的困难题',
|
||||||
helpText: '去重后通过的困难题目数(不含比赛)',
|
helpText: '去重后通过的困难题目数(含比赛题)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'submission_count',
|
key: 'submission_count',
|
||||||
name: '提交次数',
|
name: '提交次数',
|
||||||
helpText: '提交次数(不含比赛)',
|
helpText: '提交次数(含比赛)',
|
||||||
},
|
},
|
||||||
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
|
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
|
||||||
{
|
{
|
||||||
@@ -45,19 +45,24 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
{
|
{
|
||||||
key: 'max_ac_week_streak',
|
key: 'max_ac_week_streak',
|
||||||
name: '连续有新做对的周数',
|
name: '连续有新做对的周数',
|
||||||
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)',
|
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
|
||||||
},
|
},
|
||||||
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
|
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
|
||||||
{
|
{
|
||||||
key: 'contest_joined',
|
key: 'contest_joined',
|
||||||
name: '参加比赛',
|
name: '参加比赛',
|
||||||
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)',
|
helpText: '交过题的比赛数量',
|
||||||
},
|
},
|
||||||
{ key: 'badge_count', name: '题单奖章', helpText: '获得的题单奖章数量' },
|
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
|
||||||
{
|
{
|
||||||
key: 'problemset_completed',
|
key: 'problemset_completed',
|
||||||
name: '做完的题单',
|
name: '做完的专题',
|
||||||
helpText: '完成的题单数量',
|
helpText: '完成的专题数量',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'challenge_count',
|
||||||
|
name: '挑战成功',
|
||||||
|
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'first_try_ac_count',
|
key: 'first_try_ac_count',
|
||||||
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
{
|
{
|
||||||
key: 'max_code_lines',
|
key: 'max_code_lines',
|
||||||
name: '最长的代码行数',
|
name: '最长的代码行数',
|
||||||
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)',
|
helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'achievement_unlocked_count',
|
key: 'achievement_unlocked_count',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import { db, schema, type DbOrTx } from '../db'
|
import { db, schema, type DbOrTx } from '../db'
|
||||||
import { publishAchievementNotification } from '../events'
|
import { publishAchievementNotification } from '../events'
|
||||||
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
||||||
|
* 参加过几场比赛、挑战成功几次也在这里一起数。
|
||||||
*
|
*
|
||||||
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
||||||
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
||||||
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
||||||
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
||||||
* 一个人的提交也就几百条。
|
* 一个人的提交也就几百条。
|
||||||
|
*
|
||||||
|
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
|
||||||
|
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
|
||||||
|
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
|
||||||
*/
|
*/
|
||||||
async function solvedCounts(tx: DbOrTx, userId: number) {
|
async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||||
const [row] = await tx.execute<{
|
const [row] = await tx.execute<{
|
||||||
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
|||||||
mid: number
|
mid: number
|
||||||
hard: number
|
hard: number
|
||||||
first_try: number
|
first_try: number
|
||||||
|
contests: number
|
||||||
|
challenges: number
|
||||||
}>(
|
}>(
|
||||||
sql`
|
sql`
|
||||||
with mine as (
|
with mine as (
|
||||||
select s.problem_id, s.result, s.create_time, s.id, p.difficulty
|
select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
|
||||||
from ${schema.submission} s
|
from ${schema.submission} s
|
||||||
join ${schema.problem} p on p.id = s.problem_id
|
join ${schema.problem} p on p.id = s.problem_id
|
||||||
where s.user_id = ${userId} and s.contest_id is null
|
where s.user_id = ${userId}
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
||||||
@@ -111,11 +118,13 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
|||||||
select count(*)::int from (
|
select count(*)::int from (
|
||||||
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
||||||
) first where result in (${ACCEPTED_RESULTS})
|
) first where result in (${ACCEPTED_RESULTS})
|
||||||
) as first_try
|
) as first_try,
|
||||||
|
count(distinct contest_id)::int as contests,
|
||||||
|
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
|
||||||
from mine
|
from mine
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 }
|
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +139,7 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
|||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||||
.where(eq(schema.submission.id, submissionId))
|
.where(eq(schema.submission.id, submissionId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!row || row.submission.contestId !== null) return []
|
if (!row) return []
|
||||||
|
|
||||||
const rejudged = countedAs !== null
|
const rejudged = countedAs !== null
|
||||||
const accepted = isAccepted(row.submission.result)
|
const accepted = isAccepted(row.submission.result)
|
||||||
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
|||||||
value.mid_ac_count = solved.mid
|
value.mid_ac_count = solved.mid
|
||||||
value.hard_ac_count = solved.hard
|
value.hard_ac_count = solved.hard
|
||||||
value.first_try_ac_count = solved.first_try
|
value.first_try_ac_count = solved.first_try
|
||||||
|
value.contest_joined = solved.contests
|
||||||
|
value.challenge_count = solved.challenges
|
||||||
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
|
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
|
||||||
if (firstAc) {
|
if (firstAc) {
|
||||||
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
||||||
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
|||||||
and(
|
and(
|
||||||
eq(schema.submission.userId, row.submission.userId),
|
eq(schema.submission.userId, row.submission.userId),
|
||||||
eq(schema.submission.problemId, row.submission.problemId),
|
eq(schema.submission.problemId, row.submission.problemId),
|
||||||
isNull(schema.submission.contestId),
|
|
||||||
lt(schema.submission.createTime, row.submission.createTime),
|
lt(schema.submission.createTime, row.submission.createTime),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
|
|||||||
const metric = findMetric(achievement.metric)
|
const metric = findMetric(achievement.metric)
|
||||||
if (!metric) return { scanned: 0, unlocked: 0 }
|
if (!metric) return { scanned: 0, unlocked: 0 }
|
||||||
|
|
||||||
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
|
// contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
|
||||||
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
|
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
|
||||||
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
|
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
|
||||||
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
|
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
|
||||||
@@ -482,7 +492,7 @@ async function refreshContestJoinedForAll() {
|
|||||||
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
|
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
|
||||||
* 其余指标不动,写法同 refreshContestJoinedForAll)。
|
* 其余指标不动,写法同 refreshContestJoinedForAll)。
|
||||||
*
|
*
|
||||||
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
* 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
||||||
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
|
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
|
||||||
*/
|
*/
|
||||||
async function refreshAcWeekStreakForAll() {
|
async function refreshAcWeekStreakForAll() {
|
||||||
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
|
|||||||
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
|
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
|
||||||
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
|
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
|
||||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
.where(and(isNull(schema.submission.contestId), inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])))
|
.where(inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))
|
||||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||||
.as('first_accepted')
|
.as('first_accepted')
|
||||||
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
|
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ export async function challengeList(user: { id: number; className: string | null
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
|
/** 课堂任务用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
|
||||||
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
|
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
|
||||||
if (userIds.length === 0 || problemIds.length === 0) return []
|
if (userIds.length === 0 || problemIds.length === 0) return []
|
||||||
return db
|
return db
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { db, schema } from '../db'
|
|||||||
import { JudgeStatus } from '../judge/status'
|
import { JudgeStatus } from '../judge/status'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」
|
* 课堂上「谁做了哪道题」的取数口径:课堂条、任务页、自学情况、后台首页「正在上课的班」
|
||||||
* 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班,
|
* 都从这里数。原来写在课堂任务的路由里,后台 conf.ts 要猜正在上课的班,
|
||||||
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
|
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
|
||||||
*/
|
*/
|
||||||
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
|
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
|
||||||
@@ -30,7 +30,7 @@ export function classMemberIds(className: string) {
|
|||||||
/**
|
/**
|
||||||
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
|
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
|
||||||
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
|
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
|
||||||
* 口径和课堂条 / 看板的「做完」一致:
|
* 口径和课堂条 / 任务页的「做完」一致:
|
||||||
*
|
*
|
||||||
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
|
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
|
||||||
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
|
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
|
||||||
@@ -99,7 +99,7 @@ export function activityRows(options: {
|
|||||||
return code.unionAll(flowchart).as('activity')
|
return code.unionAll(flowchart).as('activity')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
|
/** 任务页没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
|
||||||
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
|
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
*
|
*
|
||||||
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
|
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
|
||||||
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
|
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
|
||||||
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts):
|
* 一节课的题怎么定,和课堂任务 / 首页「班里在做」同一个口径(services/lesson.ts 的 classLessonProblems):
|
||||||
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
|
* 老师在任务页布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
|
||||||
*
|
*
|
||||||
* 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
|
* 做对的口径也同任务页:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
|
||||||
* (比赛是课堂测验,题目也不在题库里)。
|
* (比赛是课堂测验,题目也不在题库里)。
|
||||||
*/
|
*/
|
||||||
import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LearningDay, type LearningItem, type LearningPeriod, type LearningReport } from '@oj2/contract'
|
import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LearningDay, type LearningItem, type LearningPeriod, type LearningReport } from '@oj2/contract'
|
||||||
@@ -19,7 +19,7 @@ import { calendarDay, monthStart, termStart, weekStart } from '../time'
|
|||||||
|
|
||||||
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||||
|
|
||||||
/** 同 routes/classroom.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
|
/** 同 services/lesson.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
|
||||||
const LESSON_MIN_USERS = 5
|
const LESSON_MIN_USERS = 5
|
||||||
|
|
||||||
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
|
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
|||||||
|
|
||||||
import { db, schema } from '../db'
|
import { db, schema } from '../db'
|
||||||
import { publishLessonUpdate } from '../events'
|
import { publishLessonUpdate } from '../events'
|
||||||
import { calendarDay, dayNumber, dayStart } from '../time'
|
import { calendarDay, dayNumber, dayStart, localTime } from '../time'
|
||||||
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
|
import { activityRows, classMemberIds, FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、
|
* 布置的课(class_lesson)几处共用的口径:课堂任务布置、后台「课堂任务」增改、
|
||||||
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。
|
* 数据统计按布置切课。放在一起,免得任务页能布置的题后台布置不上,或者两边「做完」数得不一样。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,11 +55,20 @@ export function lessonDayEnd(day: string) {
|
|||||||
/**
|
/**
|
||||||
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
|
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
|
||||||
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
|
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
|
||||||
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。
|
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和任务页的格子同一个口径。
|
||||||
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
|
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
|
||||||
*/
|
*/
|
||||||
export async function lessonDoneCounts(
|
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) =>
|
const rows = lessons.flatMap((lesson) =>
|
||||||
lesson.problemIds.map((problemId) => ({
|
lesson.problemIds.map((problemId) => ({
|
||||||
@@ -67,7 +76,7 @@ export async function lessonDoneCounts(
|
|||||||
class_name: lesson.className,
|
class_name: lesson.className,
|
||||||
problem_id: problemId,
|
problem_id: problemId,
|
||||||
language: lesson.language,
|
language: lesson.language,
|
||||||
mode: lesson.mode,
|
mode: lesson.modeOf?.(problemId) ?? lesson.mode,
|
||||||
until: lessonDayEnd(lesson.day),
|
until: lessonDayEnd(lesson.day),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
@@ -119,7 +128,7 @@ interface LessonSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法),
|
* 学生此刻看到的那一节:这个班今天最后布置的(和下面 lessonPlan 同一个取法),
|
||||||
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
|
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
|
||||||
*/
|
*/
|
||||||
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
|
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
|
||||||
@@ -175,3 +184,219 @@ export async function announceLessonChange(className: string, before: LessonSnap
|
|||||||
console.error('Failed to announce lesson change', error)
|
console.error('Failed to announce lesson change', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
|
||||||
|
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
|
||||||
|
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
|
||||||
|
*/
|
||||||
|
export const CLASS_ACTIVITY_MIN_USERS = 5
|
||||||
|
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
|
||||||
|
export const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
|
||||||
|
const CLASS_ACTIVITY_LIMIT = 8
|
||||||
|
interface ClassDayGroup {
|
||||||
|
day: string
|
||||||
|
problemId: number
|
||||||
|
firstAt: string
|
||||||
|
userCount: number
|
||||||
|
acceptedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
|
||||||
|
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
|
||||||
|
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
|
||||||
|
*
|
||||||
|
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
|
||||||
|
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
|
||||||
|
*/
|
||||||
|
export async function classDayGroups(
|
||||||
|
className: string,
|
||||||
|
since: string,
|
||||||
|
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {},
|
||||||
|
): Promise<ClassDayGroup[]> {
|
||||||
|
const { mode } = options
|
||||||
|
const activity = activityRows({
|
||||||
|
since,
|
||||||
|
users: classMemberIds(className),
|
||||||
|
problemIds: options.problemIds,
|
||||||
|
language: options.language,
|
||||||
|
only: mode === 'code' || mode === 'flowchart' ? mode : undefined,
|
||||||
|
})
|
||||||
|
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
|
||||||
|
const perUser = db
|
||||||
|
.select({
|
||||||
|
day: day.as('day'),
|
||||||
|
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
|
||||||
|
userId: sql<number>`${activity.userId}`.as('user_id'),
|
||||||
|
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
|
||||||
|
solved: (mode === 'both'
|
||||||
|
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
|
||||||
|
: sql<boolean>`bool_or(${activity.solved})`
|
||||||
|
).as('solved'),
|
||||||
|
})
|
||||||
|
.from(activity)
|
||||||
|
.groupBy(day, activity.problemId, activity.userId)
|
||||||
|
.as('per_user')
|
||||||
|
const userCount = sql<number>`count(*)::int`
|
||||||
|
const query = db
|
||||||
|
.select({
|
||||||
|
day: perUser.day,
|
||||||
|
problemId: perUser.problemId,
|
||||||
|
firstAt: sql<string>`min(${perUser.firstAt})`,
|
||||||
|
userCount,
|
||||||
|
acceptedCount: sql<number>`(count(*) filter (where ${perUser.solved}))::int`,
|
||||||
|
})
|
||||||
|
.from(perUser)
|
||||||
|
.groupBy(perUser.day, perUser.problemId)
|
||||||
|
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
|
||||||
|
async function visibleProblems(ids: number[]) {
|
||||||
|
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: schema.problem.id,
|
||||||
|
displayId: schema.problem.displayId,
|
||||||
|
title: schema.problem.title,
|
||||||
|
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
|
||||||
|
})
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||||
|
return new Map(rows.map((row) => [row.id, row]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
|
||||||
|
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
|
||||||
|
*/
|
||||||
|
async function lessonPlan(className: string, day: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: schema.classLesson.id,
|
||||||
|
problemIds: schema.classLesson.problemIds,
|
||||||
|
language: schema.classLesson.language,
|
||||||
|
mode: schema.classLesson.mode,
|
||||||
|
createdAt: schema.classLesson.createdAt,
|
||||||
|
createdBy: sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`,
|
||||||
|
})
|
||||||
|
.from(schema.classLesson)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
|
||||||
|
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
|
||||||
|
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||||
|
.limit(1)
|
||||||
|
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 人做过的题,只取最近的
|
||||||
|
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
|
||||||
|
*
|
||||||
|
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
|
||||||
|
* 学生最需要它的时候。
|
||||||
|
*/
|
||||||
|
export async function classLessonProblems(className: string, lookbackDays: number) {
|
||||||
|
const today = calendarDay()
|
||||||
|
const plan = await lessonPlan(className, today)
|
||||||
|
if (plan) {
|
||||||
|
const planned = plan.problemIds
|
||||||
|
const steps = await todayNeeds(className, planned, plan)
|
||||||
|
// 每道题按它合起来的要求数「做过 / 做对」:要求一样的几道一起数
|
||||||
|
const byMode = Map.groupBy(planned, (id) => steps.modeOf(id))
|
||||||
|
const [problems, ...groupLists] = await Promise.all([
|
||||||
|
visibleProblems(planned),
|
||||||
|
...[...byMode].map(([mode, ids]) =>
|
||||||
|
classDayGroups(className, dayStart(), { problemIds: ids, language: lessonNeeds(mode).code ? steps.language : null, mode }),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
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) => {
|
||||||
|
const problem = problems.get(id)
|
||||||
|
if (!problem) return []
|
||||||
|
const group = groupById.get(id)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...problem,
|
||||||
|
userCount: group?.userCount ?? 0,
|
||||||
|
acceptedCount: group?.acceptedCount ?? 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
|
||||||
|
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
|
||||||
|
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
|
||||||
|
const picked = groups
|
||||||
|
.filter((row) => row.day === latest)
|
||||||
|
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
|
||||||
|
.slice(0, CLASS_ACTIVITY_LIMIT)
|
||||||
|
const problems = await visibleProblems(picked.map((row) => row.problemId))
|
||||||
|
return {
|
||||||
|
source: picked.length ? ('inferred' as const) : null,
|
||||||
|
lessonId: null,
|
||||||
|
day: picked.length ? latest : null,
|
||||||
|
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
|
||||||
|
language: null,
|
||||||
|
mode: null,
|
||||||
|
steps: null,
|
||||||
|
assignedAt: null,
|
||||||
|
assignedBy: null,
|
||||||
|
problems: picked.flatMap((row) => {
|
||||||
|
const problem = problems.get(row.problemId)
|
||||||
|
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import type { AuthUser } from '../auth/session'
|
|||||||
import { db, schema } from '../db'
|
import { db, schema } from '../db'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、题单、
|
* 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、专题、
|
||||||
* 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里
|
* 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里
|
||||||
* 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。
|
* 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { cached } from './cache'
|
|||||||
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
|
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
|
||||||
*
|
*
|
||||||
* 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到
|
* 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到
|
||||||
* A / S 也算做对、交过流程图就算交过 —— 和题单、课堂条一个口径(题目页设计文档
|
* A / S 也算做对、交过流程图就算交过 —— 和专题、课堂条一个口径(题目页设计文档
|
||||||
* 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。
|
* 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。
|
||||||
*/
|
*/
|
||||||
export async function problemStates(userId: number) {
|
export async function problemStates(userId: number) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type ProblemLink = { problemId: number; isRequired: boolean }
|
|||||||
type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCount' | 'progressDetail'>
|
type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCount' | 'progressDetail'>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
|
* 专题进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
|
||||||
*
|
*
|
||||||
* 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大,
|
* 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大,
|
||||||
* 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。
|
* 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。
|
||||||
@@ -19,7 +19,7 @@ type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCou
|
|||||||
*/
|
*/
|
||||||
export function computeProgress(detail: Record<string, unknown>, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) {
|
export function computeProgress(detail: Record<string, unknown>, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) {
|
||||||
const inSet = new Set(links.map((link) => String(link.problemId)))
|
const inSet = new Set(links.map((link) => String(link.problemId)))
|
||||||
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。
|
// 已经移出专题的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。
|
||||||
// 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害
|
// 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害
|
||||||
const kept: Record<string, unknown> = {}
|
const kept: Record<string, unknown> = {}
|
||||||
for (const [key, value] of Object.entries(detail)) {
|
for (const [key, value] of Object.entries(detail)) {
|
||||||
@@ -27,17 +27,17 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
|
|||||||
}
|
}
|
||||||
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
|
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
|
||||||
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
|
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
|
||||||
// (题单 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。
|
// (专题 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。
|
||||||
//
|
//
|
||||||
// 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是
|
// 一道必做都没标的专题退回「全部都算必做」:那种专题多半是没用这个字段,而不是
|
||||||
// 真的整单选做;不兜住的话它永远完不成。
|
// 真的整单选做;不兜住的话它永远完不成。
|
||||||
const required = links.filter((link) => link.isRequired)
|
const required = links.filter((link) => link.isRequired)
|
||||||
const graded = required.length ? required : links
|
const graded = required.length ? required : links
|
||||||
const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
|
const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
|
||||||
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
|
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
|
||||||
const total = graded.length
|
const total = graded.length
|
||||||
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
|
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的专题会让人一加入就算「完成」,
|
||||||
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。
|
// 还会写下 complete_time、计进「完成专题数」成就,而且后面补上题目也不会自愈。
|
||||||
const isCompleted = total > 0 && completed === total
|
const isCompleted = total > 0 && completed === total
|
||||||
return {
|
return {
|
||||||
progressDetail: kept,
|
progressDetail: kept,
|
||||||
@@ -46,9 +46,9 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
|
|||||||
isCompleted,
|
isCompleted,
|
||||||
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
|
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
|
||||||
//
|
//
|
||||||
// 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 题单 8 那批人在它
|
// 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 专题 8 那批人在它
|
||||||
// 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。
|
// 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。
|
||||||
// 反过来清空的代价是不可逆:往一个 100 人已完成的题单里加一道题、再改主意删掉,
|
// 反过来清空的代价是不可逆:往一个 100 人已完成的专题里加一道题、再改主意删掉,
|
||||||
// 这 100 个人的历史完成时间就一起被冲成了「现在」。
|
// 这 100 个人的历史完成时间就一起被冲成了「现在」。
|
||||||
completeTime: previousCompleteTime ?? (isCompleted ? now : null),
|
completeTime: previousCompleteTime ?? (isCompleted ? now : null),
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
|
|||||||
type ProgressWrite = ReturnType<typeof computeProgress> & { id: number }
|
type ProgressWrite = ReturnType<typeof computeProgress> & { id: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的题单就是上百次往返,
|
* 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的专题就是上百次往返,
|
||||||
* 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。
|
* 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。
|
||||||
*/
|
*/
|
||||||
async function writeProgress(rows: ProgressWrite[]) {
|
async function writeProgress(rows: ProgressWrite[]) {
|
||||||
@@ -93,11 +93,11 @@ async function writeProgress(rows: ProgressWrite[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 奖章达标判定的唯一实现。学生做出一题、后台改题单、补发脚本三处都调它 ——
|
* 奖章达标判定的唯一实现。学生做出一题、后台改专题、补发脚本三处都调它 ——
|
||||||
* 以前是三份各写一遍。
|
* 以前是三份各写一遍。
|
||||||
*
|
*
|
||||||
* problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount
|
* problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount
|
||||||
* (自从分母只算必做,那个只数必做题)。老师当初是按题单的总题数设阈值的:题单 5
|
* (自从分母只算必做,那个只数必做题)。老师当初是按专题的总题数设阈值的:专题 5
|
||||||
* 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间
|
* 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间
|
||||||
* 不可得,76 个已经拿到的人被 recalculateBadge 收回。
|
* 不可得,76 个已经拿到的人被 recalculateBadge 收回。
|
||||||
*/
|
*/
|
||||||
@@ -144,11 +144,11 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。
|
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这个专题的奖章。
|
||||||
*
|
*
|
||||||
* 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了,
|
* 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了,
|
||||||
* 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete
|
* 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete
|
||||||
* 上重算全部参与者的进度、再重算该题单全部奖章的资格。重写时 views 里看不到显式调用,
|
* 上重算全部参与者的进度、再重算该专题全部奖章的资格。重写时 views 里看不到显式调用,
|
||||||
* 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。
|
* 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。
|
||||||
*
|
*
|
||||||
* 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补
|
* 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补
|
||||||
@@ -177,16 +177,16 @@ export async function resyncProgress(problemsetId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。
|
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的专题。
|
||||||
*
|
*
|
||||||
* 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调
|
* 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调
|
||||||
* 路由参数里那一个题单。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失;
|
* 路由参数里那一个专题。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失;
|
||||||
* 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次
|
* 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次
|
||||||
* 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。
|
* 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。
|
||||||
*
|
*
|
||||||
* 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。
|
* 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。
|
||||||
*
|
*
|
||||||
* 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。
|
* 不按 visible / status 过滤:进度是学生自己的记录,老师把专题藏起来不该让它停止累积。
|
||||||
*/
|
*/
|
||||||
export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) {
|
export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) {
|
||||||
const joined = await db
|
const joined = await db
|
||||||
@@ -250,7 +250,7 @@ export async function recordSolvedProblem(userId: number, problemId: number, sol
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记题单进度,并把这一下拿到的徽章、解锁的题单类成就推给学生。判题(judge/run.ts)和
|
* 记专题进度,并把这一下拿到的徽章、解锁的专题类成就推给学生。判题(judge/run.ts)和
|
||||||
* 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。
|
* 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。
|
||||||
* 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。
|
* 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。
|
||||||
*/
|
*/
|
||||||
@@ -289,16 +289,16 @@ export async function recordSolvedAndNotify(userId: number, problemId: number, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
|
/** 专题现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
|
||||||
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
|
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
|
* 专题防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
|
||||||
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
|
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
|
||||||
* 共用这一道闸。
|
* 共用这一道闸。
|
||||||
*
|
*
|
||||||
* 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前**
|
* 规则(2026-10 题单重设计,用户拍板):专题**布置期内**,专题里这几道题,学生在**布置开始之前**
|
||||||
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的
|
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习专题:快照里 16% 的
|
||||||
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
|
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
|
||||||
*
|
*
|
||||||
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
|
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
|
||||||
@@ -306,11 +306,11 @@ export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not
|
|||||||
* 在这一两周里也看不到自己的旧代码,用户认了。
|
* 在这一两周里也看不到自己的旧代码,用户认了。
|
||||||
*
|
*
|
||||||
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
||||||
* - 题单不在布置期(没布置,或者布置期过了)
|
* - 专题不在布置期(没布置,或者布置期过了)
|
||||||
* - 题单没公开
|
* - 专题没公开
|
||||||
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key)
|
* - 已经在这个专题里做对了这道题(progress_detail 里有这道题的 key)
|
||||||
*
|
*
|
||||||
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。
|
* 一道题可能同时落在几个正在布置的专题里,取最晚的布置开始 ——「任一专题要求遮挡就遮挡」。
|
||||||
*/
|
*/
|
||||||
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
|
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
|
||||||
const cutoffs = new Map<number, string>()
|
const cutoffs = new Map<number, string>()
|
||||||
@@ -338,7 +338,7 @@ export async function problemSetLockCutoffs(userId: number, problemIds: number[]
|
|||||||
return cutoffs
|
return cutoffs
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */
|
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的专题里 */
|
||||||
export async function problemSetLocksFor(userId: number, problemId: number) {
|
export async function problemSetLocksFor(userId: number, problemId: number) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ export interface Entrant {
|
|||||||
className: string | null
|
className: string | null
|
||||||
avatar: string | null
|
avatar: string | null
|
||||||
mood: string | null
|
mood: string | null
|
||||||
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
|
/** 每道题第一次做对的时刻(毫秒),升序 */
|
||||||
times: number[]
|
times: number[]
|
||||||
stamps: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Standing {
|
export interface Standing {
|
||||||
@@ -64,10 +63,26 @@ export function avatarOf(avatar: string | null) {
|
|||||||
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
|
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
|
||||||
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
|
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
|
||||||
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
|
* 走 `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[]> {
|
export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
|
||||||
const firstSolved = min(schema.submission.createTime)
|
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([
|
const [people, solves] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.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))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(where),
|
.where(where),
|
||||||
db
|
db
|
||||||
.select({ userId: schema.submission.userId, at: firstSolved })
|
.select({ userId: firsts.userId, times: sql<number[]>`array_agg(${firsts.at} order by ${firsts.at})` })
|
||||||
.from(schema.submission)
|
.from(firsts)
|
||||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
.groupBy(firsts.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),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const byUser = new Map<number, string[]>()
|
const byUser = new Map(solves.map((row) => [row.userId, row.times]))
|
||||||
for (const row of solves) {
|
return people.map((person) => ({
|
||||||
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,
|
id: person.id,
|
||||||
username: person.username,
|
username: person.username,
|
||||||
className: person.className,
|
className: person.className,
|
||||||
avatar: avatarOf(person.avatar),
|
avatar: avatarOf(person.avatar),
|
||||||
mood: person.mood?.trim() || null,
|
mood: person.mood?.trim() || null,
|
||||||
times: pairs.map(([time]) => time),
|
times: byUser.get(person.id) ?? [],
|
||||||
stamps: pairs.map(([, stamp]) => stamp),
|
}))
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,7 +129,7 @@ export function standings(entrants: Entrant[], cutoff: number | null, includeZer
|
|||||||
entrant,
|
entrant,
|
||||||
solved,
|
solved,
|
||||||
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
|
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)
|
.filter((row) => includeZero || row.solved > 0)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function dayNumber(day: string): number {
|
|||||||
return Date.UTC(year!, month! - 1, date!) / DAY_MS
|
return Date.UTC(year!, month! - 1, date!) / DAY_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂作业用 */
|
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂任务用 */
|
||||||
export function localDateTime(day: string, time: string): string {
|
export function localDateTime(day: string, time: string): string {
|
||||||
const [hour, minute] = time.split(':').map(Number)
|
const [hour, minute] = time.split(':').map(Number)
|
||||||
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
|
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
|
|||||||
|
|
||||||
Shared logic lives in `shared/`:
|
Shared logic lives in `shared/`:
|
||||||
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
|
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
|
||||||
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
|
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂任务的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
|
||||||
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||||
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const editing = ref<AdminAchievement | null>(null)
|
|||||||
|
|
||||||
const offline = computed(() => list.value.filter((a) => !a.visible).length)
|
const offline = computed(() => list.value.filter((a) => !a.visible).length)
|
||||||
|
|
||||||
/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛题单 → 趣味),组内按门槛从低到高 */
|
/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛和专题 → 趣味),组内按门槛从低到高 */
|
||||||
const groups = computed(() => {
|
const groups = computed(() => {
|
||||||
const byMetric = new Map<string, AdminAchievement[]>()
|
const byMetric = new Map<string, AdminAchievement[]>()
|
||||||
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
|
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
|
||||||
|
|||||||
+8
-4
@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
||||||
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。
|
* 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
detail: StuckProblemDetail
|
detail: StuckProblemDetail
|
||||||
@@ -205,7 +205,7 @@ const links = computed(() => {
|
|||||||
<div class="muted small">交得最多的</div>
|
<div class="muted small">交得最多的</div>
|
||||||
<div class="names">
|
<div class="names">
|
||||||
<div v-for="row in mostTries" :key="row.username" class="nrow">
|
<div v-for="row in mostTries" :key="row.username" class="nrow">
|
||||||
<UserName :username="row.username" class="uname" />
|
<UserName :username="row.username" :class-name="className ? null : row.className" short-class class="uname" :class="{ 'with-class': !className }" />
|
||||||
<span class="num strong ntries">交了 {{ row.tries }} 次</span>
|
<span class="num strong ntries">交了 {{ row.tries }} 次</span>
|
||||||
<span class="muted num">{{ gap(row.minutes) }}</span>
|
<span class="muted num">{{ gap(row.minutes) }}</span>
|
||||||
<span class="grow" />
|
<span class="grow" />
|
||||||
@@ -243,7 +243,7 @@ const links = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="names">
|
<div class="names">
|
||||||
<div v-for="row in stuckStudents" :key="row.username" class="nrow">
|
<div v-for="row in stuckStudents" :key="row.username" class="nrow">
|
||||||
<UserName :username="row.username" class="uname" />
|
<UserName :username="row.username" :class-name="className ? null : row.className" short-class class="uname" :class="{ 'with-class': !className }" />
|
||||||
<span class="muted num ntries">交了 {{ row.tries }} 次</span>
|
<span class="muted num ntries">交了 {{ row.tries }} 次</span>
|
||||||
<span class="legend" :style="{ color: resultColor(row.lastResult) }">
|
<span class="legend" :style="{ color: resultColor(row.lastResult) }">
|
||||||
<i :style="{ background: resultColor(row.lastResult) }" />{{ resultName(row.lastResult) }}
|
<i :style="{ background: resultColor(row.lastResult) }" />{{ resultName(row.lastResult) }}
|
||||||
@@ -450,10 +450,14 @@ const links = computed(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* 名字最长 4 个字;没选班(全部班级)时名字后面还有班号(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽) */
|
||||||
.uname {
|
.uname {
|
||||||
width: 110px;
|
width: 80px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.uname.with-class {
|
||||||
|
width: 108px;
|
||||||
|
}
|
||||||
.ntries {
|
.ntries {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
/** 卡点分析(stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
||||||
|
|
||||||
/** 百分比,取整;分母是 0 时给 0 */
|
/** 百分比,取整;分母是 0 时给 0 */
|
||||||
export function rate(part: number, whole: number) {
|
export function rate(part: number, whole: number) {
|
||||||
File renamed without changes.
File renamed without changes.
@@ -103,6 +103,7 @@ export function getBaseInfo() {
|
|||||||
return api.get<DashboardInfo>('admin/dashboard')
|
return api.get<DashboardInfo>('admin/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后台首页「抽一个人」:从这个班(班号)随机拿 10 个显示用的名字 */
|
||||||
export function randomUser10(classroom: string) {
|
export function randomUser10(classroom: string) {
|
||||||
return api.get<string[]>('admin/random-usernames', {
|
return api.get<string[]>('admin/random-usernames', {
|
||||||
params: { classroom },
|
params: { classroom },
|
||||||
@@ -245,9 +246,11 @@ export function getUserList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 批量建号之前查重:这些用户名哪些已经有了 */
|
/** 批量建号之前查重:这些用户名哪些已经有了 */
|
||||||
export function checkUsernames(usernames: string[]) {
|
/** 批量建号查重:这个班里已经有的同名账号,和每个名字会建成的用户名 */
|
||||||
|
export function checkUsernames(className: string, names: string[]) {
|
||||||
return api.post<CheckUsernamesResponse>('admin/users/check', {
|
return api.post<CheckUsernamesResponse>('admin/users/check', {
|
||||||
usernames,
|
className,
|
||||||
|
names,
|
||||||
} satisfies CheckUsernamesRequest)
|
} satisfies CheckUsernamesRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +263,7 @@ export function editUser(user: AdminUserForm) {
|
|||||||
adminType: toAdminType(user.adminType),
|
adminType: toAdminType(user.adminType),
|
||||||
problemPermission: toProblemPermission(user.problemPermission),
|
problemPermission: toProblemPermission(user.problemPermission),
|
||||||
realName: user.realName ?? null,
|
realName: user.realName ?? null,
|
||||||
|
className: user.className ?? null,
|
||||||
isDisabled: user.isDisabled,
|
isDisabled: user.isDisabled,
|
||||||
password: user.password ?? '',
|
password: user.password ?? '',
|
||||||
} satisfies UpdateUserRequest)
|
} satisfies UpdateUserRequest)
|
||||||
@@ -600,7 +604,7 @@ export function updateACMHelperChecked(contest_id: number, rank_id: number, prob
|
|||||||
} satisfies UpdateAcmHelperRequest)
|
} satisfies UpdateAcmHelperRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题单管理 API
|
// 专题管理 API
|
||||||
export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
|
export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
|
||||||
return api.get<AdminProblemSetList>('admin/problem-sets', {
|
return api.get<AdminProblemSetList>('admin/problem-sets', {
|
||||||
params: { offset, limit, keyword },
|
params: { offset, limit, keyword },
|
||||||
@@ -635,7 +639,7 @@ export function toggleProblemSetVisible(id: number) {
|
|||||||
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
|
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题单题目管理 API
|
// 专题题目管理 API
|
||||||
export function getProblemSetProblems(problemSetId: number) {
|
export function getProblemSetProblems(problemSetId: number) {
|
||||||
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
|
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
|
||||||
}
|
}
|
||||||
@@ -663,7 +667,7 @@ export function removeProblemFromSet(problemSetId: number, itemId: number) {
|
|||||||
return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
|
return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题单奖章管理 API
|
// 专题奖章管理 API
|
||||||
export function getProblemSetBadges(problemSetId: number) {
|
export function getProblemSetBadges(problemSetId: number) {
|
||||||
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
|
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
|
||||||
}
|
}
|
||||||
@@ -780,11 +784,11 @@ export function deleteAchievement(id: number) {
|
|||||||
return api.delete(`admin/achievements/${id}`)
|
return api.delete(`admin/achievements/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 课堂作业(class_lesson) ----------
|
// ---------- 课堂任务(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', {
|
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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File renamed without changes.
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { displayName } from '@oj2/contract'
|
||||||
import type { DashboardInfo } from '@oj2/contract'
|
import type { DashboardInfo } from '@oj2/contract'
|
||||||
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from 'chart.js'
|
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from 'chart.js'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
@@ -15,7 +16,7 @@ import { getBaseInfo, randomUser10 } from '../api'
|
|||||||
/**
|
/**
|
||||||
* 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、
|
* 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、
|
||||||
* 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、
|
* 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、
|
||||||
* 提交、比赛、题单、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次;
|
* 提交、比赛、专题、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次;
|
||||||
* 最下面是常用的四个入口和「抽一个人」。
|
* 最下面是常用的四个入口和「抽一个人」。
|
||||||
*
|
*
|
||||||
* 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 ——
|
* 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 ——
|
||||||
@@ -51,7 +52,7 @@ const siteTiles = computed(() => {
|
|||||||
{ label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` },
|
{ label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` },
|
||||||
{ label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' },
|
{ label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' },
|
||||||
{ label: '比赛', value: n(site.contests), note: '', to: '/admin/contest/list' },
|
{ label: '比赛', value: n(site.contests), note: '', to: '/admin/contest/list' },
|
||||||
{ label: '题单', value: n(site.problemSets), note: '', to: '/admin/problemset/list' },
|
{ label: '专题', value: n(site.problemSets), note: '', to: '/admin/problemset/list' },
|
||||||
{ label: '自学', value: n(site.tutorials), note: '课', to: '/admin/tutorial/list' },
|
{ label: '自学', value: n(site.tutorials), note: '课', to: '/admin/tutorial/list' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -94,7 +95,7 @@ const shortcuts = [
|
|||||||
{ label: '新建题目', to: '/admin/problem/create' },
|
{ label: '新建题目', to: '/admin/problem/create' },
|
||||||
{ label: '新建比赛', to: '/admin/contest/create' },
|
{ label: '新建比赛', to: '/admin/contest/create' },
|
||||||
{ label: '批量建号', to: '/admin/user/generate' },
|
{ label: '批量建号', to: '/admin/user/generate' },
|
||||||
{ label: '课堂看板', to: '/classroom' },
|
{ label: '课堂任务', to: '/task' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 抽一个人
|
// ---------------------------------------------------------------- 抽一个人
|
||||||
@@ -118,8 +119,8 @@ function stopRolling() {
|
|||||||
/** 名字滚两秒停下:接口一次给 10 个随机的,最后一个就是抽中的 */
|
/** 名字滚两秒停下:接口一次给 10 个随机的,最后一个就是抽中的 */
|
||||||
async function draw() {
|
async function draw() {
|
||||||
if (!luckyClass.value) return
|
if (!luckyClass.value) return
|
||||||
const prefix = `ks${luckyClass.value}`
|
// 后端按班级随机拿 10 个,回的就是显示的名字(老号已经剥掉 ks 前缀)
|
||||||
const names = (await randomUser10(prefix)).map((name) => name.slice(prefix.length) || name)
|
const names = await randomUser10(luckyClass.value)
|
||||||
if (!names.length) {
|
if (!names.length) {
|
||||||
luckyGuy.value = '这个班没有人'
|
luckyGuy.value = '这个班没有人'
|
||||||
return
|
return
|
||||||
@@ -146,7 +147,7 @@ onUnmounted(stopRolling)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader :title="`你好,${userStore.user?.username ?? '管理员'}`" />
|
<PageHeader :title="`你好,${userStore.user ? displayName(userStore.user.username) : '管理员'}`" />
|
||||||
|
|
||||||
<div v-if="info" class="tiles">
|
<div v-if="info" class="tiles">
|
||||||
<div class="tile">
|
<div class="tile">
|
||||||
@@ -170,7 +171,7 @@ onUnmounted(stopRolling)
|
|||||||
<div class="tile">
|
<div class="tile">
|
||||||
<span class="label">正在上课</span>
|
<span class="label">正在上课</span>
|
||||||
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b>
|
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b>
|
||||||
<router-link v-if="info.activeClass" class="link" :to="{ name: 'classroom board', query: { className: info.activeClass } }"> 去课堂看板 → </router-link>
|
<router-link v-if="info.activeClass" class="link" :to="{ name: 'task', query: { className: info.activeClass } }"> 去课堂任务 → </router-link>
|
||||||
<span v-else class="note">最近两小时没有哪个班在交题</span>
|
<span v-else class="note">最近两小时没有哪个班在交题</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,13 +207,13 @@ onUnmounted(stopRolling)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lucky">
|
<div class="lucky">
|
||||||
<b class="lucky-title">抽一个人</b>
|
<b class="lucky-title">抽签</b>
|
||||||
<n-select v-model:value="luckyClass" :options="classOptions" filterable placeholder="选班级" aria-label="班级" class="lucky-select" />
|
<n-select v-model:value="luckyClass" :options="classOptions" filterable placeholder="选班级" aria-label="班级" class="lucky-select" />
|
||||||
<span :key="pulseKey" class="lucky-name" :class="{ pulse: !isRolling && luckyGuy }">
|
<span :key="pulseKey" class="lucky-name" :class="{ pulse: !isRolling && luckyGuy }">
|
||||||
{{ luckyGuy }}
|
{{ luckyGuy }}
|
||||||
</span>
|
</span>
|
||||||
<n-button type="primary" :disabled="!luckyClass || isRolling" @click="draw">
|
<n-button type="primary" :disabled="!luckyClass || isRolling" @click="draw">
|
||||||
{{ luckyGuy ? '再抽一个' : '抽' }}
|
{{ luckyGuy ? '再抽一个' : '抽一个人' }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,7 +22,7 @@ const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
|||||||
{
|
{
|
||||||
title: '学生',
|
title: '学生',
|
||||||
key: 'username',
|
key: 'username',
|
||||||
width: 140,
|
width: 100,
|
||||||
render: (row) => h(UserName, { username: row.username }),
|
render: (row) => h(UserName, { username: row.username }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const desktopColumns: DataTableColumn<ProblemReader>[] = [
|
|||||||
{
|
{
|
||||||
title: '学生',
|
title: '学生',
|
||||||
key: 'username',
|
key: 'username',
|
||||||
width: 150,
|
width: 100,
|
||||||
render: (row) => h(UserName, { username: row.username }),
|
render: (row) => h(UserName, { username: row.username }),
|
||||||
},
|
},
|
||||||
{ title: '结果', key: 'result', width: 150, render: renderResult },
|
{ title: '结果', key: 'result', width: 150, render: renderResult },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { LearnStudentDetail } from 'utils/types'
|
|||||||
import UserName from 'shared/components/UserName.vue'
|
import UserName from 'shared/components/UserName.vue'
|
||||||
import { useBackClose } from 'shared/composables/backClose'
|
import { useBackClose } from 'shared/composables/backClose'
|
||||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
import { splitTitle, type TutorialType } from 'oj/learn/lesson'
|
import { splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||||
import { EXERCISE_KIND } from './kinds'
|
import { EXERCISE_KIND } from './kinds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,31 +30,31 @@ const theme = useThemeVars()
|
|||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
useBackClose(show, () => !isDesktop.value)
|
useBackClose(show, () => !isDesktop.value)
|
||||||
|
|
||||||
type Lesson = LearnStudentDetail['lessons'][number]
|
type Tutorial = LearnStudentDetail['tutorials'][number]
|
||||||
type Exercise = Lesson['exercises'][number]
|
type Exercise = Tutorial['exercises'][number]
|
||||||
type Problem = Lesson['problems'][number]
|
type Problem = Tutorial['problems'][number]
|
||||||
|
|
||||||
const detail = ref<LearnStudentDetail | null>(null)
|
const detail = ref<LearnStudentDetail | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const expanded = ref(new Set<number>())
|
const expanded = ref(new Set<number>())
|
||||||
|
|
||||||
const summary = computed(() => {
|
const summary = computed(() => {
|
||||||
const lessons = detail.value?.lessons ?? []
|
const tutorials = detail.value?.tutorials ?? []
|
||||||
const exercises = lessons.flatMap((lesson) => lesson.exercises)
|
const exercises = tutorials.flatMap((tutorial) => tutorial.exercises)
|
||||||
// 一道题挂在两课只算一次
|
// 一道题挂在两课只算一次
|
||||||
const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p]))
|
const problems = new Map(tutorials.flatMap((tutorial) => tutorial.problems).map((p) => [p.id, p]))
|
||||||
return {
|
return {
|
||||||
problemSolved: [...problems.values()].filter((p) => p.solved).length,
|
problemSolved: [...problems.values()].filter((p) => p.solved).length,
|
||||||
problemTotal: problems.size,
|
problemTotal: problems.size,
|
||||||
opened: lessons.filter((lesson) => lesson.viewCount > 0).length,
|
opened: tutorials.filter((tutorial) => tutorial.viewCount > 0).length,
|
||||||
seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0),
|
seconds: tutorials.reduce((n, tutorial) => n + tutorial.totalSeconds, 0),
|
||||||
solved: exercises.filter((ex) => ex.solved).length,
|
solved: exercises.filter((ex) => ex.solved).length,
|
||||||
total: exercises.length,
|
total: exercises.length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function solvedOf(lesson: Lesson) {
|
function solvedOf(tutorial: Tutorial) {
|
||||||
return lesson.exercises.filter((ex) => ex.solved).length
|
return tutorial.exercises.filter((ex) => ex.solved).length
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateOf(ex: Exercise) {
|
function stateOf(ex: Exercise) {
|
||||||
@@ -92,13 +92,13 @@ watch(
|
|||||||
const data = await getLearnStudent(userId, { type })
|
const data = await getLearnStudent(userId, { type })
|
||||||
detail.value = data
|
detail.value = data
|
||||||
expanded.value = new Set(
|
expanded.value = new Set(
|
||||||
data.lessons
|
data.tutorials
|
||||||
.filter(
|
.filter(
|
||||||
(lesson) =>
|
(tutorial) =>
|
||||||
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) ||
|
(tutorial.exercises.some((ex) => ex.attempts > 0) && tutorial.exercises.some((ex) => !ex.solved)) ||
|
||||||
lesson.problems.some((p) => p.submissions > 0 && !p.solved),
|
tutorial.problems.some((p) => p.submissions > 0 && !p.solved),
|
||||||
)
|
)
|
||||||
.map((lesson) => lesson.tutorialId),
|
.map((tutorial) => tutorial.tutorialId),
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
detail.value = null
|
detail.value = null
|
||||||
@@ -133,40 +133,42 @@ watch(
|
|||||||
'--bad': theme.errorColor,
|
'--bad': theme.errorColor,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="detail && !detail.lessons.length" class="muted none">还没打开过</div>
|
<div v-if="detail && !detail.tutorials.length" class="muted none">还没打开过</div>
|
||||||
<div v-for="lesson in detail?.lessons ?? []" :key="lesson.tutorialId" class="lesson">
|
<div v-for="tutorial in detail?.tutorials ?? []" :key="tutorial.tutorialId" class="tutorial">
|
||||||
<button type="button" class="lesson-head" @click="toggle(lesson.tutorialId)">
|
<button type="button" class="tutorial-head" @click="toggle(tutorial.tutorialId)">
|
||||||
<span class="lesson-line">
|
<span class="tutorial-line">
|
||||||
<span class="lesson-title">
|
<span class="tutorial-title">
|
||||||
<span class="muted num">第 {{ steps.get(lesson.tutorialId) }} 课</span>
|
<span class="muted num">第 {{ steps.get(tutorial.tutorialId) }} 课</span>
|
||||||
{{ splitTitle(lesson.title).kp }}
|
{{ splitTitle(tutorial.title).kp }}
|
||||||
</span>
|
</span>
|
||||||
<span class="grow" />
|
<span class="grow" />
|
||||||
<span v-if="lesson.exercises.length" class="small num"> {{ solvedOf(lesson) }}/{{ lesson.exercises.length }} </span>
|
<span v-if="tutorial.exercises.length" class="small num"> {{ solvedOf(tutorial) }}/{{ tutorial.exercises.length }} </span>
|
||||||
<span class="squares">
|
<span class="squares">
|
||||||
<span v-for="ex in lesson.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
|
<span v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
|
||||||
</span>
|
</span>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="lesson.exercises.length || lesson.problems.length"
|
v-if="tutorial.exercises.length || tutorial.problems.length"
|
||||||
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
:icon="expanded.has(tutorial.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
||||||
:width="12"
|
:width="12"
|
||||||
class="muted"
|
class="muted"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="lesson-line small muted num">
|
<span class="tutorial-line small muted num">
|
||||||
<template v-if="lesson.viewCount">
|
<template v-if="tutorial.viewCount">
|
||||||
打开 {{ lesson.viewCount }} 次
|
打开 {{ tutorial.viewCount }} 次
|
||||||
<template v-if="lesson.totalSeconds"> · 停留 {{ readableDuration(lesson.totalSeconds) }}</template>
|
<template v-if="tutorial.totalSeconds"> · 停留 {{ readableDuration(tutorial.totalSeconds) }}</template>
|
||||||
<template v-if="lesson.lastViewedAt"> · {{ parseTime(lesson.lastViewedAt, 'M月D日 HH:mm') }}</template>
|
<template v-if="tutorial.lastViewedAt"> · {{ parseTime(tutorial.lastViewedAt, 'M月D日 HH:mm') }}</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>没打开过,直接做的练一练</template>
|
<template v-else>没打开过,直接做的练一练</template>
|
||||||
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template>
|
<template v-if="!tutorial.exercises.length"> · 这课没有练一练</template>
|
||||||
<template v-if="lesson.problems.length"> · 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{ lesson.problems.length }}</template>
|
<template v-if="tutorial.problems.length">
|
||||||
|
· 配套题 {{ tutorial.problems.filter((p) => p.solved).length }}/{{ tutorial.problems.length }}</template
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)" class="ex-list">
|
<div v-if="expanded.has(tutorial.tutorialId) && (tutorial.exercises.length || tutorial.problems.length)" class="ex-list">
|
||||||
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex">
|
<div v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="ex">
|
||||||
<span class="square" :class="stateOf(ex)" />
|
<span class="square" :class="stateOf(ex)" />
|
||||||
<div class="ex-body">
|
<div class="ex-body">
|
||||||
<div class="ex-question">
|
<div class="ex-question">
|
||||||
@@ -183,8 +185,8 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="lesson.problems.length" class="sub-head small muted">配套题</div>
|
<div v-if="tutorial.problems.length" class="sub-head small muted">配套题</div>
|
||||||
<div v-for="problem in lesson.problems" :key="problem.id" class="ex">
|
<div v-for="problem in tutorial.problems" :key="problem.id" class="ex">
|
||||||
<span class="square" :class="problemStateOf(problem)" />
|
<span class="square" :class="problemStateOf(problem)" />
|
||||||
<div class="ex-body">
|
<div class="ex-body">
|
||||||
<div class="ex-question">
|
<div class="ex-question">
|
||||||
@@ -235,13 +237,13 @@ watch(
|
|||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.lesson {
|
.tutorial {
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
}
|
}
|
||||||
.lesson:first-child {
|
.tutorial:first-child {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
.lesson-head {
|
.tutorial-head {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -254,13 +256,13 @@ watch(
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.lesson-line {
|
.tutorial-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.lesson-title {
|
.tutorial-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseTime, readableDuration } from 'utils/functions'
|
|||||||
import { useConfigStore } from 'shared/store/config'
|
import { useConfigStore } from 'shared/store/config'
|
||||||
import UserName from 'shared/components/UserName.vue'
|
import UserName from 'shared/components/UserName.vue'
|
||||||
import { groupClassOptions } from 'utils/classes'
|
import { groupClassOptions } from 'utils/classes'
|
||||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||||
import 'oj/learn/learn.css'
|
import 'oj/learn/learn.css'
|
||||||
import ExerciseAttempts from './ExerciseAttempts.vue'
|
import ExerciseAttempts from './ExerciseAttempts.vue'
|
||||||
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
|
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
|
||||||
@@ -30,7 +30,7 @@ const type = ref<TutorialType>('python')
|
|||||||
const className = ref<string | null>(null)
|
const className = ref<string | null>(null)
|
||||||
const board = ref<LearnBoard | null>(null)
|
const board = ref<LearnBoard | null>(null)
|
||||||
const exercises = ref<LearnExerciseProgress[]>([])
|
const exercises = ref<LearnExerciseProgress[]>([])
|
||||||
const lessonId = ref<number | null>(null)
|
const selectedTutorialId = ref<number | null>(null)
|
||||||
const expanded = ref<number | null>(null)
|
const expanded = ref<number | null>(null)
|
||||||
const side = ref<'exercise' | 'problem'>('exercise')
|
const side = ref<'exercise' | 'problem'>('exercise')
|
||||||
const problemOpen = ref<number | null>(null)
|
const problemOpen = ref<number | null>(null)
|
||||||
@@ -58,32 +58,32 @@ const openedCount = computed(() => {
|
|||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
})
|
})
|
||||||
const lessonProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
|
const tutorialProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
|
||||||
/** 学生 → 题 → 交过的情况 */
|
/** 学生 → 题 → 交过的情况 */
|
||||||
const problemStates = computed(
|
const problemStates = computed(
|
||||||
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
|
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
|
||||||
)
|
)
|
||||||
const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])))
|
const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])))
|
||||||
const steps = computed(() => new Map(groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step]))))
|
const steps = computed(() => new Map(groups.value.flatMap((group) => group.tutorials.map((tutorial) => [tutorial.id, tutorial.step]))))
|
||||||
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
||||||
const lessonOptions = computed(() =>
|
const tutorialOptions = computed(() =>
|
||||||
groups.value.flatMap((group) =>
|
groups.value.flatMap((group) =>
|
||||||
group.lessons.map((lesson) => ({
|
group.tutorials.map((tutorial) => ({
|
||||||
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`,
|
label: `第 ${tutorial.step} 课 ${splitTitle(tutorial.title).kp}`,
|
||||||
value: lesson.id,
|
value: tutorial.id,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value))
|
const tutorialExercises = computed(() => exercises.value.filter((row) => row.tutorialId === selectedTutorialId.value))
|
||||||
/** 读过(或做过练一练)这一课的人 */
|
/** 读过(或做过练一练)这一课的人 */
|
||||||
function readersOf(id: number) {
|
function readersOf(id: number) {
|
||||||
return started.value.filter((row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id))
|
return started.value.filter((row) => row.opened.includes(id) || row.tutorials.some((tutorial) => tutorial.tutorialId === id))
|
||||||
}
|
}
|
||||||
const sideProblems = computed(() => {
|
const sideProblems = computed(() => {
|
||||||
const id = lessonId.value
|
const id = selectedTutorialId.value
|
||||||
if (id === null) return []
|
if (id === null) return []
|
||||||
const readers = readersOf(id)
|
const readers = readersOf(id)
|
||||||
return (lessonProblems.value.get(id) ?? []).map((problem) => {
|
return (tutorialProblems.value.get(id) ?? []).map((problem) => {
|
||||||
const people: ProblemReader[] = readers.map((row) => ({
|
const people: ProblemReader[] = readers.map((row) => ({
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -98,43 +98,43 @@ const sideProblems = computed(() => {
|
|||||||
})
|
})
|
||||||
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
|
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
|
||||||
|
|
||||||
function lessonOf(row: LearnBoardStudent, id: number) {
|
function tutorialOf(row: LearnBoardStudent, id: number) {
|
||||||
return row.lessons.find((lesson) => lesson.tutorialId === id)
|
return row.tutorials.find((tutorial) => tutorial.tutorialId === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellOf(row: LearnBoardStudent, id: number) {
|
function cellOf(row: LearnBoardStudent, id: number) {
|
||||||
if (row.done.includes(id)) return 'done'
|
if (row.done.includes(id)) return 'done'
|
||||||
const lesson = lessonOf(row, id)
|
const tutorial = tutorialOf(row, id)
|
||||||
if (lesson) return lesson.solved ? 'partial' : 'partial stuck'
|
if (tutorial) return tutorial.solved ? 'partial' : 'partial stuck'
|
||||||
if (row.opened.includes(id)) return 'opened'
|
if (row.opened.includes(id)) return 'opened'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
||||||
function cellStyle(row: LearnBoardStudent, id: number) {
|
function cellStyle(row: LearnBoardStudent, id: number) {
|
||||||
const lesson = lessonOf(row, id)
|
const tutorial = tutorialOf(row, id)
|
||||||
const total = exerciseTotal.value.get(id) ?? 0
|
const total = exerciseTotal.value.get(id) ?? 0
|
||||||
if (!lesson || !total || row.done.includes(id)) return undefined
|
if (!tutorial || !total || row.done.includes(id)) return undefined
|
||||||
return { '--fill': `${Math.round((lesson.solved / total) * 100)}%` }
|
return { '--fill': `${Math.round((tutorial.solved / total) * 100)}%` }
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
||||||
const head = `第 ${step} 课 ${splitTitle(title).kp}`
|
const head = `第 ${step} 课 ${splitTitle(title).kp}`
|
||||||
const total = exerciseTotal.value.get(id) ?? 0
|
const total = exerciseTotal.value.get(id) ?? 0
|
||||||
const lesson = lessonOf(row, id)
|
const tutorial = tutorialOf(row, id)
|
||||||
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ''
|
const wrong = tutorial?.wrongAttempts ? `,错了 ${tutorial.wrongAttempts} 次` : ''
|
||||||
const problems = lessonProblems.value.get(id) ?? []
|
const problems = tutorialProblems.value.get(id) ?? []
|
||||||
const states = problemStates.value.get(row.userId)
|
const states = problemStates.value.get(row.userId)
|
||||||
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
|
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
|
||||||
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
|
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
|
||||||
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}`
|
if (tutorial) return `${head}:练一练 ${tutorial.solved}/${total}${wrong}${tail}`
|
||||||
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
|
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
|
||||||
return `${head}:没打开`
|
return `${head}:没打开`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 他打开过或做过练一练的那些课 */
|
/** 他打开过或做过练一练的那些课 */
|
||||||
function touched(row: LearnBoardStudent) {
|
function touched(row: LearnBoardStudent) {
|
||||||
return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)])
|
return new Set([...row.opened, ...row.tutorials.map((tutorial) => tutorial.tutorialId)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 应做 = 他碰过的那些课,练一练加起来几道 */
|
/** 应做 = 他碰过的那些课,练一练加起来几道 */
|
||||||
@@ -148,7 +148,7 @@ function exerciseDue(row: LearnBoardStudent) {
|
|||||||
function problemCount(row: LearnBoardStudent) {
|
function problemCount(row: LearnBoardStudent) {
|
||||||
const ids = new Set<number>()
|
const ids = new Set<number>()
|
||||||
for (const id of touched(row)) {
|
for (const id of touched(row)) {
|
||||||
for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id)
|
for (const problem of tutorialProblems.value.get(id) ?? []) ids.add(problem.id)
|
||||||
}
|
}
|
||||||
const states = problemStates.value.get(row.userId)
|
const states = problemStates.value.get(row.userId)
|
||||||
let solved = 0
|
let solved = 0
|
||||||
@@ -180,7 +180,7 @@ async function load() {
|
|||||||
exercises.value = ex.results
|
exercises.value = ex.results
|
||||||
// 练一练默认看大家打开得最多的那课
|
// 练一练默认看大家打开得最多的那课
|
||||||
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
|
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||||
lessonId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
|
selectedTutorialId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -235,11 +235,11 @@ onMounted(load)
|
|||||||
<div class="line head">
|
<div class="line head">
|
||||||
<span class="c-name strong">学到哪了</span>
|
<span class="c-name strong">学到哪了</span>
|
||||||
<div class="cells-head">
|
<div class="cells-head">
|
||||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head">
|
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-head">
|
||||||
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
|
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
|
||||||
<span class="group-steps">
|
<span class="group-steps">
|
||||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp">
|
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="step" :title="splitTitle(tutorial.title).kp">
|
||||||
{{ lesson.step }}
|
{{ tutorial.step }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,14 +254,14 @@ onMounted(load)
|
|||||||
<UserName :username="row.username" />
|
<UserName :username="row.username" />
|
||||||
</button>
|
</button>
|
||||||
<div class="cells">
|
<div class="cells">
|
||||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
|
||||||
<span
|
<span
|
||||||
v-for="lesson in group.lessons"
|
v-for="tutorial in group.tutorials"
|
||||||
:key="lesson.id"
|
:key="tutorial.id"
|
||||||
class="cell"
|
class="cell"
|
||||||
:class="cellOf(row, lesson.id)"
|
:class="cellOf(row, tutorial.id)"
|
||||||
:style="cellStyle(row, lesson.id)"
|
:style="cellStyle(row, tutorial.id)"
|
||||||
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
|
:title="cellTitle(row, tutorial.step, tutorial.title, tutorial.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,14 +284,14 @@ onMounted(load)
|
|||||||
<div v-else class="line foot">
|
<div v-else class="line foot">
|
||||||
<span class="c-name muted small">每课打开过</span>
|
<span class="c-name muted small">每课打开过</span>
|
||||||
<div class="cells bars">
|
<div class="cells bars">
|
||||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
|
||||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col">
|
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="bar-col">
|
||||||
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span>
|
<span class="bar-num num">{{ openedCount.get(tutorial.id) || '' }}</span>
|
||||||
<span
|
<span
|
||||||
class="bar"
|
class="bar"
|
||||||
:class="{ zero: !openedCount.get(lesson.id) }"
|
:class="{ zero: !openedCount.get(tutorial.id) }"
|
||||||
:style="{
|
:style="{
|
||||||
height: `${Math.max(2, ((openedCount.get(lesson.id) ?? 0) / maxOpened) * 40)}px`,
|
height: `${Math.max(2, ((openedCount.get(tutorial.id) ?? 0) / maxOpened) * 40)}px`,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -316,7 +316,7 @@ onMounted(load)
|
|||||||
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
|
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
|
||||||
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
|
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
|
||||||
</div>
|
</div>
|
||||||
<n-select v-model:value="lessonId" :options="lessonOptions" size="small" class="lesson-select" />
|
<n-select v-model:value="selectedTutorialId" :options="tutorialOptions" size="small" class="tutorial-select" />
|
||||||
</div>
|
</div>
|
||||||
<template v-if="side === 'problem'">
|
<template v-if="side === 'problem'">
|
||||||
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
|
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
|
||||||
@@ -333,8 +333,8 @@ onMounted(load)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
|
<div v-else-if="!tutorialExercises.length" class="muted none">这课没有练一练</div>
|
||||||
<div v-for="row in side === 'exercise' ? lessonExercises : []" :key="row.exerciseId" class="ex">
|
<div v-for="row in side === 'exercise' ? tutorialExercises : []" :key="row.exerciseId" class="ex">
|
||||||
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
||||||
<span class="ex-line">
|
<span class="ex-line">
|
||||||
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
||||||
@@ -503,8 +503,9 @@ onMounted(load)
|
|||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
/* 名字最长 4 个字,表头「每课打开过」5 个小字也放得下(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽) */
|
||||||
.c-name {
|
.c-name {
|
||||||
width: 110px;
|
width: 80px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -711,7 +712,7 @@ onMounted(load)
|
|||||||
.class-select {
|
.class-select {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
.lesson-select {
|
.tutorial-select {
|
||||||
flex: 1 0 100%;
|
flex: 1 0 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -756,7 +757,7 @@ onMounted(load)
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 96px;
|
width: 72px;
|
||||||
margin-left: -16px;
|
margin-left: -16px;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
background: v-bind('theme.bodyColor');
|
background: v-bind('theme.bodyColor');
|
||||||
|
|||||||
+5
-5
@@ -12,11 +12,11 @@ import { errorCode, errorMessage, isApiError } from 'utils/api'
|
|||||||
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
||||||
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
||||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
||||||
import PreviewStrip from './components/PreviewStrip.vue'
|
import PreviewStrip from './PreviewStrip.vue'
|
||||||
import { usePreviewCollapsed } from './components/previewCollapse'
|
import { usePreviewCollapsed } from './previewCollapse'
|
||||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api'
|
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../../api'
|
||||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm'
|
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './problemForm'
|
||||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript'
|
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './sqlScript'
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||||
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
||||||
@@ -3,7 +3,7 @@ import { isSqlProblem } from '@oj2/contract'
|
|||||||
import type { AdminProblem } from 'utils/types'
|
import type { AdminProblem } from 'utils/types'
|
||||||
import { getProblem } from '../api'
|
import { getProblem } from '../api'
|
||||||
import ProblemDetail from './detail.vue'
|
import ProblemDetail from './detail.vue'
|
||||||
import SqlDetail from './SqlDetail.vue'
|
import SqlDetail from './components/SqlDetail.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import {
|
|||||||
import BadgeModal from './components/BadgeModal.vue'
|
import BadgeModal from './components/BadgeModal.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
|
* 新建 / 编辑专题,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
|
||||||
* 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。
|
* 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。
|
||||||
*
|
*
|
||||||
* 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。
|
* 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。
|
||||||
* 新建时题目先攒在页面上,保存时建好题单再一次加进去;编辑时加、删、排、切选做当场生效。
|
* 新建时题目先攒在页面上,保存时建好专题再一次加进去;编辑时加、删、排、切选做当场生效。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{ problemSetId?: string }>()
|
const props = defineProps<{ problemSetId?: string }>()
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ function remove(item: Item) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: '从题单里拿掉这道题?',
|
title: '从专题里拿掉这道题?',
|
||||||
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
|
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
|
||||||
positiveText: '拿掉',
|
positiveText: '拿掉',
|
||||||
negativeText: '不拿',
|
negativeText: '不拿',
|
||||||
@@ -312,7 +312,7 @@ async function load() {
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!form.title.trim()) {
|
if (!form.title.trim()) {
|
||||||
message.error('题单还没起名字')
|
message.error('专题还没起名字')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (untilMs.value !== null && untilMs.value <= Date.now()) {
|
if (untilMs.value !== null && untilMs.value <= Date.now()) {
|
||||||
@@ -346,7 +346,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message.success(items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : '题单建好了')
|
message.success(items.value.length ? `专题建好了,${items.value.length} 道题都加进去了` : '专题建好了')
|
||||||
router.replace({ name: 'admin problemset edit', params: { problemSetId: created.id } })
|
router.replace({ name: 'admin problemset edit', params: { problemSetId: created.id } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(errorMessage(err))
|
message.error(errorMessage(err))
|
||||||
@@ -358,7 +358,7 @@ async function submit() {
|
|||||||
function removeSet() {
|
function removeSet() {
|
||||||
const set = current.value!
|
const set = current.value!
|
||||||
dialog.error({
|
dialog.error({
|
||||||
title: `删除题单「${set.title}」?`,
|
title: `删除专题「${set.title}」?`,
|
||||||
content: set.participantCount
|
content: set.participantCount
|
||||||
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
|
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
|
||||||
: '还没有人加入。删掉找不回来。',
|
: '还没有人加入。删掉找不回来。',
|
||||||
@@ -380,9 +380,9 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }">
|
<PageHeader :title="editing ? '编辑专题' : '新建专题'" :back="{ name: 'admin problemset list' }">
|
||||||
<template v-if="editing" #actions>
|
<template v-if="editing" #actions>
|
||||||
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank"> 到前台看这个题单 </n-button>
|
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank"> 到前台看这个专题 </n-button>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -410,7 +410,7 @@ onMounted(load)
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
<span class="hint">{{ assignHint }}</span>
|
<span class="hint">{{ assignHint }}</span>
|
||||||
<span class="hint">
|
<span class="hint">
|
||||||
布置期内,题单里这几道题学生以前交的代码先藏起来(不管加没加入),在题单里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。
|
布置期内,专题里这几道题学生以前交的代码先藏起来(不管加没加入),在专题里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +418,7 @@ onMounted(load)
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">公开</span>
|
<span class="label">公开</span>
|
||||||
<n-switch v-model:value="form.visible" />
|
<n-switch v-model:value="form.visible" />
|
||||||
<span class="hint">{{ form.visible ? '学生能在题单列表里看到' : '藏起来,学生看不到也进不去' }}</span>
|
<span class="hint">{{ form.visible ? '学生能在专题列表里看到' : '藏起来,学生看不到也进不去' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="current" class="stats">
|
<div v-if="current" class="stats">
|
||||||
@@ -434,7 +434,7 @@ onMounted(load)
|
|||||||
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
|
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
|
||||||
>
|
>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<n-button v-if="editing" text type="error" @click="removeSet">删除题单</n-button>
|
<n-button v-if="editing" text type="error" @click="removeSet">删除专题</n-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -503,7 +503,7 @@ onMounted(load)
|
|||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button>
|
<n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!editing" class="empty hint">先保存题单,再加奖章</div>
|
<div v-if="!editing" class="empty hint">先保存专题,再加奖章</div>
|
||||||
<div v-else-if="badges.length" class="list">
|
<div v-else-if="badges.length" class="list">
|
||||||
<div v-for="badge in badges" :key="badge.id" class="item badge">
|
<div v-for="badge in badges" :key="badge.id" class="item badge">
|
||||||
<img :src="badge.icon" alt="" class="badge-icon" />
|
<img :src="badge.icon" alt="" class="badge-icon" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
|||||||
import { useListLoader } from 'shared/composables/listLoader'
|
import { useListLoader } from 'shared/composables/listLoader'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
|
* 后台专题列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
|
||||||
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
|
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
|
||||||
*/
|
*/
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -42,7 +42,7 @@ function renderVisible(row: AdminProblemSet) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手机上一个题单一张卡:名字一行(点了编辑),题数 · 加入 / 做完 · 奖章 · 创建者 · 日期一行小字 */
|
/** 手机上一个专题一张卡:名字一行(点了编辑),题数 · 加入 / 做完 · 奖章 · 创建者 · 日期一行小字 */
|
||||||
function renderMobileCard(row: AdminProblemSet) {
|
function renderMobileCard(row: AdminProblemSet) {
|
||||||
const items = [
|
const items = [
|
||||||
`${row.problemsCount} 道题`,
|
`${row.problemsCount} 道题`,
|
||||||
@@ -62,14 +62,14 @@ function renderMobileCard(row: AdminProblemSet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mobileColumns: DataTableColumn<AdminProblemSet>[] = [
|
const mobileColumns: DataTableColumn<AdminProblemSet>[] = [
|
||||||
{ title: '题单', key: 'title', render: renderMobileCard },
|
{ title: '专题', key: 'title', render: renderMobileCard },
|
||||||
{ title: '公开', key: 'visible', width: 72, align: 'center', render: renderVisible },
|
{ title: '公开', key: 'visible', width: 72, align: 'center', render: renderVisible },
|
||||||
]
|
]
|
||||||
|
|
||||||
const desktopColumns: DataTableColumn<AdminProblemSet>[] = [
|
const desktopColumns: DataTableColumn<AdminProblemSet>[] = [
|
||||||
{ title: 'ID', key: 'id', width: 70 },
|
{ title: 'ID', key: 'id', width: 70 },
|
||||||
{
|
{
|
||||||
title: '题单',
|
title: '专题',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
render: renderTitle,
|
render: renderTitle,
|
||||||
@@ -131,12 +131,12 @@ watch(() => [query.page, query.limit], listProblemSets)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader title="题单列表">
|
<PageHeader title="专题列表">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })"> 新建题单 </n-button>
|
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })"> 新建专题 </n-button>
|
||||||
</template>
|
</template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<n-input v-model:value="query.keyword" placeholder="搜题单名字" clearable @clear="clearQuery" :style="{ width: isMobile ? '100%' : '220px' }" />
|
<n-input v-model:value="query.keyword" placeholder="搜专题名字" clearable @clear="clearQuery" :style="{ width: isMobile ? '100%' : '220px' }" />
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-data-table :loading="loading" striped class="set-table" :columns="columns" :data="problemSets" />
|
<n-data-table :loading="loading" striped class="set-table" :columns="columns" :data="problemSets" />
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
|
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
|
||||||
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue'
|
import ProblemPicker from 'oj/task/components/ProblemPicker.vue'
|
||||||
import { useConfigStore } from 'shared/store/config'
|
import { useConfigStore } from 'shared/store/config'
|
||||||
import { errorMessage } from 'utils/api'
|
import { errorMessage } from 'utils/api'
|
||||||
import { groupClassOptions } from 'utils/classes'
|
import { groupClassOptions } from 'utils/classes'
|
||||||
@@ -10,8 +10,8 @@ import { parseTime } from 'utils/functions'
|
|||||||
import { createAdminLesson, updateAdminLesson } from '../../api'
|
import { createAdminLesson, updateAdminLesson } from '../../api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置,
|
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 任务页上点错了班、隔天才想起来布置,
|
||||||
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
|
* 都在这里纠正;补的是任务页外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** null = 补一节 */
|
/** null = 补一节 */
|
||||||
@@ -54,7 +54,7 @@ function inputIds() {
|
|||||||
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 选题弹框,和看板上那个是同一个 ----
|
// ---- 选题弹框,和任务页上那个是同一个 ----
|
||||||
const picking = ref(false)
|
const picking = ref(false)
|
||||||
const pickerInitial = ref<{ id: string; title?: string }[]>([])
|
const pickerInitial = ref<{ id: string; title?: string }[]>([])
|
||||||
function openPicker() {
|
function openPicker() {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRouteQuery } from '@vueuse/router'
|
||||||
import PageHeader from 'admin/components/PageHeader.vue'
|
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 { dayStartOfDate } from 'oj/statistics/period'
|
||||||
import Pagination from 'shared/components/Pagination.vue'
|
import Pagination from 'shared/components/Pagination.vue'
|
||||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
@@ -17,8 +17,8 @@ import { deleteAdminLesson, getAdminLessons } from '../api'
|
|||||||
import LessonEditModal from './components/LessonEditModal.vue'
|
import LessonEditModal from './components/LessonEditModal.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里,
|
* 后台「课堂任务」:布置过的每一节课一行,新的在前。课堂任务上当场布置的都在这里,
|
||||||
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到
|
* 布置错了能改、删;任务页外上的课能补一节 —— 数据统计按布置切课,补了才认得到
|
||||||
*/
|
*/
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -26,14 +26,17 @@ const theme = useThemeVars()
|
|||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const { isMobile } = useBreakpoints()
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
// 从看板「布置过的课」进来带着 ?className=
|
// 从任务页「布置过的课」进来带着 ?className=
|
||||||
const { query } = usePagination<{ className: string; teacher: string }>({
|
const { query } = usePagination<{ className: string; teacher: string; mode: string }>({
|
||||||
className: useRouteQuery('className', '').value,
|
className: useRouteQuery('className', '').value,
|
||||||
teacher: useRouteQuery('teacher', '').value,
|
teacher: useRouteQuery('teacher', '').value,
|
||||||
|
mode: useRouteQuery('mode', '').value,
|
||||||
})
|
})
|
||||||
const data = ref<AdminLessonList | null>(null)
|
const data = ref<AdminLessonList | null>(null)
|
||||||
|
|
||||||
const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])])
|
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(() => [
|
const teacherOptions = computed(() => [
|
||||||
{ label: '全部老师', value: '' },
|
{ label: '全部老师', value: '' },
|
||||||
...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })),
|
...(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, {
|
getAdminLessons((query.page - 1) * query.limit, query.limit, {
|
||||||
className: query.className,
|
className: query.className,
|
||||||
teacher: query.teacher ? Number(query.teacher) : null,
|
teacher: query.teacher ? Number(query.teacher) : null,
|
||||||
|
mode: query.mode,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (res) data.value = res
|
if (res) data.value = res
|
||||||
}
|
}
|
||||||
onMounted(list)
|
onMounted(list)
|
||||||
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次
|
// 换了班级、老师、做法 usePagination 会把页码拨回 1,同一拍里只拉一次
|
||||||
watch(() => [query.page, query.limit, query.className, query.teacher], list)
|
watch(() => [query.page, query.limit, query.className, query.teacher, query.mode], list)
|
||||||
|
|
||||||
// ---- 改 / 补 / 删 ----
|
// ---- 改 / 补 / 删 ----
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
@@ -133,17 +137,24 @@ function renderActions(lesson: AdminLesson) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */
|
/** 三种做法各一个颜色,一眼分得开;写代码也写出来,不当默认值省掉 */
|
||||||
function wayText(lesson: AdminLesson) {
|
const MODE_TAG_TYPE: Record<LessonMode, 'default' | 'info' | 'warning'> = { code: 'default', both: 'info', flowchart: 'warning' }
|
||||||
return [lesson.mode !== 'code' ? LESSON_MODE_LABEL[lesson.mode] : '', lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : ''].filter(Boolean).join(' · ')
|
|
||||||
|
/** 「写代码 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) {
|
function renderMobileCard(lesson: AdminLesson) {
|
||||||
return h('div', { class: 'm-card' }, [
|
return h('div', { class: 'm-card' }, [
|
||||||
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
|
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
|
||||||
|
renderWay(lesson),
|
||||||
renderProblems(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),
|
renderActions(lesson),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -153,7 +164,7 @@ const desktopColumns: DataTableColumn<AdminLesson>[] = [
|
|||||||
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
|
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
|
||||||
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
|
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
|
||||||
{ title: '老师', key: 'createdBy', width: 90 },
|
{ 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: 'problems', width: 300, render: renderProblems },
|
||||||
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
|
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
|
||||||
{ title: '', key: 'actions', render: renderActions },
|
{ title: '', key: 'actions', render: renderActions },
|
||||||
@@ -163,7 +174,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader title="课堂作业">
|
<PageHeader title="课堂任务">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
|
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
|
||||||
</template>
|
</template>
|
||||||
@@ -173,6 +184,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
|
|||||||
<template #filters>
|
<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.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.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>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" />
|
<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;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lesson-table :deep(.way) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.lesson-table :deep(.actions) {
|
.lesson-table :deep(.actions) {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -4,9 +4,9 @@ import type { AdminTutorialProblem } from '@oj2/contract'
|
|||||||
import { errorMessage } from 'utils/api'
|
import { errorMessage } from 'utils/api'
|
||||||
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
|
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
|
||||||
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
|
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
|
||||||
import { parseLesson } from 'oj/learn/composables/useLessonParse'
|
import { parseTutorial } from 'oj/learn/composables/useTutorialParse'
|
||||||
import { joinTitle, lessonPath, splitTitle } from 'oj/learn/lesson'
|
import { joinTitle, tutorialPath, splitTitle } from 'oj/learn/utils'
|
||||||
import LessonBody from 'oj/learn/components/LessonBody.vue'
|
import TutorialBody from 'oj/learn/components/TutorialBody.vue'
|
||||||
import 'oj/learn/learn.css'
|
import 'oj/learn/learn.css'
|
||||||
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
|
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
|
||||||
import ExerciseManager from './components/ExerciseManager.vue'
|
import ExerciseManager from './components/ExerciseManager.vue'
|
||||||
@@ -69,7 +69,7 @@ const heading = computed(() => {
|
|||||||
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
|
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
|
||||||
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
|
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
|
||||||
})
|
})
|
||||||
const preview = computed(() => parseLesson(tutorial.content, exercises.value))
|
const preview = computed(() => parseTutorial(tutorial.content, exercises.value))
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
|
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
|
||||||
@@ -131,7 +131,7 @@ function insertExercise(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openStudentView() {
|
function openStudentView() {
|
||||||
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, '_blank')
|
if (step.value) window.open(router.resolve(tutorialPath(tutorial.type, step.value)).href, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.tutorialID, init)
|
watch(() => props.tutorialID, init)
|
||||||
@@ -199,7 +199,7 @@ onMounted(init)
|
|||||||
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
|
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
|
||||||
</div>
|
</div>
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<LessonBody
|
<TutorialBody
|
||||||
:segments="preview.segments"
|
:segments="preview.segments"
|
||||||
:lang="tutorial.type"
|
:lang="tutorial.type"
|
||||||
:tutorial-id="tutorial.id"
|
:tutorial-id="tutorial.id"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { errorMessage } from 'utils/api'
|
import { errorMessage } from 'utils/api'
|
||||||
import type { TutorialListItem } from 'utils/types'
|
import type { TutorialListItem } from 'utils/types'
|
||||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||||
import ProjectTile from 'oj/learn/components/ProjectTile.vue'
|
import ProjectTile from 'oj/learn/components/ProjectTile.vue'
|
||||||
import 'oj/learn/learn.css'
|
import 'oj/learn/learn.css'
|
||||||
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
|
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
|
||||||
@@ -28,11 +28,11 @@ const list = computed(() => all.value[type.value] ?? [])
|
|||||||
const groups = computed(() =>
|
const groups = computed(() =>
|
||||||
groupByProject(list.value)
|
groupByProject(list.value)
|
||||||
.map((group) => {
|
.map((group) => {
|
||||||
const items = group.lessons.map((lesson) => list.value.find((item) => item.id === lesson.id)!)
|
const items = group.tutorials.map((entry) => list.value.find((item) => item.id === entry.id)!)
|
||||||
return {
|
return {
|
||||||
...group,
|
...group,
|
||||||
items,
|
items,
|
||||||
key: `${group.project}:${group.lessons[0]!.id}`,
|
key: `${group.project}:${group.tutorials[0]!.id}`,
|
||||||
hidden: items.every((item) => !item.isPublic),
|
hidden: items.every((item) => !item.isPublic),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -189,7 +189,7 @@ onMounted(load)
|
|||||||
<div
|
<div
|
||||||
v-for="item in group.items"
|
v-for="item in group.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="row lesson"
|
class="row tutorial"
|
||||||
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
|
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="dragId = item.id"
|
@dragstart="dragId = item.id"
|
||||||
@@ -330,10 +330,10 @@ onMounted(load)
|
|||||||
.group-name {
|
.group-name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.lesson.off {
|
.tutorial.off {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.lesson.dragging {
|
.tutorial.dragging {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
.handle {
|
.handle {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const classError = computed(() => {
|
|||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : ''))
|
/** 填好了、格式也对的班号;没填或填错是空串 */
|
||||||
|
const klass = computed(() => (classNo.value.trim() && !classError.value ? classNo.value.trim() : ''))
|
||||||
|
|
||||||
/** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
|
/** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
|
||||||
const names = computed(() =>
|
const names = computed(() =>
|
||||||
@@ -56,11 +57,16 @@ function passwordOf(name: string) {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名就是名字(2026-11 起不再加 ks + 班号)。名字被别的班、注册的号占了,后端给的是加了数字的(杨涵宇2);
|
||||||
|
* 查重还没回来之前先按名字摆着
|
||||||
|
*/
|
||||||
|
const suggested = ref<Map<string, string>>(new Map())
|
||||||
const rows = computed(() =>
|
const rows = computed(() =>
|
||||||
names.value.map((name) => ({
|
names.value.map((name) => ({
|
||||||
name,
|
name,
|
||||||
username: prefix.value + name,
|
username: suggested.value.get(name) ?? name,
|
||||||
password: passwordOf(prefix.value + name),
|
password: passwordOf(name),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +75,7 @@ const duplicates = computed(() => {
|
|||||||
const first = new Map<string, number>()
|
const first = new Map<string, number>()
|
||||||
const result: { line: number; firstLine: number; name: string }[] = []
|
const result: { line: number; firstLine: number; name: string }[] = []
|
||||||
rows.value.forEach((row, i) => {
|
rows.value.forEach((row, i) => {
|
||||||
const key = row.username.toLowerCase()
|
const key = row.name.toLowerCase()
|
||||||
const seen = first.get(key)
|
const seen = first.get(key)
|
||||||
if (seen === undefined) first.set(key, i + 1)
|
if (seen === undefined) first.set(key, i + 1)
|
||||||
else result.push({ line: i + 1, firstLine: seen, name: row.name })
|
else result.push({ line: i + 1, firstLine: seen, name: row.name })
|
||||||
@@ -77,24 +83,39 @@ const duplicates = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
// 已经有的账号:停手半秒再去查
|
// 这个班已经有的同名账号(老号按剥掉 ks 前缀的名字比),顺带拿回每个名字会建成的用户名:停手半秒再去查
|
||||||
const existing = ref<{ username: string; createTime: string | null }[]>([])
|
const existing = ref<{ name: string; username: string; createTime: string | null }[]>([])
|
||||||
const checking = ref(false)
|
const checking = ref(false)
|
||||||
|
/** 查重结果对应的是哪一份「班级 + 名单」:和眼前的对不上(刚改过、还在防抖)就不让建 */
|
||||||
|
const inputKey = computed(() => `${klass.value}\n${names.value.join('\n')}`)
|
||||||
|
const checkedKey = ref('')
|
||||||
|
// 改得快时前一次请求可能后回来,只认最后发出去的那次
|
||||||
|
let checkSeq = 0
|
||||||
watchDebounced(
|
watchDebounced(
|
||||||
() => rows.value.map((row) => row.username).join('\n'),
|
inputKey,
|
||||||
async () => {
|
async (key) => {
|
||||||
const usernames = [...new Set(rows.value.map((row) => row.username))]
|
const seq = ++checkSeq
|
||||||
if (!usernames.length || classError.value || usernames.length > 500) {
|
const list = [...new Set(names.value)]
|
||||||
|
if (!list.length || !klass.value || list.length > 500) {
|
||||||
existing.value = []
|
existing.value = []
|
||||||
|
suggested.value = new Map()
|
||||||
|
checkedKey.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
checking.value = true
|
checking.value = true
|
||||||
try {
|
try {
|
||||||
existing.value = (await checkUsernames(usernames)).existing
|
const res = await checkUsernames(klass.value, list)
|
||||||
|
if (seq !== checkSeq) return
|
||||||
|
existing.value = res.existing
|
||||||
|
suggested.value = new Map(list.map((name, i) => [name, res.usernames[i] ?? name]))
|
||||||
|
checkedKey.value = key
|
||||||
} catch {
|
} catch {
|
||||||
|
if (seq !== checkSeq) return
|
||||||
existing.value = []
|
existing.value = []
|
||||||
|
suggested.value = new Map()
|
||||||
|
checkedKey.value = ''
|
||||||
} finally {
|
} finally {
|
||||||
checking.value = false
|
if (seq === checkSeq) checking.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ debounce: 500, immediate: true },
|
{ debounce: 500, immediate: true },
|
||||||
@@ -102,7 +123,7 @@ watchDebounced(
|
|||||||
|
|
||||||
const tooMany = computed(() => rows.value.length > 500)
|
const tooMany = computed(() => rows.value.length > 500)
|
||||||
const issueCount = computed(() => (classError.value ? 1 : 0) + duplicates.value.length + existing.value.length + (tooMany.value ? 1 : 0))
|
const issueCount = computed(() => (classError.value ? 1 : 0) + duplicates.value.length + existing.value.length + (tooMany.value ? 1 : 0))
|
||||||
const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!prefix.value)
|
const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!klass.value && checkedKey.value === inputKey.value)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 建号
|
// ---------------------------------------------------------------- 建号
|
||||||
|
|
||||||
@@ -118,8 +139,9 @@ async function submit() {
|
|||||||
const payload: ImportUsersRequest['users'] = rows.value.map((row, i) => [
|
const payload: ImportUsersRequest['users'] = rows.value.map((row, i) => [
|
||||||
row.username,
|
row.username,
|
||||||
row.password,
|
row.password,
|
||||||
`${prefix.value || 'user'}.${batch}.${i + 1}@example.com`,
|
`c${klass.value}.${batch}.${i + 1}@example.com`,
|
||||||
row.name,
|
row.name,
|
||||||
|
klass.value,
|
||||||
])
|
])
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
@@ -142,7 +164,7 @@ function again() {
|
|||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
if (!done.value) return
|
if (!done.value) return
|
||||||
downloadAccountsCsv(done.value, prefix.value || '账号')
|
downloadAccountsCsv(done.value, klass.value ? classLabel(klass.value) : '账号')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
|
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
|
||||||
@@ -196,7 +218,6 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
<div class="left">
|
<div class="left">
|
||||||
<label class="label" for="class-no">班级号</label>
|
<label class="label" for="class-no">班级号</label>
|
||||||
<div class="class-row">
|
<div class="class-row">
|
||||||
<span class="ks">ks</span>
|
|
||||||
<n-input v-model:value="classNo" :input-props="{ id: 'class-no', inputmode: 'numeric' }" placeholder="如 2612" style="width: 120px" />
|
<n-input v-model:value="classNo" :input-props="{ id: 'class-no', inputmode: 'numeric' }" placeholder="如 2612" style="width: 120px" />
|
||||||
<span v-if="classNo.trim() && !classError" class="class-name">
|
<span v-if="classNo.trim() && !classError" class="class-name">
|
||||||
{{ classLabel(classNo.trim()) }}
|
{{ classLabel(classNo.trim()) }}
|
||||||
@@ -210,7 +231,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
<template v-if="rows.length">
|
<template v-if="rows.length">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<b>会建这 {{ rows.length }} 个账号</b>
|
<b>会建这 {{ rows.length }} 个账号</b>
|
||||||
<n-tag v-if="!issueCount && !checking && prefix" type="success" :bordered="false" round> 没有重名 </n-tag>
|
<n-tag v-if="!issueCount && !checking && klass && checkedKey === inputKey" type="success" :bordered="false" round> 没有重名 </n-tag>
|
||||||
<n-tag v-if="issueCount" type="warning" :bordered="false" round> 有 {{ issueCount }} 处要先改 </n-tag>
|
<n-tag v-if="issueCount" type="warning" :bordered="false" round> 有 {{ issueCount }} 处要先改 </n-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,7 +242,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 —— 同班同名的,名字后面加个 1、2 区分
|
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 —— 同班同名的,名字后面加个 1、2 区分
|
||||||
</div>
|
</div>
|
||||||
<div v-for="it in existing" :key="it.username">
|
<div v-for="it in existing" :key="it.username">
|
||||||
{{ it.username }} 已经有这个账号了<template v-if="it.createTime">({{ parseTime(it.createTime, 'YYYY-MM-DD') }} 建的)</template>
|
{{ classLabel(klass) }}已经有「{{ it.name }}」了<template v-if="it.createTime">({{ parseTime(it.createTime, 'YYYY-MM-DD') }} 建的)</template>
|
||||||
—— 删掉这一行,或者换个名字
|
—— 删掉这一行,或者换个名字
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +270,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
<n-button type="primary" size="large" :disabled="!canSubmit" :loading="saving || checking" @click="submit">
|
<n-button type="primary" size="large" :disabled="!canSubmit" :loading="saving || checking" @click="submit">
|
||||||
建好这 {{ rows.length }} 个账号
|
建好这 {{ rows.length }} 个账号
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-text v-if="!prefix" depth="3" class="hint" style="margin-left: 12px"> 先填班级号 </n-text>
|
<n-text v-if="!klass" depth="3" class="hint" style="margin-left: 12px"> 先填班级号 </n-text>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n-empty v-else :description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`" :style="{ marginTop: isMobile ? '24px' : '80px' }" />
|
<n-empty v-else :description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`" :style="{ marginTop: isMobile ? '24px' : '80px' }" />
|
||||||
@@ -288,11 +309,6 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ks {
|
|
||||||
font-size: 18px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.class-name {
|
.class-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import UserNameCell from './components/UserNameCell.vue'
|
|||||||
import PasswordCell from './components/PasswordCell.vue'
|
import PasswordCell from './components/PasswordCell.vue'
|
||||||
import { PROBLEM_PERMISSION, USER_TYPE } from 'utils/constants'
|
import { PROBLEM_PERMISSION, USER_TYPE } from 'utils/constants'
|
||||||
import { useRouteQuery } from '@vueuse/router'
|
import { useRouteQuery } from '@vueuse/router'
|
||||||
import { NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from '@oj2/contract'
|
import { legacyClassOf, NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from '@oj2/contract'
|
||||||
import { downloadAccountsCsv } from './accountsCsv'
|
import { downloadAccountsCsv } from './accountsCsv'
|
||||||
import { classLabel, groupClassOptions } from 'utils/classes'
|
import { classLabel, groupClassOptions } from 'utils/classes'
|
||||||
import { useConfigStore } from 'shared/store/config'
|
import { useConfigStore } from 'shared/store/config'
|
||||||
@@ -50,6 +50,10 @@ const classOptions = computed(() => [
|
|||||||
...groupClassOptions(configStore.config?.classList ?? []),
|
...groupClassOptions(configStore.config?.classList ?? []),
|
||||||
{ label: classLabel(NO_CLASS), value: NO_CLASS },
|
{ label: classLabel(NO_CLASS), value: NO_CLASS },
|
||||||
])
|
])
|
||||||
|
/** 编辑框的班级:配置里的班,外加这个人现在的班(老班不在配置里也要显示得出来) */
|
||||||
|
const editClassOptions = computed(() =>
|
||||||
|
groupClassOptions([...(configStore.config?.classList ?? []), ...(userEditing.value?.className ? [userEditing.value.className] : [])]),
|
||||||
|
)
|
||||||
const neverLoggedIn = ref(0)
|
const neverLoggedIn = ref(0)
|
||||||
|
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -311,10 +315,21 @@ async function handleEditUser() {
|
|||||||
}
|
}
|
||||||
// http 拦截器只对 login-required / permission-denied 自动弹提示,
|
// http 拦截器只对 login-required / permission-denied 自动弹提示,
|
||||||
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
|
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
|
||||||
|
if (create.value && !userEditing.value.className && legacyClassOf(userEditing.value.username) === undefined) {
|
||||||
|
message.error('用户名里 ks 后面的班级号位数不对,班级号要是 3~4 位数字')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (create.value) {
|
if (create.value) {
|
||||||
const newUser: ImportUsersRequest['users'] = [
|
const newUser: ImportUsersRequest['users'] = [
|
||||||
[userEditing.value.username, password.value, userEditing.value.email ?? '', userEditing.value.realName ?? ''],
|
[
|
||||||
|
userEditing.value.username,
|
||||||
|
password.value,
|
||||||
|
userEditing.value.email ?? '',
|
||||||
|
userEditing.value.realName ?? '',
|
||||||
|
// 没选班、但手写的是老格式 ks232张三:按前缀补上班级
|
||||||
|
userEditing.value.className ?? legacyClassOf(userEditing.value.username) ?? null,
|
||||||
|
],
|
||||||
]
|
]
|
||||||
await importUsers(newUser)
|
await importUsers(newUser)
|
||||||
listUsers()
|
listUsers()
|
||||||
@@ -388,10 +403,9 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
|||||||
<n-form-item-gi :span="1" label="真名">
|
<n-form-item-gi :span="1" label="真名">
|
||||||
<n-input v-model:value="userEditing.realName" />
|
<n-input v-model:value="userEditing.realName" />
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
<!-- 班级是后端从用户名的 ks 数字前缀推出来的(classNameOf),
|
<!-- 班级单独存一列,2026-11 起不再从用户名的 ks 前缀推(新号不带前缀)。老师、管理员不填 -->
|
||||||
editUser 也不发这个字段,所以这里只读 —— 以前是个能改、改了没用的输入框 -->
|
<n-form-item-gi :span="1" label="班级">
|
||||||
<n-form-item-gi v-if="!create" :span="1" label="班级">
|
<n-select v-model:value="userEditing.className" :options="editClassOptions" filterable clearable placeholder="没填班级" />
|
||||||
<n-input :value="userEditing.className" disabled placeholder="由用户名自动推导" />
|
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
<n-form-item-gi :span="1" label="邮箱">
|
<n-form-item-gi :span="1" label="邮箱">
|
||||||
<n-input v-model:value="userEditing.email" />
|
<n-input v-model:value="userEditing.email" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ body {
|
|||||||
机房屏 1366×768 的可用高度只有 640 左右,行高 48 是和用户商量过的:题目列表在
|
机房屏 1366×768 的可用高度只有 640 左右,行高 48 是和用户商量过的:题目列表在
|
||||||
这个高度还能看到 8 行。题目详情页(编辑器)不吃这张表,它按 640 高逐像素排过。 */
|
这个高度还能看到 8 行。题目详情页(编辑器)不吃这张表,它按 640 高逐像素排过。 */
|
||||||
:root {
|
:root {
|
||||||
--oj-page-width: 1200px; /* 阅读型页面(比赛、题单、公告……)的限宽,居中 */
|
--oj-page-width: 1200px; /* 阅读型页面(比赛、专题、公告……)的限宽,居中 */
|
||||||
--oj-fs-title: 24px; /* 页标题 */
|
--oj-fs-title: 24px; /* 页标题 */
|
||||||
--oj-fs-h2: 17px; /* 区块标题 */
|
--oj-fs-h2: 17px; /* 区块标题 */
|
||||||
--oj-fs-body: 15px; /* 列表主文字:题目名、比赛名 */
|
--oj-fs-body: 15px; /* 列表主文字:题目名、比赛名 */
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import type { Group } from '../series'
|
|||||||
import Medal from './Medal.vue'
|
import Medal from './Medal.vue'
|
||||||
import SeriesRow from './SeriesRow.vue'
|
import SeriesRow from './SeriesRow.vue'
|
||||||
|
|
||||||
/** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「题单和比赛」组末尾是拿到的题单奖章 */
|
/** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「专题和比赛」组末尾是拿到的专题奖章 */
|
||||||
const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>()
|
const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const showBadges = computed(() => props.group.name === '题单和比赛' && !!props.badges?.length)
|
const showBadges = computed(() => props.group.name === '专题和比赛' && !!props.badges?.length)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,7 +27,7 @@ const showBadges = computed(() => props.group.name === '题单和比赛' && !!pr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showBadges" class="badges">
|
<div v-if="showBadges" class="badges">
|
||||||
<span class="masked-title">拿到的题单奖章</span>
|
<span class="masked-title">拿到的专题奖章</span>
|
||||||
<div class="badge-list">
|
<div class="badge-list">
|
||||||
<n-tooltip v-for="b in badges" :key="b.id" :delay="150">
|
<n-tooltip v-for="b in badges" :key="b.id" :delay="150">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -36,7 +36,7 @@ const showBadges = computed(() => props.group.name === '题单和比赛' && !!pr
|
|||||||
<span>{{ b.badge.name }}</span>
|
<span>{{ b.badge.name }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
{{ b.badge.description }} · 来自题单「{{ b.problemSet.title }}」
|
{{ b.badge.description }} · 来自专题「{{ b.problemSet.title }}」
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
|||||||
import UserName from 'shared/components/UserName.vue'
|
import UserName from 'shared/components/UserName.vue'
|
||||||
import AdminLink from 'shared/components/AdminLink.vue'
|
import AdminLink from 'shared/components/AdminLink.vue'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import { useMascotGuide } from 'shared/mascot/guide'
|
||||||
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
|
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
|
||||||
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
|
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
|
||||||
import ClassCard from './components/ClassCard.vue'
|
import ClassCard from './components/ClassCard.vue'
|
||||||
@@ -37,9 +38,9 @@ const got = computed(() => data.value?.achievements.filter((item) => item.unlock
|
|||||||
const rarities: AchievementRarity[] = ['platinum', 'gold', 'silver', 'bronze']
|
const rarities: AchievementRarity[] = ['platinum', 'gold', 'silver', 'bronze']
|
||||||
const countOf = (rarity: AchievementRarity) => got.value.filter((item) => item.rarity === rarity).length
|
const countOf = (rarity: AchievementRarity) => got.value.filter((item) => item.rarity === rarity).length
|
||||||
|
|
||||||
// 桌面两栏:左边做题、其他、彩蛋,右边坚持、题单和比赛 —— 两边差不多高
|
// 桌面两栏:左边做题、其他、彩蛋,右边坚持、专题和比赛 —— 两边差不多高
|
||||||
const LEFT: GroupName[] = ['做题', '其他', '彩蛋']
|
const LEFT: GroupName[] = ['做题', '其他', '彩蛋']
|
||||||
const RIGHT: GroupName[] = ['坚持', '题单和比赛']
|
const RIGHT: GroupName[] = ['坚持', '专题和比赛']
|
||||||
const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name))
|
const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -59,6 +60,15 @@ async function load() {
|
|||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
watch(name, 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Achievement } from 'utils/types'
|
|||||||
* 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看,
|
* 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看,
|
||||||
* 在后台加了新指标记得回来补一行。
|
* 在后台加了新指标记得回来补一行。
|
||||||
*/
|
*/
|
||||||
export const GROUPS = ['做题', '坚持', '题单和比赛', '其他', '彩蛋'] as const
|
export const GROUPS = ['做题', '坚持', '专题和比赛', '其他', '彩蛋'] as const
|
||||||
export type GroupName = (typeof GROUPS)[number]
|
export type GroupName = (typeof GROUPS)[number]
|
||||||
|
|
||||||
interface MetricInfo {
|
interface MetricInfo {
|
||||||
@@ -34,6 +34,12 @@ const METRICS: Record<string, MetricInfo> = {
|
|||||||
unit: '道',
|
unit: '道',
|
||||||
remain: (r) => `再做对 ${r} 道困难题`,
|
remain: (r) => `再做对 ${r} 道困难题`,
|
||||||
},
|
},
|
||||||
|
challenge_count: {
|
||||||
|
group: '做题',
|
||||||
|
label: '挑战成功',
|
||||||
|
unit: '次',
|
||||||
|
remain: (r) => `再挑战成功 ${r} 次`,
|
||||||
|
},
|
||||||
max_ac_week_streak: {
|
max_ac_week_streak: {
|
||||||
group: '坚持',
|
group: '坚持',
|
||||||
label: '连着几周有做对',
|
label: '连着几周有做对',
|
||||||
@@ -55,19 +61,19 @@ const METRICS: Record<string, MetricInfo> = {
|
|||||||
},
|
},
|
||||||
submission_count: { group: '坚持', label: '交了几次', unit: '次', remain: (r) => `再交 ${r} 次` },
|
submission_count: { group: '坚持', label: '交了几次', unit: '次', remain: (r) => `再交 ${r} 次` },
|
||||||
problemset_completed: {
|
problemset_completed: {
|
||||||
group: '题单和比赛',
|
group: '专题和比赛',
|
||||||
label: '做完的题单',
|
label: '做完的专题',
|
||||||
unit: '个',
|
unit: '个',
|
||||||
remain: (r) => `再做完 ${r} 个题单`,
|
remain: (r) => `再做完 ${r} 个专题`,
|
||||||
},
|
},
|
||||||
badge_count: {
|
badge_count: {
|
||||||
group: '题单和比赛',
|
group: '专题和比赛',
|
||||||
label: '题单奖章',
|
label: '专题奖章',
|
||||||
unit: '枚',
|
unit: '枚',
|
||||||
remain: (r) => `再拿 ${r} 枚题单奖章`,
|
remain: (r) => `再拿 ${r} 枚专题奖章`,
|
||||||
},
|
},
|
||||||
contest_joined: {
|
contest_joined: {
|
||||||
group: '题单和比赛',
|
group: '专题和比赛',
|
||||||
label: '参加的比赛',
|
label: '参加的比赛',
|
||||||
unit: '场',
|
unit: '场',
|
||||||
remain: (r) => `再参加 ${r} 场比赛`,
|
remain: (r) => `再参加 ${r} 场比赛`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import AdminLink from 'shared/components/AdminLink.vue'
|
|||||||
import NotFound from 'shared/components/NotFound.vue'
|
import NotFound from 'shared/components/NotFound.vue'
|
||||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import { useMascotGuide } from 'shared/mascot/guide'
|
||||||
import { errorCode } from 'utils/api'
|
import { errorCode } from 'utils/api'
|
||||||
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||||
import type { Announcement, AnnouncementListItem } from 'utils/types'
|
import type { Announcement, AnnouncementListItem } from 'utils/types'
|
||||||
@@ -27,6 +28,9 @@ const { isDesktop } = useBreakpoints()
|
|||||||
const announcements = ref<AnnouncementListItem[]>([])
|
const announcements = ref<AnnouncementListItem[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
// 看板娘:只念叨一句,不给按钮
|
||||||
|
useMascotGuide(() => ({ situation: 'announcement' }))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await getAnnouncementList(0, LIMIT)
|
const res = await getAnnouncementList(0, LIMIT)
|
||||||
announcements.value = res.results
|
announcements.value = res.results
|
||||||
|
|||||||
+18
-15
@@ -26,7 +26,7 @@ import {
|
|||||||
type Tutorial,
|
type Tutorial,
|
||||||
type ClassToday,
|
type ClassToday,
|
||||||
type LastVisit,
|
type LastVisit,
|
||||||
type ClassBoard,
|
type TaskBoard,
|
||||||
type ClassLesson,
|
type ClassLesson,
|
||||||
type KnowledgeMap,
|
type KnowledgeMap,
|
||||||
type ClassBattleItem,
|
type ClassBattleItem,
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type ProblemRank,
|
type ProblemRank,
|
||||||
type CreateSubmissionResponse,
|
type CreateSubmissionResponse,
|
||||||
type ProblemListItem,
|
type ProblemListItem,
|
||||||
|
type SimilarProblem,
|
||||||
type ProblemStats,
|
type ProblemStats,
|
||||||
type ProblemProgress,
|
type ProblemProgress,
|
||||||
type CreateFlowchartResponse,
|
type CreateFlowchartResponse,
|
||||||
@@ -179,7 +180,7 @@ export function getSubmissionStatistics(
|
|||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
problemDisplayId?: string,
|
problemDisplayId?: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
/** 班级(user.class_name)精确匹配。别再拼 `ks231` 塞进 username —— 会连带 2311、2312 班 */
|
/** 班级(user.class_name)精确匹配。别拿用户名前缀当班级 —— 新号不带 ks 前缀,老号 `ks231` 还会连带 2311、2312 班 */
|
||||||
className?: string,
|
className?: string,
|
||||||
) {
|
) {
|
||||||
return api.get<SubmissionStatistics>('submissions/statistics', {
|
return api.get<SubmissionStatistics>('submissions/statistics', {
|
||||||
@@ -251,14 +252,14 @@ export function getWeeklyRank(scope: 'global' | 'class') {
|
|||||||
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
|
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */
|
/** 课堂任务(老师)。不给班级时后端猜最近两小时在交题的那个班 */
|
||||||
export function getClassBoard(className?: string) {
|
export function getTaskBoard(className?: string) {
|
||||||
return api.get<ClassBoard>('classroom/board', { params: { className } })
|
return api.get<TaskBoard>('task/board', { params: { className } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */
|
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和任务页一样 */
|
||||||
export function getClassLesson(className?: string) {
|
export function getClassLesson(className?: string) {
|
||||||
return api.get<ClassLesson>('classroom/lesson', { params: { className } })
|
return api.get<ClassLesson>('task/lesson', { params: { className } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,7 +267,7 @@ export function getClassLesson(className?: string) {
|
|||||||
* (一个班一天可以上几节内容不同的课),否则改那一节
|
* (一个班一天可以上几节内容不同的课),否则改那一节
|
||||||
*/
|
*/
|
||||||
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
|
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
|
||||||
return api.put<null>('classroom/lesson', {
|
return api.put<null>('task/lesson', {
|
||||||
className,
|
className,
|
||||||
problemDisplayIds,
|
problemDisplayIds,
|
||||||
language,
|
language,
|
||||||
@@ -282,7 +283,7 @@ export function getKnowledgeMap() {
|
|||||||
|
|
||||||
/** 老师首页「今天的课」:今天在上课的每个班 */
|
/** 老师首页「今天的课」:今天在上课的每个班 */
|
||||||
export function getClassToday() {
|
export function getClassToday() {
|
||||||
return api.get<ClassToday>('classroom/today')
|
return api.get<ClassToday>('task/today')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
|
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
|
||||||
@@ -346,7 +347,7 @@ export function uploadAvatar(file: File) {
|
|||||||
return api.post('me/avatar', form)
|
return api.post('me/avatar', form)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProfile(data: { realName?: string; mood?: string }) {
|
export function updateProfile(data: { mood?: string }) {
|
||||||
return api.put<Profile>('me/profile', data satisfies UpdateProfileRequest)
|
return api.put<Profile>('me/profile', data satisfies UpdateProfileRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +413,9 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
|
|||||||
// ==================== 相似题目推荐 ====================
|
// ==================== 相似题目推荐 ====================
|
||||||
|
|
||||||
export function getSimilarProblems(problemId: string) {
|
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 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 挑战 ====================
|
// ==================== 挑战 ====================
|
||||||
@@ -463,12 +466,12 @@ export function retryFlowchartSubmission(submissionId: string) {
|
|||||||
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自己在这道题上评完的每一次(早的在前),题单布置期内藏着的已经滤掉 */
|
/** 自己在这道题上评完的每一次(早的在前),专题布置期内藏着的已经滤掉 */
|
||||||
export function getFlowchartScores(problemId: number) {
|
export function getFlowchartScores(problemId: number) {
|
||||||
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
|
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 题单相关API ====================
|
// ==================== 专题相关API ====================
|
||||||
|
|
||||||
export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
|
export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
|
||||||
return api.get<ProblemSetList>('problem-sets', { params: { offset, limit, keyword } })
|
return api.get<ProblemSetList>('problem-sets', { params: { offset, limit, keyword } })
|
||||||
@@ -490,14 +493,14 @@ export function getUserBadges(username?: string) {
|
|||||||
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? 'me')}/badges`)
|
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? 'me')}/badges`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */
|
/** 老师看某个班在专题里的情况;不传班级就看服务端推断的那个班 */
|
||||||
export function getProblemSetClassView(problemSetId: number, className = '') {
|
export function getProblemSetClassView(problemSetId: number, className = '') {
|
||||||
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
|
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
|
||||||
params: className ? { className } : {},
|
params: className ? { className } : {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */
|
/** 这道题以前的代码为什么藏着:落在哪些正在布置的专题里。传主键或题号都行 */
|
||||||
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
|
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
|
||||||
return api.get<ProblemSetLock[]>('problem-sets/locks', {
|
return api.get<ProblemSetLock[]>('problem-sets/locks', {
|
||||||
params: 'id' in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
|
params: 'id' in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { usePagination } from 'shared/composables/pagination'
|
|||||||
import AdminLink from 'shared/components/AdminLink.vue'
|
import AdminLink from 'shared/components/AdminLink.vue'
|
||||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||||
import { ContestStatus } from 'utils/constants'
|
import { ContestStatus } from 'utils/constants'
|
||||||
import ContestBadge from './components/ContestBadge.vue'
|
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}`)
|
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) {
|
function mineText(contest: Contest) {
|
||||||
const mine = contest.mine
|
const mine = contest.mine
|
||||||
if (!mine) return ''
|
if (!mine) return ''
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { displayName } from '@oj2/contract'
|
||||||
import type { ContestClassView, ContestScoreCell, ContestScoreRow } from '@oj2/contract'
|
import type { ContestClassView, ContestScoreCell, ContestScoreRow } from '@oj2/contract'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { updateACMHelperChecked } from 'admin/api'
|
import { updateACMHelperChecked } from 'admin/api'
|
||||||
@@ -18,7 +19,7 @@ import { useVisibleInterval } from 'shared/composables/visibleInterval'
|
|||||||
*
|
*
|
||||||
* 名字和排行榜一样用用户名(ks241XXX),不用真名。
|
* 名字和排行榜一样用用户名(ks241XXX),不用真名。
|
||||||
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
|
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
|
||||||
* 没进来的人在最下面(看板里标了请假的写「请假」)。
|
* 没进来的人在最下面(课堂任务里标了请假的写「请假」)。
|
||||||
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
|
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
|
||||||
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView)
|
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView)
|
||||||
*/
|
*/
|
||||||
@@ -225,12 +226,13 @@ onKeyStroke(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'], (event) => {
|
|||||||
|
|
||||||
function exportGrades() {
|
function exportGrades() {
|
||||||
const title = contestStore.contest?.title ?? '比赛'
|
const title = contestStore.contest?.title ?? '比赛'
|
||||||
const head = ['用户名', '班级', '做对', '名次', '用时', ...problems.value.map((p) => `第${p._id}题 ${p.title}`)]
|
const head = ['名字', '班级', '做对', '名次', '用时', ...problems.value.map((p) => `第${p._id}题 ${p.title}`)]
|
||||||
const lines = [head]
|
const lines = [head]
|
||||||
const byUsername = [...rows.value].sort((a, b) => a.username.localeCompare(b.username, 'zh-CN'))
|
// 按名字的拼音排:老号 ks 开头、新号是中文名,按用户名排两种号会各排各的
|
||||||
for (const row of byUsername) {
|
const byName = [...rows.value].sort((a, b) => displayName(a.username).localeCompare(displayName(b.username), 'zh-CN'))
|
||||||
|
for (const row of byName) {
|
||||||
lines.push([
|
lines.push([
|
||||||
row.username,
|
displayName(row.username),
|
||||||
row.className ? classLabel(row.className) : '',
|
row.className ? classLabel(row.className) : '',
|
||||||
String(row.solved),
|
String(row.solved),
|
||||||
String(row.rank ?? ''),
|
String(row.rank ?? ''),
|
||||||
@@ -242,7 +244,14 @@ function exportGrades() {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
for (const user of absent.value) {
|
for (const user of absent.value) {
|
||||||
lines.push([user.username, user.className ? classLabel(user.className) : '', user.leave ? '请假' : '没考', '', '', ...problems.value.map(() => '')])
|
lines.push([
|
||||||
|
displayName(user.username),
|
||||||
|
user.className ? classLabel(user.className) : '',
|
||||||
|
user.leave ? '请假' : '没考',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
...problems.value.map(() => ''),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
const csv = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(',')).join('\r\n')
|
const csv = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(',')).join('\r\n')
|
||||||
// 带 BOM,Excel 双击打开中文才不乱码
|
// 带 BOM,Excel 双击打开中文才不乱码
|
||||||
@@ -258,8 +267,9 @@ function exportGrades() {
|
|||||||
const success = computed(() => tone('success'))
|
const success = computed(() => tone('success'))
|
||||||
const danger = computed(() => tone('error'))
|
const danger = computed(() => tone('error'))
|
||||||
const warning = computed(() => tone('warning'))
|
const warning = computed(() => tone('warning'))
|
||||||
const liveColumns = computed(() => `130px 48px repeat(${problems.value.length}, minmax(70px, 96px)) 90px 1fr`)
|
// 名字列只放名字,最长 4 个字(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽)
|
||||||
const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.value.length}, 50px)`)
|
const liveColumns = computed(() => `88px 48px repeat(${problems.value.length}, minmax(70px, 96px)) 90px 1fr`)
|
||||||
|
const gradeColumns = computed(() => `24px 88px 40px 36px repeat(${problems.value.length}, 50px)`)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -357,7 +367,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
|||||||
<span v-for="p in problems" :key="p.id"></span>
|
<span v-for="p in problems" :key="p.id"></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span class="flag-wrap">
|
<span class="flag-wrap">
|
||||||
<span class="flag grey">{{ user.leave ? '请假(看板里标的)' : '没进来' }}</span>
|
<span class="flag grey">{{ user.leave ? '请假(课堂任务里标的)' : '没进来' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -430,7 +440,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
|||||||
no: cellOf(row, p.id) && !cellOf(row, p.id)!.isAc,
|
no: cellOf(row, p.id) && !cellOf(row, p.id)!.isAc,
|
||||||
sel: selected?.userId === row.userId && selected.problemId === p.id,
|
sel: selected?.userId === row.userId && selected.problemId === p.id,
|
||||||
}"
|
}"
|
||||||
:title="`${row.username} · 第 ${p._id} 题`"
|
:title="`${displayName(row.username)} · 第 ${p._id} 题`"
|
||||||
@click="pick(row, p.id)"
|
@click="pick(row, p.id)"
|
||||||
>
|
>
|
||||||
<template v-if="cellOf(row, p.id)?.isAc">✓</template>
|
<template v-if="cellOf(row, p.id)?.isAc">✓</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { displayName } from '@oj2/contract'
|
||||||
import type { ContestScoreRow } from '@oj2/contract'
|
import type { ContestScoreRow } from '@oj2/contract'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { useContestStore } from 'oj/store/contest'
|
import { useContestStore } from 'oj/store/contest'
|
||||||
@@ -131,7 +132,7 @@ const warning = computed(() => tone('warning'))
|
|||||||
<span class="count-num">{{ item.solved }} 人做对</span>
|
<span class="count-num">{{ item.solved }} 人做对</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="first">
|
<span class="first">
|
||||||
<template v-if="item.first">最先:{{ item.first.username }} · {{ firstMinute(item.first.acTime) }}</template>
|
<template v-if="item.first">最先:{{ displayName(item.first.username) }} · {{ firstMinute(item.first.acTime) }}</template>
|
||||||
<template v-else>还没人做对</template>
|
<template v-else>还没人做对</template>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ const narrow = useMediaQuery('(max-width: 640px)')
|
|||||||
const columns = computed(() => {
|
const columns = computed(() => {
|
||||||
const move = running.value ? '36px ' : ''
|
const move = running.value ? '36px ' : ''
|
||||||
const count = problems.value.length
|
const count = problems.value.length
|
||||||
return narrow.value ? `28px ${move}112px 36px 70px repeat(${count}, 52px)` : `40px ${move}170px 48px 80px repeat(${count}, 64px)`
|
// 名字列:名字最长 4 个字(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽)
|
||||||
|
return narrow.value ? `28px ${move}76px 36px 70px repeat(${count}, 52px)` : `40px ${move}96px 48px 80px repeat(${count}, 64px)`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ import storage from 'utils/storage'
|
|||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
|
||||||
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
||||||
const Dashboard = defineAsyncComponent(() => import('./dashboard.vue'))
|
const StudentHome = defineAsyncComponent(() => import('./student.vue'))
|
||||||
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
|
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
|
||||||
const TeacherHome = defineAsyncComponent(() => import('./TeacherHome.vue'))
|
const TeacherHome = defineAsyncComponent(() => import('./teacher.vue'))
|
||||||
const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue'))
|
const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue'))
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
|
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
|
||||||
// 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表
|
// 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表
|
||||||
const showDashboard = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED)))
|
const showHome = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED)))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="showDashboard">
|
<template v-if="showHome">
|
||||||
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
|
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
|
||||||
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
|
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
|
||||||
<Dashboard v-else-if="userStore.isFinished" />
|
<StudentHome v-else-if="userStore.isFinished" />
|
||||||
</template>
|
</template>
|
||||||
<ProblemList v-else />
|
<ProblemList v-else />
|
||||||
</template>
|
</template>
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
import { displayName } from '@oj2/contract'
|
||||||
|
import { continueStep, doneCount, tutorialPath, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
||||||
import { getClassActivity } from 'shared/api'
|
import { getClassActivity } from 'shared/api'
|
||||||
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
|
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 { 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 { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||||
import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions'
|
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 {
|
interface Track {
|
||||||
type: TutorialType
|
type: TutorialType
|
||||||
@@ -34,6 +37,8 @@ const classActivity = ref<ClassActivity | null>(null)
|
|||||||
const lastVisit = ref<LastVisit | null>(null)
|
const lastVisit = ref<LastVisit | null>(null)
|
||||||
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
|
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
|
||||||
const knowledgeHeadline = ref<Headline | null>(null)
|
const knowledgeHeadline = ref<Headline | null>(null)
|
||||||
|
/** 地图原样留着给看板娘:她要知识点名、档位名、还差几道,不是拼好的那句话 */
|
||||||
|
const knowledge = ref<KnowledgeMap | null>(null)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
||||||
@@ -74,7 +79,7 @@ const weeklyText = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/lesson.ts),不是本机记的:
|
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/utils.ts),不是本机记的:
|
||||||
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
|
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
|
||||||
*/
|
*/
|
||||||
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
|
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
|
||||||
@@ -121,7 +126,8 @@ async function loadAnnouncements() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadKnowledge() {
|
async function loadKnowledge() {
|
||||||
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap())
|
knowledge.value = await getKnowledgeMap()
|
||||||
|
knowledgeHeadline.value = pickHeadline(knowledge.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClassActivity() {
|
async function loadClassActivity() {
|
||||||
@@ -187,10 +193,10 @@ const solvedText = computed(() => (visit.value?.solved ?? []).map((item) => `${i
|
|||||||
const classActivityTitle = computed(() => {
|
const classActivityTitle = computed(() => {
|
||||||
const day = classActivity.value?.day
|
const day = classActivity.value?.day
|
||||||
if (!day) return ''
|
if (!day) return ''
|
||||||
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||||
if (classActivity.value?.source === 'teacher') {
|
if (classActivity.value?.source === 'teacher') {
|
||||||
const language = classActivity.value.language
|
const language = classActivity.value.language
|
||||||
const mode = classActivity.value.mode
|
const mode = activityMode(classActivity.value)
|
||||||
if (mode === 'flowchart') return '这节课 · 画流程图'
|
if (mode === 'flowchart') return '这节课 · 画流程图'
|
||||||
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
|
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
|
||||||
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
|
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
|
||||||
@@ -242,8 +248,88 @@ watch(
|
|||||||
{ immediate: true },
|
{ 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) {
|
function learnLink(track: Track) {
|
||||||
return lessonPath(track.type, track.step)
|
return tutorialPath(track.type, track.step)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSubmission(row: SubmissionListItem) {
|
function openSubmission(row: SubmissionListItem) {
|
||||||
@@ -276,8 +362,8 @@ function contestTime(contest: Contest) {
|
|||||||
<div class="home">
|
<div class="home">
|
||||||
<!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 -->
|
<!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ userStore.user?.username }}</h1>
|
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ displayName(userStore.user?.username ?? '') }}</h1>
|
||||||
<h1 v-else class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
|
<h1 v-else class="hello">{{ greeting }},{{ displayName(userStore.user?.username ?? '') }}</h1>
|
||||||
<n-text v-if="firstVisit" depth="3">老师报了题号,在右上角的框里输入就能找到题</n-text>
|
<n-text v-if="firstVisit" depth="3">老师报了题号,在右上角的框里输入就能找到题</n-text>
|
||||||
<n-text v-else depth="3">
|
<n-text v-else depth="3">
|
||||||
<template v-if="longAway">{{ awayText }} · </template>
|
<template v-if="longAway">{{ awayText }} · </template>
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { displayName } from '@oj2/contract'
|
||||||
import type { ClassToday } from '@oj2/contract'
|
import type { ClassToday } from '@oj2/contract'
|
||||||
import type { AnnouncementListItem } from 'utils/types'
|
import type { AnnouncementListItem } from 'utils/types'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { getAnnouncementList, getClassToday } from 'oj/api'
|
import { getAnnouncementList, getClassToday } from 'oj/api'
|
||||||
import { classLabel } from 'utils/classes'
|
import { classLabel } from 'utils/classes'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||||
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
|
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
|
||||||
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
|
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
|
||||||
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
|
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂任务」进那个班。
|
||||||
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
|
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
|
||||||
*/
|
*/
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
@@ -61,13 +63,39 @@ function metaOf(row: ClassRow) {
|
|||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBoard(row: ClassRow) {
|
function openTask(row: ClassRow) {
|
||||||
router.push({ name: 'classroom board', query: { className: row.className } })
|
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' }
|
||||||
|
// 有人交过了就说这节课做完几个,还没人交就照旧那几句
|
||||||
|
const progress = row.activeCount
|
||||||
|
? {
|
||||||
|
situation: row.allDoneCount >= row.rosterCount ? ('teacher.home.progressDone' as const) : ('teacher.home.progress' as const),
|
||||||
|
vars: { cls: classLabel(row.className), done: row.allDoneCount, total: row.rosterCount, left: row.rosterCount - row.allDoneCount },
|
||||||
|
}
|
||||||
|
: { situation: 'teacher.home.assigned' as const }
|
||||||
|
return {
|
||||||
|
...progress,
|
||||||
|
actions: [
|
||||||
|
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
|
||||||
|
{ label: '课堂任务', run: () => openTask(row) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const shortcuts = computed(() =>
|
const shortcuts = computed(() =>
|
||||||
[
|
[
|
||||||
{ label: '课堂看板', to: '/classroom', show: true },
|
{ label: '课堂任务', to: '/task', show: true },
|
||||||
{ label: '数据统计', to: '/statistics', show: true },
|
{ label: '数据统计', to: '/statistics', show: true },
|
||||||
{ label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission },
|
{ label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission },
|
||||||
{ label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true },
|
{ label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true },
|
||||||
@@ -78,7 +106,7 @@ const shortcuts = computed(() =>
|
|||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1 class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
|
<h1 class="hello">{{ greeting }},{{ displayName(userStore.user?.username ?? '') }}</h1>
|
||||||
<n-text v-if="today" depth="3" class="tagline">
|
<n-text v-if="today" depth="3" class="tagline">
|
||||||
{{ today.classes.length ? `今天 ${today.classes.length} 个班上过课` : '今天还没有班在上课' }}
|
{{ today.classes.length ? `今天 ${today.classes.length} 个班上过课` : '今天还没有班在上课' }}
|
||||||
</n-text>
|
</n-text>
|
||||||
@@ -92,7 +120,7 @@ const shortcuts = computed(() =>
|
|||||||
<span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
|
<span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
|
||||||
<span class="muted">{{ metaOf(row) }}</span>
|
<span class="muted">{{ metaOf(row) }}</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openBoard(row)"> 课堂看板 </n-button>
|
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openTask(row)"> 课堂任务 </n-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.problems.length" class="minis">
|
<div v-if="row.problems.length" class="minis">
|
||||||
<router-link
|
<router-link
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { ProjectGroup } from '../lesson'
|
import type { ProjectGroup } from '../utils'
|
||||||
|
|
||||||
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
|
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
|
||||||
defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>()
|
defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { TutorialProgress } from '@oj2/contract'
|
import type { TutorialProgress } from '@oj2/contract'
|
||||||
import { lessonPath, lessonState, type ProjectGroup, type TutorialType } from '../lesson'
|
import { tutorialPath, tutorialState, type ProjectGroup, type TutorialType } from '../utils'
|
||||||
import LessonRow from './LessonRow.vue'
|
import TutorialRow from './TutorialRow.vue'
|
||||||
import ProjectTile from './ProjectTile.vue'
|
import ProjectTile from './ProjectTile.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,14 +21,14 @@ const emit = defineEmits<{ go: [] }>()
|
|||||||
const open = ref(new Set<string>())
|
const open = ref(new Set<string>())
|
||||||
|
|
||||||
function keyOf(group: ProjectGroup) {
|
function keyOf(group: ProjectGroup) {
|
||||||
return `${group.project}:${group.lessons[0]!.id}`
|
return `${group.project}:${group.tutorials[0]!.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
|
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
|
||||||
watch(
|
watch(
|
||||||
() => [props.groups, props.currentStep] as const,
|
() => [props.groups, props.currentStep] as const,
|
||||||
([groups, step]) => {
|
([groups, step]) => {
|
||||||
const group = groups.find((item) => item.lessons.some((lesson) => lesson.step === step))
|
const group = groups.find((item) => item.tutorials.some((tutorial) => tutorial.step === step))
|
||||||
if (group) open.value = new Set([...open.value, keyOf(group)])
|
if (group) open.value = new Set([...open.value, keyOf(group)])
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -43,7 +43,7 @@ function toggle(group: ProjectGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function doneIn(group: ProjectGroup) {
|
function doneIn(group: ProjectGroup) {
|
||||||
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === 'done').length
|
return group.tutorials.filter((tutorial) => tutorialState(props.progress[tutorial.id]) === 'done').length
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -62,23 +62,23 @@ function doneIn(group: ProjectGroup) {
|
|||||||
{{ group.name }}
|
{{ group.name }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="doneIn(group) === group.lessons.length" class="count all">
|
<span v-if="doneIn(group) === group.tutorials.length" class="count all">
|
||||||
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.lessons.length }}
|
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.tutorials.length }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="count">{{ doneIn(group) }}/{{ group.lessons.length }}</span>
|
<span v-else class="count">{{ doneIn(group) }}/{{ group.tutorials.length }}</span>
|
||||||
<span v-if="big" class="chev">
|
<span v-if="big" class="chev">
|
||||||
<Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" />
|
<Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="open.has(keyOf(group))" class="lessons">
|
<div v-if="open.has(keyOf(group))" class="tutorials">
|
||||||
<LessonRow
|
<TutorialRow
|
||||||
v-for="lesson in group.lessons"
|
v-for="tutorial in group.tutorials"
|
||||||
:key="lesson.id"
|
:key="tutorial.id"
|
||||||
:title="lesson.title"
|
:title="tutorial.title"
|
||||||
:step="lesson.step"
|
:step="tutorial.step"
|
||||||
:to="lessonPath(type, lesson.step)"
|
:to="tutorialPath(type, tutorial.step)"
|
||||||
:progress="progress[lesson.id]"
|
:progress="progress[tutorial.id]"
|
||||||
:active="lesson.step === currentStep"
|
:active="tutorial.step === currentStep"
|
||||||
:height="big ? 50 : 46"
|
:height="big ? 50 : 46"
|
||||||
@go="emit('go')"
|
@go="emit('go')"
|
||||||
/>
|
/>
|
||||||
@@ -160,13 +160,13 @@ function doneIn(group: ProjectGroup) {
|
|||||||
color: var(--learn-green-text);
|
color: var(--learn-green-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.lessons {
|
.tutorials {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
}
|
}
|
||||||
.big .lessons {
|
.big .tutorials {
|
||||||
padding: 0 0 8px;
|
padding: 0 0 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { LessonState, ProjectGroup } from '../lesson'
|
import type { TutorialState, ProjectGroup } from '../utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
|
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
|
||||||
@@ -7,7 +7,7 @@ import type { LessonState, ProjectGroup } from '../lesson'
|
|||||||
*/
|
*/
|
||||||
defineProps<{
|
defineProps<{
|
||||||
groups: ProjectGroup[]
|
groups: ProjectGroup[]
|
||||||
stateOf: (id: number) => LessonState
|
stateOf: (id: number) => TutorialState
|
||||||
current?: number
|
current?: number
|
||||||
/** 每段的宽、高 */
|
/** 每段的宽、高 */
|
||||||
width?: number
|
width?: number
|
||||||
@@ -19,12 +19,12 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
|
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
|
||||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }">
|
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }">
|
||||||
<span
|
<span
|
||||||
v-for="lesson in group.lessons"
|
v-for="tutorial in group.tutorials"
|
||||||
:key="lesson.id"
|
:key="tutorial.id"
|
||||||
class="cell"
|
class="cell"
|
||||||
:class="[stateOf(lesson.id), { current: lesson.id === current }]"
|
:class="[stateOf(tutorial.id), { current: tutorial.id === current }]"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${width ?? 20}px`,
|
width: `${width ?? 20}px`,
|
||||||
height: `${height ?? 6}px`,
|
height: `${height ?? 6}px`,
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Markdown from 'shared/components/Markdown.vue'
|
import Markdown from 'shared/components/Markdown.vue'
|
||||||
import type { LessonSegment } from '../composables/useLessonParse'
|
import type { TutorialSegment } from '../composables/useTutorialParse'
|
||||||
import CodeRunBlock from './CodeRunBlock.vue'
|
import CodeRunBlock from './CodeRunBlock.vue'
|
||||||
|
|
||||||
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
|
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
|
||||||
|
|
||||||
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */
|
/** 一课的正文:按 useTutorialParse 拆好的段落逐段渲染 */
|
||||||
defineProps<{
|
defineProps<{
|
||||||
segments: LessonSegment[]
|
segments: TutorialSegment[]
|
||||||
lang: 'python' | 'c'
|
lang: 'python' | 'c'
|
||||||
tutorialId: number
|
tutorialId: number
|
||||||
exerciseCount: number
|
exerciseCount: number
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { LessonState } from '../lesson'
|
import type { TutorialState } from '../utils'
|
||||||
|
|
||||||
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
|
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
|
||||||
defineProps<{ step: number; state: LessonState; current?: boolean }>()
|
defineProps<{ step: number; state: TutorialState; current?: boolean }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { TutorialProblem } from '@oj2/contract'
|
import type { TutorialProblem } from '@oj2/contract'
|
||||||
import { splitTitle, type LessonState, type ProjectGroup } from '../lesson'
|
import { splitTitle, type TutorialState, type ProjectGroup } from '../utils'
|
||||||
import SegBar from './SegBar.vue'
|
import SegBar from './SegBar.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +19,7 @@ const props = defineProps<{
|
|||||||
problems: TutorialProblem[]
|
problems: TutorialProblem[]
|
||||||
next: { title: string; to: string } | null
|
next: { title: string; to: string } | null
|
||||||
groups: ProjectGroup[]
|
groups: ProjectGroup[]
|
||||||
stateOf: (id: number) => LessonState
|
stateOf: (id: number) => TutorialState
|
||||||
doneCount: number
|
doneCount: number
|
||||||
/** 窄屏:进度条短一点、题号挪到题目名下面 */
|
/** 窄屏:进度条短一点、题号挪到题目名下面 */
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { OutlineItem } from '../composables/useLessonParse'
|
import type { OutlineItem } from '../composables/useTutorialParse'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
|
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TutorialProgress } from '@oj2/contract'
|
import type { TutorialProgress } from '@oj2/contract'
|
||||||
import { lessonState, splitTitle } from '../lesson'
|
import { tutorialState, splitTitle } from '../utils'
|
||||||
import LessonDot from './LessonDot.vue'
|
import TutorialDot from './TutorialDot.vue'
|
||||||
|
|
||||||
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
|
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -16,12 +16,12 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{ go: [] }>()
|
const emit = defineEmits<{ go: [] }>()
|
||||||
|
|
||||||
const parts = computed(() => splitTitle(props.title))
|
const parts = computed(() => splitTitle(props.title))
|
||||||
const state = computed(() => lessonState(props.progress))
|
const state = computed(() => tutorialState(props.progress))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')">
|
<router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')">
|
||||||
<LessonDot :step="step" :state="state" :current="active" />
|
<TutorialDot :step="step" :state="state" :current="active" />
|
||||||
<span class="text">
|
<span class="text">
|
||||||
<span class="kp">{{ parts.kp }}</span>
|
<span class="kp">{{ parts.kp }}</span>
|
||||||
<span v-if="parts.scene" class="scene">{{ parts.scene }}</span>
|
<span v-if="parts.scene" class="scene">{{ parts.scene }}</span>
|
||||||
+4
-4
@@ -3,7 +3,7 @@ import type { Exercise } from 'utils/types'
|
|||||||
/**
|
/**
|
||||||
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
|
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
|
||||||
*
|
*
|
||||||
* - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
|
* - `heading`:二级标专题独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
|
||||||
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
|
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
|
||||||
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
|
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
|
||||||
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
|
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
|
||||||
@@ -14,7 +14,7 @@ import type { Exercise } from 'utils/types'
|
|||||||
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
|
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
|
||||||
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
|
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
|
||||||
*/
|
*/
|
||||||
export type LessonSegment =
|
export type TutorialSegment =
|
||||||
| { type: 'md'; content: string }
|
| { type: 'md'; content: string }
|
||||||
| { type: 'heading'; id: string; text: string }
|
| { type: 'heading'; id: string; text: string }
|
||||||
| { type: 'goals'; id: string; items: string[] }
|
| { type: 'goals'; id: string; items: string[] }
|
||||||
@@ -57,9 +57,9 @@ function stripPreamble(lines: string[]) {
|
|||||||
return lines.slice(i)
|
return lines.slice(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseLesson(content: string, exercises: Exercise[]) {
|
export function parseTutorial(content: string, exercises: Exercise[]) {
|
||||||
const byId = new Map(exercises.map((item) => [item.id, item]))
|
const byId = new Map(exercises.map((item) => [item.id, item]))
|
||||||
const segments: LessonSegment[] = []
|
const segments: TutorialSegment[] = []
|
||||||
const outline: OutlineItem[] = []
|
const outline: OutlineItem[] = []
|
||||||
let md: string[] = []
|
let md: string[] = []
|
||||||
let codeIndex = 0
|
let codeIndex = 0
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from '@iconify/vue'
|
|
||||||
import type { TutorialProgress, TutorialSummary } from '@oj2/contract'
|
|
||||||
import { getLearnProgress, getTutorials } from '../api'
|
|
||||||
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 { COURSES, continueStep, doneCount, groupByProject, lessonPath, lessonState, splitProject, splitTitle, type TutorialType } from './lesson'
|
|
||||||
import LessonRow from './components/LessonRow.vue'
|
|
||||||
import ProjectTile from './components/ProjectTile.vue'
|
|
||||||
import ProjectTree from './components/ProjectTree.vue'
|
|
||||||
import SegBar from './components/SegBar.vue'
|
|
||||||
import './learn.css'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自学首页 /learn(设计稿「自学首页」):最上面「接着学」,下面按项目分组的课程地图。
|
|
||||||
*
|
|
||||||
* 「接着学」看的是**服务端留痕**里最近打开的那一课,不是本机记的:机房一台电脑
|
|
||||||
* 轮着好几个班用,本地记的是上一个人学到哪。
|
|
||||||
*/
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const authModal = useAuthModalStore()
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
interface Course {
|
|
||||||
type: TutorialType
|
|
||||||
label: string
|
|
||||||
titles: TutorialSummary[]
|
|
||||||
progress: Record<number, TutorialProgress>
|
|
||||||
}
|
|
||||||
|
|
||||||
const courses = ref<Course[]>([])
|
|
||||||
const loaded = ref(false)
|
|
||||||
const tab = ref<TutorialType>(route.query.course === 'c' ? 'c' : 'python')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const authed = userStore.isAuthed
|
|
||||||
courses.value = await Promise.all(
|
|
||||||
COURSES.map(async ({ type, label }) => {
|
|
||||||
const [titles, rows] = await Promise.all([getTutorials(type), authed ? getLearnProgress(type).catch(() => []) : Promise.resolve([])])
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
label,
|
|
||||||
titles,
|
|
||||||
progress: Object.fromEntries(rows.map((row) => [row.tutorialId, row])),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
// 没指定看哪门课时,打开最近在学的那门
|
|
||||||
if (!route.query.course && resume.value) tab.value = resume.value.course.type
|
|
||||||
loaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestViewed(course: Course) {
|
|
||||||
return Object.values(course.progress).reduce<string>((acc, row) => (row.lastViewedAt && row.lastViewedAt > acc ? row.lastViewedAt : acc), '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 接着学:最近打开过的那门课里该接着学的那课 */
|
|
||||||
const resume = computed(() => {
|
|
||||||
const started = courses.value.filter((course) => course.titles.length && latestViewed(course)).sort((a, b) => latestViewed(b).localeCompare(latestViewed(a)))
|
|
||||||
const course = started[0]
|
|
||||||
if (!course) return null
|
|
||||||
const step = continueStep(course.titles, course.progress)
|
|
||||||
if (!step) return null
|
|
||||||
const item = course.titles[step - 1]!
|
|
||||||
return { course, step, item, parts: splitTitle(item.title), project: splitProject(item.project) }
|
|
||||||
})
|
|
||||||
|
|
||||||
const others = computed(() => courses.value.filter((course) => course.titles.length && course.type !== resume.value?.course.type))
|
|
||||||
const current = computed(() => courses.value.find((course) => course.type === tab.value))
|
|
||||||
const groups = computed(() => groupByProject(current.value?.titles ?? []))
|
|
||||||
|
|
||||||
function stateOf(course: Course) {
|
|
||||||
return (id: number) => lessonState(course.progress[id])
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstLesson(course: Course) {
|
|
||||||
return course.titles[0] ? splitTitle(course.titles[0].title) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function courseStep(course: Course) {
|
|
||||||
return continueStep(course.titles, course.progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickTab(type: TutorialType) {
|
|
||||||
tab.value = type
|
|
||||||
router.replace({ query: { ...route.query, course: type } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function doneIn(course: Course, ids: number[]) {
|
|
||||||
return ids.filter((id) => lessonState(course.progress[id]) === 'done').length
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => userStore.isAuthed, load)
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="home">
|
|
||||||
<template v-if="loaded">
|
|
||||||
<!-- 第一排:接着学 + 另一门课;一课没学过就是两张「开始学」 -->
|
|
||||||
<div class="top" :class="{ two: !resume }">
|
|
||||||
<section v-if="resume" class="resume">
|
|
||||||
<div class="resume-main">
|
|
||||||
<div class="resume-meta">
|
|
||||||
<span class="resume-label">接着学</span>
|
|
||||||
<span class="muted">
|
|
||||||
{{ resume.course.label }}
|
|
||||||
<template v-if="resume.item.project"> · {{ resume.item.project }}</template>
|
|
||||||
· 第 {{ resume.step }} 课
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="resume-title">
|
|
||||||
<span class="resume-kp">{{ resume.parts.kp }}</span>
|
|
||||||
<span v-if="resume.parts.scene" class="resume-scene">{{ resume.parts.scene }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="resume-progress">
|
|
||||||
<SegBar
|
|
||||||
:groups="groupByProject(resume.course.titles)"
|
|
||||||
:state-of="stateOf(resume.course)"
|
|
||||||
:current="resume.item.id"
|
|
||||||
:width="isDesktop ? 20 : 11"
|
|
||||||
:gap="isDesktop ? 3 : 2"
|
|
||||||
:group-gap="isDesktop ? 10 : 6"
|
|
||||||
/>
|
|
||||||
<span class="num sec"> 学完 {{ doneCount(resume.course.titles, resume.course.progress) }} / {{ resume.course.titles.length }} 课 </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="large" class="resume-go" icon-placement="right" @click="router.push(lessonPath(resume.course.type, resume.step))">
|
|
||||||
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
|
||||||
接着学
|
|
||||||
</n-button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 手机上有了「接着学」就不再摆另一门课的卡,下面的页签切得到 -->
|
|
||||||
<section v-for="course in resume ? (isDesktop ? others : []) : courses.filter((c) => c.titles.length)" :key="course.type" class="course card">
|
|
||||||
<div class="course-text">
|
|
||||||
<div class="course-head">
|
|
||||||
<span class="course-name">{{ course.label }}</span>
|
|
||||||
<span class="muted small">
|
|
||||||
{{ course.titles.length }} 课
|
|
||||||
<template v-if="resume">
|
|
||||||
·
|
|
||||||
{{ courseStep(course) ? `学完 ${doneCount(course.titles, course.progress)} 课` : '还没开始' }}</template
|
|
||||||
>
|
|
||||||
<template v-else> · {{ groupByProject(course.titles).length }} 个项目</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!resume && firstLesson(course)" class="first">
|
|
||||||
<span class="muted">第 1 课</span>
|
|
||||||
<span class="first-kp">{{ firstLesson(course)!.kp }}</span>
|
|
||||||
<span class="sec">{{ firstLesson(course)!.scene }}</span>
|
|
||||||
</div>
|
|
||||||
<SegBar :groups="groupByProject(course.titles)" :state-of="stateOf(course)" :width="resume ? 12 : 14" />
|
|
||||||
<router-link v-if="resume" :to="lessonPath(course.type, courseStep(course) ?? 1)" class="course-link">
|
|
||||||
{{ courseStep(course) ? `接着学第 ${courseStep(course)} 课` : '从第 1 课开始' }}
|
|
||||||
<Icon icon="ph:caret-right-bold" :width="13" />
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<n-button v-if="!resume" type="primary" icon-placement="right" @click="router.push(lessonPath(course.type, 1))">
|
|
||||||
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
|
||||||
开始学
|
|
||||||
</n-button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tabs" role="tablist">
|
|
||||||
<button
|
|
||||||
v-for="course in courses.filter((c) => c.titles.length)"
|
|
||||||
:key="course.type"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
class="tab"
|
|
||||||
:class="{ on: tab === course.type }"
|
|
||||||
:aria-selected="tab === course.type"
|
|
||||||
@click="pickTab(course.type)"
|
|
||||||
>
|
|
||||||
{{ course.label }}
|
|
||||||
<span class="tab-sub">
|
|
||||||
{{
|
|
||||||
userStore.isAuthed && doneCount(course.titles, course.progress)
|
|
||||||
? `学完 ${doneCount(course.titles, course.progress)}/${course.titles.length}`
|
|
||||||
: `${course.titles.length} 课`
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<span class="grow" />
|
|
||||||
<button v-if="!userStore.isAuthed" type="button" class="login" @click="authModal.openLoginModal()">登录后记下学到哪</button>
|
|
||||||
<div v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
|
|
||||||
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
|
|
||||||
<AdminLink v-if="userStore.isSuperAdmin" :to="{ path: '/admin/tutorial/list', query: { type: tab } }" size="small"> 管理教程 </AdminLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="current && isDesktop" class="map">
|
|
||||||
<section v-for="group in groups" :key="group.lessons[0]!.id" class="project card">
|
|
||||||
<div class="project-head">
|
|
||||||
<ProjectTile :icon="group.icon" :tone="group.tone" />
|
|
||||||
<span class="project-name">
|
|
||||||
<span v-if="group.no" class="muted small">{{ group.no }}</span>
|
|
||||||
<span class="project-title">{{ group.name }}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="
|
|
||||||
userStore.isAuthed &&
|
|
||||||
doneIn(
|
|
||||||
current,
|
|
||||||
group.lessons.map((l) => l.id),
|
|
||||||
) === group.lessons.length
|
|
||||||
"
|
|
||||||
class="project-count all"
|
|
||||||
>
|
|
||||||
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{ group.lessons.length }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else-if="
|
|
||||||
userStore.isAuthed &&
|
|
||||||
doneIn(
|
|
||||||
current,
|
|
||||||
group.lessons.map((l) => l.id),
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="project-count"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
doneIn(
|
|
||||||
current,
|
|
||||||
group.lessons.map((l) => l.id),
|
|
||||||
)
|
|
||||||
}}/{{ group.lessons.length }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="project-count">{{ group.lessons.length }} 课</span>
|
|
||||||
</div>
|
|
||||||
<LessonRow
|
|
||||||
v-for="lesson in group.lessons"
|
|
||||||
:key="lesson.id"
|
|
||||||
:title="lesson.title"
|
|
||||||
:step="lesson.step"
|
|
||||||
:to="lessonPath(current.type, lesson.step)"
|
|
||||||
:progress="current.progress[lesson.id]"
|
|
||||||
:active="resume?.course.type === current.type && resume.step === lesson.step"
|
|
||||||
:height="48"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<ProjectTree
|
|
||||||
v-else-if="current"
|
|
||||||
:groups="groups"
|
|
||||||
:progress="current.progress"
|
|
||||||
:type="current.type"
|
|
||||||
:current-step="resume?.course.type === current.type ? resume.step : 0"
|
|
||||||
big
|
|
||||||
/>
|
|
||||||
<n-empty v-if="!courses.some((c) => c.titles.length)" description="还没有公开的教程" style="margin-top: 80px" />
|
|
||||||
</template>
|
|
||||||
<n-spin v-else class="spin" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.home {
|
|
||||||
max-width: 1286px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.sec {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.small {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.num {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.grow {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.spin {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 80px;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: 1px solid var(--learn-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 380px;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
.top.two {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
.resume {
|
|
||||||
border: 1px solid var(--learn-green-line);
|
|
||||||
background: var(--learn-green-bg);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px 26px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
.resume-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.resume-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.resume-label {
|
|
||||||
color: var(--learn-green-text);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.resume-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.resume-kp {
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.resume-scene {
|
|
||||||
font-size: 15px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.resume-progress {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.course {
|
|
||||||
padding: 18px 22px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
.course-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.course-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.course-name {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.first {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.first-kp {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.course-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--learn-green-text);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 24px;
|
|
||||||
height: 40px;
|
|
||||||
border-bottom: 1px solid var(--learn-line);
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 15px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.tab.on {
|
|
||||||
color: var(--learn-green-text);
|
|
||||||
font-weight: 700;
|
|
||||||
border-bottom-color: var(--learn-green);
|
|
||||||
}
|
|
||||||
.tab-sub {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 400;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.admin-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.login {
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--learn-green-text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.map {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.map {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.project {
|
|
||||||
padding: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.project-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 8px 10px;
|
|
||||||
}
|
|
||||||
.project-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.project-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.project-count {
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
.project-count.all {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--learn-green-text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.home {
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.top,
|
|
||||||
.top.two {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.resume {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
.resume-kp {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.resume-go {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.course {
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
.tabs {
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+433
-507
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,619 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { useThemeVars } from 'naive-ui'
|
||||||
|
import type { Tutorial, TutorialProgress, TutorialSummary } from '@oj2/contract'
|
||||||
|
import type { Exercise } from 'utils/types'
|
||||||
|
import { getExercises, getLearnProgress, getTutorial, getTutorials } from '../api'
|
||||||
|
import { parseTutorial } from './composables/useTutorialParse'
|
||||||
|
import { useLearnTrace } from './composables/useLearnTrace'
|
||||||
|
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'
|
||||||
|
import TutorialOutline from './components/TutorialOutline.vue'
|
||||||
|
import ProjectTree from './components/ProjectTree.vue'
|
||||||
|
import SegBar from './components/SegBar.vue'
|
||||||
|
import './learn.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读一课。设计稿「自学重设计」页的阅读页 A(选定):左边目录按项目折叠、中间正文、
|
||||||
|
* 右边这一课的小节和练一练;手机上是顶部课程条 + 底部弹出的目录 + 底部上一课/下一课。
|
||||||
|
*/
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
|
// 未登录也能看教程,只是不留痕、不记进度
|
||||||
|
const traced = computed(() => userStore.isAuthed)
|
||||||
|
|
||||||
|
const type = computed<TutorialType>(() => (route.params.type === 'c' ? 'c' : 'python'))
|
||||||
|
const courseLabel = computed(() => COURSES.find((item) => item.type === type.value)!.label)
|
||||||
|
const step = computed(() => {
|
||||||
|
const value = parseInt((route.params.step as string | undefined) ?? '1')
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const titles = ref<TutorialSummary[]>([])
|
||||||
|
const progress = ref<Record<number, TutorialProgress>>({})
|
||||||
|
const tutorial = ref<Tutorial | null>(null)
|
||||||
|
const exercises = ref<Exercise[]>([])
|
||||||
|
const solvedIds = ref(new Set<number>())
|
||||||
|
const loading = ref(true)
|
||||||
|
const isEmpty = ref(false)
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
// 手机上的目录是大半屏的底部抽屉:按返回键收起它,不是退出这一课
|
||||||
|
useBackClose(menuOpen, () => !isDesktop.value)
|
||||||
|
|
||||||
|
const groups = computed(() => groupByProject(titles.value))
|
||||||
|
const parsed = computed(() => (tutorial.value ? parseTutorial(tutorial.value.content, exercises.value) : { segments: [], outline: [], exerciseCount: 0 }))
|
||||||
|
const parts = computed(() => splitTitle(tutorial.value?.title ?? ''))
|
||||||
|
const solvedCount = computed(() => exercises.value.filter((item) => solvedIds.value.has(item.id)).length)
|
||||||
|
const firstUnsolved = computed(() => {
|
||||||
|
for (const seg of parsed.value.segments) {
|
||||||
|
if (seg.type === 'exercise' && !solvedIds.value.has(seg.exercise.id)) {
|
||||||
|
return { id: seg.exercise.id, index: seg.index + 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const prev = computed(() => tutorialAt(step.value - 1))
|
||||||
|
const next = computed(() => tutorialAt(step.value + 1))
|
||||||
|
const done = computed(() => doneCount(titles.value, progress.value))
|
||||||
|
|
||||||
|
function tutorialAt(at: number) {
|
||||||
|
const item = titles.value[at - 1]
|
||||||
|
return item ? { title: item.title, to: tutorialPath(type.value, at) } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateOf(id: number) {
|
||||||
|
return tutorialState(progress.value[id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
|
||||||
|
useLearnTrace(
|
||||||
|
computed(() => tutorial.value?.id ?? 0),
|
||||||
|
traced,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响学,
|
||||||
|
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
|
||||||
|
*/
|
||||||
|
async function loadProgress() {
|
||||||
|
if (!traced.value) {
|
||||||
|
progress.value = {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rows = await getLearnProgress(type.value)
|
||||||
|
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
|
||||||
|
} catch {
|
||||||
|
progress.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let generation = 0
|
||||||
|
async function init() {
|
||||||
|
const mine = ++generation
|
||||||
|
loading.value = true
|
||||||
|
menuOpen.value = false
|
||||||
|
const list = await getTutorials(type.value)
|
||||||
|
if (mine !== generation) return
|
||||||
|
titles.value = list
|
||||||
|
isEmpty.value = list.length === 0
|
||||||
|
if (isEmpty.value) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (step.value > list.length) {
|
||||||
|
router.replace(tutorialPath(type.value, list.length))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = list[step.value - 1]!.id
|
||||||
|
const [loaded, exs] = await Promise.allSettled([getTutorial(id), getExercises(id), loadProgress()])
|
||||||
|
if (mine !== generation) return
|
||||||
|
tutorial.value = loaded.status === 'fulfilled' ? loaded.value : null
|
||||||
|
exercises.value = exs.status === 'fulfilled' ? exs.value : []
|
||||||
|
solvedIds.value = new Set(exercises.value.filter((item) => item.solved).map((item) => item.id))
|
||||||
|
loading.value = false
|
||||||
|
activeId.value = ''
|
||||||
|
await nextTick()
|
||||||
|
reader.value?.scrollTo({ top: 0 })
|
||||||
|
// 手机上滚的是外层 n-layout 的容器,不是 window —— 原来 window.scrollTo 什么都没滚,
|
||||||
|
// 点「下一课」落在下一课的末尾
|
||||||
|
if (!isDesktop.value) scroller()?.scrollTo({ top: 0 })
|
||||||
|
pagerHidden.value = false
|
||||||
|
atEnd.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.params.type, route.params.step],
|
||||||
|
() => {
|
||||||
|
if (route.name === 'learn tutorial') init()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 在教程页上登录/退出时把勾重新拉一遍(学生多半是点「运行」被拦下才登录的)
|
||||||
|
watch(traced, async () => {
|
||||||
|
await loadProgress()
|
||||||
|
if (!tutorial.value) return
|
||||||
|
const exs = await getExercises(tutorial.value.id).catch(() => exercises.value)
|
||||||
|
exercises.value = exs
|
||||||
|
solvedIds.value = new Set([...solvedIds.value, ...exs.filter((item) => item.solved).map((item) => item.id)])
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 刚做对一道:本课的进度就地加上,目录、进度条、课末跟着变,不用等刷新 */
|
||||||
|
function onSolved(exerciseId: number) {
|
||||||
|
if (solvedIds.value.has(exerciseId) || !tutorial.value) return
|
||||||
|
solvedIds.value = new Set([...solvedIds.value, exerciseId])
|
||||||
|
const id = tutorial.value.id
|
||||||
|
const row = progress.value[id] ?? {
|
||||||
|
tutorialId: id,
|
||||||
|
viewCount: 1,
|
||||||
|
totalSeconds: 0,
|
||||||
|
firstViewedAt: null,
|
||||||
|
lastViewedAt: null,
|
||||||
|
exerciseTotal: exercises.value.length,
|
||||||
|
exerciseSolved: 0,
|
||||||
|
}
|
||||||
|
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 手机底栏
|
||||||
|
// 往下读时收起上一课 / 目录 / 下一课,往回翻再出来;读到课末也收着 ——
|
||||||
|
// 课末卡片里就有「下一课」,两个摆在一起是重复的
|
||||||
|
const mobileRoot = ref<HTMLElement | null>(null)
|
||||||
|
const endRef = ref<HTMLElement | null>(null)
|
||||||
|
const pagerHidden = ref(false)
|
||||||
|
const atEnd = ref(false)
|
||||||
|
|
||||||
|
/** 往上找真正在滚的那层。n-layout-content 自己也带 .n-layout-scroll-container 类却不滚,按类名找会找错 */
|
||||||
|
function scroller(): HTMLElement | null {
|
||||||
|
for (let el = mobileRoot.value?.parentElement; el; el = el.parentElement) {
|
||||||
|
const overflow = getComputedStyle(el).overflowY
|
||||||
|
if ((overflow === 'auto' || overflow === 'scroll') && el.scrollHeight > el.clientHeight) {
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastTop = 0
|
||||||
|
function onMobileScroll(event: Event) {
|
||||||
|
const box = event.target as HTMLElement
|
||||||
|
const top = box.scrollTop
|
||||||
|
if (top < 60) pagerHidden.value = false
|
||||||
|
else if (top > lastTop + 6) pagerHidden.value = true
|
||||||
|
else if (top < lastTop - 6) pagerHidden.value = false
|
||||||
|
lastTop = top
|
||||||
|
const end = endRef.value?.getBoundingClientRect().top
|
||||||
|
atEnd.value = end !== undefined && end < box.getBoundingClientRect().bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动事件不冒泡,在 document 上用捕获拿,不用先找到容器再挂
|
||||||
|
useEventListener(
|
||||||
|
document,
|
||||||
|
'scroll',
|
||||||
|
(event: Event) => {
|
||||||
|
const box = event.target
|
||||||
|
if (isDesktop.value || !(box instanceof HTMLElement)) return
|
||||||
|
// 只认包着本页的那层(目录抽屉、代码块里横着滚的不算)
|
||||||
|
if (!mobileRoot.value || !box.contains(mobileRoot.value)) return
|
||||||
|
onMobileScroll(event)
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 读到哪一节
|
||||||
|
const reader = ref<HTMLElement | null>(null)
|
||||||
|
const activeId = ref('')
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const box = reader.value
|
||||||
|
if (!box) return
|
||||||
|
const top = box.getBoundingClientRect().top + 160
|
||||||
|
let current = parsed.value.outline[0]?.id ?? ''
|
||||||
|
for (const item of parsed.value.outline) {
|
||||||
|
const el = document.getElementById(item.id)
|
||||||
|
if (el && el.getBoundingClientRect().top <= top) current = item.id
|
||||||
|
}
|
||||||
|
activeId.value = current
|
||||||
|
}
|
||||||
|
|
||||||
|
function jump(id: string) {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div class="learn">
|
||||||
|
<n-empty v-if="isEmpty" description="这门课还没有公开的课" style="margin-top: 80px" />
|
||||||
|
|
||||||
|
<!-- 桌面:目录 | 正文 | 这一课 -->
|
||||||
|
<div v-else-if="isDesktop" class="layout">
|
||||||
|
<aside class="rail">
|
||||||
|
<router-link to="/learn" class="back"> <Icon icon="ph:caret-left-bold" :width="13" />自学首页 </router-link>
|
||||||
|
<div class="course">
|
||||||
|
<div class="course-head">
|
||||||
|
<span class="course-name">{{ courseLabel }}</span>
|
||||||
|
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
|
||||||
|
</div>
|
||||||
|
<SegBar :groups="groups" :state-of="stateOf" :current="tutorial?.id" :width="9" :height="5" :gap="2" :group-gap="6" />
|
||||||
|
</div>
|
||||||
|
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" />
|
||||||
|
<div v-if="!traced" class="login-tip muted">登录后记下学到哪</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div ref="reader" class="reader" @scroll.passive="onScroll">
|
||||||
|
<!-- 按课重建:复用的话正文里的编辑器是就地换内容,CodeMirror 会把光标滚进视野,
|
||||||
|
刚滚回顶上的页面又被拽到某个代码块那儿 -->
|
||||||
|
<article v-if="tutorial" :key="tutorial.id" class="article">
|
||||||
|
<div class="crumb muted">
|
||||||
|
<span v-if="tutorial.project">{{ tutorial.project }}</span>
|
||||||
|
<span v-if="tutorial.project">·</span>
|
||||||
|
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
|
||||||
|
<template v-if="userStore.isSuperAdmin">
|
||||||
|
<span class="grow" />
|
||||||
|
<AdminLink :to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }" size="tiny"> 编辑这一课 </AdminLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<h1 class="h1">{{ parts.kp }}</h1>
|
||||||
|
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
||||||
|
<TutorialBody
|
||||||
|
:segments="parsed.segments"
|
||||||
|
:lang="type"
|
||||||
|
:tutorial-id="tutorial.id"
|
||||||
|
:exercise-count="parsed.exerciseCount"
|
||||||
|
:solved-ids="solvedIds"
|
||||||
|
@solved="onSolved"
|
||||||
|
/>
|
||||||
|
<TutorialEnd
|
||||||
|
:step="step"
|
||||||
|
:total="titles.length"
|
||||||
|
:course-label="courseLabel"
|
||||||
|
:exercise-count="parsed.exerciseCount"
|
||||||
|
:solved-count="solvedCount"
|
||||||
|
:first-unsolved="firstUnsolved"
|
||||||
|
:problems="tutorial.problems"
|
||||||
|
:next="next"
|
||||||
|
:groups="groups"
|
||||||
|
:state-of="stateOf"
|
||||||
|
:done-count="done"
|
||||||
|
@jump="jumpExercise"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
<n-spin v-else-if="loading" class="spin" />
|
||||||
|
</div>
|
||||||
|
<nav class="pager">
|
||||||
|
<div class="pager-inner">
|
||||||
|
<n-button v-if="prev" tag="a" :href="prev.to" @click.prevent="router.push(prev.to)">
|
||||||
|
<template #icon><Icon icon="ph:caret-left-bold" /></template>
|
||||||
|
上一课<span class="pager-name muted">{{ splitTitle(prev.title).kp }}</span>
|
||||||
|
</n-button>
|
||||||
|
<span class="grow" />
|
||||||
|
<n-button v-if="next" type="primary" icon-placement="right" tag="a" :href="next.to" @click.prevent="router.push(next.to)">
|
||||||
|
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
||||||
|
下一课<span class="pager-name next-name">{{ splitTitle(next.title).kp }}</span>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="side">
|
||||||
|
<TutorialOutline
|
||||||
|
v-if="tutorial"
|
||||||
|
:items="parsed.outline"
|
||||||
|
:active-id="activeId || parsed.outline[0]?.id || ''"
|
||||||
|
:solved-ids="solvedIds"
|
||||||
|
:exercise-count="parsed.exerciseCount"
|
||||||
|
@jump="jump"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手机 -->
|
||||||
|
<div v-else ref="mobileRoot" class="mobile">
|
||||||
|
<div class="mbar">
|
||||||
|
<n-button @click="menuOpen = true">
|
||||||
|
<template #icon><Icon icon="ph:list" /></template>
|
||||||
|
目录
|
||||||
|
</n-button>
|
||||||
|
<span class="num muted-strong">第 {{ step }} / {{ titles.length }} 课</span>
|
||||||
|
<span class="grow" />
|
||||||
|
<span v-if="parsed.exerciseCount" class="mpractice">
|
||||||
|
<span>练一练</span>
|
||||||
|
<span class="mboxes">
|
||||||
|
<span v-for="n in parsed.exerciseCount" :key="n" class="mbox" :class="{ on: n <= solvedCount }" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<article v-if="tutorial" :key="tutorial.id" class="marticle">
|
||||||
|
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
|
||||||
|
<h1 class="h1 small">{{ parts.kp }}</h1>
|
||||||
|
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
||||||
|
<TutorialBody
|
||||||
|
:segments="parsed.segments"
|
||||||
|
:lang="type"
|
||||||
|
:tutorial-id="tutorial.id"
|
||||||
|
:exercise-count="parsed.exerciseCount"
|
||||||
|
:solved-ids="solvedIds"
|
||||||
|
@solved="onSolved"
|
||||||
|
/>
|
||||||
|
<div ref="endRef">
|
||||||
|
<TutorialEnd
|
||||||
|
:step="step"
|
||||||
|
:total="titles.length"
|
||||||
|
:course-label="courseLabel"
|
||||||
|
:exercise-count="parsed.exerciseCount"
|
||||||
|
:solved-count="solvedCount"
|
||||||
|
:first-unsolved="firstUnsolved"
|
||||||
|
:problems="tutorial.problems"
|
||||||
|
:next="next"
|
||||||
|
:groups="groups"
|
||||||
|
:state-of="stateOf"
|
||||||
|
:done-count="done"
|
||||||
|
compact
|
||||||
|
@jump="jumpExercise"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<n-spin v-else-if="loading" class="spin" />
|
||||||
|
<nav class="mpager" :class="{ hidden: pagerHidden || atEnd }">
|
||||||
|
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
|
||||||
|
<template #icon><Icon icon="ph:caret-left-bold" /></template>
|
||||||
|
上一课
|
||||||
|
</n-button>
|
||||||
|
<span class="grow" />
|
||||||
|
<n-button size="large" quaternary @click="menuOpen = true">
|
||||||
|
<template #icon><Icon icon="ph:list" /></template>
|
||||||
|
目录
|
||||||
|
</n-button>
|
||||||
|
<span class="grow" />
|
||||||
|
<n-button v-if="next" size="large" type="primary" icon-placement="right" @click="router.push(next.to)">
|
||||||
|
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
||||||
|
下一课
|
||||||
|
</n-button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 不自动聚焦:一打开焦点框套在「自学首页」上,手机上看着像出了错 -->
|
||||||
|
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet" :auto-focus="false">
|
||||||
|
<n-drawer-content :native-scrollbar="false" closable>
|
||||||
|
<template #header>
|
||||||
|
<span class="sheet-head">
|
||||||
|
<span class="course-name">{{ courseLabel }}</span>
|
||||||
|
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
|
||||||
|
<router-link to="/learn" class="sheet-home">自学首页</router-link>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" big @go="menuOpen = false" />
|
||||||
|
</n-drawer-content>
|
||||||
|
</n-drawer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.muted {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 桌面端固定高度,三栏各自滚动。72 = 顶栏 56 + 外框上内边距 16;
|
||||||
|
底下的 16 内边距用负外边距吃掉,上一课 / 下一课那条贴着屏幕底 */
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr) 220px;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
height: calc(100vh - 72px);
|
||||||
|
margin-bottom: -16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.side {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rail {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-right: 1px solid var(--learn-line);
|
||||||
|
padding: 0 12px 16px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--learn-green-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.course {
|
||||||
|
padding: 10px 8px 12px;
|
||||||
|
}
|
||||||
|
.course-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.course-name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.login-tip {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.reader {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 40px 0;
|
||||||
|
}
|
||||||
|
.article {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.crumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.h1.small {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.scene {
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 4px 0 18px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--learn-line);
|
||||||
|
padding: 12px 40px;
|
||||||
|
}
|
||||||
|
.pager-inner {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pager-name {
|
||||||
|
margin-left: 6px;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.next-name {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.side {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 24px 0 0 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机 */
|
||||||
|
/* 外层 n-layout 是 overflow: hidden,sticky 粘不住:顶部的条跟着滚走,
|
||||||
|
上一课 / 下一课(带目录按钮)固定在屏幕底部 */
|
||||||
|
.mobile {
|
||||||
|
padding-bottom: 72px;
|
||||||
|
}
|
||||||
|
.mbar {
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 -12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid var(--learn-line);
|
||||||
|
background: v-bind('theme.bodyColor');
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.muted-strong {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.mpractice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.mboxes {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.mbox {
|
||||||
|
width: 14px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--learn-todo);
|
||||||
|
}
|
||||||
|
.mbox.on {
|
||||||
|
background: var(--learn-green);
|
||||||
|
}
|
||||||
|
.marticle {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
.mpager {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--learn-line);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: v-bind('theme.bodyColor');
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.mpager.hidden {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
.sheet-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sheet-home {
|
||||||
|
margin-left: 12px;
|
||||||
|
color: var(--learn-green-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isLessonDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract'
|
import { isTutorialDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract'
|
||||||
|
|
||||||
export type TutorialType = 'python' | 'c'
|
export type TutorialType = 'python' | 'c'
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export interface ProjectGroup {
|
|||||||
icon: string
|
icon: string
|
||||||
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
|
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
|
||||||
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
|
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
|
||||||
lessons: { id: number; title: string; step: number }[]
|
tutorials: { id: number; title: string; step: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */
|
/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */
|
||||||
@@ -57,7 +57,7 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
|
|||||||
titles.forEach((item, index) => {
|
titles.forEach((item, index) => {
|
||||||
const last = groups[groups.length - 1]
|
const last = groups[groups.length - 1]
|
||||||
if (last && last.project === item.project) {
|
if (last && last.project === item.project) {
|
||||||
last.lessons.push({ id: item.id, title: item.title, step: index + 1 })
|
last.tutorials.push({ id: item.id, title: item.title, step: index + 1 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { no, name } = splitProject(item.project)
|
const { no, name } = splitProject(item.project)
|
||||||
@@ -66,21 +66,21 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
|
|||||||
no,
|
no,
|
||||||
name,
|
name,
|
||||||
...projectLook(name),
|
...projectLook(name),
|
||||||
lessons: [{ id: item.id, title: item.title, step: index + 1 }],
|
tutorials: [{ id: item.id, title: item.title, step: index + 1 }],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LessonState = 'done' | 'started' | 'todo'
|
export type TutorialState = 'done' | 'started' | 'todo'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 目录上的三态。学完 = 练一练全对(契约的 `isLessonDone`);打开过或者做过练一练但没
|
* 目录上的三态。学完 = 练一练全对(契约的 `isTutorialDone`);打开过或者做过练一练但没
|
||||||
* 全对是「在学」;别的是没开始。
|
* 全对是「在学」;别的是没开始。
|
||||||
*/
|
*/
|
||||||
export function lessonState(progress: TutorialProgress | undefined): LessonState {
|
export function tutorialState(progress: TutorialProgress | undefined): TutorialState {
|
||||||
if (!progress) return 'todo'
|
if (!progress) return 'todo'
|
||||||
if (isLessonDone(progress)) return 'done'
|
if (isTutorialDone(progress)) return 'done'
|
||||||
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started'
|
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started'
|
||||||
return 'todo'
|
return 'todo'
|
||||||
}
|
}
|
||||||
@@ -98,17 +98,17 @@ export function continueStep(titles: TutorialSummary[], progress: Record<number,
|
|||||||
if (!latest) return null
|
if (!latest) return null
|
||||||
const index = titles.findIndex((item) => item.id === latest!.tutorialId)
|
const index = titles.findIndex((item) => item.id === latest!.tutorialId)
|
||||||
if (index < 0) return null
|
if (index < 0) return null
|
||||||
if (lessonState(latest) !== 'done') return index + 1
|
if (tutorialState(latest) !== 'done') return index + 1
|
||||||
for (let i = index + 1; i < titles.length; i++) {
|
for (let i = index + 1; i < titles.length; i++) {
|
||||||
if (lessonState(progress[titles[i]!.id]) !== 'done') return i + 1
|
if (tutorialState(progress[titles[i]!.id]) !== 'done') return i + 1
|
||||||
}
|
}
|
||||||
return index + 1
|
return index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
|
export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
|
||||||
return titles.filter((item) => lessonState(progress[item.id]) === 'done').length
|
return titles.filter((item) => tutorialState(progress[item.id]) === 'done').length
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lessonPath(type: TutorialType, step: number) {
|
export function tutorialPath(type: TutorialType, step: number) {
|
||||||
return `/learn/${type}/${step.toString().padStart(2, '0')}`
|
return `/learn/${type}/${step.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
File renamed without changes.
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user