Compare commits
| 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 |
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(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -302,6 +302,20 @@
|
|||||||
"when": 1793632543220,
|
"when": 1793632543220,
|
||||||
"tag": "0042_class_name_cleanup",
|
"tag": "0042_class_name_cleanup",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 43,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793693911394,
|
||||||
|
"tag": "0043_mascot_event",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793695621303,
|
||||||
|
"tag": "0044_achievement_more_tiers",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,7 @@ import type {
|
|||||||
JudgeStatus,
|
JudgeStatus,
|
||||||
LessonLanguage,
|
LessonLanguage,
|
||||||
LessonMode,
|
LessonMode,
|
||||||
|
MascotEventKind,
|
||||||
ProblemChallenges,
|
ProblemChallenges,
|
||||||
ProblemDifficulty,
|
ProblemDifficulty,
|
||||||
ProblemLanguage,
|
ProblemLanguage,
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -21,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'
|
||||||
@@ -44,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 读不出
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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[],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
{
|
{
|
||||||
key: 'accepted_count',
|
key: 'accepted_count',
|
||||||
name: '做对的题数',
|
name: '做对的题数',
|
||||||
helpText: '去重后通过的题目数量(不含比赛)',
|
helpText: '去重后通过的题目数量(含比赛题)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mid_ac_count',
|
key: 'mid_ac_count',
|
||||||
name: '做对的中等题',
|
name: '做对的中等题',
|
||||||
helpText: '去重后通过的中等难度题目数(不含比赛)',
|
helpText: '去重后通过的中等难度题目数(含比赛题)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'hard_ac_count',
|
key: 'hard_ac_count',
|
||||||
name: '做对的困难题',
|
name: '做对的困难题',
|
||||||
helpText: '去重后通过的困难题目数(不含比赛)',
|
helpText: '去重后通过的困难题目数(含比赛题)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'submission_count',
|
key: 'submission_count',
|
||||||
name: '提交次数',
|
name: '提交次数',
|
||||||
helpText: '提交次数(不含比赛)',
|
helpText: '提交次数(含比赛)',
|
||||||
},
|
},
|
||||||
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
|
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
|
||||||
{
|
{
|
||||||
@@ -45,13 +45,13 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
{
|
{
|
||||||
key: 'max_ac_week_streak',
|
key: 'max_ac_week_streak',
|
||||||
name: '连续有新做对的周数',
|
name: '连续有新做对的周数',
|
||||||
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)',
|
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
|
||||||
},
|
},
|
||||||
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
|
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
|
||||||
{
|
{
|
||||||
key: 'contest_joined',
|
key: 'contest_joined',
|
||||||
name: '参加比赛',
|
name: '参加比赛',
|
||||||
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)',
|
helpText: '交过题的比赛数量',
|
||||||
},
|
},
|
||||||
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
|
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
|
||||||
{
|
{
|
||||||
@@ -59,6 +59,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
name: '做完的专题',
|
name: '做完的专题',
|
||||||
helpText: '完成的专题数量',
|
helpText: '完成的专题数量',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'challenge_count',
|
||||||
|
name: '挑战成功',
|
||||||
|
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'first_try_ac_count',
|
key: 'first_try_ac_count',
|
||||||
name: '一次就对的题',
|
name: '一次就对的题',
|
||||||
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
|||||||
{
|
{
|
||||||
key: 'max_code_lines',
|
key: 'max_code_lines',
|
||||||
name: '最长的代码行数',
|
name: '最长的代码行数',
|
||||||
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)',
|
helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'achievement_unlocked_count',
|
key: 'achievement_unlocked_count',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import { db, schema, type DbOrTx } from '../db'
|
import { db, schema, type DbOrTx } from '../db'
|
||||||
import { publishAchievementNotification } from '../events'
|
import { publishAchievementNotification } from '../events'
|
||||||
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
||||||
|
* 参加过几场比赛、挑战成功几次也在这里一起数。
|
||||||
*
|
*
|
||||||
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
||||||
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
||||||
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
||||||
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
||||||
* 一个人的提交也就几百条。
|
* 一个人的提交也就几百条。
|
||||||
|
*
|
||||||
|
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
|
||||||
|
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
|
||||||
|
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
|
||||||
*/
|
*/
|
||||||
async function solvedCounts(tx: DbOrTx, userId: number) {
|
async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||||
const [row] = await tx.execute<{
|
const [row] = await tx.execute<{
|
||||||
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
|||||||
mid: number
|
mid: number
|
||||||
hard: number
|
hard: number
|
||||||
first_try: number
|
first_try: number
|
||||||
|
contests: number
|
||||||
|
challenges: number
|
||||||
}>(
|
}>(
|
||||||
sql`
|
sql`
|
||||||
with mine as (
|
with mine as (
|
||||||
select s.problem_id, s.result, s.create_time, s.id, p.difficulty
|
select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
|
||||||
from ${schema.submission} s
|
from ${schema.submission} s
|
||||||
join ${schema.problem} p on p.id = s.problem_id
|
join ${schema.problem} p on p.id = s.problem_id
|
||||||
where s.user_id = ${userId} and s.contest_id is null
|
where s.user_id = ${userId}
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
||||||
@@ -111,11 +118,13 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
|
|||||||
select count(*)::int from (
|
select count(*)::int from (
|
||||||
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
||||||
) first where result in (${ACCEPTED_RESULTS})
|
) first where result in (${ACCEPTED_RESULTS})
|
||||||
) as first_try
|
) as first_try,
|
||||||
|
count(distinct contest_id)::int as contests,
|
||||||
|
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
|
||||||
from mine
|
from mine
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 }
|
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +139,7 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
|||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||||
.where(eq(schema.submission.id, submissionId))
|
.where(eq(schema.submission.id, submissionId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!row || row.submission.contestId !== null) return []
|
if (!row) return []
|
||||||
|
|
||||||
const rejudged = countedAs !== null
|
const rejudged = countedAs !== null
|
||||||
const accepted = isAccepted(row.submission.result)
|
const accepted = isAccepted(row.submission.result)
|
||||||
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
|||||||
value.mid_ac_count = solved.mid
|
value.mid_ac_count = solved.mid
|
||||||
value.hard_ac_count = solved.hard
|
value.hard_ac_count = solved.hard
|
||||||
value.first_try_ac_count = solved.first_try
|
value.first_try_ac_count = solved.first_try
|
||||||
|
value.contest_joined = solved.contests
|
||||||
|
value.challenge_count = solved.challenges
|
||||||
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
|
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
|
||||||
if (firstAc) {
|
if (firstAc) {
|
||||||
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
||||||
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
|
|||||||
and(
|
and(
|
||||||
eq(schema.submission.userId, row.submission.userId),
|
eq(schema.submission.userId, row.submission.userId),
|
||||||
eq(schema.submission.problemId, row.submission.problemId),
|
eq(schema.submission.problemId, row.submission.problemId),
|
||||||
isNull(schema.submission.contestId),
|
|
||||||
lt(schema.submission.createTime, row.submission.createTime),
|
lt(schema.submission.createTime, row.submission.createTime),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
|
|||||||
const metric = findMetric(achievement.metric)
|
const metric = findMetric(achievement.metric)
|
||||||
if (!metric) return { scanned: 0, unlocked: 0 }
|
if (!metric) return { scanned: 0, unlocked: 0 }
|
||||||
|
|
||||||
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
|
// contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
|
||||||
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
|
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
|
||||||
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
|
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
|
||||||
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
|
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
|
||||||
@@ -482,7 +492,7 @@ async function refreshContestJoinedForAll() {
|
|||||||
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
|
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
|
||||||
* 其余指标不动,写法同 refreshContestJoinedForAll)。
|
* 其余指标不动,写法同 refreshContestJoinedForAll)。
|
||||||
*
|
*
|
||||||
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
* 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
|
||||||
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
|
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
|
||||||
*/
|
*/
|
||||||
async function refreshAcWeekStreakForAll() {
|
async function refreshAcWeekStreakForAll() {
|
||||||
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
|
|||||||
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
|
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
|
||||||
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
|
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
|
||||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
.where(and(isNull(schema.submission.contestId), inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])))
|
.where(inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))
|
||||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||||
.as('first_accepted')
|
.as('first_accepted')
|
||||||
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
|
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
|
||||||
|
|||||||
@@ -6,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'
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '连着几周有做对',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 挑战 ====================
|
// ==================== 挑战 ====================
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ 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 { 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
|
||||||
@@ -36,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)
|
||||||
|
|
||||||
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
||||||
@@ -123,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() {
|
||||||
@@ -244,6 +248,86 @@ 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 tutorialPath(track.type, track.step)
|
return tutorialPath(track.type, track.step)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +67,32 @@ function openTask(row: ClassRow) {
|
|||||||
router.push({ name: 'task', 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: '/task', show: true },
|
{ label: '课堂任务', to: '/task', show: true },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
|||||||
import { useAuthModalStore } from 'shared/store/authModal'
|
import { useAuthModalStore } from 'shared/store/authModal'
|
||||||
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 { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||||
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
|
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
|
||||||
import TutorialRow from './components/TutorialRow.vue'
|
import TutorialRow from './components/TutorialRow.vue'
|
||||||
import ProjectTile from './components/ProjectTile.vue'
|
import ProjectTile from './components/ProjectTile.vue'
|
||||||
@@ -94,6 +95,23 @@ function doneIn(course: Course, ids: number[]) {
|
|||||||
return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length
|
return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 看板娘:学过就带去「接着学」那一课,一课没学过就从当前页签那门课的第 1 课开始
|
||||||
|
useMascotGuide(() => {
|
||||||
|
if (!loaded.value || userStore.isTeacherOrAbove) return null
|
||||||
|
const it = resume.value
|
||||||
|
if (it) {
|
||||||
|
const next = goAction(router, '接着学', tutorialPath(it.course.type, it.step))
|
||||||
|
// 接着学的那课之前,离它最近的一课打开过、练一练却没全对(「在学」那一态):先叫回去看看
|
||||||
|
const { titles, progress } = it.course
|
||||||
|
for (let step = it.step - 1; step >= 1; step--) {
|
||||||
|
if (tutorialState(progress[titles[step - 1]!.id]) !== 'started') continue
|
||||||
|
return { situation: 'learn.wrongExercise', n: step, actions: [goAction(router, '去看看', tutorialPath(it.course.type, step)), next] }
|
||||||
|
}
|
||||||
|
return { situation: 'learn.continue', n: it.step, actions: [next] }
|
||||||
|
}
|
||||||
|
return { situation: 'learn.fresh', actions: [goAction(router, '第 1 课', tutorialPath(tab.value, 1))] }
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => userStore.isAuthed, load)
|
watch(() => userStore.isAuthed, load)
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useBackClose } from 'shared/composables/backClose'
|
|||||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
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, type MascotAction } from 'shared/mascot/guide'
|
||||||
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
|
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
|
||||||
import TutorialBody from './components/TutorialBody.vue'
|
import TutorialBody from './components/TutorialBody.vue'
|
||||||
import TutorialEnd from './components/TutorialEnd.vue'
|
import TutorialEnd from './components/TutorialEnd.vue'
|
||||||
@@ -233,6 +234,16 @@ function jump(id: string) {
|
|||||||
function jumpExercise(id: number) {
|
function jumpExercise(id: number) {
|
||||||
jump(`exercise-${id}`)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { classLabel, groupClassOptions } from 'utils/classes'
|
|||||||
import { useAIStream } from 'shared/composables/aiStream'
|
import { useAIStream } from 'shared/composables/aiStream'
|
||||||
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 PkDuel from './components/PkDuel.vue'
|
import PkDuel from './components/PkDuel.vue'
|
||||||
import PkGrid from './components/PkGrid.vue'
|
import PkGrid from './components/PkGrid.vue'
|
||||||
import PkRace from './components/PkRace.vue'
|
import PkRace from './components/PkRace.vue'
|
||||||
@@ -31,6 +32,10 @@ const { isDesktop } = useBreakpoints()
|
|||||||
const MAX_CLASSES = 8
|
const MAX_CLASSES = 8
|
||||||
|
|
||||||
const teacher = computed(() => userStore.isTeacherOrAbove)
|
const teacher = computed(() => userStore.isTeacherOrAbove)
|
||||||
|
|
||||||
|
// 看板娘:学生念叨一句班级对决,老师只打招呼
|
||||||
|
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
|
||||||
|
|
||||||
const pk = ref<ClassPk | null>(null)
|
const pk = ref<ClassPk | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const battle = ref<ClassBattleItem[]>([])
|
const battle = ref<ClassBattleItem[]>([])
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ const label = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
width: 20px;
|
width: 22px;
|
||||||
height: 20px;
|
height: 22px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { applyFixes, clearErrorMark, currentCode, showErrorMark, showPunctuation
|
|||||||
import { explainCCompileError } from 'oj/problem/utils/cError'
|
import { explainCCompileError } from 'oj/problem/utils/cError'
|
||||||
import { explainPythonCompileError } from 'utils/pythonError'
|
import { explainPythonCompileError } from 'utils/pythonError'
|
||||||
import { stripJudgePath } from 'utils/judge'
|
import { stripJudgePath } from 'utils/judge'
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
|
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
|
||||||
@@ -80,17 +81,17 @@ onUnmounted(clearErrorMark)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="explain-card">
|
<!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明;那一行编辑器里已经标红了 -->
|
||||||
|
<div class="explain-section">
|
||||||
<template v-if="explanation">
|
<template v-if="explanation">
|
||||||
<p class="explain">
|
<SectionHead icon="problem" tone="amber">{{ explanation.line ? `第 ${explanation.line} 行` : '代码的格式写错了' }}</SectionHead>
|
||||||
<b v-if="explanation.line">第 {{ explanation.line }} 行:</b>{{ explanation.message }}
|
<p class="explain">{{ explanation.message }}</p>
|
||||||
</p>
|
|
||||||
<p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p>
|
<p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p>
|
||||||
<div v-if="explanation.punctuation">
|
<div v-if="explanation.punctuation" class="fix">
|
||||||
<n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button>
|
<n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button>
|
||||||
<n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text>
|
<n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text>
|
||||||
</div>
|
</div>
|
||||||
<n-collapse>
|
<n-collapse class="raw-collapse">
|
||||||
<n-collapse-item title="原始报错(英文)" name="raw">
|
<n-collapse-item title="原始报错(英文)" name="raw">
|
||||||
<div class="raw">{{ rawError }}</div>
|
<div class="raw">{{ rawError }}</div>
|
||||||
</n-collapse-item>
|
</n-collapse-item>
|
||||||
@@ -98,6 +99,7 @@ onUnmounted(clearErrorMark)
|
|||||||
</template>
|
</template>
|
||||||
<!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 -->
|
<!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<SectionHead icon="problem" tone="amber">代码的格式写错了</SectionHead>
|
||||||
<p class="explain">请仔细检查,看看代码的格式是不是写错了!</p>
|
<p class="explain">请仔细检查,看看代码的格式是不是写错了!</p>
|
||||||
<div class="raw">{{ rawError }}</div>
|
<div class="raw">{{ rawError }}</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -105,20 +107,15 @@ onUnmounted(clearErrorMark)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 和答错的说明同一种浅灰卡片(设计稿「语法没过」) */
|
.explain-section {
|
||||||
.explain-card {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: rgba(128, 128, 128, 0.06);
|
|
||||||
border: 1px solid v-bind('theme.dividerColor');
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.explain {
|
.explain {
|
||||||
margin: 0;
|
margin: 0 0 10px;
|
||||||
line-height: 1.7;
|
font-size: 16px;
|
||||||
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.explain.other {
|
.explain.other {
|
||||||
@@ -126,6 +123,10 @@ onUnmounted(clearErrorMark)
|
|||||||
color: v-bind('theme.textColor2');
|
color: v-bind('theme.textColor2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fix {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.raw {
|
.raw {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api'
|
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api'
|
||||||
@@ -33,15 +34,16 @@ const ctx = useProblemPageContext()
|
|||||||
const { isMobile } = useBreakpoints()
|
const { isMobile } = useBreakpoints()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
|
/** 做完的实心绿圈里的勾:暗色下主题绿很亮,白勾看不清 */
|
||||||
|
const doneDotText = computed(() => (isDark.value ? 'rgba(0, 0, 0, 0.85)' : '#fff'))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、专题浅蓝、比赛浅黄。
|
* 课堂、比赛在电脑上是左边的竖栏(课堂浅绿底、比赛浅黄底),专题是一条不带底色的细横条;
|
||||||
|
* 手机上三种都是横条(课堂浅绿、比赛浅黄)。
|
||||||
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
|
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
|
||||||
*/
|
*/
|
||||||
const tone = computed(() => ({
|
const tone = computed(() => ({
|
||||||
lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
|
lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
|
||||||
setText: isDark.value ? '#8fb4ee' : '#1d4f9c',
|
|
||||||
setBar: isDark.value ? '#6f9be0' : '#2f6bc4',
|
|
||||||
contestText: isDark.value ? '#f2c97d' : '#8a5200',
|
contestText: isDark.value ? '#f2c97d' : '#8a5200',
|
||||||
}))
|
}))
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -241,8 +243,35 @@ onSolvedHere(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 课堂 -->
|
<!--
|
||||||
<div v-if="lesson" class="context-bar lesson">
|
课堂(电脑):左栏最左边一条竖栏,只写题号,题名鼠标停上去看(学生认题靠题号)。
|
||||||
|
不占高度:机房 1366×768 那种屏,顶上每省一行,题面和编辑器就多一行(设计稿「题目页观感」合一版)
|
||||||
|
-->
|
||||||
|
<nav v-if="lesson && !isMobile" class="lesson-rail" aria-label="这节课的题">
|
||||||
|
<div class="rail-head">
|
||||||
|
<span>这节课</span>
|
||||||
|
<b class="rail-done">{{ lesson.done }}/{{ lesson.total }}</b>
|
||||||
|
<span v-if="lessonStore.languageText" class="rail-lang">{{ lessonStore.languageText }}</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-for="(chip, index) in lesson.chips"
|
||||||
|
:key="chip.displayId"
|
||||||
|
:to="chip.to"
|
||||||
|
class="rail-item"
|
||||||
|
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
|
||||||
|
:title="chip.note ? `${chip.title}(${chip.note})` : chip.title"
|
||||||
|
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
<span class="rail-dot">
|
||||||
|
<Icon v-if="chip.status === 'done'" icon="ph:check-bold" :width="14" />
|
||||||
|
<template v-else>{{ index + 1 }}</template>
|
||||||
|
</span>
|
||||||
|
<span class="rail-id">{{ chip.displayId }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 课堂(手机):一条横的 -->
|
||||||
|
<div v-else-if="lesson" class="context-bar lesson">
|
||||||
<span class="lead lesson-lead"
|
<span class="lead lesson-lead"
|
||||||
>这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span
|
>这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span
|
||||||
>
|
>
|
||||||
@@ -356,7 +385,37 @@ onSolvedHere(() => {
|
|||||||
</component>
|
</component>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 比赛 -->
|
<!--
|
||||||
|
比赛(电脑):和课堂一样是左边一条竖栏 —— 比赛一般 3 到 10 道题,题号一列摆得下。
|
||||||
|
顶上「‹ 比赛」回比赛页(比赛名鼠标停上去看)、还剩多久;底下「排名」(期中期末进行中不给)
|
||||||
|
-->
|
||||||
|
<nav v-else-if="contestBar && !isMobile" class="lesson-rail contest-rail" aria-label="比赛的题">
|
||||||
|
<router-link :to="contestBar.home" class="rail-back" :title="`回到比赛:${contestBar.title}`">
|
||||||
|
<Icon icon="ph:caret-left-bold" :width="12" aria-hidden="true" />比赛
|
||||||
|
</router-link>
|
||||||
|
<div class="rail-head">
|
||||||
|
<template v-if="contestBar.remaining">
|
||||||
|
<span>还剩</span>
|
||||||
|
<b class="rail-time">{{ contestBar.remaining }}</b>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ contestBar.notStarted ? '还没开始' : '已结束' }}</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-for="chip in contestBar.chips"
|
||||||
|
:key="chip.displayId"
|
||||||
|
:to="chip.to"
|
||||||
|
class="rail-item"
|
||||||
|
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
|
||||||
|
:title="chip.title"
|
||||||
|
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
<!-- 比赛题号本来就短(1、2、A),直接写在圈里;做对了圈涂绿、号还在,认得出是哪道 -->
|
||||||
|
<span class="rail-dot">{{ chip.displayId }}</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="contestBar.rank" :to="contestBar.rank" class="rail-rank"> <Icon icon="ph:ranking" :width="18" aria-hidden="true" />排名 </router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 比赛(手机) -->
|
||||||
<div v-else-if="contestBar" class="context-bar contest">
|
<div v-else-if="contestBar" class="context-bar contest">
|
||||||
<router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link>
|
<router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link>
|
||||||
<ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" />
|
<ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" />
|
||||||
@@ -371,6 +430,165 @@ onSolvedHere(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 比赛竖栏:浅黄底,和课堂分得开 */
|
||||||
|
.contest-rail {
|
||||||
|
background-color: rgba(208, 138, 28, 0.07);
|
||||||
|
border-right-color: rgba(208, 138, 28, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contest-rail .rail-item:hover {
|
||||||
|
background-color: rgba(208, 138, 28, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contest-rail .rail-dot {
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind('tone.contestText');
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-back:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-time {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind('tone.contestText');
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-rank {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 8px 0 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind('theme.textColor2');
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-rank:hover {
|
||||||
|
color: v-bind('tone.contestText');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 课堂竖栏(电脑) ==================== */
|
||||||
|
.lesson-rail {
|
||||||
|
width: 56px;
|
||||||
|
flex: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
background-color: rgba(24, 160, 88, 0.06);
|
||||||
|
border-right: 1px solid rgba(24, 160, 88, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-done {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind('tone.lessonText');
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-lang {
|
||||||
|
max-width: 52px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 7px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind('theme.textColor2');
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-item:hover {
|
||||||
|
background-color: rgba(24, 160, 88, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-item.current {
|
||||||
|
font-weight: 700;
|
||||||
|
color: v-bind('tone.lessonText');
|
||||||
|
background-color: v-bind('theme.bodyColor');
|
||||||
|
box-shadow: inset 3px 0 v-bind('theme.primaryColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-id {
|
||||||
|
max-width: 52px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-dot {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1.5px solid rgba(128, 128, 128, 0.4);
|
||||||
|
background-color: v-bind('theme.bodyColor');
|
||||||
|
color: v-bind('theme.textColor2');
|
||||||
|
}
|
||||||
|
|
||||||
|
.done .rail-dot {
|
||||||
|
border-color: v-bind('theme.successColor');
|
||||||
|
background-color: v-bind('theme.successColor');
|
||||||
|
color: v-bind('doneDotText');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 做过没对:红圈,和题目列表的红「−」一个意思 */
|
||||||
|
.tried .rail-dot {
|
||||||
|
border-color: v-bind('theme.errorColor');
|
||||||
|
color: v-bind('theme.errorColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.current .rail-dot {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: v-bind('theme.primaryColor');
|
||||||
|
color: v-bind('tone.lessonText');
|
||||||
|
}
|
||||||
|
|
||||||
|
.current.done .rail-dot {
|
||||||
|
color: v-bind('doneDotText');
|
||||||
|
}
|
||||||
|
|
||||||
.context-bar {
|
.context-bar {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -390,10 +608,12 @@ onSolvedHere(() => {
|
|||||||
border-bottom-color: rgba(24, 160, 88, 0.18);
|
border-bottom-color: rgba(24, 160, 88, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 专题:不带底色的一条细横条(设计稿「题目页观感」合一版)。专题可以有几十道题、还有「加入专题」,
|
||||||
|
竖栏放不下,留在页签行上面 */
|
||||||
.problemset {
|
.problemset {
|
||||||
|
height: 40px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background-color: rgba(47, 107, 196, 0.06);
|
border-bottom-color: v-bind('theme.dividerColor');
|
||||||
border-bottom-color: rgba(47, 107, 196, 0.16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contest {
|
.contest {
|
||||||
@@ -437,7 +657,7 @@ onSolvedHere(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.set-back {
|
.set-back {
|
||||||
color: v-bind('tone.setText');
|
color: v-bind('theme.textColor1');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
|
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
|
||||||
@@ -452,14 +672,14 @@ onSolvedHere(() => {
|
|||||||
width: 110px;
|
width: 110px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: rgba(47, 107, 196, 0.16);
|
background-color: rgba(128, 128, 128, 0.16);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-progress-fill {
|
.set-progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: v-bind('tone.setBar');
|
background-color: v-bind('theme.primaryColor');
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdown {
|
.countdown {
|
||||||
@@ -503,7 +723,7 @@ onSolvedHere(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.problemset .step.forward {
|
.problemset .step.forward {
|
||||||
color: v-bind('tone.setText');
|
color: v-bind('theme.textColor1');
|
||||||
}
|
}
|
||||||
|
|
||||||
.step:not(.disabled):hover {
|
.step:not(.disabled):hover {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useCodeStore } from 'oj/store/code'
|
import { useCodeStore } from 'oj/store/code'
|
||||||
@@ -29,7 +31,7 @@ function fillSample(input: string) {
|
|||||||
<template>
|
<template>
|
||||||
<!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) -->
|
<!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) -->
|
||||||
<div class="custom">
|
<div class="custom">
|
||||||
<span class="label">输入</span>
|
<SectionHead icon="input">输入</SectionHead>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="customInput"
|
v-model:value="customInput"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -38,15 +40,16 @@ function fillSample(input: string) {
|
|||||||
class="mono"
|
class="mono"
|
||||||
/>
|
/>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<n-button type="primary" ghost :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()">
|
<n-button type="primary" secondary :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()">
|
||||||
|
<template #icon><Icon icon="ph:play-fill" /></template>
|
||||||
用这组数据运行
|
用这组数据运行
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text class="fill" @click="fillSample(sample.input)">
|
<n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text type="primary" class="fill" @click="fillSample(sample.input)">
|
||||||
填入例子 {{ index + 1 }}
|
填入例子 {{ index + 1 }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="label">输出</span>
|
<SectionHead icon="output" class="output-head">输出</SectionHead>
|
||||||
<TrialErrorNote
|
<TrialErrorNote
|
||||||
v-if="customResult !== null && customResult !== SubmissionStatus.accepted"
|
v-if="customResult !== null && customResult !== SubmissionStatus.accepted"
|
||||||
:result="customResult"
|
:result="customResult"
|
||||||
@@ -68,13 +71,14 @@ function fillSample(input: string) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.output-head {
|
||||||
font-size: 13px;
|
margin-top: 14px;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono :deep(textarea) {
|
.mono :deep(textarea) {
|
||||||
font-family: Consolas, Monaco, monospace;
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -85,13 +89,12 @@ function fillSample(input: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fill {
|
.fill {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: v-bind('theme.textColor2');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.output {
|
.output {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 88px;
|
min-height: 60px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 10px 10px;
|
padding: 10px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -99,7 +102,9 @@ function fillSample(input: string) {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-family: Consolas, Monaco, monospace;
|
font-family: Consolas, Monaco, monospace;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.output.empty {
|
.output.empty {
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
<n-select
|
<n-select
|
||||||
v-if="!drawing"
|
v-if="!drawing"
|
||||||
v-model:value="codeStore.code.language"
|
v-model:value="codeStore.code.language"
|
||||||
|
class="language"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
|
:bordered="false"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
:options="languageOptions"
|
:options="languageOptions"
|
||||||
:disabled="showCollabBar"
|
:disabled="showCollabBar"
|
||||||
@@ -190,15 +192,19 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
|
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
|
||||||
|
<span v-if="!drawing" class="divider" aria-hidden="true" />
|
||||||
|
|
||||||
|
<!-- 浅绿底:比实心的「提交」轻一档,又比旁边不带框的按钮重(设计稿「题目页观感」合一版) -->
|
||||||
<n-button
|
<n-button
|
||||||
v-if="canRunSamples && isDesktop"
|
v-if="canRunSamples && isDesktop"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
secondary
|
||||||
:loading="samplesRunning"
|
:loading="samplesRunning"
|
||||||
:disabled="!codeStore.code.value.trim()"
|
:disabled="!codeStore.code.value.trim()"
|
||||||
@click="submissionStore.runSamples()"
|
@click="submissionStore.runSamples()"
|
||||||
>
|
>
|
||||||
|
<template #icon><Icon icon="ph:play-fill" /></template>
|
||||||
运行例子
|
运行例子
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
@@ -215,22 +221,26 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
:type="helpButtonType"
|
:type="helpButtonType"
|
||||||
:secondary="teacherIn || collabStore.helpStatus !== 'idle'"
|
:secondary="teacherIn || collabStore.helpStatus !== 'idle'"
|
||||||
|
:quaternary="!teacherIn && collabStore.helpStatus === 'idle'"
|
||||||
:title="helpButtonTitle"
|
:title="helpButtonTitle"
|
||||||
class="help"
|
class="help"
|
||||||
:class="{ busy: teacherIn }"
|
:class="{ busy: teacherIn }"
|
||||||
@click="toggleHelp"
|
@click="toggleHelp"
|
||||||
>
|
>
|
||||||
<span v-if="teacherIn" class="dot" aria-hidden="true" />
|
<span v-if="teacherIn" class="dot" aria-hidden="true" />
|
||||||
|
<Icon v-else-if="collabStore.helpStatus === 'idle'" icon="ph:hand" :width="17" class="help-icon" aria-hidden="true" />
|
||||||
{{ helpButtonText }}
|
{{ helpButtonText }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" @click="openStatistics(problem!._id)">
|
<n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" quaternary @click="openStatistics(problem!._id)">
|
||||||
课堂统计
|
课堂统计
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 -->
|
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 -->
|
||||||
<n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect">
|
<n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect">
|
||||||
<n-button :size="buttonSize" class="more" aria-label="更多:去自测猫、复制代码、重置代码……"> ⋯ </n-button>
|
<n-button :size="buttonSize" quaternary class="more" aria-label="更多:去自测猫、复制代码、重置代码……">
|
||||||
|
<Icon icon="ph:dots-three-bold" :width="18" />
|
||||||
|
</n-button>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -288,6 +298,23 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 语言选择不带框,字加粗:它不是一个要常去点的按钮 */
|
||||||
|
.language :deep(.n-base-selection-label) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
flex: none;
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 2px;
|
||||||
|
background-color: v-bind('theme.dividerColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.help.busy {
|
.help.busy {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { useLessonStore } from 'shared/store/lesson'
|
import { useLessonStore } from 'shared/store/lesson'
|
||||||
import ChallengeList from './ChallengeList.vue'
|
import ChallengeList from './ChallengeList.vue'
|
||||||
@@ -44,16 +45,17 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 设计稿「答案正确:下一题在结果页签里」:浅绿卡片,一个 44px 的大按钮 -->
|
<!--
|
||||||
<div v-if="lesson" class="lesson-next">
|
设计稿「题目页观感」结果 X:「接着做」一节,一句话说还剩几道,一个 44px 的大按钮写下一题。
|
||||||
|
原来是一张浅绿卡片:左边竖栏已经有这节课的进度,不用再框一块
|
||||||
|
-->
|
||||||
|
<section v-if="lesson" class="lesson-next">
|
||||||
|
<SectionHead icon="next">接着做</SectionHead>
|
||||||
<template v-if="lesson.next">
|
<template v-if="lesson.next">
|
||||||
<div class="text">
|
<p class="text">
|
||||||
<span class="headline">{{ label }}还剩 {{ lesson.remaining }} 道</span>
|
{{ label }}还剩 {{ lesson.remaining }} 道,下一道 {{ lesson.next.problemDisplayId }}
|
||||||
<span class="detail">
|
{{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}。
|
||||||
{{ lesson.next.problemDisplayId }}
|
</p>
|
||||||
{{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next">
|
<router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next">
|
||||||
<span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span>
|
<span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span>
|
||||||
<svg
|
<svg
|
||||||
@@ -72,15 +74,12 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
|
|||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text">
|
<p class="text">{{ label }}都做完了,一共 {{ lesson.total }} 道。<template v-if="!challenges">想接着练,可以去专题里挑一个。</template></p>
|
||||||
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
|
|
||||||
<span v-if="!challenges" class="detail">想接着练,可以去专题里挑一个</span>
|
|
||||||
</div>
|
|
||||||
<router-link v-if="!challenges" to="/problemset" class="next secondary">
|
<router-link v-if="!challenges" to="/problemset" class="next secondary">
|
||||||
<span class="next-label">去专题继续练</span>
|
<span class="next-label">去专题继续练</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</section>
|
||||||
<!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 -->
|
<!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 -->
|
||||||
<template v-if="lesson && !lesson.next && challenges">
|
<template v-if="lesson && !lesson.next && challenges">
|
||||||
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
|
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
|
||||||
@@ -90,30 +89,15 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.lesson-next {
|
.lesson-next {
|
||||||
margin-top: 12px;
|
margin-top: 4px;
|
||||||
padding: 16px 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: rgba(24, 160, 88, 0.07);
|
|
||||||
border: 1px solid rgba(24, 160, 88, 0.25);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
display: flex;
|
margin: 0 0 12px;
|
||||||
flex-direction: column;
|
font-size: 16px;
|
||||||
gap: 4px;
|
line-height: 1.75;
|
||||||
}
|
|
||||||
|
|
||||||
.headline {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail {
|
|
||||||
font-size: 13px;
|
|
||||||
color: v-bind('theme.textColor2');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.next {
|
.next {
|
||||||
@@ -125,10 +109,10 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background-color: v-bind('theme.primaryColor');
|
background-color: v-bind('theme.primaryColor');
|
||||||
color: v-bind('buttonText');
|
color: v-bind('buttonText');
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
import { isSqlProblem, parseFlowDoc } from '@oj2/contract'
|
import { isSqlProblem, parseFlowDoc } from '@oj2/contract'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useProblemStore } from 'oj/store/problem'
|
import { useProblemStore } from 'oj/store/problem'
|
||||||
import { DIFFICULTY } from 'utils/constants'
|
import { DIFFICULTY } from 'utils/constants'
|
||||||
import { copyToClipboard, getTagColor } from 'utils/functions'
|
import { getTagColor } from 'utils/functions'
|
||||||
import { useSubmissionStore } from 'oj/store/submission'
|
import { useSubmissionStore } from 'oj/store/submission'
|
||||||
import Markdown from 'shared/components/Markdown.vue'
|
import Markdown from 'shared/components/Markdown.vue'
|
||||||
import SqlProblemData from './SqlProblemData.vue'
|
import SqlProblemData from './SqlProblemData.vue'
|
||||||
import SimilarProblems from './SimilarProblems.vue'
|
import SimilarProblems from './SimilarProblems.vue'
|
||||||
|
import SampleList from './SampleList.vue'
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
|
import { tidyStatement } from '../utils/statement'
|
||||||
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
|
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
|
||||||
import { useMyFlowchartStore } from 'shared/store/myFlowchart'
|
import { useMyFlowchartStore } from 'shared/store/myFlowchart'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
@@ -83,21 +87,6 @@ const flowchartGrade = computed(() => {
|
|||||||
})
|
})
|
||||||
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
|
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
|
||||||
|
|
||||||
// 例子旁的「复制」:点完变成「已复制」一秒
|
|
||||||
const message = useMessage()
|
|
||||||
const copiedKey = ref('')
|
|
||||||
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, {
|
|
||||||
immediate: false,
|
|
||||||
})
|
|
||||||
async function copySample(key: string, text: string) {
|
|
||||||
if (await copyToClipboard(text)) {
|
|
||||||
copiedKey.value = key
|
|
||||||
resetCopied()
|
|
||||||
} else {
|
|
||||||
message.error('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
|
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
|
||||||
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
|
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
|
||||||
watch(
|
watch(
|
||||||
@@ -108,141 +97,96 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位),
|
// 例子只是摆出来(SampleList)。试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
|
||||||
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
|
|
||||||
const samples = computed(() => problem.value?.samples ?? [])
|
const samples = computed(() => problem.value?.samples ?? [])
|
||||||
const submissionStore = useSubmissionStore()
|
const submissionStore = useSubmissionStore()
|
||||||
|
|
||||||
const SAMPLE_SIDES = ['input', 'output'] as const
|
/** 描述、输入、输出、提示:去掉老师为了隔开敲的空段落(见 utils/statement) */
|
||||||
const SAMPLE_SIDE_LABEL = { input: '输入', output: '输出' } as const
|
const statement = computed(() => ({
|
||||||
|
description: tidyStatement(problem.value?.description),
|
||||||
/*
|
input: tidyStatement(problem.value?.inputDescription),
|
||||||
* 例子默认摆成「例子 N | 输入 | 输出」的表,可机房那两种屏上左栏只有 550px 左右,每格只放得下
|
output: tidyStatement(problem.value?.outputDescription),
|
||||||
* 二十来个字符,而例子输出最长那行的 p90 就有 28 个字符宽(中文按两个算)—— 一成的题会在
|
hint: tidyStatement(problem.value?.hint),
|
||||||
* 半格里横着滚。所以按这道题最长那行量一下:半格放得下就摆表,放不下这道题的例子就整体改成
|
}))
|
||||||
* 上下排(输入一行、输出一行,各占满宽)。整宽还放不下的二十来道才横着滚。不自动折行:折出来
|
|
||||||
* 的行学生分不清是不是真换了行,照着它输出就错了。
|
|
||||||
* 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数。
|
|
||||||
*/
|
|
||||||
const SAMPLE_FONT = '14px Consolas, Monaco, monospace'
|
|
||||||
const SAMPLE_NO_W = 56
|
|
||||||
const SAMPLE_GAP = 8
|
|
||||||
const SAMPLE_PAD_L = 10
|
|
||||||
const SAMPLE_PAD_R = 4
|
|
||||||
const SAMPLE_COPY_W = 44
|
|
||||||
const sampleCss = {
|
|
||||||
font: SAMPLE_FONT,
|
|
||||||
noW: `${SAMPLE_NO_W}px`,
|
|
||||||
gap: `${SAMPLE_GAP}px`,
|
|
||||||
pad: `2px ${SAMPLE_PAD_R}px 2px ${SAMPLE_PAD_L}px`,
|
|
||||||
copyW: `${SAMPLE_COPY_W}px`,
|
|
||||||
}
|
|
||||||
|
|
||||||
let measureCtx: CanvasRenderingContext2D | null = null
|
|
||||||
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
|
|
||||||
const sampleTextWidth = computed(() => {
|
|
||||||
measureCtx ??= document.createElement('canvas').getContext('2d')
|
|
||||||
if (!measureCtx) return 0
|
|
||||||
measureCtx.font = SAMPLE_FONT
|
|
||||||
let widest = 0
|
|
||||||
for (const sample of samples.value) {
|
|
||||||
for (const side of SAMPLE_SIDES) {
|
|
||||||
for (const line of sample[side].split('\n')) {
|
|
||||||
// <pre> 里 tab 按 8 列排
|
|
||||||
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
|
|
||||||
if (width > widest) widest = width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return widest
|
|
||||||
})
|
|
||||||
|
|
||||||
const sampleTable = useTemplateRef<HTMLElement>('sampleTable')
|
|
||||||
const { width: sampleTableWidth } = useElementSize(sampleTable)
|
|
||||||
/**
|
|
||||||
* 窄(手机,或桌面左栏拖到最窄):「例子 N」不占一列,改成每个例子头上一行小字。
|
|
||||||
* 390 宽的手机上那一列连同间距要吃掉 64px,框只剩一半多
|
|
||||||
*/
|
|
||||||
const sampleNarrow = computed(() => sampleTableWidth.value > 0 && sampleTableWidth.value < 480)
|
|
||||||
/** 摆成表时,半格里留给文字的宽度 */
|
|
||||||
const sampleHalfWidth = computed(
|
|
||||||
() => (sampleTableWidth.value - (sampleNarrow.value ? 0 : SAMPLE_NO_W + SAMPLE_GAP) - SAMPLE_GAP) / 2 - SAMPLE_PAD_L - SAMPLE_PAD_R - SAMPLE_COPY_W,
|
|
||||||
)
|
|
||||||
const samplesStacked = computed(() => sampleTableWidth.value > 0 && sampleTextWidth.value > sampleHalfWidth.value)
|
|
||||||
|
|
||||||
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
|
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
|
||||||
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
|
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
|
||||||
// astRequirementSchema。规则原文(engine / target)不下发给学生。
|
// astRequirementSchema。规则原文(engine / target)不下发给学生。
|
||||||
const KIND_TAG_TYPE = {
|
// 每条规则前面一个小图标说是哪一类,规则的字照后端给的原样写(里面已经有「必须 / 不能」)
|
||||||
require: 'success',
|
const KIND_ICON = {
|
||||||
forbid: 'error',
|
require: 'ph:check-circle-fill',
|
||||||
count: 'info',
|
forbid: 'ph:prohibit-fill',
|
||||||
|
count: 'ph:hash-bold',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
|
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="problem" class="problem-content">
|
<div v-if="problem" class="problem-content">
|
||||||
|
<!-- 题号、题名、难度和状态一行(设计稿「题目页观感」定稿)。题名太长折到下一行,标签留在右边 -->
|
||||||
<header class="problem-head">
|
<header class="problem-head">
|
||||||
<div class="title-row">
|
|
||||||
<span class="pid">{{ problem._id }}</span>
|
<span class="pid">{{ problem._id }}</span>
|
||||||
<h1 class="problem-title">{{ problem.title }}</h1>
|
<h1 class="problem-title">{{ problem.title }}</h1>
|
||||||
</div>
|
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)">
|
<n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)">
|
||||||
{{ DIFFICULTY[problem.difficulty] }}
|
{{ DIFFICULTY[problem.difficulty] }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag>
|
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" class="solved"> 已解决 </n-tag>
|
||||||
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
|
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
|
||||||
「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
|
「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
|
||||||
「还没交过」,旁边的「流程图 C 级」已经说了交过什么 -->
|
「还没交过」,旁边的「流程图 C 级」已经说了交过什么。
|
||||||
|
「做过,还没对」用红色:橙色和「中等」撞色,两个标签挨着分不出来 -->
|
||||||
<template v-else-if="!flowchartPassed">
|
<template v-else-if="!flowchartPassed">
|
||||||
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag>
|
<n-tag v-if="myStatus === 'tried'" size="small" type="error" :bordered="false"> 做过,还没对 </n-tag>
|
||||||
<n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
|
<n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
|
||||||
</template>
|
</template>
|
||||||
<n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
|
<n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
|
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了。
|
||||||
<div v-if="astRequirements.length > 0" class="requirements">
|
黄色,和提示同色;每条一行,前面的小图标说是「必须 / 不能 / 次数」哪一类 -->
|
||||||
<span class="requirements-head">语法要求</span>
|
<section v-if="astRequirements.length > 0" class="block">
|
||||||
|
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
|
||||||
<template v-for="[lang, rules] in astRequirements" :key="lang">
|
<template v-for="[lang, rules] in astRequirements" :key="lang">
|
||||||
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span>
|
<div v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</div>
|
||||||
<n-tag v-for="(rule, i) in rules" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false">
|
<ul class="rules">
|
||||||
{{ rule.description }}
|
<li v-for="(rule, i) in rules" :key="`${lang}-${i}`" :class="rule.kind">
|
||||||
</n-tag>
|
<Icon :icon="KIND_ICON[rule.kind]" :width="16" class="rule-icon" aria-hidden="true" />
|
||||||
|
<span>{{ rule.description }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
<span class="requirements-note">提交时会逐条检查</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="block">
|
|
||||||
<h3 class="title">描述</h3>
|
|
||||||
<Markdown html :content="problem.description" />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 -->
|
|
||||||
<div v-if="!isSQL" class="io">
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h3 class="title">输入</h3>
|
<SectionHead icon="description">描述</SectionHead>
|
||||||
<Markdown html :content="problem.inputDescription" />
|
<Markdown html :content="statement.description" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 输入、输出上下排:并排的时候多一两句就挤成窄条(2020 的输入折成三行),旁边一栏空着 -->
|
||||||
|
<template v-if="!isSQL">
|
||||||
|
<section class="block">
|
||||||
|
<SectionHead icon="input">输入</SectionHead>
|
||||||
|
<Markdown html :content="statement.input" />
|
||||||
</section>
|
</section>
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h3 class="title">输出</h3>
|
<SectionHead icon="output">输出</SectionHead>
|
||||||
<Markdown html :content="problem.outputDescription" />
|
<Markdown html :content="statement.output" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
|
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
|
||||||
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
|
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
|
||||||
<div class="block-head">
|
<SectionHead icon="flowchart">
|
||||||
<h3 class="title">你画的流程图</h3>
|
你画的流程图
|
||||||
<n-button text type="primary" size="small" @click="myFlowchartZoom = true"> 放大看 </n-button>
|
<template #extra>
|
||||||
<n-button text type="primary" size="small" @click="myFlowchartOpen = !myFlowchartOpen">
|
<n-button text type="primary" @click="myFlowchartZoom = true">放大看 ›</n-button>
|
||||||
|
<n-button text type="primary" @click="myFlowchartOpen = !myFlowchartOpen">
|
||||||
{{ myFlowchartOpen ? '收起' : '展开' }}
|
{{ myFlowchartOpen ? '收起' : '展开' }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</template>
|
||||||
|
</SectionHead>
|
||||||
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
|
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
|
||||||
<MyFlowchart compact />
|
<MyFlowchart compact />
|
||||||
</div>
|
</div>
|
||||||
@@ -251,49 +195,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<template v-if="!isSQL">
|
<section v-if="!isSQL && samples.length" class="block">
|
||||||
<!--
|
<SectionHead icon="samples">
|
||||||
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子
|
例子
|
||||||
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」
|
<template #extra>
|
||||||
-->
|
<n-button text type="primary" @click="submissionStore.revealResult('custom')">自己输入数据试试 ›</n-button>
|
||||||
<section v-if="samples.length" class="block">
|
|
||||||
<div class="block-head">
|
|
||||||
<h3 class="title">例子</h3>
|
|
||||||
<n-button text type="primary" size="small" class="block-link" @click="submissionStore.revealResult('custom')"> 自己输入数据试试 › </n-button>
|
|
||||||
</div>
|
|
||||||
<!-- 半格放不下最长那行就整体改成上下排,见 samplesStacked -->
|
|
||||||
<div ref="sampleTable" class="sample-table" :class="{ stacked: samplesStacked, narrow: sampleNarrow }" role="table" aria-label="例子">
|
|
||||||
<div v-if="!samplesStacked" class="sample-row" role="row">
|
|
||||||
<span v-if="!sampleNarrow" role="columnheader"></span>
|
|
||||||
<span role="columnheader" class="sample-label">输入</span>
|
|
||||||
<span role="columnheader" class="sample-label">输出</span>
|
|
||||||
</div>
|
|
||||||
<div v-for="(sample, index) of samples" :key="index" class="sample-row" role="row">
|
|
||||||
<span class="sample-no" role="rowheader">例子 {{ index + 1 }}</span>
|
|
||||||
<template v-for="side in SAMPLE_SIDES" :key="side">
|
|
||||||
<span v-if="samplesStacked" class="sample-label sample-side">
|
|
||||||
{{ SAMPLE_SIDE_LABEL[side] }}
|
|
||||||
</span>
|
|
||||||
<div class="sample-cell" role="cell">
|
|
||||||
<pre class="testcase">{{ sample[side] }}</pre>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="sample-copy"
|
|
||||||
:aria-label="`复制例子 ${index + 1} 的${SAMPLE_SIDE_LABEL[side]}`"
|
|
||||||
@click="copySample(`${index}-${side}`, sample[side])"
|
|
||||||
>
|
|
||||||
{{ copiedKey === `${index}-${side}` ? '已复制' : '复制' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</SectionHead>
|
||||||
</div>
|
<SampleList :samples="samples" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
|
||||||
|
|
||||||
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
|
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
|
||||||
<template #title="{ text }">
|
<template #title="{ text }">
|
||||||
<h3 class="title sql-title">{{ text }}</h3>
|
<SectionHead :icon="text === '数据表' ? 'table' : 'expected'" class="sql-head">{{ text }}</SectionHead>
|
||||||
</template>
|
</template>
|
||||||
</SqlProblemData>
|
</SqlProblemData>
|
||||||
|
|
||||||
@@ -302,10 +216,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
|||||||
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
|
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
|
||||||
-->
|
-->
|
||||||
<section v-if="canShowReferenceFlowchart" class="block">
|
<section v-if="canShowReferenceFlowchart" class="block">
|
||||||
<div class="block-head">
|
<SectionHead icon="flowchart">
|
||||||
<h3 class="title">参考流程图</h3>
|
参考流程图
|
||||||
<n-button text type="primary" size="small" class="block-link" @click="referenceZoom = true"> 放大看 </n-button>
|
<template #extra>
|
||||||
</div>
|
<n-button text type="primary" @click="referenceZoom = true">放大看 ›</n-button>
|
||||||
|
</template>
|
||||||
|
</SectionHead>
|
||||||
<div
|
<div
|
||||||
class="reference-flowchart"
|
class="reference-flowchart"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -323,268 +239,158 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="problem.hint" class="block">
|
<section v-if="statement.hint" class="block">
|
||||||
<h3 class="title">提示</h3>
|
<SectionHead icon="hint" tone="amber">提示</SectionHead>
|
||||||
<Markdown html :content="problem.hint" />
|
<Markdown html :content="statement.hint" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="problem.source" class="block">
|
<SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
|
||||||
<h3 class="title">来源</h3>
|
|
||||||
<Markdown html :content="problem.source" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<SimilarProblems v-if="showSimilar" title="相似题目推荐" />
|
<!-- 来源不单起一节:学生做题用不上它,缩成最底下一行灰字 -->
|
||||||
|
<footer v-if="problem.source" class="source">
|
||||||
|
<span class="source-label">来源</span>
|
||||||
|
<Markdown html :content="problem.source" class="source-text" />
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 设计稿「题目页签:还没运行过」 */
|
|
||||||
.problem-head {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pid {
|
|
||||||
flex: none;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: rgba(128, 128, 128, 0.12);
|
|
||||||
font-size: 13px;
|
|
||||||
color: v-bind('theme.textColor2');
|
|
||||||
}
|
|
||||||
|
|
||||||
.problem-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-head .title {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.problem-content {
|
.problem-content {
|
||||||
container: problem-content / inline-size;
|
container: problem-content / inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-link {
|
/* 题号 | 题名 | ……… | 难度 状态 */
|
||||||
margin-left: auto;
|
.problem-head {
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 小节标题(描述 / 输入 / 输出 / 例子 / 提示……)。画板上是 15px 半粗,比 14px 的正文只大一号,
|
|
||||||
* 一屏题面扫下来分不出哪是标题(用户 2026-09-29 提出):加粗、大一号,左边一道主色竖条
|
|
||||||
*/
|
|
||||||
.title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
padding-left: 9px;
|
|
||||||
border-left: 3px solid v-bind('theme.primaryColor');
|
|
||||||
}
|
|
||||||
|
|
||||||
.io {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 窄的时候输入、输出上下排:并排的话长一点的说明挤成一字两行的窄条,旁边一栏空着 */
|
|
||||||
@container problem-content (max-width: 480px) {
|
|
||||||
.io {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
|
|
||||||
.requirements {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(24, 160, 88, 0.3);
|
|
||||||
background-color: rgba(24, 160, 88, 0.06);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px 8px;
|
align-items: baseline;
|
||||||
font-size: 13px;
|
gap: 4px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.requirements-head {
|
.pid {
|
||||||
font-weight: 600;
|
flex: none;
|
||||||
}
|
font-size: 16px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
.requirements-note {
|
|
||||||
color: v-bind('theme.textColor3');
|
color: v-bind('theme.textColor3');
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-label {
|
.problem-title {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「已解决」实心绿:做完了要一眼看得出 */
|
||||||
|
.meta .solved {
|
||||||
|
background-color: v-bind('theme.successColor');
|
||||||
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 参考流程图:和例子的格子同一种浅灰底 */
|
.block {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 题面里老师自己写的标题(2020 开头的「鸣潮公式」是 h1):降成和正文一样大的粗体,
|
||||||
|
不然比题名还大,顶在「描述」下面像另起了一道题 */
|
||||||
|
.problem-content :deep(.oj-markdown :is(h1, h2, h3, h4, h5, h6)) {
|
||||||
|
margin: 0.8em 0 0.3em;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语法要求:每条一行 */
|
||||||
|
.lang-label {
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-icon {
|
||||||
|
flex: none;
|
||||||
|
margin-top: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules .require .rule-icon {
|
||||||
|
color: v-bind('theme.successColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules .forbid .rule-icon {
|
||||||
|
color: v-bind('theme.errorColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules .count .rule-icon {
|
||||||
|
color: v-bind('theme.infoColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SQL 的「数据表 / 期望结果」和别的小节一样隔开 */
|
||||||
|
.sql-head {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 参考流程图:和例子的输入格同一种浅灰底 */
|
||||||
.reference-flowchart {
|
.reference-flowchart {
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.14);
|
||||||
background-color: rgba(128, 128, 128, 0.05);
|
background-color: rgba(128, 128, 128, 0.05);
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.my-flowchart {
|
.my-flowchart {
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background-color: rgba(128, 128, 128, 0.06);
|
border: 1px solid rgba(128, 128, 128, 0.14);
|
||||||
|
background-color: rgba(128, 128, 128, 0.05);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
.source {
|
||||||
* 例子:例子 N | 输入 | 输出,每格右边一个「复制」。
|
margin-top: 24px;
|
||||||
* 放不下时(.stacked)每个例子两行:例子 N | 输入 | 那格,下一行 输出 | 那格
|
padding-top: 10px;
|
||||||
*/
|
border-top: 1px solid v-bind('theme.dividerColor');
|
||||||
.sample-table {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: baseline;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
font-size: 13px;
|
||||||
|
|
||||||
.sample-table.stacked {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: v-bind('sampleCss.noW') minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
gap: 6px v-bind('sampleCss.gap');
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stacked .sample-row {
|
|
||||||
grid-template-columns: v-bind('sampleCss.noW') 32px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-label,
|
|
||||||
.sample-no {
|
|
||||||
font-size: 12px;
|
|
||||||
color: v-bind('theme.textColor3');
|
color: v-bind('theme.textColor3');
|
||||||
}
|
}
|
||||||
|
|
||||||
.sample-no {
|
.source-label {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 上下排时「例子 N」竖跨输入、输出两行,和「输入」对齐在顶上 */
|
|
||||||
.stacked .sample-no {
|
|
||||||
grid-row: span 2;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stacked .sample-no,
|
|
||||||
.sample-side {
|
|
||||||
padding-top: 8px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 窄:「例子 N」占满一行当小标题,下面输入、输出(并排或上下排) */
|
|
||||||
.narrow .sample-row {
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.narrow.stacked .sample-row {
|
|
||||||
grid-template-columns: 32px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.narrow .sample-no,
|
|
||||||
.narrow.stacked .sample-no {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: auto;
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-table.narrow {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-cell {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: v-bind('sampleCss.pad');
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: rgba(128, 128, 128, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.testcase {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 4px 0;
|
|
||||||
font: v-bind('sampleCss.font');
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 整宽也放不下、只好横着滚的那二十来道:Windows 上默认那根带箭头的粗滚动条比例子本身还显眼 */
|
|
||||||
.testcase::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.testcase::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: rgba(128, 128, 128, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.testcase::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-copy {
|
|
||||||
flex: none;
|
flex: none;
|
||||||
width: v-bind('sampleCss.copyW');
|
|
||||||
height: 24px;
|
|
||||||
margin-top: 2px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
color: v-bind('theme.primaryColorPressed');
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sample-copy:hover {
|
.source-text {
|
||||||
background-color: rgba(24, 160, 88, 0.1);
|
min-width: 0;
|
||||||
}
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
.sql-title {
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -101,8 +101,8 @@ const color = computed(() => {
|
|||||||
.result-header {
|
.result-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
@@ -114,8 +114,8 @@ const color = computed(() => {
|
|||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 24px;
|
width: 26px;
|
||||||
height: 24px;
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -125,12 +125,14 @@ const color = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 18px;
|
font-size: 22px;
|
||||||
|
line-height: 1.3;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: v-bind('color.text');
|
color: v-bind('color.text');
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub {
|
.sub {
|
||||||
|
font-size: 15px;
|
||||||
color: v-bind('theme.textColor3');
|
color: v-bind('theme.textColor3');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ function pick(segment: Segment) {
|
|||||||
<template>
|
<template>
|
||||||
<FlowchartResult v-if="drawing" />
|
<FlowchartResult v-if="drawing" />
|
||||||
<div v-show="!drawing" class="result-pane">
|
<div v-show="!drawing" class="result-pane">
|
||||||
<!-- 设计稿:三个胶囊,选中的深色实心,带上那次结果的时间 -->
|
<!-- 三个分段,带上那次结果的时间 -->
|
||||||
<div v-if="showSegments" class="segments" role="tablist" aria-label="结果">
|
<div v-if="showSegments" class="segments">
|
||||||
|
<div class="segment-track" role="tablist" aria-label="结果">
|
||||||
<button
|
<button
|
||||||
v-for="segment in segments"
|
v-for="segment in segments"
|
||||||
:key="segment.value"
|
:key="segment.value"
|
||||||
@@ -86,8 +87,9 @@ function pick(segment: Segment) {
|
|||||||
:aria-selected="isActive(segment.value)"
|
:aria-selected="isActive(segment.value)"
|
||||||
@click="pick(segment.value)"
|
@click="pick(segment.value)"
|
||||||
>
|
>
|
||||||
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template>
|
{{ segment.label }}<span v-if="segment.time" class="segment-time">{{ segment.time }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span>
|
<span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,35 +118,53 @@ function pick(segment: Segment) {
|
|||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 设计稿「题目页观感」结果 X:浅灰底的分段按钮,选中的白底加粗(和工具栏「写代码 / 画流程图」一个样子) */
|
||||||
.segments {
|
.segments {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
margin-bottom: 14px;
|
gap: 8px 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-track {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgba(128, 128, 128, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment {
|
.segment {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 15px;
|
border-radius: 6px;
|
||||||
background-color: rgba(128, 128, 128, 0.12);
|
background: transparent;
|
||||||
color: v-bind('theme.textColor2');
|
color: v-bind('theme.textColor2');
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segment-time {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
}
|
||||||
|
|
||||||
.segment-note {
|
.segment-note {
|
||||||
margin-left: auto;
|
|
||||||
align-self: center;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: v-bind('theme.textColor3');
|
color: v-bind('theme.textColor3');
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment.active {
|
.segment.active {
|
||||||
background-color: v-bind('theme.textColor1');
|
background-color: v-bind('theme.cardColor');
|
||||||
color: v-bind('theme.cardColor');
|
color: v-bind('theme.textColor1');
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.formatted {
|
.formatted {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark'
|
import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark'
|
||||||
import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError'
|
import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError'
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运行时错误的中文说明卡片(诊断由后端 judge/runtime-diagnosis.ts 给)。
|
* 运行时错误的中文说明(诊断由后端 judge/runtime-diagnosis.ts 给)。
|
||||||
* 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。
|
* 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -34,34 +35,50 @@ onUnmounted(clearErrorMark)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card embedded class="explain-card">
|
<!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明,再把那一行代码摆出来 -->
|
||||||
<n-flex vertical :size="12">
|
<div class="explain-section">
|
||||||
<div class="explain">
|
<SectionHead icon="problem" tone="amber">{{ info.line ? `第 ${info.line} 行出错` : '程序跑到一半出错了' }}</SectionHead>
|
||||||
<b v-if="info.line">第 {{ info.line }} 行运行时出错:</b>{{ message }}
|
<p class="explain">{{ message }}</p>
|
||||||
|
<div v-if="sourceLine" class="explain-code">
|
||||||
|
<span class="line-no">{{ info.line }}</span>
|
||||||
|
<pre>{{ sourceLine }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre v-if="sourceLine" class="explain-code">{{ sourceLine }}</pre>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 结果弹窗不限宽,长句子不折行会把弹窗撑出屏幕 */
|
|
||||||
.explain-card {
|
|
||||||
max-width: 560px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explain {
|
.explain {
|
||||||
|
margin: 0 0 10px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.7;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 出错的那一行:红底,前面写行号,和编辑器里标红的那一行对得上 */
|
||||||
.explain-code {
|
.explain-code {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(208, 48, 80, 0.06);
|
||||||
|
border: 1px solid rgba(208, 48, 80, 0.24);
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-no {
|
||||||
|
flex: none;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-code pre {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 12px;
|
font: inherit;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 15px;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
background-color: rgba(208, 48, 80, 0.12);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { copyToClipboard } from 'utils/functions'
|
||||||
|
import { SubmissionStatus } from 'utils/constants'
|
||||||
|
import { useCodeStore } from 'oj/store/code'
|
||||||
|
import { useSubmissionStore } from 'oj/store/submission'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题面的例子:每组一行「输入 → 输出」,输出浅绿底(设计稿「题目页观感」ExCard,2026-11-04 定)。
|
||||||
|
*
|
||||||
|
* - 输入写的是「无」或者空的(常用题里 12% 的例子)写灰字「不用输入」,不给复制:
|
||||||
|
* 原来摆成一格可复制的「无」,学生以为要输入一个「无」。
|
||||||
|
* - 行首的空格画成浅灰点:菱形、三角形那几道靠行首空格对齐,数不清空格就照着排错了。
|
||||||
|
* 只是画成点,复制出去的还是空格。
|
||||||
|
* - 半格放不下最长那行,这道题的例子整体改成上下排(输入、↓、输出)。整宽还放不下的那一行
|
||||||
|
* 才折行,**折下来的部分缩进两格**:原来不折、横着滚,就是怕学生分不清折行和真换行、
|
||||||
|
* 照着多输出一个换行;缩进之后两者一眼能分开,又不用去找那根细滚动条。
|
||||||
|
* - 「运行例子」跑过之后,序号上标 ✓ / ✗(还是会跳到「结果」页签,切回来就看得到)。
|
||||||
|
* 代码改过就不再标:标的是上一份代码的对错,留着会让学生以为改完的还对。
|
||||||
|
*/
|
||||||
|
const { samples } = defineProps<{
|
||||||
|
samples: { input: string; output: string }[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const SIDES = ['input', 'output'] as const
|
||||||
|
const SIDE_LABEL = { input: '输入', output: '输出' } as const
|
||||||
|
|
||||||
|
/** 输入写「无」或者空着:这道题不用输入 */
|
||||||
|
function noInput(text: string) {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
return trimmed === '' || trimmed === '无'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每一行拆成「行首空格数 + 余下的字」,模板里把行首空格画成点 */
|
||||||
|
function linesOf(text: string) {
|
||||||
|
return text.split('\n').map((line) => {
|
||||||
|
const lead = /^ +/.exec(line)?.[0].length ?? 0
|
||||||
|
return { lead, rest: line.slice(lead) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 摆成一行还是上下排 ====================
|
||||||
|
// 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数
|
||||||
|
const FONT_SIZE = 16
|
||||||
|
const FONT = `${FONT_SIZE}px Consolas, Monaco, monospace`
|
||||||
|
const DOT_W = 26
|
||||||
|
const ARROW_W = 22
|
||||||
|
const GAP = 6
|
||||||
|
/** 格子里除了字以外占掉的:左右内边距、复制按钮、按钮前的间距、边框 */
|
||||||
|
const PANE_CHROME = 10 + 4 + 26 + 4 + 2
|
||||||
|
|
||||||
|
let measureCtx: CanvasRenderingContext2D | null = null
|
||||||
|
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
|
||||||
|
const widest = computed(() => {
|
||||||
|
measureCtx ??= document.createElement('canvas').getContext('2d')
|
||||||
|
if (!measureCtx) return 0
|
||||||
|
measureCtx.font = FONT
|
||||||
|
let max = 0
|
||||||
|
for (const sample of samples) {
|
||||||
|
for (const side of SIDES) {
|
||||||
|
if (side === 'input' && noInput(sample.input)) continue
|
||||||
|
for (const line of sample[side].split('\n')) {
|
||||||
|
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
|
||||||
|
if (width > max) max = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = useTemplateRef<HTMLElement>('list')
|
||||||
|
const { width: listWidth } = useElementSize(list)
|
||||||
|
const halfText = computed(() => (listWidth.value - DOT_W - ARROW_W - GAP * 3) / 2 - PANE_CHROME)
|
||||||
|
const stacked = computed(() => listWidth.value > 0 && widest.value > halfText.value)
|
||||||
|
|
||||||
|
// ==================== 复制 ====================
|
||||||
|
const message = useMessage()
|
||||||
|
const copiedKey = ref('')
|
||||||
|
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, { immediate: false })
|
||||||
|
async function copy(key: string, text: string) {
|
||||||
|
if (await copyToClipboard(text)) {
|
||||||
|
copiedKey.value = key
|
||||||
|
resetCopied()
|
||||||
|
} else {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 运行例子的 ✓ / ✗ ====================
|
||||||
|
const submissionStore = useSubmissionStore()
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
const marks = computed<Record<number, 'ok' | 'bad'>>(() => {
|
||||||
|
const trial = submissionStore.trial
|
||||||
|
const ran = trial.samplesCode.value
|
||||||
|
if (trial.samplesRunning.value || !ran) return {}
|
||||||
|
if (ran.value !== codeStore.code.value || ran.language !== codeStore.code.language) return {}
|
||||||
|
const out: Record<number, 'ok' | 'bad'> = {}
|
||||||
|
for (const run of trial.sampleRuns.value) {
|
||||||
|
out[run.index] = run.result === SubmissionStatus.accepted ? 'ok' : 'bad'
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
function markLabel(index: number) {
|
||||||
|
const mark = marks.value[index]
|
||||||
|
if (mark === 'ok') return `例子 ${index + 1},运行结果对了`
|
||||||
|
if (mark === 'bad') return `例子 ${index + 1},运行结果不对`
|
||||||
|
return `例子 ${index + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = {
|
||||||
|
font: FONT,
|
||||||
|
dotW: `${DOT_W}px`,
|
||||||
|
arrowW: `${ARROW_W}px`,
|
||||||
|
gap: `${GAP}px`,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="list" class="sample-list" :class="{ stacked }" role="list" aria-label="例子">
|
||||||
|
<div v-if="!stacked" class="cols" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span>输入</span>
|
||||||
|
<span />
|
||||||
|
<span>输出</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="(sample, index) of samples" :key="index" class="pair" role="listitem">
|
||||||
|
<span class="dot" :class="marks[index]" :aria-label="markLabel(index)">
|
||||||
|
<Icon v-if="marks[index] === 'ok'" icon="ph:check-bold" :width="13" />
|
||||||
|
<Icon v-else-if="marks[index] === 'bad'" icon="ph:x-bold" :width="12" />
|
||||||
|
<template v-else>{{ index + 1 }}</template>
|
||||||
|
</span>
|
||||||
|
<div class="sides">
|
||||||
|
<template v-for="side in SIDES" :key="side">
|
||||||
|
<span v-if="side === 'output'" class="arrow" aria-hidden="true">
|
||||||
|
<Icon :icon="stacked ? 'ph:arrow-down' : 'ph:arrow-right'" :width="16" />
|
||||||
|
</span>
|
||||||
|
<div v-if="side === 'input' && noInput(sample.input)" class="pane in none">不用输入</div>
|
||||||
|
<div v-else class="pane" :class="side === 'input' ? 'in' : 'out'">
|
||||||
|
<pre class="text" :aria-label="`例子 ${index + 1} 的${SIDE_LABEL[side]}`"><span
|
||||||
|
v-for="(line, i) of linesOf(sample[side])"
|
||||||
|
:key="i"
|
||||||
|
class="line"
|
||||||
|
><span v-if="line.lead" class="lead">{{ '·'.repeat(line.lead) }}</span>{{ line.rest }}</span
|
||||||
|
></pre>
|
||||||
|
<button type="button" class="copy" :aria-label="`复制例子 ${index + 1} 的${SIDE_LABEL[side]}`" @click="copy(`${index}-${side}`, sample[side])">
|
||||||
|
<Icon :icon="copiedKey === `${index}-${side}` ? 'ph:check' : 'ph:copy'" :width="15" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sample-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
--pane-in-bg: rgba(128, 128, 128, 0.07);
|
||||||
|
--pane-in-border: rgba(128, 128, 128, 0.14);
|
||||||
|
--pane-out-bg: rgba(24, 160, 88, 0.08);
|
||||||
|
--pane-out-border: rgba(24, 160, 88, 0.26);
|
||||||
|
--lead: rgba(24, 160, 88, 0.45);
|
||||||
|
--muted: rgba(128, 128, 128, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .sample-list {
|
||||||
|
--pane-out-bg: rgba(99, 226, 183, 0.08);
|
||||||
|
--pane-out-border: rgba(99, 226, 183, 0.26);
|
||||||
|
--lead: rgba(99, 226, 183, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-list.stacked {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols,
|
||||||
|
.pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr);
|
||||||
|
gap: 0 v-bind('css.gap');
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols {
|
||||||
|
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
|
||||||
|
margin-bottom: -4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 一行:输入 → 输出;上下排:输入、↓、输出 */
|
||||||
|
.sides {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
|
||||||
|
gap: 0 v-bind('css.gap');
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .sides {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-top: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background-color: rgba(128, 128, 128, 0.14);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.ok {
|
||||||
|
background-color: #18a058;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.bad {
|
||||||
|
background-color: #d03050;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .arrow {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 2px 0 2px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 4px 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane.in {
|
||||||
|
background-color: var(--pane-in-bg);
|
||||||
|
border: 1px solid var(--pane-in-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane.out {
|
||||||
|
background-color: var(--pane-out-bg);
|
||||||
|
border: 1px solid var(--pane-out-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane.none {
|
||||||
|
align-self: start;
|
||||||
|
background-color: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
padding-right: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: v-bind('css.font');
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 一行一块:整宽还放不下才折,折下来的缩进两格,和真换行分得开 */
|
||||||
|
.line {
|
||||||
|
display: block;
|
||||||
|
min-height: 24px;
|
||||||
|
padding-left: 2ch;
|
||||||
|
text-indent: -2ch;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
flex: none;
|
||||||
|
width: 26px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy:hover {
|
||||||
|
background-color: rgba(128, 128, 128, 0.14);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -103,22 +103,14 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
|
|||||||
</ResultHeader>
|
</ResultHeader>
|
||||||
|
|
||||||
<template v-if="current">
|
<template v-if="current">
|
||||||
<!-- 对上了:三栏摆出来就行 -->
|
<!-- 对上了:输入、正确输出、你的输出竖着摆出来就行,你的输出也是绿的 -->
|
||||||
<div v-if="current.result === SubmissionStatus.accepted" class="passed-card">
|
<div v-if="current.result === SubmissionStatus.accepted" class="passed-card">
|
||||||
<div class="blocks">
|
<span class="label">输入</span>
|
||||||
<div class="block">
|
<pre class="pane in">{{ current.input }}</pre>
|
||||||
<div class="label">输入</div>
|
<span class="label">正确输出</span>
|
||||||
<pre>{{ current.input }}</pre>
|
<pre class="pane ok">{{ current.expected }}</pre>
|
||||||
</div>
|
<span class="label">你的输出</span>
|
||||||
<div class="block">
|
<pre class="pane ok">{{ current.output }}</pre>
|
||||||
<div class="label">正确输出</div>
|
|
||||||
<pre>{{ current.expected }}</pre>
|
|
||||||
</div>
|
|
||||||
<div class="block">
|
|
||||||
<div class="label">你的输出</div>
|
|
||||||
<pre>{{ current.output }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 跑完了、输出对不上:和判完之后同一套说明 -->
|
<!-- 跑完了、输出对不上:和判完之后同一套说明 -->
|
||||||
<WrongAnswerExplain
|
<WrongAnswerExplain
|
||||||
@@ -134,6 +126,7 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
|
|||||||
:code="samplesCode?.value ?? ''"
|
:code="samplesCode?.value ?? ''"
|
||||||
:language="samplesCode?.language ?? ''"
|
:language="samplesCode?.language ?? ''"
|
||||||
:plain="inContest"
|
:plain="inContest"
|
||||||
|
section="stack"
|
||||||
/>
|
/>
|
||||||
<!-- 没跑完:编译不过、运行出错、超时 -->
|
<!-- 没跑完:编译不过、运行出错、超时 -->
|
||||||
<TrialErrorNote
|
<TrialErrorNote
|
||||||
@@ -165,15 +158,16 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.run {
|
.run {
|
||||||
height: 26px;
|
height: 28px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 13px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,56 +183,58 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
|
|||||||
color: v-bind('theme.errorColorPressed');
|
color: v-bind('theme.errorColorPressed');
|
||||||
}
|
}
|
||||||
|
|
||||||
.run.active {
|
/* 选中的那个涂实心:设计稿「运行例子」 */
|
||||||
font-weight: 600;
|
|
||||||
border-width: 1.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.run.ok.active {
|
.run.ok.active {
|
||||||
border-color: v-bind('theme.successColor');
|
border-color: v-bind('theme.successColor');
|
||||||
|
background-color: v-bind('theme.successColor');
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.run.bad.active {
|
.run.bad.active {
|
||||||
border-color: v-bind('theme.errorColor');
|
border-color: v-bind('theme.errorColor');
|
||||||
|
background-color: v-bind('theme.errorColor');
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 对上了:和答错时同一张两列的网格(标签 | 格子),只是你的输出也是绿的 */
|
||||||
.passed-card {
|
.passed-card {
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: rgba(128, 128, 128, 0.06);
|
|
||||||
border: 1px solid v-bind('theme.dividerColor');
|
|
||||||
}
|
|
||||||
|
|
||||||
.blocks {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 6px 8px;
|
||||||
}
|
align-items: start;
|
||||||
|
|
||||||
.block {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 6px 10px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: v-bind('theme.cardColor');
|
|
||||||
border: 1px solid v-bind('theme.dividerColor');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
opacity: 0.7;
|
line-height: 36px;
|
||||||
margin-bottom: 4px;
|
white-space: nowrap;
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
}
|
}
|
||||||
|
|
||||||
.block pre {
|
.pane {
|
||||||
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-family: Monaco, Consolas, monospace;
|
font-family: Consolas, Monaco, monospace;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane.in {
|
||||||
|
background-color: rgba(128, 128, 128, 0.07);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane.ok {
|
||||||
|
background-color: rgba(24, 160, 88, 0.08);
|
||||||
|
border: 1px solid rgba(24, 160, 88, 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
margin: 12px 0 0;
|
margin: 16px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: v-bind('theme.textColor3');
|
color: v-bind('theme.textColor3');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
/**
|
||||||
|
* 题面的小节标题:线条图标 + 绿字 + 一根细线拖到头,线尾可以挂一个链接
|
||||||
|
* (「自己输入数据试试 ›」「放大看 ›」)。设计稿「题目页观感」⑦,2026-11-04 定。
|
||||||
|
*
|
||||||
|
* 语法要求、提示用黄色:一个是硬性条件、一个是帮忙的话,扫一眼要能和别的节分开。
|
||||||
|
* 结果页签里「例子 1 没对上」「第 2 行出错」这类说哪里不对的也用黄色(设计稿结果 X)。
|
||||||
|
* 明暗两套色都在 CSS 里(html.dark),别在这里调 useDark() —— 理由见 ProblemTypeTag。
|
||||||
|
*/
|
||||||
|
export type SectionIcon =
|
||||||
|
| 'description'
|
||||||
|
| 'input'
|
||||||
|
| 'output'
|
||||||
|
| 'samples'
|
||||||
|
| 'hint'
|
||||||
|
| 'rules'
|
||||||
|
| 'table'
|
||||||
|
| 'expected'
|
||||||
|
| 'flowchart'
|
||||||
|
| 'similar'
|
||||||
|
| 'problem'
|
||||||
|
| 'next'
|
||||||
|
| 'challenge'
|
||||||
|
|
||||||
|
const ICON: Record<SectionIcon, string> = {
|
||||||
|
description: 'ph:book-open',
|
||||||
|
input: 'ph:sign-in',
|
||||||
|
output: 'ph:sign-out',
|
||||||
|
samples: 'ph:flask',
|
||||||
|
hint: 'ph:lightbulb',
|
||||||
|
rules: 'ph:code',
|
||||||
|
table: 'ph:table',
|
||||||
|
expected: 'ph:check-circle',
|
||||||
|
flowchart: 'ph:flow-arrow',
|
||||||
|
similar: 'ph:shuffle-angular',
|
||||||
|
// 结果页签:哪里不对、接着做、还能挑战
|
||||||
|
problem: 'ph:warning-circle',
|
||||||
|
next: 'ph:arrow-right',
|
||||||
|
challenge: 'ph:target',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icon, tone = 'green' } = defineProps<{
|
||||||
|
icon: SectionIcon
|
||||||
|
tone?: 'green' | 'amber'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="section-head" :class="tone">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<Icon :icon="ICON[icon]" :width="19" class="section-icon" aria-hidden="true" />
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
<span class="section-line" aria-hidden="true" />
|
||||||
|
<span v-if="$slots.extra" class="section-extra"><slot name="extra" /></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
--head-text: #0c6b3b;
|
||||||
|
--head-icon: #18a058;
|
||||||
|
--head-line: rgba(24, 160, 88, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head.amber {
|
||||||
|
--head-text: #8a5a00;
|
||||||
|
--head-icon: #c98a00;
|
||||||
|
--head-line: rgba(201, 138, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .section-head {
|
||||||
|
--head-text: #63e2b7;
|
||||||
|
--head-icon: #63e2b7;
|
||||||
|
--head-line: rgba(99, 226, 183, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .section-head.amber {
|
||||||
|
--head-text: #f2c97d;
|
||||||
|
--head-icon: #f2c97d;
|
||||||
|
--head-line: rgba(242, 201, 125, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--head-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
flex: none;
|
||||||
|
color: var(--head-icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-line {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 12px;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--head-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-extra {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ProblemRow } from 'utils/types'
|
import type { ProblemRow } from 'utils/types'
|
||||||
|
|
||||||
|
type SimilarRow = ProblemRow & { sharedTags: string[] }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
|
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
|
||||||
* 每个实例各一份
|
* 每个实例各一份
|
||||||
*/
|
*/
|
||||||
const cache = new Map<string, Promise<ProblemRow[]>>()
|
const cache = new Map<string, Promise<SimilarRow[]>>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -13,17 +15,28 @@ import { storeToRefs } from 'pinia'
|
|||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { getSimilarProblems } from 'oj/api'
|
import { getSimilarProblems } from 'oj/api'
|
||||||
import { useProblemStore } from 'oj/store/problem'
|
import { useProblemStore } from 'oj/store/problem'
|
||||||
|
import { useTone } from 'shared/composables/tone'
|
||||||
|
import { problemTypes } from '../utils/problemType'
|
||||||
|
import ProblemStatus from './ProblemStatus.vue'
|
||||||
|
import ProblemTypeTag from './ProblemTypeTag.vue'
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
|
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
|
||||||
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
|
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
|
||||||
|
* `place` 只管标题的样子:题面里用题面的小节标题(SectionHead),结果页签里和「还能挑战」对齐。
|
||||||
|
*
|
||||||
|
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
|
||||||
|
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)。整行可点,题目名
|
||||||
|
* 和题目列表一样用正文色、不用主题绿:绿字细、在白底上发虚
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{ title: string }>()
|
const props = defineProps<{ title: string; place: 'content' | 'result' }>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
const { problem } = storeToRefs(useProblemStore())
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
const list = ref<ProblemRow[]>([])
|
const list = ref<SimilarRow[]>([])
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => problem.value?._id,
|
() => problem.value?._id,
|
||||||
@@ -41,30 +54,37 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High
|
// 做对了的接口已经筛掉,剩下的只有「没做过」和「做过没对」两种;一道都没做过就不留这一列
|
||||||
function difficultyType(difficulty: string) {
|
const showStatus = computed(() => list.value.some((row) => row.status === 'failed'))
|
||||||
if (difficulty === '简单') return 'success'
|
|
||||||
if (difficulty === '困难') return 'error'
|
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
|
||||||
return 'warning'
|
|
||||||
|
function difficultyStyle(row: SimilarRow) {
|
||||||
|
if (!row.difficulty) return {}
|
||||||
|
const t = tone(DIFFICULTY_TONE[row.difficulty])
|
||||||
|
return { color: t.color, background: t.background }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="list.length" class="similar">
|
<section v-if="list.length" class="similar" :class="props.place">
|
||||||
<h3 class="title">{{ props.title }}</h3>
|
<SectionHead v-if="props.place === 'content'" icon="similar">{{ props.title }}</SectionHead>
|
||||||
<n-list bordered>
|
<h3 v-else class="heading">{{ props.title }}</h3>
|
||||||
<n-list-item v-for="sp in list" :key="sp._id">
|
<div class="rows">
|
||||||
<n-flex align="center" justify="space-between" :wrap="false">
|
<router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
|
||||||
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link">
|
<span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
|
||||||
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag>
|
<span class="id">{{ sp._id }}</span>
|
||||||
<span class="name">{{ sp.title }}</span>
|
<span class="name">
|
||||||
|
<span class="text">{{ sp.title }}</span>
|
||||||
|
<ProblemTypeTag v-for="kind in problemTypes(sp)" :key="kind" :kind="kind" />
|
||||||
|
</span>
|
||||||
|
<span v-if="sp.difficulty" class="difficulty" :style="difficultyStyle(sp)">{{ sp.difficulty }}</span>
|
||||||
|
<span class="shared">{{ sp.sharedTags.join(' · ') }}</span>
|
||||||
|
<span class="people"
|
||||||
|
><b>{{ sp.solvedUsers }}</b> 人做对</span
|
||||||
|
>
|
||||||
</router-link>
|
</router-link>
|
||||||
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)">
|
</div>
|
||||||
{{ sp.difficulty }}
|
|
||||||
</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
</n-list-item>
|
|
||||||
</n-list>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -73,34 +93,126 @@ function difficultyType(difficulty: string) {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.similar.result {
|
||||||
font-size: 16px;
|
margin-top: 14px;
|
||||||
font-weight: 600;
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表项的内容区默认按内容撑宽,长标题会把难度挤出屏幕,省略号也不出来 */
|
/* 和结果页签里的「还能挑战」一样 */
|
||||||
.similar :deep(.n-list-item__main) {
|
.result .heading {
|
||||||
min-width: 0;
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.rows {
|
||||||
display: inline-flex;
|
border: 1px solid v-bind('theme.dividerColor');
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 两行:题号 | 题目名 + 类型标签 | 难度
|
||||||
|
* | 共同的标签 | 做对人数
|
||||||
|
* 做过没对的题最前面多一列红「−」
|
||||||
|
*/
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'id name difficulty'
|
||||||
|
'. shared people';
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 4px 12px;
|
||||||
min-width: 0;
|
padding: 10px 16px;
|
||||||
color: inherit;
|
color: v-bind('theme.textColor1');
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover .name {
|
.row.with-status {
|
||||||
|
grid-template-columns: 16px auto minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'status id name difficulty'
|
||||||
|
'. . shared people';
|
||||||
|
}
|
||||||
|
|
||||||
|
.row + .row {
|
||||||
|
border-top: 1px solid v-bind('theme.dividerColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: v-bind('theme.hoverColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .text {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
grid-area: status;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id {
|
||||||
|
grid-area: id;
|
||||||
|
min-width: 36px;
|
||||||
|
font-size: var(--oj-fs-body);
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
color: v-bind('theme.primaryColor');
|
grid-area: name;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--oj-fs-body);
|
||||||
|
font-weight: 500;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.difficulty {
|
||||||
|
grid-area: difficulty;
|
||||||
|
justify-self: end;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: var(--oj-fs-meta);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared {
|
||||||
|
grid-area: shared;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--oj-fs-sec);
|
||||||
|
color: v-bind('theme.textColor2');
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people {
|
||||||
|
grid-area: people;
|
||||||
|
justify-self: end;
|
||||||
|
font-size: var(--oj-fs-sec);
|
||||||
|
color: v-bind('theme.textColor2');
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind('theme.textColor1');
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -33,8 +33,6 @@ const changedTables = computed(() => {
|
|||||||
|
|
||||||
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
|
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
|
||||||
const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length)
|
const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length)
|
||||||
|
|
||||||
const anyChanged = computed(() => changedTables.value.some(({ changes }) => changes && (changes.cells.size > 0 || changes.newRows.size > 0)))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -62,7 +60,6 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
|
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
|
||||||
<p v-if="anyChanged" class="sqlNote legend"><span class="swatch" aria-hidden="true"></span>执行后变了的格子</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -77,22 +74,4 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
|
|||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swatch {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border: 1px solid #f0d48a;
|
|
||||||
background-color: #fff4d6;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .swatch {
|
|
||||||
border-color: rgba(240, 180, 40, 0.45);
|
|
||||||
background-color: rgba(240, 180, 40, 0.18);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -16,6 +16,7 @@ import RuntimeErrorExplain from './RuntimeErrorExplain.vue'
|
|||||||
import WrongAnswerExplain from './WrongAnswerExplain.vue'
|
import WrongAnswerExplain from './WrongAnswerExplain.vue'
|
||||||
import LessonNext from './LessonNext.vue'
|
import LessonNext from './LessonNext.vue'
|
||||||
import ResultHeader from './ResultHeader.vue'
|
import ResultHeader from './ResultHeader.vue'
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
import SimilarProblems from './SimilarProblems.vue'
|
import SimilarProblems from './SimilarProblems.vue'
|
||||||
import ChallengeList from './ChallengeList.vue'
|
import ChallengeList from './ChallengeList.vue'
|
||||||
import ChallengeOutcome from './ChallengeOutcome.vue'
|
import ChallengeOutcome from './ChallengeOutcome.vue'
|
||||||
@@ -357,26 +358,34 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
|||||||
:language="submission.language"
|
:language="submission.language"
|
||||||
:refresh-key="submission.id"
|
:refresh-key="submission.id"
|
||||||
/>
|
/>
|
||||||
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
|
<SimilarProblems v-if="showSimilar" title="再练几道相似的" place="result" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-show="!waitingReveal">
|
<div v-show="!waitingReveal">
|
||||||
<n-flex vertical v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length">
|
<n-flex
|
||||||
|
vertical
|
||||||
|
:size="20"
|
||||||
|
v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length"
|
||||||
|
>
|
||||||
<CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" />
|
<CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" />
|
||||||
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
|
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
|
||||||
<WrongAnswerExplain v-if="sampleCheck" full :check="sampleCheck" :code="submission.code" :language="submission.language" />
|
<WrongAnswerExplain v-if="sampleCheck" full section="stack" :check="sampleCheck" :code="submission.code" :language="submission.language" />
|
||||||
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
|
<!-- 语法要求逐条对没对:和题面的「语法要求」同一个黄色小节标题 -->
|
||||||
<n-flex vertical :size="8">
|
<section v-if="submission.statisticInfo?.ast_results?.length">
|
||||||
<n-flex v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i" align="center" :size="6">
|
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
|
||||||
<n-icon :color="rule.passed ? theme.successColor : theme.errorColor">
|
<ul class="ast-rules">
|
||||||
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
|
<li v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i">
|
||||||
</n-icon>
|
<Icon
|
||||||
|
:icon="rule.passed ? 'ph:check-circle-fill' : 'ph:x-circle-fill'"
|
||||||
|
:width="17"
|
||||||
|
:color="rule.passed ? theme.successColor : theme.errorColor"
|
||||||
|
/>
|
||||||
<span>{{ rule.description }}</span>
|
<span>{{ rule.description }}</span>
|
||||||
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
|
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
|
||||||
<span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span>
|
<span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span>
|
||||||
</n-flex>
|
</li>
|
||||||
</n-flex>
|
</ul>
|
||||||
</n-card>
|
</section>
|
||||||
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
|
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
|
||||||
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
|
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -386,7 +395,10 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
|||||||
问过之后,AI 的回答才放进卡片
|
问过之后,AI 的回答才放进卡片
|
||||||
-->
|
-->
|
||||||
<div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions">
|
<div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions">
|
||||||
<n-button v-if="showAIHint && !hintTarget" type="primary" ghost :loading="hintLoading" @click="fetchHint(submission.id)"> 让 AI 分析我的代码 </n-button>
|
<n-button v-if="showAIHint && !hintTarget" type="primary" secondary :loading="hintLoading" @click="fetchHint(submission.id)">
|
||||||
|
<template #icon><Icon icon="ph:sparkle-fill" /></template>
|
||||||
|
让 AI 分析我的代码
|
||||||
|
</n-button>
|
||||||
<n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')">
|
<n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')">
|
||||||
{{ customRunHint }}
|
{{ customRunHint }}
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -425,7 +437,24 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px 16px;
|
gap: 8px 16px;
|
||||||
margin-top: 12px;
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ast-rules {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ast-rules li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */
|
/* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useProblemStore } from 'oj/store/problem'
|
|||||||
import { markdownToText } from 'oj/problem/utils/plainText'
|
import { markdownToText } from 'oj/problem/utils/plainText'
|
||||||
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
|
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
|
||||||
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer'
|
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer'
|
||||||
|
import SectionHead from './SectionHead.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
|
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
|
||||||
@@ -31,6 +32,15 @@ const props = defineProps<{
|
|||||||
* 提示句全是对学生说的第二人称,整句换掉人称就读得通
|
* 提示句全是对学生说的第二人称,整句换掉人称就读得通
|
||||||
*/
|
*/
|
||||||
peer?: boolean
|
peer?: boolean
|
||||||
|
/**
|
||||||
|
* 新的样子(设计稿「题目页观感」结果 X):黄色小节标题说哪里不对,说明用正文大小,
|
||||||
|
* 输入 / 正确输出 / 你的输出是灰、绿、红三格。
|
||||||
|
* - stack:三格竖着排,和题面的例子一个样子。题目页的结果页签用(左栏只有 500 多宽)
|
||||||
|
* - row:三格横着并排,正确输出和你的输出一行对一行,不一样的那行两边都垫一层浅黄。
|
||||||
|
* 提交详情页用(一栏 1200 宽,多行输出对照着看);放不下(窄于 600,比如手机)就退回竖排
|
||||||
|
* 不给的话是原来那张浅灰卡片
|
||||||
|
*/
|
||||||
|
section?: 'stack' | 'row'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
@@ -114,39 +124,48 @@ function lines(text: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="explain-card">
|
<div :class="section ? ['explain-section', section] : 'explain-card'">
|
||||||
<p v-if="check.passed" class="explain">题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p>
|
<template v-if="section">
|
||||||
<template v-else>
|
<SectionHead icon="problem" tone="amber">
|
||||||
<p class="explain">
|
{{ check.passed ? '例子都对了,错在没给你看的测试点' : `例子 ${(check.index ?? 0) + 1} 没对上` }}
|
||||||
|
</SectionHead>
|
||||||
|
<p v-if="check.passed" class="explain">想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p>
|
||||||
|
<p v-else-if="!plain" class="explain">{{ hint }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="check.passed" class="explain">
|
||||||
|
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
|
||||||
|
</p>
|
||||||
|
<p v-else class="explain">
|
||||||
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b
|
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b
|
||||||
><template v-if="!plain">{{ hint }}</template>
|
><template v-if="!plain">{{ hint }}</template>
|
||||||
</p>
|
</p>
|
||||||
|
<template v-if="!check.passed">
|
||||||
<div class="blocks">
|
<div class="blocks">
|
||||||
<div class="block">
|
<div class="block in">
|
||||||
<div class="label">输入</div>
|
<div class="label">输入</div>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" />
|
<div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="block">
|
<div class="block ok">
|
||||||
<div class="label">正确输出</div>
|
<div class="label">正确输出</div>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<template v-for="(line, i) in lines(check.expected ?? '')" :key="i">
|
<template v-for="(line, i) in lines(check.expected ?? '')" :key="i">
|
||||||
<div v-if="i === diffLine && diffParts?.expected" class="line">
|
<div v-if="i === diffLine && diffParts?.expected" class="line hit">
|
||||||
<span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" />
|
<span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="line" v-text="line" />
|
<div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="block">
|
<div class="block bad">
|
||||||
<div class="label">{{ peer ? '学生' : '你' }}的输出</div>
|
<div class="label">{{ peer ? '学生' : '你' }}的输出</div>
|
||||||
<div v-if="check.output" class="text">
|
<div v-if="check.output" class="text">
|
||||||
<template v-for="(line, i) in lines(check.output)" :key="i">
|
<template v-for="(line, i) in lines(check.output)" :key="i">
|
||||||
<div v-if="i === diffLine && diffParts?.output" class="line">
|
<div v-if="i === diffLine && diffParts?.output" class="line hit">
|
||||||
<span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" />
|
<span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="line" v-text="line" />
|
<div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text empty">(什么都没有输出)</div>
|
<div v-else class="text empty">(什么都没有输出)</div>
|
||||||
@@ -241,4 +260,145 @@ function lines(text: string) {
|
|||||||
color: v-bind('theme.textColor2');
|
color: v-bind('theme.textColor2');
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 题目页结果页签(section):标签一列、格子一列,输入灰、正确输出绿、你的输出红。
|
||||||
|
* .block 用 display: contents 把「标签 + 格子」直接摊进这张两列的网格
|
||||||
|
*/
|
||||||
|
.explain-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .explain {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .blocks {
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
gap: 6px 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .block {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 36px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .bad .label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind('theme.errorColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .text {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .text.empty {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .in .text {
|
||||||
|
background-color: rgba(128, 128, 128, 0.07);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .ok .text {
|
||||||
|
background-color: rgba(24, 160, 88, 0.08);
|
||||||
|
border: 1px solid rgba(24, 160, 88, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .bad .text {
|
||||||
|
background-color: rgba(208, 48, 80, 0.06);
|
||||||
|
border: 1px solid rgba(208, 48, 80, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .line {
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .cut {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section .requirement {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 横排(row,提交详情页):三格并排,标签在格子上面;行的左右留白挪到每一行上,
|
||||||
|
* 不一样的那行垫的浅黄才能铺满整格宽、两边对齐
|
||||||
|
*/
|
||||||
|
.explain-section.row {
|
||||||
|
container: explain-row / inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .blocks {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .label {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .text {
|
||||||
|
padding: 6px 0;
|
||||||
|
line-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .line {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .text.empty {
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .line.hit {
|
||||||
|
background-color: rgba(227, 160, 8, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 放不下三格(窄于 600:手机、课堂任务的弹窗)就退回竖排 */
|
||||||
|
@container explain-row (max-width: 600px) {
|
||||||
|
.explain-section.row .blocks {
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
gap: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .block {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-section.row .label {
|
||||||
|
line-height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
|
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||||
|
import { HINT_MIN_FAILURES, isFlowchartPass } from '@oj2/contract'
|
||||||
|
import { habitSituation, logMascotEvent, refreshMascotProfile, requestSpeech } from 'shared/mascot/behavior'
|
||||||
|
import { useChallengeStore } from 'oj/store/challenge'
|
||||||
|
import { useFlowchartStore } from 'oj/store/flowchart'
|
||||||
|
import type { MascotSituation } from 'shared/mascot/lines'
|
||||||
|
import { useCollabStore } from 'shared/store/collab'
|
||||||
|
import { useLessonStore } from 'shared/store/lesson'
|
||||||
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import { useCodeStore } from 'oj/store/code'
|
||||||
|
import { useProblemStore } from 'oj/store/problem'
|
||||||
|
import { useSubmissionStore, type ResultSegment } from 'oj/store/submission'
|
||||||
|
import { SubmissionStatus } from 'utils/constants'
|
||||||
|
import type { ProblemDetail } from 'utils/types'
|
||||||
|
import { useProblemPageContext } from './problemPageContext'
|
||||||
|
import { useTeacherCollab } from './teacherCollab'
|
||||||
|
|
||||||
|
/** 同一道题错到这么多次,她就不怼了,劝去举手 */
|
||||||
|
const STUCK_FAILS = 5
|
||||||
|
/** 错过这么多次以后做对,算「终于」 */
|
||||||
|
const FINALLY_FAILS = 2
|
||||||
|
|
||||||
|
const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
|
||||||
|
[SubmissionStatus.wrong_answer]: 'problem.wrong',
|
||||||
|
[SubmissionStatus.partial_accepted]: 'problem.wrong',
|
||||||
|
[SubmissionStatus.ast_check_failed]: 'problem.wrong',
|
||||||
|
[SubmissionStatus.compile_error]: 'problem.compileError',
|
||||||
|
[SubmissionStatus.runtime_error]: 'problem.runtimeError',
|
||||||
|
[SubmissionStatus.memory_limit_exceeded]: 'problem.runtimeError',
|
||||||
|
[SubmissionStatus.cpu_time_limit_exceeded]: 'problem.timeout',
|
||||||
|
[SubmissionStatus.real_time_limit_exceeded]: 'problem.timeout',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一道题上这么久没交、没跑例子,她就来问一句(主动开口) */
|
||||||
|
const IDLE_MS = 15 * 60_000
|
||||||
|
/** 连着这么多次没跑例子就交,而且错了,她就念一句 */
|
||||||
|
const UNSAMPLED_SUBMITS = 3
|
||||||
|
/** 同班做对这么多人,就拿出来说「全班已经 N 个人做对了」 */
|
||||||
|
const CLASS_MANY = 5
|
||||||
|
/** 同班交过这么多人、做对的不超过 CLASS_FEW 个,算「全班都卡着」 */
|
||||||
|
const CLASS_TRIED = 5
|
||||||
|
const CLASS_FEW = 2
|
||||||
|
/** 全站交过这么多次,通过率才有意义 */
|
||||||
|
const RATE_MIN_SUBMISSIONS = 30
|
||||||
|
const HARD_RATE = 0.3
|
||||||
|
const EASY_RATE = 0.7
|
||||||
|
|
||||||
|
/** 几种「换个说法」各自的机会:不是每次都说同一件事 */
|
||||||
|
const CHANCE = { classMany: 0.3, classFew: 0.5, rate: 0.4, hint: 0.5, challenge: 0.4 } as const
|
||||||
|
const roll = (chance: number) => Math.random() < chance
|
||||||
|
|
||||||
|
const FINISHED = (result: number | undefined) =>
|
||||||
|
result !== undefined && result !== SubmissionStatus.pending && result !== SubmissionStatus.judging && result !== SubmissionStatus.submitting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目页上看板娘说什么、给哪几个按钮。看的是这一页上最近的那件事:刚交的结果、刚跑的例子,
|
||||||
|
* 都没有就看写没写代码。比赛入口不用管(Mascot.vue 在比赛里不弹)。
|
||||||
|
*
|
||||||
|
* 另外几件事她会主动开口(只对学生,频率上限在 Mascot.vue):同一道题错到第 5 次、连着 3 次
|
||||||
|
* 没跑例子就交还错了、又犯了自己的老毛病(behavior.ts 的 habitSituation)、15 分钟没交没跑。
|
||||||
|
*
|
||||||
|
* `showResult` 是「我错在哪」:把左栏切到「结果」页签的那一段(提交结果 / 运行例子),页签归页面管。
|
||||||
|
*/
|
||||||
|
export function useProblemMascotGuide(showResult: (segment: ResultSegment) => void) {
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const submissionStore = useSubmissionStore()
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
const collabStore = useCollabStore()
|
||||||
|
const lessonStore = useLessonStore()
|
||||||
|
const ctx = useProblemPageContext()
|
||||||
|
const teacherCollab = useTeacherCollab()
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
const flowchartStore = useFlowchartStore()
|
||||||
|
const challengeStore = useChallengeStore()
|
||||||
|
|
||||||
|
/** 学生在这道题上能用的几个按钮,点她和她主动开口共用 */
|
||||||
|
function studentActions(problem: ProblemDetail) {
|
||||||
|
const language = codeStore.code.language
|
||||||
|
const entry = ctx.value
|
||||||
|
const runSamples: MascotAction | null =
|
||||||
|
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
|
||||||
|
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
|
||||||
|
const help: MascotAction | null =
|
||||||
|
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
|
||||||
|
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
|
||||||
|
: null
|
||||||
|
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
|
||||||
|
const whySamples: MascotAction = { label: '我错在哪', run: () => showResult('samples') }
|
||||||
|
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
|
||||||
|
const submit: MascotAction | null = busy
|
||||||
|
? null
|
||||||
|
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
|
||||||
|
return { runSamples, help, why, whySamples, submit }
|
||||||
|
}
|
||||||
|
|
||||||
|
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
|
||||||
|
situation,
|
||||||
|
actions: actions.filter((it): it is MascotAction => !!it),
|
||||||
|
})
|
||||||
|
|
||||||
|
useMascotGuide(() => {
|
||||||
|
const problem = problemStore.problem
|
||||||
|
if (!problem) return null
|
||||||
|
|
||||||
|
if (userStore.isTeacherOrAbove) {
|
||||||
|
// 协作中别把老师带离这一页:跳走协作就断了
|
||||||
|
if (teacherCollab.value) return null
|
||||||
|
const actions: MascotAction[] = [{ label: '全班这题的提交', run: () => router.push({ name: 'submissions', query: { problem: problem._id } }) }]
|
||||||
|
if (isDesktop.value && userStore.canEditProblem(problem)) {
|
||||||
|
actions.push({ label: '编辑这道题', run: () => window.open(router.resolve(`/admin/problem/edit/${problem.id}`).href, '_blank') })
|
||||||
|
}
|
||||||
|
return { situation: 'teacher.problem', actions }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = ctx.value
|
||||||
|
const { runSamples, help, why, whySamples, submit } = studentActions(problem)
|
||||||
|
|
||||||
|
if (codeStore.code.language === 'Flowchart') {
|
||||||
|
// 画过、评过分了:分低就说分低,拿了 S 夸一句
|
||||||
|
const grade = flowchartStore.latestRating.grade
|
||||||
|
if (grade === 'S') return guide('flowchart.good', help)
|
||||||
|
if (grade && !isFlowchartPass(grade)) return guide('flowchart.low', help)
|
||||||
|
return guide('problem.flowchart', help)
|
||||||
|
}
|
||||||
|
// 正在判、正在跑例子:没什么好说的,打个招呼
|
||||||
|
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
|
||||||
|
|
||||||
|
const stuck = problemStore.failCount >= STUCK_FAILS
|
||||||
|
if (submissionStore.syntaxErrorInfo) return stuck ? guide('problem.stuck', help, why) : guide('problem.compileError', why, help)
|
||||||
|
|
||||||
|
const runs = submissionStore.trial.sampleRuns.value
|
||||||
|
if (submissionStore.resultSegment === 'samples' && runs.length) {
|
||||||
|
return runs.every((run) => run.result === SubmissionStatus.accepted)
|
||||||
|
? guide('problem.samplesPassed', submit)
|
||||||
|
: guide('problem.samplesFailed', whySamples, help)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = submissionStore.submission?.result
|
||||||
|
const lesson = lessonStore.find(problem._id)
|
||||||
|
if (result === SubmissionStatus.accepted) {
|
||||||
|
const next = nextAction()
|
||||||
|
// 课堂条的数是拉的时候的,刚做对重拉回来可能已经把自己算进去了
|
||||||
|
if (lesson && lesson.acceptedCount <= (lesson.myStatus === 'accepted' ? 1 : 0)) return guide('class.firstSolver', next)
|
||||||
|
if (entry.challenges && challengeStore.current?.challenges.length && roll(CHANCE.challenge)) return guide('problem.challenge', next)
|
||||||
|
const fails = problemStore.failCount
|
||||||
|
return guide(fails === 0 ? 'problem.firstTry' : fails >= FINALLY_FAILS ? 'problem.finally' : 'problem.accepted', next)
|
||||||
|
}
|
||||||
|
const failed = result === undefined ? undefined : RESULT_SITUATION[result as SubmissionStatus]
|
||||||
|
if (failed) {
|
||||||
|
if (stuck) return guide('problem.stuck', help, why)
|
||||||
|
const actions = [why, failed === 'problem.wrong' ? runSamples : null, help]
|
||||||
|
// AI 提示开了还没要:提一句,按钮带去结果里(提示在那儿)
|
||||||
|
const hintOpen = !problem.contestId && problemStore.failCount >= HINT_MIN_FAILURES && !submissionStore.hint.hintContent.value
|
||||||
|
if (hintOpen && roll(CHANCE.hint)) return guide('problem.hintReady', ...actions)
|
||||||
|
const classGuide = classSituation(lesson)
|
||||||
|
if (classGuide) return { ...guide(classGuide.situation, ...actions), n: classGuide.n }
|
||||||
|
const rate = problem.submissionNumber >= RATE_MIN_SUBMISSIONS ? problem.acceptedNumber / problem.submissionNumber : null
|
||||||
|
if (rate !== null && rate < HARD_RATE && roll(CHANCE.rate)) return guide('problem.hardWrong', ...actions)
|
||||||
|
if (rate !== null && rate > EASY_RATE && failed === 'problem.wrong' && roll(CHANCE.rate)) return guide('problem.easyWrong', ...actions)
|
||||||
|
return guide(failed, ...actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这两种只说话、只留举手:上线第一天「做过了」的「下一道」没人点过,空着代码跑例子也没意义,
|
||||||
|
// 而空白页上「举手求助」是真有人点(不会下手)。没有按钮的气泡 5 秒自己收起,不一直挡着
|
||||||
|
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore')
|
||||||
|
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', help)
|
||||||
|
const classGuide = classSituation(lesson)
|
||||||
|
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
|
||||||
|
return guide('problem.unsubmitted', runSamples, help)
|
||||||
|
|
||||||
|
/** 这节课的题:同班多少人做对了(课堂条那份数),不在这节课里就没有 */
|
||||||
|
function classSituation(item: typeof lesson) {
|
||||||
|
if (!item || item.myStatus === 'accepted') return null
|
||||||
|
if (item.acceptedCount >= CLASS_MANY && roll(CHANCE.classMany)) return { situation: 'class.manyDone' as const, n: item.acceptedCount }
|
||||||
|
if (item.userCount >= CLASS_TRIED && item.acceptedCount <= CLASS_FEW && roll(CHANCE.classFew))
|
||||||
|
return { situation: 'class.fewDone' as const, n: item.acceptedCount }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 做对之后去哪:专题里回专题,这节课里去下一道还没做的 */
|
||||||
|
function nextAction(): MascotAction | null {
|
||||||
|
if (entry.entry === 'problemset') return { label: '回专题', run: () => router.push(`/problemset/${entry.problemSetId}`) }
|
||||||
|
const next = lessonStore.nextAfter(problem!._id)?.next
|
||||||
|
return next ? { label: '下一道', run: () => router.push(`/problem/${next.problemDisplayId}`) } : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 主动开口(只对学生;比赛里 Mascot.vue 不弹)----
|
||||||
|
|
||||||
|
const student = () => userStore.isAuthed && !userStore.isTeacherOrAbove && ctx.value.entry !== 'contest'
|
||||||
|
|
||||||
|
/** 这道题上连着几次没跑例子就交了。跑过一次例子就清零 */
|
||||||
|
let unsampledSubmits = 0
|
||||||
|
let sampledSinceSubmit = false
|
||||||
|
/** 上一次「动静」(进题、交、跑例子)的时刻,给「盯了好久」用 */
|
||||||
|
let lastActivity = Date.now()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => problemStore.problem?.id,
|
||||||
|
() => {
|
||||||
|
unsampledSubmits = 0
|
||||||
|
sampledSinceSubmit = false
|
||||||
|
lastActivity = Date.now()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => submissionStore.trial.samplesRunAt.value,
|
||||||
|
(at) => {
|
||||||
|
if (!at) return
|
||||||
|
sampledSinceSubmit = true
|
||||||
|
lastActivity = Date.now()
|
||||||
|
if (student()) logMascotEvent({ kind: 'trial', action: 'samples' })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// 「运行例子」「自己输入」「举手」别处都不存,记下来给她看一周的习惯(behavior.ts)
|
||||||
|
watch(
|
||||||
|
() => submissionStore.trial.customRunning.value,
|
||||||
|
(running) => {
|
||||||
|
if (!running) return
|
||||||
|
lastActivity = Date.now()
|
||||||
|
if (student()) logMascotEvent({ kind: 'trial', action: 'custom' })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => collabStore.helpStatus,
|
||||||
|
(status, previous) => {
|
||||||
|
if (status === 'pending' && previous === 'idle' && student()) logMascotEvent({ kind: 'help' })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => submissionStore.submission?.id,
|
||||||
|
(id) => {
|
||||||
|
if (!id) return
|
||||||
|
unsampledSubmits = sampledSinceSubmit ? 0 : unsampledSubmits + 1
|
||||||
|
sampledSinceSubmit = false
|
||||||
|
lastActivity = Date.now()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 判完了:错了就看要不要说一句;不管对错都刷新行为数(最近的错、今天做对几道)
|
||||||
|
watch(
|
||||||
|
() => [submissionStore.submission?.id, submissionStore.submission?.result] as const,
|
||||||
|
([id, result]) => {
|
||||||
|
const problem = problemStore.problem
|
||||||
|
if (!id || !FINISHED(result) || !problem || !student()) return
|
||||||
|
refreshMascotProfile(true)
|
||||||
|
const failed = RESULT_SITUATION[result as SubmissionStatus]
|
||||||
|
if (!failed) return
|
||||||
|
const { runSamples, help, why } = studentActions(problem)
|
||||||
|
if (problemStore.failCount >= STUCK_FAILS) {
|
||||||
|
requestSpeech({ key: `stuck:${problem.id}`, ...guide('problem.stuck', help, why) })
|
||||||
|
} else if (unsampledSubmits >= UNSAMPLED_SUBMITS && runSamples) {
|
||||||
|
requestSpeech({ key: `unsampled:${problem.id}`, ...guide('habit.noSamples', runSamples) })
|
||||||
|
} else {
|
||||||
|
const habit = habitSituation(failed)
|
||||||
|
if (habit) requestSpeech({ key: `habit:${habit}`, ...guide(habit, why) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 盯着一道没做对的题 15 分钟,没交也没跑:问一句
|
||||||
|
useIntervalFn(() => {
|
||||||
|
const problem = problemStore.problem
|
||||||
|
if (!problem || !student() || Date.now() - lastActivity < IDLE_MS) return
|
||||||
|
if (problem.myStatus === SubmissionStatus.accepted || submissionStore.submission?.result === SubmissionStatus.accepted) return
|
||||||
|
if (codeStore.code.language === 'Flowchart') return
|
||||||
|
const { runSamples, help } = studentActions(problem)
|
||||||
|
requestSpeech({ key: `idle:${problem.id}`, ...guide('idle.stuck', help, runSamples) })
|
||||||
|
}, 30_000)
|
||||||
|
}
|
||||||
@@ -14,28 +14,36 @@ const EMOJI_SVGS = [
|
|||||||
// 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用
|
// 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用
|
||||||
let emojiShapesPromise: Promise<confetti.Shape[]> | null = null
|
let emojiShapesPromise: Promise<confetti.Shape[]> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表情在屏幕上的边长(px)。位图就按这个大小画,放的时候 `scalar: 1`、矩阵不缩放。
|
||||||
|
*
|
||||||
|
* **别改回「位图画大、矩阵缩小、scalar 放大」**:canvas-confetti 画位图粒子是每帧
|
||||||
|
* `fillRect(位图边长 × scalar)` 再用图案填,填的矩形和表情实际多大无关。原来位图 64、
|
||||||
|
* scalar 5,表情显示 50px,每个粒子每帧却要填 320×320 —— 两团 32 个粒子一帧三百多万像素,
|
||||||
|
* 机房电脑(显卡弱、Chrome 常走软件渲染)放到这一种就卡(用户 2026-11 反馈)。
|
||||||
|
* 现在填的正好是 50×50,同样大小、同样轨迹,像素少到四十分之一。
|
||||||
|
*/
|
||||||
|
const EMOJI_PX = 50
|
||||||
|
|
||||||
function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
|
function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
const renderSize = 64
|
|
||||||
const baseUnit = 10
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = renderSize
|
canvas.width = EMOJI_PX
|
||||||
canvas.height = renderSize
|
canvas.height = EMOJI_PX
|
||||||
const ctx = canvas.getContext('2d')!
|
const ctx = canvas.getContext('2d')!
|
||||||
ctx.drawImage(img, 0, 0, renderSize, renderSize)
|
ctx.drawImage(img, 0, 0, EMOJI_PX, EMOJI_PX)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bitmap = await createImageBitmap(canvas)
|
const bitmap = await createImageBitmap(canvas)
|
||||||
const scale = baseUnit / renderSize
|
|
||||||
resolve({
|
resolve({
|
||||||
type: 'bitmap',
|
type: 'bitmap',
|
||||||
bitmap,
|
bitmap,
|
||||||
matrix: [scale, 0, 0, scale, (-renderSize * scale) / 2, (-renderSize * scale) / 2],
|
matrix: [1, 0, 0, 1, -EMOJI_PX / 2, -EMOJI_PX / 2],
|
||||||
} as unknown as confetti.Shape)
|
} as unknown as confetti.Shape)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(e)
|
reject(e)
|
||||||
@@ -415,7 +423,8 @@ export function useFireworks() {
|
|||||||
const options: confetti.Options = {
|
const options: confetti.Options = {
|
||||||
shapes: emojiShapes,
|
shapes: emojiShapes,
|
||||||
flat: true,
|
flat: true,
|
||||||
scalar: 5,
|
// 大小在位图里(EMOJI_PX),这里放大的话填的矩形跟着放大,见 EMOJI_PX
|
||||||
|
scalar: 1,
|
||||||
particleCount: 16,
|
particleCount: 16,
|
||||||
spread: 360,
|
spread: 360,
|
||||||
startVelocity: 40,
|
startVelocity: 40,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { errorCode } from 'utils/api'
|
import { errorCode } from 'utils/api'
|
||||||
import { getProblem } from 'oj/api'
|
import { getProblem } from 'oj/api'
|
||||||
@@ -19,6 +20,7 @@ import ContextBar from './components/ContextBar.vue'
|
|||||||
import { useEditorMenu } from './composables/editorMenu'
|
import { useEditorMenu } from './composables/editorMenu'
|
||||||
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
|
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
|
||||||
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
|
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
|
||||||
|
import { useProblemMascotGuide } from './composables/mascotGuide'
|
||||||
|
|
||||||
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
|
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
|
||||||
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
|
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
|
||||||
@@ -120,6 +122,13 @@ watch(
|
|||||||
)
|
)
|
||||||
const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key])
|
const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key])
|
||||||
|
|
||||||
|
/** 抽屉按钮前的小图标:页签行右边三个不带框的按钮,和左边的页签分得开(原来是三个圆角胶囊,看着像另一组页签) */
|
||||||
|
const DRAWER_ICON: Record<DrawerKey, string> = {
|
||||||
|
info: 'ph:chart-bar',
|
||||||
|
reaction: 'ph:chat-circle',
|
||||||
|
submission: 'ph:clock-counter-clockwise',
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDrawer(key: DrawerKey) {
|
function toggleDrawer(key: DrawerKey) {
|
||||||
drawer.value = drawer.value === key ? null : key
|
drawer.value = drawer.value === key ? null : key
|
||||||
}
|
}
|
||||||
@@ -208,6 +217,13 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 看板娘气泡里的「我错在哪」:切到「结果」页签里出错的那一段
|
||||||
|
useProblemMascotGuide((segment) => {
|
||||||
|
drawer.value = null
|
||||||
|
currentTab.value = 'result'
|
||||||
|
submissionStore.resultSegment = segment
|
||||||
|
})
|
||||||
|
|
||||||
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
||||||
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
|
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
|
||||||
|
|
||||||
@@ -306,7 +322,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="ctx.drawers.length" class="drawer-buttons">
|
<div v-if="ctx.drawers.length" class="drawer-buttons">
|
||||||
<button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)">
|
<button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)">
|
||||||
{{ drawerLabel(key) }}
|
<Icon :icon="DRAWER_ICON[key]" :width="16" aria-hidden="true" />{{ drawerLabel(key) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,26 +425,46 @@ onBeforeUnmount(() => {
|
|||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 左栏:课堂的时候最左边是一条竖栏(ContextBar 的 .lesson-rail),竖栏右边从上到下是
|
||||||
|
* 专题 / 比赛条、页签行、正文。两个页签的正文叠在同一格里,v-show 只显示一个
|
||||||
|
*/
|
||||||
.left-pane {
|
.left-pane {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.left-pane > :deep(.lesson-rail) {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-pane > :deep(.context-bar),
|
||||||
|
.left-pane > .tab-row {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-pane > .pane-body {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
.right-pane {
|
.right-pane {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个胶囊按钮。
|
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个按钮(图标 + 字,不带框)。
|
||||||
高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排,
|
高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排,
|
||||||
差 6px 的话两条下边线在分隔线处错成一个台阶 */
|
差 6px 的话两条下边线在分隔线处错成一个台阶 */
|
||||||
.tab-row {
|
.tab-row {
|
||||||
flex: none;
|
flex: none;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 20px 0 12px;
|
padding: 0 10px 0 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -460,24 +496,32 @@ onBeforeUnmount(() => {
|
|||||||
.drawer-buttons {
|
.drawer-buttons {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-button {
|
.drawer-button {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 11px;
|
padding: 0 9px;
|
||||||
border: 1px solid v-bind('theme.borderColor');
|
border: 0;
|
||||||
border-radius: 16px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: v-bind('theme.textColor2');
|
color: v-bind('theme.textColor2');
|
||||||
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-button svg {
|
||||||
|
color: v-bind('theme.textColor3');
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-button:hover {
|
.drawer-button:hover {
|
||||||
border-color: v-bind('theme.primaryColorHover');
|
background-color: rgba(128, 128, 128, 0.1);
|
||||||
color: v-bind('theme.primaryColor');
|
color: v-bind('theme.textColor1');
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-body {
|
.pane-body {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { usePagination } from 'shared/composables/pagination'
|
|||||||
import { useAuthModalStore } from 'shared/store/authModal'
|
import { useAuthModalStore } from 'shared/store/authModal'
|
||||||
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 { focusJumpAction, useMascotGuide, type MascotAction } from 'shared/mascot/guide'
|
||||||
import ProblemStatus from './components/ProblemStatus.vue'
|
import ProblemStatus from './components/ProblemStatus.vue'
|
||||||
import ProblemTypeTag from './components/ProblemTypeTag.vue'
|
import ProblemTypeTag from './components/ProblemTypeTag.vue'
|
||||||
import ProgressRow from './components/ProgressRow.vue'
|
import ProgressRow from './components/ProgressRow.vue'
|
||||||
@@ -272,6 +273,18 @@ async function pickRandom() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 看板娘:学生给找题的几条路(「随便来一道」按这页选着的知识点和类型挑),老师给新建题目
|
||||||
|
useMascotGuide(() => {
|
||||||
|
if (teacher.value) {
|
||||||
|
return {
|
||||||
|
situation: 'teacher.problems',
|
||||||
|
actions: userStore.hasProblemPermission ? [{ label: '新建题目', run: () => router.push('/admin/problem/create') }] : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const undone: MascotAction | null = userStore.isAuthed && !undoneOn.value ? { label: '只看没做完', run: () => (undoneOn.value = true) } : null
|
||||||
|
return { situation: 'problems', actions: [focusJumpAction(), ...(undone ? [undone] : []), { label: '随便来一道', run: pickRandom }] }
|
||||||
|
})
|
||||||
|
|
||||||
// ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ----
|
// ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ----
|
||||||
const nextTag = computed(() => {
|
const nextTag = computed(() => {
|
||||||
const p = mine.value
|
const p = mine.value
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 题面正文(描述 / 输入 / 输出 / 提示)在渲染前去掉空段落。
|
||||||
|
*
|
||||||
|
* 库里的题面多是 wangEditor 时代的 HTML,老师为了「隔开一点」敲的空行存成
|
||||||
|
* `<p><br></p>`、`<p> </p>`,每个都撑出一整行高的缝(2020 一道题就有两处)。
|
||||||
|
* 段落之间的间距题面自己会给,这些空段落一律去掉。只动「整段什么字都没有」的,
|
||||||
|
* 段落里带图片、带字的都不碰。
|
||||||
|
*/
|
||||||
|
const EMPTY_PARAGRAPH = /<p(?:\s[^>]*)?>(?:\s| | | |<br\s*\/?>)*<\/p>/gi
|
||||||
|
|
||||||
|
export function tidyStatement(html: string | null | undefined): string {
|
||||||
|
if (!html) return ''
|
||||||
|
return html.replace(EMPTY_PARAGRAPH, '')
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { parseTime } from 'utils/functions'
|
|||||||
import type { ProblemSet, ProblemSetProblem } from 'utils/types'
|
import type { ProblemSet, ProblemSetProblem } from 'utils/types'
|
||||||
import { useAuthModalStore } from 'shared/store/authModal'
|
import { useAuthModalStore } from 'shared/store/authModal'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import { goAction, useMascotGuide } from 'shared/mascot/guide'
|
||||||
import { useFireworks } from '../problem/composables/useFireworks'
|
import { useFireworks } from '../problem/composables/useFireworks'
|
||||||
import NotFound from 'shared/components/NotFound.vue'
|
import NotFound from 'shared/components/NotFound.vue'
|
||||||
import BadgeLadder from './components/BadgeLadder.vue'
|
import BadgeLadder from './components/BadgeLadder.vue'
|
||||||
@@ -122,6 +123,20 @@ async function join() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 看板娘:老师看全班,学生还差几道必做题、带去下一道;没加入就只打招呼
|
||||||
|
useMascotGuide(() => {
|
||||||
|
if (!set.value) return null
|
||||||
|
if (userStore.isTeacherOrAbove) return { situation: 'teacher.problemset', actions: [{ label: '全班情况', run: () => (tab.value = 'class') }] }
|
||||||
|
if (!joined.value) return null
|
||||||
|
const todo = problems.value.filter((item) => item.isRequired && !item.isCompleted)
|
||||||
|
const next = todo[0]
|
||||||
|
if (progress.value?.isCompleted || !next) return { situation: 'problemset.done', actions: [goAction(router, '专题列表', '/problemset')] }
|
||||||
|
const actions = [{ label: '去做下一道', run: () => router.push(problemLink(next)) }]
|
||||||
|
// 只剩最后一道、做完还有奖章没拿:说「就差最后 1 道」比「还差 1 道」更想让人做完
|
||||||
|
if (todo.length === 1 && set.value.badges.some((badge) => !badge.isEarned)) return { situation: 'problemset.lastOne', actions }
|
||||||
|
return { situation: 'problemset.unfinished', n: todo.length, actions }
|
||||||
|
})
|
||||||
|
|
||||||
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
|
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
|
||||||
const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id))
|
const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id))
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useThemeVars } from 'naive-ui'
|
|||||||
import AdminLink from 'shared/components/AdminLink.vue'
|
import 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 { getProblemSetList } from 'oj/api'
|
import { getProblemSetList } from 'oj/api'
|
||||||
import { rgba, useTone } from 'shared/composables/tone'
|
import { rgba, useTone } from 'shared/composables/tone'
|
||||||
import { parseTime } from 'utils/functions'
|
import { parseTime } from 'utils/functions'
|
||||||
@@ -55,6 +56,16 @@ const others = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 看板娘:加入了还没做完的专题,布置中的优先,其次最近加入的
|
||||||
|
useMascotGuide(() => {
|
||||||
|
if (userStore.isTeacherOrAbove) return null
|
||||||
|
const pending = sets.value.filter((set) => set.userProgress.isJoined && !set.userProgress.isCompleted)
|
||||||
|
const latest = (set: ProblemSet) => Date.parse(set.userProgress.joinTime ?? '') || 0
|
||||||
|
const target = pending.find((set) => set.assigning) ?? [...pending].sort((a, b) => latest(b) - latest(a))[0]
|
||||||
|
if (!target) return null
|
||||||
|
return { situation: 'problemsets.unfinished', actions: [goAction(router, '接着做', `/problemset/${target.id}`)] }
|
||||||
|
})
|
||||||
|
|
||||||
function description(set: ProblemSet) {
|
function description(set: ProblemSet) {
|
||||||
// 简介大多就是把标题再抄一遍,一样的就不重复显示
|
// 简介大多就是把标题再抄一遍,一样的就不重复显示
|
||||||
return set.description && set.description !== set.title ? set.description : ''
|
return set.description && set.description !== set.title ? set.description : ''
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import UserName from 'shared/components/UserName.vue'
|
|||||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||||
import { useConfigStore } from 'shared/store/config'
|
import { useConfigStore } from 'shared/store/config'
|
||||||
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 { displayName } from '@oj2/contract'
|
import { displayName } from '@oj2/contract'
|
||||||
import { STORAGE_KEY } from 'utils/constants'
|
import { STORAGE_KEY } from 'utils/constants'
|
||||||
@@ -271,6 +272,23 @@ function openDetail(className: string) {
|
|||||||
showDetail.value = true
|
showDetail.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 看板娘:自己在榜上就把自己那一行滚到眼前(领奖台上是「你」那个标签,赛道上是高亮的那一行)
|
||||||
|
useMascotGuide(() => {
|
||||||
|
if (teacher.value || !board.value) return null
|
||||||
|
const listed = rows.value.some((row) => row.user.id === meId.value)
|
||||||
|
const findMe = () => {
|
||||||
|
const el = document.querySelector('.track-card .entry.me, .track-card .prow.me, .track-card .tag.me')
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
const actions = listed ? [{ label: '我在哪', run: findMe }] : []
|
||||||
|
const { me: mine, ahead } = board.value
|
||||||
|
if (mine?.solved && mine.rank === 1) return { situation: 'rank.top', actions }
|
||||||
|
// 同分按先做到排,追平还在后面,所以要比前一名多做 1 道
|
||||||
|
const gap = mine && ahead ? ahead.solved - mine.solved : null
|
||||||
|
if (gap !== null && gap <= 5) return { situation: 'rank.gap', n: gap + 1, actions }
|
||||||
|
return { situation: 'rank', actions }
|
||||||
|
})
|
||||||
|
|
||||||
function open(username: string) {
|
function open(username: string) {
|
||||||
router.push({ path: '/user', query: { name: username } })
|
router.push({ path: '/user', query: { name: username } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useDark } from '@vueuse/core'
|
import { useDark } from 'shared/composables/dark'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
|
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouteQuery } from '@vueuse/router'
|
import { useRouteQuery } from '@vueuse/router'
|
||||||
|
import { useMascotGuide } from 'shared/mascot/guide'
|
||||||
import StatisticsView from './components/StatisticsView.vue'
|
import StatisticsView from './components/StatisticsView.vue'
|
||||||
import { DEFAULT_PERIOD } from './period'
|
import { DEFAULT_PERIOD } from './period'
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ const period = useRouteQuery<string>('period', DEFAULT_PERIOD, { mode: 'replace'
|
|||||||
const from = useRouteQuery<string>('from', '', { mode: 'replace' })
|
const from = useRouteQuery<string>('from', '', { mode: 'replace' })
|
||||||
const to = useRouteQuery<string>('to', '', { mode: 'replace' })
|
const to = useRouteQuery<string>('to', '', { mode: 'replace' })
|
||||||
|
|
||||||
|
useMascotGuide(() => ({ situation: 'teacher.statistics' }))
|
||||||
|
|
||||||
function openList(query: Record<string, string>) {
|
function openList(query: Record<string, string>) {
|
||||||
window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
|
window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from 'naive-ui'
|
import { useThemeVars } from 'naive-ui'
|
||||||
import { useDark } from '@vueuse/core'
|
import { useDark } from 'shared/composables/dark'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。
|
* 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import UserName from 'shared/components/UserName.vue'
|
|||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
row: SubmissionListItem
|
row: SubmissionListItem
|
||||||
|
/** 按 ↑ ↓ 会落到的上一条、下一条:这条加载完顺手把它们也拉回来,换条时不用等网络 */
|
||||||
|
neighbors?: SubmissionListItem[]
|
||||||
/** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
|
/** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
|
||||||
teacher: boolean
|
teacher: boolean
|
||||||
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
|
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
|
||||||
@@ -61,7 +63,42 @@ const detail = ref<Submission | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
|
|
||||||
const judging = computed(() => props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging)
|
function isJudging(row: SubmissionListItem) {
|
||||||
|
return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging
|
||||||
|
}
|
||||||
|
const judging = computed(() => isJudging(props.row))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一条只发一次请求:预取的还在路上时按到了它,接着等那一个,不再发第二个
|
||||||
|
*/
|
||||||
|
const inflight = new Map<string, Promise<Submission>>()
|
||||||
|
function fetchDetail(id: string) {
|
||||||
|
let request = inflight.get(id)
|
||||||
|
if (!request) {
|
||||||
|
request = getSubmission(id).finally(() => inflight.delete(id))
|
||||||
|
inflight.set(id, request)
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预取上一条、下一条。只在这一条落定之后才做(按住 ↓ 一路划过去的那些行不预取),
|
||||||
|
* 进缓存的口径和 load() 一样:还在判的不要,列表和详情的结果对不上也不要。
|
||||||
|
*
|
||||||
|
* 没预取的时候,换到没看过的一条要先把代码区卸掉、转圈、再整个重新挂上 ——
|
||||||
|
* 2026-11 实测(生产构建、CPU 降速 6 倍)每次卡 60–100ms;走缓存的只换内容,不卡。
|
||||||
|
*/
|
||||||
|
function prefetch() {
|
||||||
|
for (const row of props.neighbors ?? []) {
|
||||||
|
if (!row.showLink || isJudging(row) || cache.get(row.id)?.result === row.result) continue
|
||||||
|
fetchDetail(row.id).then(
|
||||||
|
(res) => {
|
||||||
|
if (res.result === row.result) cache.set(row.id, res)
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的
|
// 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的
|
||||||
let loadSeq = 0
|
let loadSeq = 0
|
||||||
@@ -78,15 +115,17 @@ async function load() {
|
|||||||
const hit = cache.get(row.id)
|
const hit = cache.get(row.id)
|
||||||
if (hit && hit.result === row.result) {
|
if (hit && hit.result === row.result) {
|
||||||
detail.value = hit
|
detail.value = hit
|
||||||
|
prefetch()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getSubmission(row.id)
|
const res = await fetchDetail(row.id)
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
// 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
|
// 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
|
||||||
if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
|
if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
|
||||||
detail.value = res
|
detail.value = res
|
||||||
|
prefetch()
|
||||||
} catch {
|
} catch {
|
||||||
if (seq === loadSeq) failed.value = true
|
if (seq === loadSeq) failed.value = true
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -310,7 +310,15 @@ function handleMore(key: string) {
|
|||||||
class="code"
|
class="code"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WrongAnswerExplain v-if="sampleCheck" :check="sampleCheck" :code="shown.code" :language="shown.language" :plain="!!shown.contestId" :peer="!own" />
|
<WrongAnswerExplain
|
||||||
|
v-if="sampleCheck"
|
||||||
|
section="row"
|
||||||
|
:check="sampleCheck"
|
||||||
|
:code="shown.code"
|
||||||
|
:language="shown.language"
|
||||||
|
:plain="!!shown.contestId"
|
||||||
|
:peer="!own"
|
||||||
|
/>
|
||||||
<ExplainNote v-else-if="explain" :explain="explain" :tone="explainTone" />
|
<ExplainNote v-else-if="explain" :explain="explain" :tone="explainTone" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export function useRowCursor(options: {
|
|||||||
const index = navigable.value.findIndex((row) => row.id === selectedId.value)
|
const index = navigable.value.findIndex((row) => row.id === selectedId.value)
|
||||||
return index === -1 ? null : { index: index + 1, count: navigable.value.length }
|
return index === -1 ? null : { index: index + 1, count: navigable.value.length }
|
||||||
})
|
})
|
||||||
|
/** 按 ↑ ↓ 会落到的上一条、下一条(本页之内),右栏拿去预取 */
|
||||||
|
const neighbors = computed(() => {
|
||||||
|
const list = navigable.value
|
||||||
|
const index = list.findIndex((row) => row.id === selectedId.value)
|
||||||
|
if (index === -1) return []
|
||||||
|
return [list[index + 1], list[index - 1]].filter((row): row is Row => row !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
|
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
|
||||||
let pendingJump: 'first' | 'last' | null = null
|
let pendingJump: 'first' | 'last' | null = null
|
||||||
@@ -125,5 +132,5 @@ export function useRowCursor(options: {
|
|||||||
move(e.key === 'ArrowUp' ? -1 : 1)
|
move(e.key === 'ArrowUp' ? -1 : 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump }
|
return { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump }
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getSubmission } from 'oj/api'
|
||||||
|
import { useMascotGuide } from 'shared/mascot/guide'
|
||||||
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import type { Submission } from 'utils/types'
|
||||||
import SubmissionView from './components/SubmissionView.vue'
|
import SubmissionView from './components/SubmissionView.vue'
|
||||||
|
|
||||||
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
|
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
|
||||||
defineProps<{ submissionID: string }>()
|
const props = defineProps<{ submissionID: string }>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板娘:学生看自己的提交时带回这道题。SubmissionView 里的数据拿不出来(弹层也用它,不挂看板娘),
|
||||||
|
* 这里另取一份不带「这道题交的几次」的,只给学生取
|
||||||
|
*/
|
||||||
|
const mine = ref<Submission | null>(null)
|
||||||
|
watch(
|
||||||
|
() => [props.submissionID, userStore.isAuthed && !userStore.isTeacherOrAbove] as const,
|
||||||
|
async ([id, student]) => {
|
||||||
|
mine.value = null
|
||||||
|
if (!student) return
|
||||||
|
const res = await getSubmission(id).catch(() => null)
|
||||||
|
if (res && id === props.submissionID && res.userId === userStore.user?.id) mine.value = res
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
useMascotGuide(() => {
|
||||||
|
const s = mine.value
|
||||||
|
if (!s || userStore.isTeacherOrAbove) return null
|
||||||
|
// 和页面上题目那个链接同一个去处:比赛里交的回比赛里的题
|
||||||
|
const to = s.contestId
|
||||||
|
? { name: 'contest problem', params: { contestID: String(s.contestId), problemID: s.problemDisplayId } }
|
||||||
|
: '/problem/' + s.problemDisplayId
|
||||||
|
return { situation: 'submission.detail', actions: [{ label: '回到这道题', run: () => router.push(to) }] }
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
|||||||
import { usePagination } from 'shared/composables/pagination'
|
import { usePagination } from 'shared/composables/pagination'
|
||||||
import { useConfigStore } from 'shared/store/config'
|
import { useConfigStore } from 'shared/store/config'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
import { useMascotGuide } from 'shared/mascot/guide'
|
||||||
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants'
|
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants'
|
||||||
import { parseTime } from 'utils/functions'
|
import { parseTime } from 'utils/functions'
|
||||||
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types'
|
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types'
|
||||||
@@ -263,7 +264,7 @@ watch(
|
|||||||
|
|
||||||
// ==================== 选中与翻阅 ====================
|
// ==================== 选中与翻阅 ====================
|
||||||
|
|
||||||
const { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({
|
const { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({
|
||||||
rows,
|
rows,
|
||||||
total,
|
total,
|
||||||
query,
|
query,
|
||||||
@@ -427,6 +428,18 @@ const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
|
|||||||
info: (text) => message.info(text),
|
info: (text) => message.info(text),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 看板娘:学生带去看自己的;「这节课」只有老师那一版有,老师给它
|
||||||
|
useMascotGuide(() => {
|
||||||
|
if (inContest.value) return null
|
||||||
|
if (!teacher.value) return { situation: 'submissions', actions: mine.value ? [] : [{ label: '只看我的', run: () => (scope.value = 'mine') }] }
|
||||||
|
const toLesson = () => {
|
||||||
|
// 「我的」时这节课的按钮是藏着的,先切回全部
|
||||||
|
if (mine.value) scope.value = 'all'
|
||||||
|
toggleLesson()
|
||||||
|
}
|
||||||
|
return { situation: 'teacher.submissions', actions: lessonActive.value ? [] : [{ label: '这节课', run: toLesson }] }
|
||||||
|
})
|
||||||
|
|
||||||
const activeFilters = computed(
|
const activeFilters = computed(
|
||||||
() =>
|
() =>
|
||||||
[query.username, query.className, query.result, query.problem].filter(Boolean).length +
|
[query.username, query.className, query.result, query.problem].filter(Boolean).length +
|
||||||
@@ -858,6 +871,7 @@ function dayBreak(index: number) {
|
|||||||
<SubmissionPane
|
<SubmissionPane
|
||||||
v-else
|
v-else
|
||||||
:row="selectedRow as SubmissionListItem"
|
:row="selectedRow as SubmissionListItem"
|
||||||
|
:neighbors="neighbors as SubmissionListItem[]"
|
||||||
:teacher="teacher"
|
:teacher="teacher"
|
||||||
:class-name="query.className"
|
:class-name="query.className"
|
||||||
:contest="inContest"
|
:contest="inContest"
|
||||||
@@ -893,6 +907,7 @@ function dayBreak(index: number) {
|
|||||||
<SubmissionPane
|
<SubmissionPane
|
||||||
v-else
|
v-else
|
||||||
:row="selectedRow as SubmissionListItem"
|
:row="selectedRow as SubmissionListItem"
|
||||||
|
:neighbors="neighbors as SubmissionListItem[]"
|
||||||
:teacher="teacher"
|
:teacher="teacher"
|
||||||
:class-name="query.className"
|
:class-name="query.className"
|
||||||
:contest="inContest"
|
:contest="inContest"
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ function openPicker() {
|
|||||||
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
|
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
|
||||||
picking.value = true
|
picking.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 看板娘气泡里的「选题」:课堂任务页没布置时替老师打开选题弹框
|
||||||
|
defineExpose({ openPicker })
|
||||||
function pickerConfirm(ids: string[]) {
|
function pickerConfirm(ids: string[]) {
|
||||||
lessonInput.value = ids.join(' ')
|
lessonInput.value = ids.join(' ')
|
||||||
saveLesson()
|
saveLesson()
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents'
|
|||||||
import { useTeacherHelp } from 'shared/composables/teacherHelp'
|
import { useTeacherHelp } from 'shared/composables/teacherHelp'
|
||||||
import { useCollabStore } from 'shared/store/collab'
|
import { useCollabStore } from 'shared/store/collab'
|
||||||
import { useConfigStore } from 'shared/store/config'
|
import { useConfigStore } from 'shared/store/config'
|
||||||
|
import { requestSpeech } from 'shared/mascot/behavior'
|
||||||
|
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||||
import { errorMessage } from 'utils/api'
|
import { errorMessage } from 'utils/api'
|
||||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||||
import { parseTime } from 'utils/functions'
|
import { parseTime } from 'utils/functions'
|
||||||
@@ -511,6 +513,161 @@ function submissionsHref(
|
|||||||
}).href
|
}).href
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板娘:布置了就带去这节课的提交,筛法同提交列表「这节课」(班、这几道题、今天)。
|
||||||
|
* 语言和格子一致:画流程图的课看流程图,选了语言的只看它 —— 夹着 SQL 题的不筛语言,免得把 SQL 交的藏掉。
|
||||||
|
* 还没布置时填题号的框就是这页最大的东西,不给按钮
|
||||||
|
*/
|
||||||
|
const startForm = useTemplateRef<{ openPicker: () => void }>('startForm')
|
||||||
|
// ---------- 看板娘:点她时挑全班这一刻最要紧的一件说;卡住的多起来、全班做完时她自己开口 ----------
|
||||||
|
|
||||||
|
/** 同一种错有这么多人才算「扎堆」 */
|
||||||
|
const PILE_MIN = 3
|
||||||
|
/** 布置这么久了,人在线却一道没交,才算「没动」 */
|
||||||
|
const IDLE_AFTER_MS = 10 * 60_000
|
||||||
|
/** 一道题卡住的人涨到这么多,她主动说一声 */
|
||||||
|
const STUCK_RISE = 3
|
||||||
|
|
||||||
|
/** 人最多的那一堆错(灰底那几堆不算:不是今天的错,没得讲) */
|
||||||
|
function biggestPile() {
|
||||||
|
let best: { index: number; kind: ErrorKind; people: WrongPerson[] } | null = null
|
||||||
|
const count = board.value?.problems.length ?? 0
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const groups = new Map<string, WrongPerson[]>()
|
||||||
|
for (const person of wrongOf(index)) {
|
||||||
|
if (QUIET_KINDS.has(person.kind.key)) continue
|
||||||
|
groups.set(person.kind.key, [...(groups.get(person.kind.key) ?? []), person])
|
||||||
|
}
|
||||||
|
for (const people of groups.values()) {
|
||||||
|
if (!best || people.length > best.people.length) best = { index, kind: people[0]!.kind, people }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best && best.people.length >= PILE_MIN ? best : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡住的人(cells.ts 的 isStuck),交得多的在前 */
|
||||||
|
function stuckPeople() {
|
||||||
|
return present.value
|
||||||
|
.flatMap((student) => student.cells.map((cell, index) => ({ student, cell, index })))
|
||||||
|
.filter(({ cell, index }) => isStuck(cell, modeAt(index)))
|
||||||
|
.sort((a, b) => b.cell.attempts - a.cell.attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在线、今天一道没交、也不是早就全做完了的人 */
|
||||||
|
function idlePeople() {
|
||||||
|
const assignedAt = board.value?.assignedAt
|
||||||
|
if (!assignedAt || Date.now() - Date.parse(assignedAt) < IDLE_AFTER_MS) return []
|
||||||
|
return present.value.filter(
|
||||||
|
(student) => student.online && student.cells.every((cell) => cell.attempts === 0) && student.cells.some((cell) => cell.status !== 'accepted'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDone = () => present.value.length > 0 && present.value.every((student) => student.cells.every((cell) => cell.status === 'accepted'))
|
||||||
|
|
||||||
|
/** 点名字:能讲的打开讲错误的弹层,不能讲的展开那道题的错法 */
|
||||||
|
function lookAt(index: number, student: TaskBoardStudent): MascotAction {
|
||||||
|
const cell = student.cells[index]!
|
||||||
|
const problem = board.value!.problems[index]!
|
||||||
|
return {
|
||||||
|
label: `看 ${displayName(student.username)} 的`,
|
||||||
|
run: () => (explainable(cell, index) ? openExplain(index, student.userId) : (selectedId.value = problem.problemId)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useMascotGuide((): MascotGuide | null => {
|
||||||
|
const b = board.value
|
||||||
|
if (!b?.className) return null
|
||||||
|
if (needLesson.value)
|
||||||
|
return { situation: 'teacher.task.unassigned', actions: startForm.value ? [{ label: '选题', run: () => startForm.value?.openPicker() }] : [] }
|
||||||
|
const language = b.mode === 'flowchart' ? 'Flowchart' : b.problems.some((problem) => problem.isSql) ? null : b.language
|
||||||
|
const query = {
|
||||||
|
className: b.className,
|
||||||
|
problem: b.problems.map((problem) => problem.problemDisplayId).join(','),
|
||||||
|
today: '1',
|
||||||
|
...(language ? { language } : {}),
|
||||||
|
}
|
||||||
|
// 新标签打开,和点格子一样:任务页一般开着不关
|
||||||
|
const submissions: MascotAction = { label: '这节课的提交', run: () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') }
|
||||||
|
|
||||||
|
if (allDone())
|
||||||
|
return { situation: 'teacher.task.allDone', actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }] }
|
||||||
|
|
||||||
|
const pile = biggestPile()
|
||||||
|
if (pile) {
|
||||||
|
const problem = b.problems[pile.index]!
|
||||||
|
const first = pile.people.find((person) => person.explain)
|
||||||
|
const actions: MascotAction[] = [{ label: '看这堆', run: () => (selectedId.value = problem.problemId) }]
|
||||||
|
if (first) actions.push({ label: '讲第一个', run: () => openExplain(pile.index, first.student.userId) })
|
||||||
|
return {
|
||||||
|
situation: 'teacher.task.errorPile',
|
||||||
|
n: pile.people.length,
|
||||||
|
vars: { pid: problem.problemDisplayId, kind: pile.kind.label },
|
||||||
|
actions: [...actions, submissions],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stuck = stuckPeople()
|
||||||
|
if (stuck.length) {
|
||||||
|
const top = stuck[0]!
|
||||||
|
return {
|
||||||
|
situation: 'teacher.task.stuck',
|
||||||
|
n: new Set(stuck.map((it) => it.student.userId)).size,
|
||||||
|
vars: { name: displayName(top.student.username), k: top.cell.attempts, pid: b.problems[top.index]!.problemDisplayId },
|
||||||
|
actions: [lookAt(top.index, top.student), submissions],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = idlePeople()
|
||||||
|
if (idle.length) {
|
||||||
|
const name = displayName(idle[0]!.username)
|
||||||
|
return idle.length === 1 ? { situation: 'teacher.task.idleOne', vars: { name } } : { situation: 'teacher.task.idle', n: idle.length, vars: { name } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenging = present.value.filter((student) => student.cells.some((cell) => cell.challenge === 'doing')).length
|
||||||
|
if (challenging) return { situation: 'teacher.task.challenging', n: challenging, actions: [submissions] }
|
||||||
|
|
||||||
|
return { situation: 'teacher.task.assigned', actions: [submissions] }
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动开口:每 15 秒刷新一次,和上一次比 —— 某道题卡住的人从不到 3 个涨到 3 个、全班刚做完。
|
||||||
|
* 刚打开页面(或换了班、换了一节)的那一次只记数不说,不然一刷新就把已经知道的事再说一遍
|
||||||
|
*/
|
||||||
|
let seen: { key: string; stuck: Map<number, number>; done: boolean } | null = null
|
||||||
|
watch(board, (b) => {
|
||||||
|
if (!b?.className || needLesson.value) return
|
||||||
|
const key = `${b.className}|${b.day}|${b.lessonId ?? ''}|${b.problems.map((problem) => problem.problemId).join(',')}`
|
||||||
|
const stuck = new Map<number, number>()
|
||||||
|
for (const { index } of stuckPeople()) {
|
||||||
|
const id = b.problems[index]!.problemId
|
||||||
|
stuck.set(id, (stuck.get(id) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const done = allDone()
|
||||||
|
const before = seen?.key === key ? seen : null
|
||||||
|
seen = { key, stuck, done }
|
||||||
|
if (!before) return
|
||||||
|
if (done && !before.done) {
|
||||||
|
requestSpeech({
|
||||||
|
key: `done:${key}`,
|
||||||
|
situation: 'teacher.task.allDone',
|
||||||
|
actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const [id, n] of stuck) {
|
||||||
|
if (n < STUCK_RISE || (before.stuck.get(id) ?? 0) >= STUCK_RISE) continue
|
||||||
|
const problem = b.problems.find((it) => it.problemId === id)!
|
||||||
|
requestSpeech({
|
||||||
|
key: `stuck:${key}:${id}`,
|
||||||
|
situation: 'teacher.task.stuckRise',
|
||||||
|
n,
|
||||||
|
vars: { pid: problem.problemDisplayId },
|
||||||
|
actions: [{ label: '看这道', run: () => (selectedId.value = id) }],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function lastText(student: TaskBoardStudent) {
|
function lastText(student: TaskBoardStudent) {
|
||||||
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
|
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
|
||||||
if (oftenIdle(student)) {
|
if (oftenIdle(student)) {
|
||||||
@@ -578,7 +735,16 @@ function lastText(student: TaskBoardStudent) {
|
|||||||
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
||||||
<section v-if="needLesson" class="start">
|
<section v-if="needLesson" class="start">
|
||||||
<div class="start-title">这节课做哪几道题?</div>
|
<div class="start-title">这节课做哪几道题?</div>
|
||||||
<LessonForm v-if="className" :board="board" :class-name="className" :lesson-id="null" variant="start" class="start-row" @saved="lessonSaved" />
|
<LessonForm
|
||||||
|
v-if="className"
|
||||||
|
ref="startForm"
|
||||||
|
:board="board"
|
||||||
|
:class-name="className"
|
||||||
|
:lesson-id="null"
|
||||||
|
variant="start"
|
||||||
|
class="start-row"
|
||||||
|
@saved="lessonSaved"
|
||||||
|
/>
|
||||||
<div v-if="staleLesson" class="earlier">
|
<div v-if="staleLesson" class="earlier">
|
||||||
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
|
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
|
||||||
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
|
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getProfileOverview } from 'oj/api'
|
|||||||
import { getProfile } from 'shared/api'
|
import { getProfile } from 'shared/api'
|
||||||
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 type { Profile } from 'utils/types'
|
import type { Profile } from 'utils/types'
|
||||||
import AchievementCard from './components/AchievementCard.vue'
|
import AchievementCard from './components/AchievementCard.vue'
|
||||||
import KnowledgeMap from './components/KnowledgeMap.vue'
|
import KnowledgeMap from './components/KnowledgeMap.vue'
|
||||||
@@ -17,6 +18,7 @@ import VersusCard from './components/VersusCard.vue'
|
|||||||
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
|
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
|
||||||
*/
|
*/
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
@@ -47,6 +49,12 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(username, load, { immediate: true })
|
watch(username, load, { immediate: true })
|
||||||
|
|
||||||
|
// 看板娘:只在看自己的主页时说话
|
||||||
|
useMascotGuide(() => {
|
||||||
|
if (!isSelf.value || !profile.value || userStore.isTeacherOrAbove) return null
|
||||||
|
return { situation: 'profile', actions: [goAction(router, '我的成就', '/achievement'), goAction(router, '我的提交', '/submission?myself=1')] }
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sutera-Diffusus
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |