Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f242308a3b | ||
|
|
2aa267839f | ||
|
|
4c3ce31fe1 | ||
|
|
77bfaba7ff | ||
|
|
5e5d29b2aa | ||
|
|
46e0f40cce | ||
|
|
163e553de4 | ||
|
|
9ed446f95a | ||
|
|
6a18bdf2e4 | ||
|
|
ab933d1896 | ||
|
|
50dced4d37 | ||
|
|
50946467f6 | ||
|
|
a2f03ee57f | ||
|
|
e177753ad6 | ||
|
|
f9a2739353 | ||
|
|
b292d38ba7 | ||
|
|
d6e891cdb0 | ||
|
|
53005e69e4 | ||
|
|
313acf2453 | ||
|
|
b7e7f77fb0 | ||
|
|
b3d6e1f88f | ||
|
|
f6024dc113 | ||
|
|
da623fa648 | ||
|
|
e4c53f5e55 | ||
|
|
61a32e900d | ||
|
|
4ce800c77f | ||
|
|
de277d3e9d | ||
|
|
d4b8bf9241 | ||
|
|
90b739a2c7 | ||
|
|
3b9f635c35 | ||
|
|
27fdc3615d | ||
|
|
982b1297aa | ||
|
|
e8058376dd | ||
|
|
c217eb5ca0 | ||
|
|
f304fc1ffc | ||
|
|
fe62d50881 | ||
|
|
f256b313f7 | ||
|
|
aadb2c9e93 | ||
|
|
751c4c964d | ||
|
|
65a1b3a81e | ||
|
|
b20db5721e | ||
|
|
10c50265b2 | ||
|
|
5ae07b851d | ||
|
|
2cd0111967 | ||
|
|
d60868d912 | ||
|
|
42e258ddcd | ||
|
|
13c3e4c3ec | ||
|
|
19d6872d80 | ||
|
|
eedf1aa2fb | ||
|
|
a9af26c2d6 | ||
|
|
670abe5921 | ||
|
|
6b088240da | ||
|
|
4ed9450c02 | ||
|
|
91b39603a2 | ||
|
|
8a92354fe4 | ||
|
|
91237b85b8 | ||
|
|
d6bcd11fb4 | ||
|
|
6096992507 | ||
|
|
14a82a3e24 | ||
|
|
302eae4f13 | ||
|
|
89e9956a22 | ||
|
|
4e6b0d5cb2 | ||
|
|
37fc747b49 | ||
|
|
5bb2940969 | ||
|
|
a3eb563b70 | ||
|
|
400814d1f6 | ||
|
|
7cec273d58 | ||
|
|
a00622be15 | ||
|
|
1cb2235c3d | ||
|
|
d5bec7c8af | ||
|
|
697f31e9ce | ||
|
|
081ac6f924 | ||
|
|
4c53caf7fa | ||
|
|
8feda289de | ||
|
|
02d2f0eabc | ||
|
|
ea5a25af06 | ||
|
|
03e80ce2e2 | ||
|
|
fbb8a334c3 | ||
|
|
942c319bab |
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
# 只把构建需要的源码送进 Docker
|
||||||
|
**/node_modules
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/dist
|
||||||
|
.runs
|
||||||
|
.git
|
||||||
|
docs
|
||||||
|
*.pdf
|
||||||
|
textbook-libs
|
||||||
|
.env
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 复制为 .env 后修改(.env 不进 git)。改完执行 docker compose up -d 生效。
|
||||||
|
|
||||||
|
# 老师账号(只有一个)。学生账号由老师登录后在"班级管理"里导入名单
|
||||||
|
TEACHER_USERNAME=admin
|
||||||
|
TEACHER_PASSWORD=请改成不容易猜到的密码
|
||||||
|
|
||||||
|
# AI 评阅:给老师评分项(界面、注释、命名)出建议分和评语,老师采纳后生效;不填就不做 AI 评阅
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
AI_MODEL=deepseek-flash
|
||||||
|
|
||||||
|
# 平台最多使用的内存
|
||||||
|
APP_MEM_LIMIT=4g
|
||||||
|
|
||||||
|
# 全站同时运行的学生程序上限,超过时提示"服务器繁忙"
|
||||||
|
SIM_MAX_RUNS=150
|
||||||
|
# 每个学生程序的内存上限(MB)
|
||||||
|
SIM_HEAP_LIMIT_MB=128
|
||||||
|
# 每次运行累计 CPU 时间上限(秒),防止死循环占满 CPU
|
||||||
|
SIM_CPU_LIMIT_SECONDS=120
|
||||||
|
# 每次运行最长时间(分钟)
|
||||||
|
SIM_MAX_RUN_MINUTES=60
|
||||||
|
# 全站同时跑几个评分检查项(提交评分和"自己检查"共用);2 核服务器不要超过 24,CPU 不够会误判
|
||||||
|
SIM_GRADE_CONCURRENCY=16
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物、跑测试 → 产物 rsync 到服务器的 prebuilt/ →
|
||||||
|
# 服务器上 deploy/update.sh --prebuilt(拉代码、用现成产物构建镜像、重启)。
|
||||||
|
#
|
||||||
|
# 跑在哪:开发机上的 gitea-runner(host 模式,标签 iot-deploy;和 OJ2 共用一个 runner,一次只跑一个任务,
|
||||||
|
# 两边的部署排队,不会同时挤服务器)。runner 的装法见 deploy/README.md「自动部署」。
|
||||||
|
# - 服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满。
|
||||||
|
# - 以前在 GitHub 上测试(约 5 分钟)再 SSH 到服务器编,push 后 6 分钟多才上线。
|
||||||
|
# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;
|
||||||
|
# 急着上线又不在开发机旁时,用 GitHub 上手动触发的备用部署(.github/workflows/ci.yml,服务器自己编)。
|
||||||
|
#
|
||||||
|
# host 模式直接用开发机自己的东西:PATH 里的 bun / dotnet / bwrap、~/.ssh/config 里的 `debian`(root@服务器)
|
||||||
|
# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout。
|
||||||
|
#
|
||||||
|
# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows。
|
||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REMOTE: debian # 开发机 ~/.ssh/config 里的主机名
|
||||||
|
REMOTE_DIR: /root/IOT_SIM
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||||
|
DOTNET_NOLOGO: '1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: iot-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
|
||||||
|
|
||||||
|
# server.js 是开发机的 bun 打包的,到服务器上由镜像里的 bun(Dockerfile 里写死的版本)运行。
|
||||||
|
# 打包出来是普通 JS,不像 OJ2 的单二进制那样把 bun 本身带过去,版本差一点一般没事,所以只提醒不拦。
|
||||||
|
- name: 看 bun 版本
|
||||||
|
run: |
|
||||||
|
want=$(sed -nE 's|^FROM oven/bun:([0-9.]+).*|\1|p' Dockerfile | head -1)
|
||||||
|
have=$(bun --version)
|
||||||
|
echo "开发机 bun $have,镜像里 bun $want"
|
||||||
|
[ "$have" = "$want" ] || echo "::warning::开发机的 bun 是 $have,镜像里是 $want,升级 Dockerfile 和 ci.yml 里的版本,或者确认没有用到新版本才有的功能"
|
||||||
|
|
||||||
|
- name: 装依赖
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 编 C# 运行时
|
||||||
|
run: dotnet build runtime/Runtime.slnx -c Release -v q
|
||||||
|
|
||||||
|
- name: 编网页、打包服务端
|
||||||
|
run: |
|
||||||
|
(cd web && bun run build)
|
||||||
|
bun build server/src/index.ts --target=bun --outfile=server.js
|
||||||
|
|
||||||
|
# 有一项不过就不部署。服务端测试用上一步编的 Release 版运行时,测的就是要上线的那份。
|
||||||
|
# --parallel 让 6 个测试文件同时跑;grading 里的测试标了 describe.concurrent,
|
||||||
|
# --max-concurrency 限制同时跑几个(太多时定时器来不及触发会误判)。挨个跑约 4 分钟,这样半分钟左右
|
||||||
|
- name: 测试
|
||||||
|
env:
|
||||||
|
RUNNER_DLL: runtime/Sim.Runner/bin/Release/net10.0/Sim.Runner.dll
|
||||||
|
run: |
|
||||||
|
(cd server && bun test --parallel --max-concurrency=8)
|
||||||
|
(cd web && bun test src && bunx vue-tsc --noEmit)
|
||||||
|
|
||||||
|
# 目录结构就是 Dockerfile 里 artifacts-prebuilt 阶段要的;COMMIT 让服务器确认产物和要部署的代码是同一个提交
|
||||||
|
- name: 收集产物
|
||||||
|
env:
|
||||||
|
SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
rm -rf prebuilt
|
||||||
|
mkdir -p prebuilt/runtime prebuilt/web
|
||||||
|
cp -a runtime/Sim.Runner/bin/Release/net10.0/. prebuilt/runtime/
|
||||||
|
cp -a web/dist/. prebuilt/web/
|
||||||
|
cp server.js prebuilt/
|
||||||
|
echo "$SHA" > prebuilt/COMMIT
|
||||||
|
du -sh prebuilt
|
||||||
|
|
||||||
|
# 只传产物,源码由服务器自己从 origin 拉(git.xuyue.cc 就在那台服务器上)。
|
||||||
|
# -c 按内容比:每次编出来的 mtime 都是「刚才」,按 mtime 比会把没变的文件也传一遍
|
||||||
|
# --delay-updates 全部传完才一起换上去,中途断了不会留下半新半旧的产物(反正 COMMIT 对不上也会被 update.sh 拦下)
|
||||||
|
# --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" \
|
||||||
|
prebuilt/ "$REMOTE:$REMOTE_DIR/prebuilt/" && exit 0
|
||||||
|
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# 先把服务器上的代码切到这个提交再跑 update.sh:不然跑的是上一版的 update.sh,改了它要晚一次部署才生效。
|
||||||
|
# 重启会结束正在运行的学生程序,上课时间尽量不要推送 main。
|
||||||
|
#
|
||||||
|
# update.sh 由 update-bg.sh 在服务器上后台跑,SSH 断了(开发机切代理、换网络)它照样跑完;
|
||||||
|
# 这边 ssh 返回 255(连接出错)就重连,用 --follow 接着看、拿退出码。
|
||||||
|
# 75 = 服务器上这个提交的部署还没开始(断在 git fetch 那里),重新开始。
|
||||||
|
- name: 起服务
|
||||||
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
remote() { ssh -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" "$1"; }
|
||||||
|
start="cd $REMOTE_DIR && git fetch --quiet origin && git reset --quiet --hard $SHA && deploy/update-bg.sh $SHA --prebuilt"
|
||||||
|
follow="cd $REMOTE_DIR && deploy/update-bg.sh --follow $SHA"
|
||||||
|
code=0; remote "$start" || code=$?
|
||||||
|
for attempt in 1 2 3 4 5 6; do
|
||||||
|
case $code in
|
||||||
|
255) echo "::warning::和服务器的 SSH 断了,部署在服务器上接着跑,10 秒后重连接着看"
|
||||||
|
sleep 10; code=0; remote "$follow" || code=$? ;;
|
||||||
|
75) echo "::warning::服务器上的部署还没开始,重新开始"
|
||||||
|
code=0; remote "$start" || code=$? ;;
|
||||||
|
*) exit $code ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exit $code
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 备用路线。平时 push origin(git.xuyue.cc)后,由开发机上的 gitea-runner 测试、编好、部署(.gitea/workflows/deploy.yml),
|
||||||
|
# 这里 push 不再触发,免得每次推送在两边各测一遍、各部署一遍。
|
||||||
|
# - PR:只跑测试。
|
||||||
|
# - 手动触发(Actions → CI/CD → Run workflow):测试通过后 SSH 到服务器执行 deploy/update.sh,服务器从 origin 拉代码、
|
||||||
|
# **自己编**、重启。开发机不在、又急着上线时用。部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
|
||||||
|
# 只在 github.com 上运行(Gitea 有了 .gitea/workflows 后不再读这个目录,if 再保一道)。
|
||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.4.4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
# 测试里学生程序在 bubblewrap 沙箱中运行;Ubuntu 24.04 默认禁止非特权用户命名空间,要先放开
|
||||||
|
- name: 准备沙箱
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q
|
||||||
|
sudo apt-get install -y -q bubblewrap
|
||||||
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 编译 C# 运行时
|
||||||
|
run: bun run build:runtime
|
||||||
|
|
||||||
|
# 文件之间同时跑,grading 里的测试也同时跑(describe.concurrent),见 .gitea/workflows/deploy.yml 同名一步
|
||||||
|
- name: 服务端测试(编译并运行示例、沙箱、编译检查)
|
||||||
|
working-directory: server
|
||||||
|
run: bun test --parallel --max-concurrency=8
|
||||||
|
|
||||||
|
- name: 网页端测试和类型检查
|
||||||
|
working-directory: web
|
||||||
|
run: |
|
||||||
|
bun test src
|
||||||
|
bunx vue-tsc --noEmit
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: test
|
||||||
|
if: github.server_url == 'https://github.com' && github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
# 服务器构建约几分钟;卡住时及时失败,不要一直占着
|
||||||
|
timeout-minutes: 20
|
||||||
|
# 连续推送时排队部署,不会两次部署同时进行
|
||||||
|
concurrency:
|
||||||
|
group: deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
# Secrets 还没配置时跳过部署(只跑测试),不算失败
|
||||||
|
- name: 检查部署配置
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$DEPLOY_HOST" ]; then
|
||||||
|
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "::notice::没有配置 DEPLOY_HOST 等 Secrets,跳过部署(配置方法见 deploy/README.md)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 配置 SSH
|
||||||
|
if: steps.check.outputs.configured == 'true'
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
cat > ~/.ssh/config <<EOF
|
||||||
|
Host deploy-target
|
||||||
|
HostName $DEPLOY_HOST
|
||||||
|
User $DEPLOY_USER
|
||||||
|
Port ${DEPLOY_PORT:-22}
|
||||||
|
IdentityFile ~/.ssh/deploy_key
|
||||||
|
IdentitiesOnly yes
|
||||||
|
# 不预先登记服务器公钥,第一次连接时自动接受
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
# 认证失败立即报错,不等输入密码
|
||||||
|
BatchMode yes
|
||||||
|
ConnectTimeout 30
|
||||||
|
ServerAliveInterval 30
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: 部署(在服务器上拉代码、构建、重启)
|
||||||
|
if: steps.check.outputs.configured == 'true'
|
||||||
|
run: ssh deploy-target "cd ${{ vars.DEPLOY_DIR || 'IOT_SIM' }} && deploy/update-bg.sh ${{ github.sha }}"
|
||||||
@@ -10,9 +10,23 @@ obj/
|
|||||||
|
|
||||||
# 教材资料(体积大、有版权,不进仓库)
|
# 教材资料(体积大、有版权,不进仓库)
|
||||||
物联网编程与应用配套/
|
物联网编程与应用配套/
|
||||||
|
# 导出 EXE 用的教材设备类库(真机版 dll),部署时放在服务器上挂进容器
|
||||||
|
/textbook-libs/
|
||||||
*.pdf
|
*.pdf
|
||||||
|
|
||||||
# 编辑器
|
# 编辑器
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# 部署配置(含访问口令)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# 本机开发的数据库
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# 服务器上 deploy/backup.sh 做的备份
|
||||||
|
/backups/
|
||||||
|
|
||||||
|
# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml)
|
||||||
|
/prebuilt/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# bun fmt 不格式化的文件
|
||||||
|
# 书上的摘录,保持原样
|
||||||
|
docs/教材摘要/
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
|
||||||
|
# 构建:docker compose build;部署说明见 deploy/README.md
|
||||||
|
#
|
||||||
|
# 产物(C# 运行时、网页、server.js)有两种来源,由构建参数 ARTIFACTS 选:
|
||||||
|
# build (默认)在镜像里编。只要有 docker 就能构建,但服务器只有 2 核,编一次约一分钟,期间学生程序会卡
|
||||||
|
# prebuilt 用 prebuilt/ 里现成的产物(开发机上的 gitea-runner 编好、测过后 rsync 过来,见 .gitea/workflows/deploy.yml),
|
||||||
|
# 镜像构建只剩几条 COPY。buildx 不会去跑用不到的 runtime-build / web-build 阶段
|
||||||
|
ARG ARTIFACTS=build
|
||||||
|
|
||||||
|
# ---- C# 运行时(编译服务 + 仿真库) ----
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY runtime/ runtime/
|
||||||
|
RUN dotnet build runtime/Runtime.slnx -c Release
|
||||||
|
|
||||||
|
# ---- 网页和服务端 ----
|
||||||
|
FROM oven/bun:1.4.4 AS web-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY shared/package.json shared/
|
||||||
|
COPY server/package.json server/
|
||||||
|
COPY web/package.json web/
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY shared/ shared/
|
||||||
|
COPY server/ server/
|
||||||
|
COPY web/ web/
|
||||||
|
RUN cd web && bun run build
|
||||||
|
# 服务端打包成单个文件,运行镜像里不需要 node_modules
|
||||||
|
RUN bun build server/src/index.ts --target=bun --outfile=server.js
|
||||||
|
|
||||||
|
# ---- 产物:在镜像里编的 ----
|
||||||
|
FROM scratch AS artifacts-build
|
||||||
|
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ /runtime/
|
||||||
|
COPY --from=web-build /src/web/dist/ /web/
|
||||||
|
COPY --from=web-build /src/server.js /server.js
|
||||||
|
|
||||||
|
# ---- 产物:现成的(目录结构和上面一样,名字里不带 dist / bin,免得被 .dockerignore 挡掉) ----
|
||||||
|
FROM scratch AS artifacts-prebuilt
|
||||||
|
COPY prebuilt/runtime/ /runtime/
|
||||||
|
COPY prebuilt/web/ /web/
|
||||||
|
COPY prebuilt/server.js /server.js
|
||||||
|
|
||||||
|
FROM artifacts-${ARTIFACTS} AS artifacts
|
||||||
|
|
||||||
|
# ---- 运行镜像 ----
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends bubblewrap tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=artifacts /runtime/ runtime/
|
||||||
|
COPY --from=artifacts /web/ web/dist/
|
||||||
|
COPY --from=artifacts /server.js ./
|
||||||
|
COPY samples/ samples/
|
||||||
|
ENV APP_ROOT=/app \
|
||||||
|
RUNNER_DLL=runtime/Sim.Runner.dll \
|
||||||
|
RUNS_DIR=/tmp/runs \
|
||||||
|
SIM_SANDBOX=bwrap \
|
||||||
|
TZ=Asia/Shanghai \
|
||||||
|
PORT=3000 \
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
|
# 数据目录(数据卷挂在这里)属于运行平台的用户,新建的数据卷会继承这个属主
|
||||||
|
RUN mkdir -p /data && chown "$APP_UID" /data
|
||||||
|
# 镜像自带的非 root 用户
|
||||||
|
USER $APP_UID
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["bun", "server.js"]
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
# 物联网编程仿真平台
|
# 物联网编程仿真平台
|
||||||
|
|
||||||
面向中职《物联网编程与应用(第 2 版)》的在线仿真平台:学生在浏览器里写 C# WinForms 程序,
|
面向中职《物联网编程与应用(第 2 版)》的在线仿真平台:学生在浏览器里写 C# WinForms 程序,
|
||||||
程序在服务器上运行,界面和虚拟实训台的效果在网页上实时显示。设计方案见 Claude Docs 中的《物联网 C# 虚拟仿真教学平台设计方案》。
|
程序在服务器上运行,界面和虚拟实训台的效果在网页上实时显示。设计方案见 [docs/设计方案.md](docs/设计方案.md)。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
| 目录 | 内容 |
|
| 目录 | 内容 |
|
||||||
|---|---|
|
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
||||||
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
||||||
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
||||||
| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、运行器(`Sim.Runner`) |
|
| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、仿冒 SqlClient(`Sim.Data`,T-SQL 转写成 SQLite)、运行器(`Sim.Runner`) |
|
||||||
| `samples/` | 示例工程(按教材任务整理) |
|
| `samples/` | 示例工程(按教材任务整理) |
|
||||||
| `docs/教材摘要/` | 教材逐任务摘要:控件属性表、扩展题原文、评价表 |
|
| `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) |
|
||||||
|
| `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) |
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
需要 Bun 和 .NET 10 SDK。
|
需要 Bun 和 .NET 10 SDK;装了 bubblewrap(`bwrap`)时,学生程序在本机也会放进沙箱运行,和服务器一致。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install # 安装前端和后端依赖
|
bun install # 安装前端和后端依赖
|
||||||
@@ -30,14 +31,22 @@ bun run dev:web # 启动前端(端口 5173),浏览器打开 ht
|
|||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
bun test # 端到端测试:编译并运行示例,模拟浏览器操作
|
bun test # 端到端测试:编译并运行示例,模拟浏览器操作
|
||||||
|
cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操作、插入事件处理方法
|
||||||
bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
||||||
|
BASE=http://localhost:3000 bun test/classroom.ts 45 # 全班模拟:45 个学生登录、运行、自测、提交,统计每个环节等多久
|
||||||
```
|
```
|
||||||
|
|
||||||
## 运行器的两种模式
|
## 部署
|
||||||
|
|
||||||
|
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到 origin(git.xuyue.cc)的 main 后,开发机上的 gitea-runner 跑完测试、编好产物传到服务器,服务器拉代码、用现成产物构建镜像、重启(`deploy/update.sh --prebuilt`);GitHub 上手动触发是备用路线,由服务器自己编。详见 [deploy/README.md](deploy/README.md)。
|
||||||
|
|
||||||
|
## 运行器的四种模式
|
||||||
|
|
||||||
`Sim.Runner` 通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
|
`Sim.Runner` 通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
|
||||||
|
|
||||||
- `compile-server`:常驻编译服务,全服务器共用一个。Roslyn 只加载一次,编译结果写回工程目录。
|
- `compile-server`:常驻编译服务,全服务器共用一个。Roslyn 只加载一次,编译结果写回工程目录。
|
||||||
|
- `language-server`:常驻语言服务,全服务器共用一个:代码补全、悬停提示、参数提示、边写边检查。
|
||||||
|
- `sql-server`:常驻 SQL 服务,全服务器共用一个:数据库页面的查询和对象资源管理器,评分前按实验的 `database.sql` 建库。学生的数据库在 `data/databases/<用户编号>/`,每个库一个 SQLite 文件。
|
||||||
- `run <工程目录>`:运行已编译的学生程序,每次运行一个进程,不加载编译器。
|
- `run <工程目录>`:运行已编译的学生程序,每次运行一个进程,不加载编译器。
|
||||||
|
|
||||||
## 当前进度:第 0 阶段(技术验证)已跑通
|
## 当前进度:第 0 阶段(技术验证)已跑通
|
||||||
@@ -47,7 +56,7 @@ bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
|||||||
本机实测(16 核、28 GB):
|
本机实测(16 核、28 GB):
|
||||||
|
|
||||||
| 指标 | 结果 |
|
| 指标 | 结果 |
|
||||||
|---|---|
|
| ------------------------ | ----------------------------------------------------------- |
|
||||||
| 编译(编译服务预热后) | 约 10–300 ms |
|
| 编译(编译服务预热后) | 约 10–300 ms |
|
||||||
| 启动到窗体显示 | 约 100 ms |
|
| 启动到窗体显示 | 约 100 ms |
|
||||||
| 单次操作往返 | 约 15 ms |
|
| 单次操作往返 | 约 15 ms |
|
||||||
@@ -59,10 +68,17 @@ bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
|||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
|
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
|
||||||
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),教材控件库的 Lamp、Alarm。
|
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox、PictureBox、DataGridView、DateTimePicker、TrackBar、Windows Media Player、Chart(图表),教材控件库的 Lamp、Alarm;Panel 停靠布局、背景图片、无边框窗体。
|
||||||
- 示例工程(按书上代码整理):项目 2 任务 1–3、项目 3 任务 1–2。网址带 `?sample=示例编号` 可直接打开指定示例。
|
- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3、项目 6 任务 2–3、项目 7 任务 1–3、项目 8 任务 1–5,共 23 个(项目 6 任务 1 在数据库页面上完成)。网址带 `?sample=示例编号` 可直接打开指定示例。
|
||||||
|
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
||||||
|
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||||
|
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||||
|
- 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`);导出 VS 工程:打包成教材那样的 VS 2012 工程(zip),先照机房环境(C# 5)检查(`runtime/Sim.Runner/ProjectExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。
|
||||||
|
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。
|
||||||
|
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
|
||||||
|
- 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
|
||||||
|
|
||||||
尚未完成:
|
尚未完成:
|
||||||
|
|
||||||
- 学生代码目前直接在本机进程里运行,**还没有沙箱**,只能在本机开发时使用;部署前要改为在 Docker 容器中运行。
|
- 一个班试用。
|
||||||
- 浏览器内的窗体设计器、代码编辑器(Monaco)、账号和班级、自动评测。
|
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
|
||||||
@@ -23,8 +23,12 @@
|
|||||||
"name": "@iot/web",
|
"name": "@iot/web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iot/shared": "workspace:*",
|
"@iot/shared": "workspace:*",
|
||||||
|
"chart.js": "^4.6.1",
|
||||||
|
"modern-screenshot": "^4.7.0",
|
||||||
|
"monaco-editor": "^0.57.0",
|
||||||
"naive-ui": "^2.41.0",
|
"naive-ui": "^2.41.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.0",
|
"@types/bun": "^1.2.0",
|
||||||
@@ -36,71 +40,71 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
"@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
|
"@babel/parser": ["@babel/parser@7.29.9", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.9.tgz", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
|
||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
"@babel/types": ["@babel/types@7.29.8", "https://registry.npmjs.com/@babel/types/-/types-7.29.8.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||||
|
|
||||||
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmjs.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
||||||
|
|
||||||
"@css-render/vue3-ssr": ["@css-render/vue3-ssr@0.15.14", "", { "peerDependencies": { "vue": "^3.0.11" } }, "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g=="],
|
"@css-render/vue3-ssr": ["@css-render/vue3-ssr@0.15.14", "https://registry.npmjs.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", { "peerDependencies": { "vue": "^3.0.11" } }, "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g=="],
|
||||||
|
|
||||||
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmjs.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "https://registry.npmjs.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "https://registry.npmjs.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "https://registry.npmjs.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "https://registry.npmjs.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "https://registry.npmjs.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "https://registry.npmjs.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "https://registry.npmjs.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "https://registry.npmjs.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "https://registry.npmjs.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "https://registry.npmjs.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "https://registry.npmjs.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "https://registry.npmjs.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "https://registry.npmjs.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "https://registry.npmjs.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||||
|
|
||||||
"@iot/server": ["@iot/server@workspace:server"],
|
"@iot/server": ["@iot/server@workspace:server"],
|
||||||
|
|
||||||
@@ -108,182 +112,200 @@
|
|||||||
|
|
||||||
"@iot/web": ["@iot/web@workspace:web"],
|
"@iot/web": ["@iot/web@workspace:web"],
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "https://registry.npmjs.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "https://registry.npmjs.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
||||||
|
|
||||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "https://registry.npmjs.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.12", "", { "os": "android", "cpu": "arm" }, "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.12", "", { "os": "android", "cpu": "arm64" }, "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.12", "https://registry.npmjs.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", { "os": "android", "cpu": "arm" }, "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw=="],
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.12.tgz", { "os": "android", "cpu": "arm64" }, "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg=="],
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw=="],
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.12.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ=="],
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.12.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw=="],
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ=="],
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.12.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w=="],
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.12.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.12", "", { "os": "linux", "cpu": "arm" }, "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA=="],
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.12.tgz", { "os": "linux", "cpu": "arm" }, "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A=="],
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.12.tgz", { "os": "linux", "cpu": "arm" }, "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA=="],
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ=="],
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.12.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q=="],
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ=="],
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg=="],
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A=="],
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.12.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A=="],
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A=="],
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.12", "", { "os": "linux", "cpu": "x64" }, "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q=="],
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.12.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.12", "", { "os": "linux", "cpu": "x64" }, "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw=="],
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "x64" }, "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q=="],
|
||||||
|
|
||||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg=="],
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.12.tgz", { "os": "linux", "cpu": "x64" }, "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw=="],
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.12", "", { "os": "none", "cpu": "arm64" }, "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw=="],
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.12.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w=="],
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw=="],
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.12.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.12", "", { "os": "win32", "cpu": "x64" }, "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg=="],
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.12.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.12", "", { "os": "win32", "cpu": "x64" }, "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw=="],
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
"@types/bun": ["@types/bun@1.4.3", "https://registry.npmjs.com/@types/bun/-/bun-1.4.3.tgz", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
|
||||||
|
|
||||||
"@types/lodash": ["@types/lodash@4.17.26", "", {}, "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA=="],
|
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmjs.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
"@types/lodash": ["@types/lodash@4.17.26", "https://registry.npmjs.com/@types/lodash/-/lodash-4.17.26.tgz", {}, "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
"@types/lodash-es": ["@types/lodash-es@4.17.12", "https://registry.npmjs.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||||
|
|
||||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||||
|
|
||||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmjs.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
||||||
|
|
||||||
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
"@volar/language-core": ["@volar/language-core@2.4.28", "https://registry.npmjs.com/@volar/language-core/-/language-core-2.4.28.tgz", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||||
|
|
||||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
"@volar/source-map": ["@volar/source-map@2.4.28", "https://registry.npmjs.com/@volar/source-map/-/source-map-2.4.28.tgz", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||||
|
|
||||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
|
"@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmjs.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||||
|
|
||||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
|
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
||||||
|
|
||||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "https://registry.npmjs.com/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
|
||||||
|
|
||||||
"@vue/language-core": ["@vue/language-core@3.3.13", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "https://registry.npmjs.com/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
|
||||||
|
|
||||||
"@vue/reactivity": ["@vue/reactivity@3.5.44", "", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
||||||
|
|
||||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "https://registry.npmjs.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||||
|
|
||||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
|
"@vue/language-core": ["@vue/language-core@3.3.13", "https://registry.npmjs.com/@vue/language-core/-/language-core-3.3.13.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
||||||
|
|
||||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
|
"@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
||||||
|
|
||||||
"@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
|
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "https://registry.npmjs.com/@vue/runtime-core/-/runtime-core-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
||||||
|
|
||||||
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "https://registry.npmjs.com/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
|
||||||
|
|
||||||
"async-validator": ["async-validator@4.2.5", "", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="],
|
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "https://registry.npmjs.com/@vue/server-renderer/-/server-renderer-3.5.44.tgz", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
|
"@vue/shared": ["@vue/shared@3.5.44", "https://registry.npmjs.com/@vue/shared/-/shared-3.5.44.tgz", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
|
||||||
|
|
||||||
"css-render": ["css-render@0.15.14", "", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
|
"alien-signals": ["alien-signals@3.2.1", "https://registry.npmjs.com/alien-signals/-/alien-signals-3.2.1.tgz", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"async-validator": ["async-validator@4.2.5", "https://registry.npmjs.com/async-validator/-/async-validator-4.2.5.tgz", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="],
|
||||||
|
|
||||||
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
|
"bun-types": ["bun-types@1.4.3", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.3.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
|
||||||
|
|
||||||
"date-fns-tz": ["date-fns-tz@3.2.0", "", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
|
"chart.js": ["chart.js@4.6.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g=="],
|
||||||
|
|
||||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
"css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
"csstype": ["csstype@3.2.3", "https://registry.npmjs.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"date-fns": ["date-fns@4.4.0", "https://registry.npmjs.com/date-fns/-/date-fns-4.4.0.tgz", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
|
||||||
|
|
||||||
"evtd": ["evtd@0.2.4", "", {}, "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw=="],
|
"date-fns-tz": ["date-fns-tz@3.2.0", "https://registry.npmjs.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"dompurify": ["dompurify@3.4.15", "https://registry.npmjs.com/dompurify/-/dompurify-3.4.15.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
"highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
|
"esbuild": ["esbuild@0.28.2", "https://registry.npmjs.com/esbuild/-/esbuild-0.28.2.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||||
|
|
||||||
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmjs.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
"evtd": ["evtd@0.2.4", "https://registry.npmjs.com/evtd/-/evtd-0.2.4.tgz", {}, "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw=="],
|
||||||
|
|
||||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
"fdir": ["fdir@6.5.0", "https://registry.npmjs.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"fsevents": ["fsevents@2.3.3", "https://registry.npmjs.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
"highlight.js": ["highlight.js@11.12.0", "https://registry.npmjs.com/highlight.js/-/highlight.js-11.12.0.tgz", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
|
||||||
|
|
||||||
"naive-ui": ["naive-ui@2.45.3", "", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="],
|
"hono": ["hono@4.13.13", "https://registry.npmjs.com/hono/-/hono-4.13.13.tgz", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
"lodash": ["lodash@4.18.1", "https://registry.npmjs.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||||
|
|
||||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmjs.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
"marked": ["marked@14.0.0", "https://registry.npmjs.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
|
"modern-screenshot": ["modern-screenshot@4.7.0", "https://registry.npmjs.com/modern-screenshot/-/modern-screenshot-4.7.0.tgz", {}, "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.64.12", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.12", "@rollup/rollup-android-arm64": "4.64.12", "@rollup/rollup-darwin-arm64": "4.64.12", "@rollup/rollup-darwin-x64": "4.64.12", "@rollup/rollup-freebsd-arm64": "4.64.12", "@rollup/rollup-freebsd-x64": "4.64.12", "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", "@rollup/rollup-linux-arm-musleabihf": "4.64.12", "@rollup/rollup-linux-arm64-gnu": "4.64.12", "@rollup/rollup-linux-arm64-musl": "4.64.12", "@rollup/rollup-linux-loong64-gnu": "4.64.12", "@rollup/rollup-linux-loong64-musl": "4.64.12", "@rollup/rollup-linux-ppc64-gnu": "4.64.12", "@rollup/rollup-linux-ppc64-musl": "4.64.12", "@rollup/rollup-linux-riscv64-gnu": "4.64.12", "@rollup/rollup-linux-riscv64-musl": "4.64.12", "@rollup/rollup-linux-s390x-gnu": "4.64.12", "@rollup/rollup-linux-x64-gnu": "4.64.12", "@rollup/rollup-linux-x64-musl": "4.64.12", "@rollup/rollup-openbsd-x64": "4.64.12", "@rollup/rollup-openharmony-arm64": "4.64.12", "@rollup/rollup-win32-arm64-msvc": "4.64.12", "@rollup/rollup-win32-ia32-msvc": "4.64.12", "@rollup/rollup-win32-x64-gnu": "4.64.12", "@rollup/rollup-win32-x64-msvc": "4.64.12", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ=="],
|
"monaco-editor": ["monaco-editor@0.57.0", "https://registry.npmjs.com/monaco-editor/-/monaco-editor-0.57.0.tgz", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
||||||
|
|
||||||
"seemly": ["seemly@0.3.10", "", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="],
|
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmjs.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
"naive-ui": ["naive-ui@2.45.3", "https://registry.npmjs.com/naive-ui/-/naive-ui-2.45.3.tgz", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
"nanoid": ["nanoid@3.3.20", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.20.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||||
|
|
||||||
"treemate": ["treemate@0.3.11", "", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmjs.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"picocolors": ["picocolors@1.1.1", "https://registry.npmjs.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
"picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
"vdirs": ["vdirs@0.1.8", "", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
|
"postcss": ["postcss@8.5.31", "https://registry.npmjs.com/postcss/-/postcss-8.5.31.tgz", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
|
||||||
|
|
||||||
"vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="],
|
"rollup": ["rollup@4.64.12", "https://registry.npmjs.com/rollup/-/rollup-4.64.12.tgz", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.12", "@rollup/rollup-android-arm64": "4.64.12", "@rollup/rollup-darwin-arm64": "4.64.12", "@rollup/rollup-darwin-x64": "4.64.12", "@rollup/rollup-freebsd-arm64": "4.64.12", "@rollup/rollup-freebsd-x64": "4.64.12", "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", "@rollup/rollup-linux-arm-musleabihf": "4.64.12", "@rollup/rollup-linux-arm64-gnu": "4.64.12", "@rollup/rollup-linux-arm64-musl": "4.64.12", "@rollup/rollup-linux-loong64-gnu": "4.64.12", "@rollup/rollup-linux-loong64-musl": "4.64.12", "@rollup/rollup-linux-ppc64-gnu": "4.64.12", "@rollup/rollup-linux-ppc64-musl": "4.64.12", "@rollup/rollup-linux-riscv64-gnu": "4.64.12", "@rollup/rollup-linux-riscv64-musl": "4.64.12", "@rollup/rollup-linux-s390x-gnu": "4.64.12", "@rollup/rollup-linux-x64-gnu": "4.64.12", "@rollup/rollup-linux-x64-musl": "4.64.12", "@rollup/rollup-openbsd-x64": "4.64.12", "@rollup/rollup-openharmony-arm64": "4.64.12", "@rollup/rollup-win32-arm64-msvc": "4.64.12", "@rollup/rollup-win32-ia32-msvc": "4.64.12", "@rollup/rollup-win32-x64-gnu": "4.64.12", "@rollup/rollup-win32-x64-msvc": "4.64.12", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ=="],
|
||||||
|
|
||||||
"vooks": ["vooks@0.2.12", "", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
"seemly": ["seemly@0.3.10", "https://registry.npmjs.com/seemly/-/seemly-0.3.10.tgz", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="],
|
||||||
|
|
||||||
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
"source-map-js": ["source-map-js@1.2.2", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.2.tgz", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||||
|
|
||||||
"vue": ["vue@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmjs.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
"vue-tsc": ["vue-tsc@3.3.13", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
"treemate": ["treemate@0.3.11", "https://registry.npmjs.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
||||||
|
|
||||||
"vueuc": ["vueuc@0.4.66", "", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
"typescript": ["typescript@5.9.3", "https://registry.npmjs.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
"undici-types": ["undici-types@8.12.3", "https://registry.npmjs.com/undici-types/-/undici-types-8.12.3.tgz", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
||||||
|
|
||||||
"css-render/csstype": ["csstype@3.0.11", "", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
|
"vdirs": ["vdirs@0.1.8", "https://registry.npmjs.com/vdirs/-/vdirs-0.1.8.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.3.7", "https://registry.npmjs.com/vite/-/vite-7.3.7.tgz", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="],
|
||||||
|
|
||||||
|
"vooks": ["vooks@0.2.12", "https://registry.npmjs.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.2.0", "https://registry.npmjs.com/vscode-uri/-/vscode-uri-3.2.0.tgz", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||||
|
|
||||||
|
"vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
||||||
|
|
||||||
|
"vue-router": ["vue-router@4.6.4", "https://registry.npmjs.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||||
|
|
||||||
|
"vue-tsc": ["vue-tsc@3.3.13", "https://registry.npmjs.com/vue-tsc/-/vue-tsc-3.3.13.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
||||||
|
|
||||||
|
"vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
||||||
|
|
||||||
|
"@types/node/undici-types": ["undici-types@8.9.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.9.0.tgz", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
|
|
||||||
|
"css-render/csstype": ["csstype@3.0.11", "https://registry.npmjs.com/csstype/-/csstype-3.0.11.tgz", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160)
|
||||||
|
[fmt]
|
||||||
|
printWidth = 160
|
||||||
|
semi = false
|
||||||
|
singleQuote = true
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 部署:先在 .env 里写好 TEACHER_PASSWORD(参考 .env.example),再 docker compose up -d
|
||||||
|
# HTTPS 由 Nginx Proxy Manager 负责,它在 npm_proxy 网络里转发到 iot-sim:80;步骤见 deploy/README.md
|
||||||
|
services:
|
||||||
|
# 启动前把数据卷交给平台用户(Docker 新建数据卷时属主是 root,平台用普通用户运行,写不进去)
|
||||||
|
init-data:
|
||||||
|
image: iot-sim:latest
|
||||||
|
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
|
||||||
|
build: &build
|
||||||
|
context: .
|
||||||
|
# build:在镜像里编(默认);prebuilt:用 prebuilt/ 里开发机编好的产物,见 Dockerfile 开头
|
||||||
|
args:
|
||||||
|
ARTIFACTS: ${ARTIFACTS:-build}
|
||||||
|
user: root
|
||||||
|
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
|
||||||
|
volumes:
|
||||||
|
- data:/data
|
||||||
|
restart: 'no'
|
||||||
|
networks: [default]
|
||||||
|
|
||||||
|
app:
|
||||||
|
image: iot-sim:latest
|
||||||
|
build: *build
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: &logging # 默认的 json-file 日志不限大小,会一直涨
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: '3'
|
||||||
|
depends_on:
|
||||||
|
init-data:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
# 老师账号(只有一个);学生账号由老师在网页上导入名单
|
||||||
|
TEACHER_USERNAME: ${TEACHER_USERNAME:-admin}
|
||||||
|
TEACHER_PASSWORD: ${TEACHER_PASSWORD:?请在 .env 里设置 TEACHER_PASSWORD}
|
||||||
|
DATA_DIR: /data
|
||||||
|
# AI 评阅(DeepSeek);不设置密钥就不做 AI 评阅
|
||||||
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||||
|
AI_MODEL: ${AI_MODEL:-deepseek-flash}
|
||||||
|
# 以下可在 .env 里调整,说明见 deploy/README.md
|
||||||
|
SIM_MAX_RUNS: ${SIM_MAX_RUNS:-150}
|
||||||
|
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
||||||
|
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
||||||
|
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
||||||
|
SIM_GRADE_CONCURRENCY: ${SIM_GRADE_CONCURRENCY:-16}
|
||||||
|
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)和数据卷可写
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=1g
|
||||||
|
# 数据库(账号、班级、学生作品),重新部署不会丢
|
||||||
|
volumes:
|
||||||
|
- data:/data
|
||||||
|
# 导出 EXE 用的教材设备类库(真机版 dll,有版权,不进仓库):放在服务器的 ~/IOT_SIM/textbook-libs/,见 deploy/README.md
|
||||||
|
- ./textbook-libs:/app/textbook-libs:ro
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
# bubblewrap 要创建用户命名空间、挂载新的 /proc,Docker 默认的 seccomp 规则和 /proc 遮罩会拦住它。
|
||||||
|
# 放开后,学生程序仍然在 bubblewrap 里:断网、看不到其他进程和文件、不是 root。
|
||||||
|
- seccomp=unconfined
|
||||||
|
- apparmor=unconfined
|
||||||
|
- systempaths=unconfined
|
||||||
|
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
||||||
|
mem_limit: ${APP_MEM_LIMIT:-4g}
|
||||||
|
pids_limit: 4096
|
||||||
|
expose: ['3000']
|
||||||
|
networks: [default]
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *logging
|
||||||
|
depends_on: [app]
|
||||||
|
expose:
|
||||||
|
- 80
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
npm_proxy:
|
||||||
|
# NPM 里转发到 http://iot-sim:80
|
||||||
|
aliases: [iot-sim]
|
||||||
|
volumes:
|
||||||
|
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
npm_proxy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# HTTPS 由 Nginx Proxy Manager 负责,Caddy 只在 80 端口收 HTTP,转发给应用(包括 WebSocket /ws)。
|
||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
servers {
|
||||||
|
# NPM 在 Docker 内网里,信任它带来的 X-Forwarded-For,取到学生的真实 IP。
|
||||||
|
# 如果 NPM 前面还有公网上的 CDN,把 CDN 的 IP 段加在 private_ranges 后面。
|
||||||
|
trusted_proxies static private_ranges
|
||||||
|
trusted_proxies_strict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:3000 {
|
||||||
|
header_up X-Real-IP {client_ip}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 部署
|
||||||
|
|
||||||
|
服务器上的结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
学生浏览器 ──HTTPS──▶ Nginx Proxy Manager(443) ──npm_proxy 网络──▶ caddy(iot-sim:80) ──▶ app(Bun + .NET + bubblewrap)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **app**:Bun 服务端、打包好的网页、C# 编译服务。学生程序每次运行都放进 bubblewrap 沙箱:断网,看不到服务器文件和其他进程,工程目录只读,限制内存(128 MB)、CPU 时间(120 秒)和运行时长(60 分钟)。
|
||||||
|
- **caddy**:只收 HTTP,转发给 app,压缩网页;从 NPM 转发来的 `X-Forwarded-For` 里取学生的真实 IP。
|
||||||
|
- 不映射宿主机端口,NPM 通过外部网络 `npm_proxy` 访问 `iot-sim:80`。
|
||||||
|
|
||||||
|
## 服务器要求
|
||||||
|
|
||||||
|
- Linux,Docker 和 Docker Compose v2,已有外部网络 `npm_proxy`(NPM 所在的网络)。
|
||||||
|
- 2 核 8G 足够两个班同时使用(实测 100 个程序同时运行约 1.1 GB)。
|
||||||
|
- 沙箱需要宿主机允许非特权用户命名空间。**Debian 12(本平台的服务器)默认允许**,不用设置,可用 `sysctl kernel.unprivileged_userns_clone` 确认输出为 1。
|
||||||
|
- **Ubuntu 23.10 及以上**默认禁止,沙箱会起不来,需要放开:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/60-userns.conf
|
||||||
|
sudo sysctl --system
|
||||||
|
```
|
||||||
|
|
||||||
|
其他发行版一般不需要。服务器启动时会自检沙箱,不通过会在日志里打印原因,并拒绝运行学生代码(不会在无隔离的情况下运行)。
|
||||||
|
|
||||||
|
## 第一次部署
|
||||||
|
|
||||||
|
服务器上的 `~/IOT_SIM` 是从 origin(git.xuyue.cc)克隆的仓库,镜像在服务器上构建;配置只放在 `.env`(不进 git)。
|
||||||
|
|
||||||
|
1. 服务器要能拉取 origin:仓库是私有的话,在服务器上生成一把密钥(`ssh-keygen -t ed25519`),把公钥加到 git.xuyue.cc 仓库的部署密钥(只读)里。
|
||||||
|
|
||||||
|
2. 克隆仓库,准备口令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.xuyue.cc:xuyue/IOT_SIM.git ~/IOT_SIM
|
||||||
|
cd ~/IOT_SIM
|
||||||
|
echo 'TEACHER_PASSWORD=换成你的密码' > .env
|
||||||
|
chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`~/IOT_SIM` 已经存在(比如以前用脚本上传过文件)时,就地变成仓库,已有的 `.env` 会保留:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/IOT_SIM
|
||||||
|
git init -q && git remote add origin git@git.xuyue.cc:xuyue/IOT_SIM.git
|
||||||
|
git fetch origin && git checkout -f -B main origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 构建并启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
拉取代码、构建镜像(第一次要下载 .NET SDK、Bun 等镜像和依赖,之后有缓存)、启动,最后应看到"部署成功"。
|
||||||
|
|
||||||
|
4. 在 Nginx Proxy Manager 里添加 Proxy Host:
|
||||||
|
- Domain Names:已备案的域名
|
||||||
|
- Scheme:`http`,Forward Hostname / IP:`iot-sim`,Forward Port:`80`
|
||||||
|
- **打开 Websockets Support**(运行程序靠 WebSocket,不打开会一直连不上)
|
||||||
|
- Advanced 里加一行 `client_max_body_size 10m;`(NPM 默认只允许 1 MB,带界面截图的作业提交可能超过)
|
||||||
|
- SSL:申请证书,打开 Force SSL
|
||||||
|
|
||||||
|
5. 浏览器打开域名,用老师账号登录,在"班级管理"里建班、导入学生名单。
|
||||||
|
|
||||||
|
数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。
|
||||||
|
|
||||||
|
## 自动部署(push origin 就部署)
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml`:push 到 origin(git.xuyue.cc)的 main 时,**开发机上的 gitea-runner** 编 C# 运行时、网页和 `server.js`,跑服务端测试(含沙箱)、网页端测试和类型检查(有一项不过就不部署),把产物 rsync 到服务器的 `~/IOT_SIM/prebuilt/`,再在服务器上执行 `deploy/update.sh <提交号> --prebuilt`:拉代码、用现成产物构建镜像(只剩几条 COPY)、重启,等到"编译服务已就绪"才算成功。push 后一两分钟上线,进度在 git.xuyue.cc/xuyue/IOT_SIM/actions。
|
||||||
|
|
||||||
|
- `update.sh` 由 `deploy/update-bg.sh` 在服务器上**后台**跑,SSH 断了(开发机切代理、换网络)它照样跑完,runner 重连后接着看输出、拿结果。以前它跑在 SSH 会话里,断线时正好在 `docker compose up` 中间,旧容器删了、新容器没启动,网站就停了。服务器上的部署输出在 `/var/tmp/iot_sim-update.log`。
|
||||||
|
- **重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
|
||||||
|
- **push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;排队超过 24 小时会被作废,要在 Actions 页面重跑。
|
||||||
|
- 在 Gitea 的 Actions 页面点 Run workflow 可以手动重新部署(比如改了服务器上的 `.env` 之后)。
|
||||||
|
- `prebuilt/COMMIT` 记着产物是哪个提交编的,和要部署的提交对不上时 `update.sh` 直接失败,不会把旧产物配新代码。
|
||||||
|
|
||||||
|
为什么不在服务器或 GitHub 上编:服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满;GitHub 上测试约 5 分钟,push 后 6 分钟多才上线。开发机 16 核,服务端测试同时跑(`bun test --parallel`,评分测试标了 `describe.concurrent`)约半分钟。
|
||||||
|
|
||||||
|
**服务器自己编的能力还在**:`deploy/update.sh` 不带 `--prebuilt` 就是在镜像里编(`Dockerfile` 的构建参数 `ARTIFACTS` 默认 `build`),只要有 docker 就能部署,不依赖开发机。
|
||||||
|
|
||||||
|
### 部署 runner
|
||||||
|
|
||||||
|
和 OJ2 共用开发机上的同一个 runner(host 模式,注册在 git.xuyue.cc 的 `xuyue` 用户下,标签 `oj2-deploy`、`iot-deploy`,一次只跑一个任务,两边的部署排队)。装法、配置文件位置、换机器重新注册见 OJ2 仓库 `docs/deploy.md` 的"部署 runner"。
|
||||||
|
|
||||||
|
host 模式直接用开发机自己的东西,所以**不需要配任何 secret**:
|
||||||
|
|
||||||
|
- PATH 里的 bun、dotnet 10 SDK、bubblewrap(测试里学生程序在沙箱中运行)、rsync;
|
||||||
|
- `~/.ssh/config` 里的 `debian`(root@服务器,服务器目录 `/root/IOT_SIM`);
|
||||||
|
- 能 `git fetch git@git.xuyue.cc:xuyue/IOT_SIM.git`(都不能要密码)。
|
||||||
|
|
||||||
|
开发机的 bun 版本和 `Dockerfile` 里镜像的 bun 不一样时,工作流会给出警告(`server.js` 是开发机打包、镜像里的 bun 运行的)。
|
||||||
|
|
||||||
|
### 备用:GitHub 手动触发
|
||||||
|
|
||||||
|
`.github/workflows/ci.yml` 只在 PR 时跑测试,push 不触发(不然每次推送两边各部署一遍)。开发机不在、又急着上线时,在 github.com 的 Actions → CI/CD → Run workflow:测试通过后 SSH 到服务器执行 `deploy/update-bg.sh <提交号>`(后台跑 `update.sh`),**服务器自己编**。这个提交要已经推到 origin(服务器从 origin 拉代码,没到时脚本会等最多约 100 秒)。
|
||||||
|
|
||||||
|
一次性配置(在本机执行,需要已登录的 `gh`):
|
||||||
|
|
||||||
|
1. 服务器上的部署用户要能运行 docker(在 docker 组里),并按上面"第一次部署"准备好 `~/IOT_SIM`。
|
||||||
|
2. 生成部署专用的 SSH 密钥(GitHub Actions 用它登录服务器),公钥放到服务器,私钥和服务器信息存进仓库 Secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -N '' -C iot-sim-deploy -f /tmp/iot_deploy
|
||||||
|
ssh-copy-id -i /tmp/iot_deploy.pub 用户@服务器
|
||||||
|
gh secret set DEPLOY_SSH_KEY -R yuetsh/IOT_SIM < /tmp/iot_deploy
|
||||||
|
gh secret set DEPLOY_HOST -R yuetsh/IOT_SIM -b 服务器地址
|
||||||
|
gh secret set DEPLOY_USER -R yuetsh/IOT_SIM -b 用户名
|
||||||
|
rm /tmp/iot_deploy /tmp/iot_deploy.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH 端口不是 22 时,再设置 `DEPLOY_PORT`;服务器目录不是 `~/IOT_SIM` 时,设置变量 `gh variable set DEPLOY_DIR -R yuetsh/IOT_SIM -b 目录`。
|
||||||
|
|
||||||
|
## 日常操作
|
||||||
|
|
||||||
|
在服务器的 `~/IOT_SIM` 目录下:
|
||||||
|
|
||||||
|
| 做什么 | 命令 |
|
||||||
|
| ------------ | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 更新到新版本 | `git push origin main`(开发机测试、编好后自动部署),或在服务器上执行 `deploy/update.sh`(服务器自己编) |
|
||||||
|
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
||||||
|
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
||||||
|
| 看日志 | `docker compose logs -f app` |
|
||||||
|
| 看资源占用 | `docker stats` |
|
||||||
|
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
|
||||||
|
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
||||||
|
|
||||||
|
## 导出 EXE、VS 工程用的教材类库
|
||||||
|
|
||||||
|
实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台;"导出 VS 工程"把这些 dll 放在工程的 lib 文件夹里。这些 dll 是教材配套资料,有版权,不进仓库(GitHub 上的仓库是公开的),要单独拷到服务器的 `~/IOT_SIM/textbook-libs/`(compose 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。
|
||||||
|
|
||||||
|
放进同一个目录(不分子目录)的文件,都在教材配套资料里:
|
||||||
|
|
||||||
|
| 文件 | 在配套资料里的位置 |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| `DeviceCtrlLibrary.dll` | `公共库/设备控制类库/设备控制/`(**不是** `仿真/`) |
|
||||||
|
| `ComLibrary.dll`、`ICS.Acquisition.dll`、`ICS.Common.dll`、`ICS.Models.dll`、`IPCameraDll.dll`、`LEDLibrary.dll`、`MyCamer.dll` | `公共库/设备控制类库/设备控制/API/` |
|
||||||
|
| `WinFormsDeviceControlLibrary.dll` | `公共库/设备控件库/` |
|
||||||
|
| `AxInterop.WMPLib.dll`、`Interop.WMPLib.dll`(媒体播放器,项目 7 任务 2) | `项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/` |
|
||||||
|
|
||||||
|
在本机(配套资料解压在 `~/下载/物联网编程与应用配套/`)执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B=~/下载/物联网编程与应用配套/物联网编程与应用
|
||||||
|
ssh 服务器 mkdir -p IOT_SIM/textbook-libs
|
||||||
|
scp "$B"/公共库/设备控制类库/设备控制/DeviceCtrlLibrary.dll "$B"/公共库/设备控制类库/设备控制/API/*.dll \
|
||||||
|
"$B"/公共库/设备控件库/WinFormsDeviceControlLibrary.dll \
|
||||||
|
"$B"/项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/*Interop.WMPLib.dll 服务器:IOT_SIM/textbook-libs/
|
||||||
|
```
|
||||||
|
|
||||||
|
容器里的平台用普通用户运行,目录和文件要让其他用户能读(用 root 拷的文件常常是 `-rw-------`,导出时会提示"权限不够"):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 755 ~/IOT_SIM/textbook-libs
|
||||||
|
chmod 644 ~/IOT_SIM/textbook-libs/*.dll
|
||||||
|
```
|
||||||
|
|
||||||
|
拷好后不用重启,下一次导出就能用。本机开发时同样放在仓库根目录的 `textbook-libs/`(已在 .gitignore 里),也可以用环境变量 `SIM_TEXTBOOK_LIBS` 指到别处。
|
||||||
|
|
||||||
|
## 备份与恢复
|
||||||
|
|
||||||
|
平台不自动备份,在服务器的 `~/IOT_SIM` 下手动执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/backup.sh # 备份到 backups/<日期时间>/,平台运行中也可以,学生不受影响
|
||||||
|
deploy/restore.sh backups/20261025-153000 # 用某份备份替换现在的全部数据
|
||||||
|
```
|
||||||
|
|
||||||
|
- 备份包括账号、班级、学生作品、作业、提交、评阅(`iot-sim.db`)和每个学生自己的数据库(`databases/`)。一个班一学期的数据估计两三百 MB,主要是提交时的界面截图。
|
||||||
|
- 备份放在宿主机的 `~/IOT_SIM/backups/` 里,不在 Docker 数据卷里,误删数据卷也还在;**要防服务器硬盘坏,把这个目录拷到别的机器上**。
|
||||||
|
- 建议在部署大的新版本前、学期中间、期末导出成绩后各备份一次。
|
||||||
|
- 恢复会先自动备份现在的数据(恢复错了还能换回来),然后停止平台约十几秒、替换数据、重新启动;正在运行的学生程序会结束,尽量不在上课时恢复。
|
||||||
|
|
||||||
|
## 可调参数
|
||||||
|
|
||||||
|
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
||||||
|
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
||||||
|
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
|
||||||
|
| `AI_MODEL` | `deepseek-flash` | AI 评阅用的模型(需要支持图片输入) |
|
||||||
|
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
|
||||||
|
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
|
||||||
|
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
||||||
|
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
|
||||||
|
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
|
||||||
|
| `SIM_GRADE_CONCURRENCY` | `16` | 全站同时跑几个评分检查项(提交评分和"自己检查"共用)。2 核实测:45 人同时自测项目 8 任务 3,最后一人约 1.5 分钟出结果;调到 24 以上 CPU 不够,定时器来不及触发,正确的程序会被误判 |
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:备份平台的全部数据(账号、班级、学生作品、作业、提交、评阅,以及每个学生自己的数据库)
|
||||||
|
# 到 backups/<日期时间>/。备份在宿主机上,不在 Docker 数据卷里,数据卷被删也还在;要防硬盘坏,把它拷到别的机器上。
|
||||||
|
# 平台运行中也可以备份,学生不受影响。恢复见 deploy/restore.sh。
|
||||||
|
# 用法:deploy/backup.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mkdir -p backups
|
||||||
|
name=$(date +%Y%m%d-%H%M%S)
|
||||||
|
# 同一秒里做了两次(如 restore.sh 恢复前先备份)时,等到下一秒,不和已有的备份重名
|
||||||
|
while [[ -e backups/$name ]]; do
|
||||||
|
sleep 1
|
||||||
|
name=$(date +%Y%m%d-%H%M%S)
|
||||||
|
done
|
||||||
|
inside=/data/.backup-$name
|
||||||
|
# 在容器里导出(SQLite 一致读取,不会备份出写了一半的库),再拷到宿主机
|
||||||
|
docker compose exec -T app bun server.js backup "$inside" >/dev/null
|
||||||
|
docker compose cp "app:$inside" "backups/$name" 2>/dev/null
|
||||||
|
docker compose exec -T app rm -rf "$inside"
|
||||||
|
echo "已备份到 $(pwd)/backups/$name($(du -sh "backups/$name" | cut -f1))"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:用 deploy/backup.sh 做的备份替换平台现在的全部数据。
|
||||||
|
# 替换前先自动备份现在的数据(恢复错了还能换回来);恢复时平台停止约十几秒,正在运行的学生程序会结束。
|
||||||
|
# 用法:deploy/restore.sh backups/<日期时间>
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
dir=${1:?用法:deploy/restore.sh backups/<日期时间>}
|
||||||
|
if [[ ! -f $dir/iot-sim.db ]]; then
|
||||||
|
echo "$dir 不是备份目录(里面应该有 iot-sim.db)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
read -r -p "用 $dir 替换平台现在的全部数据?现在的数据会先备份。[y/N] " answer
|
||||||
|
[[ $answer == y || $answer == Y ]] || exit 1
|
||||||
|
|
||||||
|
echo "== 备份现在的数据"
|
||||||
|
deploy/backup.sh
|
||||||
|
echo "== 停止平台、替换数据"
|
||||||
|
docker compose stop app
|
||||||
|
# 旧库的 -wal、-shm 文件一定要删掉,否则会被当成新库的未写完的日志,把恢复的库弄坏
|
||||||
|
docker compose run --rm --no-deps -T -v "$(realpath "$dir"):/restore:ro" app \
|
||||||
|
sh -c 'rm -rf /data/iot-sim.db /data/iot-sim.db-wal /data/iot-sim.db-shm /data/databases && cp -r /restore/. /data/'
|
||||||
|
echo "== 启动平台"
|
||||||
|
docker compose up -d app
|
||||||
|
echo "已恢复到 $dir"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:后台跑 deploy/update.sh,前台跟着输出,退出码和 update.sh 一样。
|
||||||
|
# 用法:deploy/update-bg.sh <提交号> [--prebuilt] 开始部署这个提交并跟着看
|
||||||
|
# deploy/update-bg.sh --follow <提交号> 接着看这个提交的部署(SSH 断线重连后用);
|
||||||
|
# 这个提交的部署还没开始过时退出码 75
|
||||||
|
#
|
||||||
|
# 为什么要后台跑:以前 update.sh 直接跑在 SSH 会话里,SSH 一断(开发机网络切换、代理重启)就跟着被杀。
|
||||||
|
# 2026-11-03 断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。
|
||||||
|
# 现在 update.sh 在新会话里跑(setsid),SSH 断了它照样跑完,开发机重连后用 --follow 拿结果。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 包在函数里:update.sh 会 git reset,运行中替换本文件,bash 先读完函数再执行,不受影响
|
||||||
|
main() {
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
local state=/var/tmp/iot_sim-update
|
||||||
|
# state.sha:这次部署的提交 state.pid:后台进程 state.log:输出 state.exit:update.sh 的退出码(跑完才有)
|
||||||
|
|
||||||
|
if [[ ${1:-} == --follow ]]; then
|
||||||
|
local sha=${2:?用法:deploy/update-bg.sh --follow <提交号>}
|
||||||
|
if [[ $(cat $state.sha 2>/dev/null || true) != "$sha" ]]; then
|
||||||
|
echo "服务器上没有 $sha 的部署记录(还没开始)"
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
follow
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sha=${1:?用法:deploy/update-bg.sh <提交号> [--prebuilt]}
|
||||||
|
|
||||||
|
# 上一次还在跑(比如它的 SSH 断了、又推了新的)就等它跑完,两次 compose up 不能同时跑
|
||||||
|
local old
|
||||||
|
old=$(cat $state.pid 2>/dev/null || true)
|
||||||
|
if [[ -n $old ]] && kill -0 "$old" 2>/dev/null; then
|
||||||
|
echo "== 上一次部署($(cat $state.sha 2>/dev/null))还在跑,等它结束"
|
||||||
|
while kill -0 "$old" 2>/dev/null; do sleep 2; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f $state.pid $state.exit
|
||||||
|
echo "$sha" >$state.sha
|
||||||
|
# 后台进程自己写 pid($$),保证记下的就是会写退出码的那个进程
|
||||||
|
setsid bash -c 'echo $$ >"$0.pid"; deploy/update.sh "$@"; echo $? >"$0.exit"' \
|
||||||
|
"$state" "$@" >$state.log 2>&1 </dev/null &
|
||||||
|
for _ in $(seq 50); do [[ -s $state.pid ]] && break; sleep 0.1; done
|
||||||
|
follow
|
||||||
|
}
|
||||||
|
|
||||||
|
follow() {
|
||||||
|
local pid code
|
||||||
|
pid=$(cat $state.pid 2>/dev/null || true)
|
||||||
|
if [[ -n $pid ]]; then
|
||||||
|
# update 进程结束后 tail 自己退出
|
||||||
|
tail -n +1 -f --pid="$pid" $state.log
|
||||||
|
else
|
||||||
|
cat $state.log 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
code=$(cat $state.exit 2>/dev/null || true)
|
||||||
|
if [[ -z $code ]]; then
|
||||||
|
echo "== 部署进程意外结束,没有留下退出码,看看 docker compose ps"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在服务器上执行:拉取代码、构建镜像、重启,等到编译服务就绪才算成功。
|
||||||
|
# 用法:deploy/update.sh [提交号,默认 origin/main] [--prebuilt]
|
||||||
|
#
|
||||||
|
# 不带 --prebuilt:服务器自己编(只要有 docker 就能跑,不依赖开发机;2 核编一次约一分钟,期间学生程序会卡)。
|
||||||
|
# GitHub 上手动触发的备用部署、登录服务器手动部署都走这条。
|
||||||
|
# --prebuilt: 用 prebuilt/ 里现成的产物,镜像构建只剩几条 COPY。push origin 后开发机上的 gitea-runner
|
||||||
|
# 编好、测过、rsync 过来再调用(.gitea/workflows/deploy.yml)。prebuilt/COMMIT 必须就是要部署的提交,
|
||||||
|
# 对不上说明产物不是这次编的,直接失败,不会把旧产物配新代码。
|
||||||
|
#
|
||||||
|
# 第一次部署前,按 deploy/README.md 在服务器上克隆仓库、准备好 .env
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 整个脚本包在函数里:git reset 会在运行中替换本文件,bash 先读完函数再执行,不受影响
|
||||||
|
main() {
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
local ref=origin/main prebuilt=0 arg
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--prebuilt) prebuilt=1 ;;
|
||||||
|
-*) echo "未知参数:$arg(可用:--prebuilt)" >&2; exit 2 ;;
|
||||||
|
*) ref=$arg ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== 拉取代码"
|
||||||
|
# 代码从 origin(git.xuyue.cc)拉取。GitHub 上手动触发时,这个提交可能还没推到 origin,多等一会儿
|
||||||
|
for attempt in $(seq 10); do
|
||||||
|
git fetch --quiet origin
|
||||||
|
if git rev-parse --quiet --verify "$ref^{commit}" >/dev/null; then break; fi
|
||||||
|
if [[ $attempt == 10 ]]; then
|
||||||
|
echo "origin 上没有 $ref:先 git push origin main,再重新运行部署"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "origin 上还没有 $ref,10 秒后重试"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
# 服务器上不改代码,配置都在 .env(不进 git,不受影响)
|
||||||
|
git checkout --quiet -B main
|
||||||
|
git reset --quiet --hard "$ref"
|
||||||
|
git log --oneline -1
|
||||||
|
|
||||||
|
if [[ $prebuilt == 1 ]]; then
|
||||||
|
local head built
|
||||||
|
head=$(git rev-parse HEAD)
|
||||||
|
built=$(cat prebuilt/COMMIT 2>/dev/null || true)
|
||||||
|
if [[ $built != "$head" ]]; then
|
||||||
|
echo "prebuilt/ 里的产物是 ${built:-(没有)} 编的,要部署的是 $head。"
|
||||||
|
echo '重新跑一次开发机上的部署,或者去掉 --prebuilt 让服务器自己编'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for f in prebuilt/runtime/Sim.Runner.dll prebuilt/web/index.html prebuilt/server.js; do
|
||||||
|
[[ -e $f ]] || { echo "prebuilt/ 不完整,缺 $f"; exit 1; }
|
||||||
|
done
|
||||||
|
export ARTIFACTS=prebuilt
|
||||||
|
echo "== 用开发机编好的产物($(du -sh prebuilt | cut -f1))"
|
||||||
|
else
|
||||||
|
export ARTIFACTS=build
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== 构建镜像并重启"
|
||||||
|
# caddy 用的是现成镜像,up 不会去拉新版,不拉的话永远是第一次部署时那份
|
||||||
|
docker compose pull --quiet caddy
|
||||||
|
docker compose up -d --build --remove-orphans
|
||||||
|
docker image prune -f >/dev/null
|
||||||
|
|
||||||
|
# 等编译服务就绪;沙箱自检失败时部署失败
|
||||||
|
for _ in $(seq 60); do
|
||||||
|
logs=$(docker compose logs app 2>&1)
|
||||||
|
if grep -q '沙箱(bubblewrap)无法启动' <<<"$logs"; then
|
||||||
|
echo "$logs" | tail -5
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'C# 编译服务已就绪' <<<"$logs"; then
|
||||||
|
echo '== 部署成功'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo '== 120 秒内没有启动完成'
|
||||||
|
docker compose logs --tail 20 app
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
# 物联网 C# 虚拟仿真教学平台设计方案
|
||||||
|
|
||||||
|
> 本文件是设计方案的正式版本,随代码一起维护。最初在 Claude Docs 中讨论定稿(2026-10-20),2026-10-22 迁入仓库,并按第 0、1 阶段的实际实现更新。
|
||||||
|
|
||||||
|
## 项目定位与教学目标
|
||||||
|
|
||||||
|
这是一门面向中职学生的 **C# WinForms 界面与事件编程课**的在线仿真平台,物联网场景是让学生程序"看得见效果"的舞台。
|
||||||
|
|
||||||
|
- **学什么**:窗体、控件、属性、事件、Timer、条件判断、方法与类等界面编程知识。核心是"事件驱动"的思维:程序等待事情发生(点按钮、定时器到点、传感器报警),再做出反应。
|
||||||
|
- **教材**:《物联网编程与应用(第 2 版)》,以智慧校园为主线的 8 个项目。平台兼容教材的设备类库,教材代码原样可运行。
|
||||||
|
- **不学什么**:通信协议、报文、CRC,以及 ADAM-4150 / 4017+ 等硬件模块的底层细节。
|
||||||
|
- **效果**:学生写的程序能控制一个生动的智慧校园场景,比如 LED 屏显示文字、路灯亮起、有人闯入报警、烟雾告警,并能实时看到反馈。
|
||||||
|
- **使用方式**:纯网页,学生不安装任何软件;只给本校使用,同时在线约一两个班。
|
||||||
|
|
||||||
|
## 已确定的关键决策
|
||||||
|
|
||||||
|
| 事项 | 决定 | 理由 |
|
||||||
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| 学生编程语言 | C# | 课程要求 |
|
||||||
|
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
||||||
|
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
||||||
|
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
|
||||||
|
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
|
||||||
|
| 账号 | 老师只有一个账号(配置文件设定);学生账号由老师上传 CSV 名单导入,密码由老师发放(可留空自动生成 6 位数字),学生不能改,老师能查看和重置 | 中职学生容易忘密码,老师能直接告诉学生 |
|
||||||
|
| 实验起点 | 布置作业时老师选:空白工程(从界面做起)或界面已做好(只写代码) | 不同课时侧重点不同 |
|
||||||
|
| 评分反馈 | 提交后立即显示每个功能项是否通过和原因,截止前可以改了再交 | 边做边学 |
|
||||||
|
| AI 评阅 | DeepSeek Flash 给老师评分项(界面、注释、命名)出建议分和评语,加上自动评分就是建议的最终成绩;老师采纳(或修改后保存)才生效,学生在老师确认前看不到 | 减轻老师批改负担,成绩仍由老师负责;服务器在国内,用国内可合规使用的模型 |
|
||||||
|
| 设备写法 | 兼容教材类库(DeviceCtrlHelper、Lamp / LED / Alarm 等控件) | 教材代码原样运行,老师不用改教案 |
|
||||||
|
| 场景 | 智慧校园;先做虚拟实训台(照实训台布局图),校园街景后加 | 和机房实物一一对应 |
|
||||||
|
| 数据库(项目 6、8) | 用 SQLite 模拟 SQL Server(仿冒 SqlClient) | 不增加服务器负担 |
|
||||||
|
| 学生是否学协议 | 不学 | 协议只是仿真内部实现 |
|
||||||
|
| 使用形式 | 纯网页,免安装 | 学生在机房和家里都能用 |
|
||||||
|
| 学生代码在哪运行 | 服务器(沙箱隔离) | 只供本校一两个班同时使用;原生 .NET 多线程行为真实;浏览器轻量 |
|
||||||
|
| 前端 | Vue 3 + Naive UI + Chart.js | 开发者熟悉 TypeScript 生态 |
|
||||||
|
| 后端 | Bun + SQLite | 一人开发,单机足够 |
|
||||||
|
| 部署 | 公网云服务器(2 核 8G,已备案);HTTPS 由服务器上已有的 Nginx Proxy Manager 负责 | 本平台只在 Docker 网络里暴露 HTTP |
|
||||||
|
| 开发人力 | 一人 | 范围和路线图按此控制 |
|
||||||
|
|
||||||
|
## 学生使用体验
|
||||||
|
|
||||||
|
页面分为编辑和运行两种模式。编辑模式照着 Visual Studio 来做,学生以后换到真的 VS 上操作一致。
|
||||||
|
|
||||||
|
**编辑模式**
|
||||||
|
|
||||||
|
- 左侧工具箱:常用控件(Button、Label、TextBox、CheckBox、ComboBox、Timer 等),以及"物联网"分组(教材的 Lamp、LED、Alarm、ShowMsg 等设备控件)。
|
||||||
|
- 中间「设计」和「代码」两个标签页:设计页拖放控件,窗体下方有组件栏(存放 Timer 和设备组件);代码页用 Monaco 编辑 `Form1.cs`。
|
||||||
|
- 右侧属性窗口和事件窗口:双击控件或事件,自动生成处理方法并跳到代码页。
|
||||||
|
- `Form1.Designer.cs` 由设计器自动生成,可以打开查看。
|
||||||
|
|
||||||
|
**运行模式**
|
||||||
|
|
||||||
|
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
|
||||||
|
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
|
||||||
|
- 实训台的接线只看、不能改:鼠标移到设备上,标题栏写出整条线路(如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3"),接的通道和串口高亮;学生自己接线属于"物联网设备安装与调试"课,见路线图第 4 阶段。
|
||||||
|
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
|
||||||
|
|
||||||
|
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
|
||||||
|
|
||||||
|
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。
|
||||||
|
|
||||||
|
**导入 VS 工程、导出 EXE 和 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案。学生作品能导出成一个 exe(实验页工具栏"导出 EXE"),在机房电脑上双击运行,控制真的实训台:服务器照教材工程(.NET Framework 4.5 窗体应用、真机版设备类库)编译学生的原始代码,教材 dll 嵌在 exe 里,不用另外拷文件;也能导出成教材那样的 VS 2012 工程(zip),在机房的 VS 里接着改。
|
||||||
|
|
||||||
|
## 设备类库设计(兼容教材)
|
||||||
|
|
||||||
|
平台提供与教材同名、同签名的仿真类库(API 已从配套 dll 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
|
||||||
|
|
||||||
|
| 对象 | 主要成员 | 平台仿真行为 |
|
||||||
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
|
||||||
|
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
|
||||||
|
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
|
||||||
|
| `zigBeeHelper` | `Open(串口)`、`OnOff(继电器, 开关)`、`Close()` | 控制 ZigBee 继电器;节点 1 接实训台照明灯 1(教材项目 3 的路灯) |
|
||||||
|
| `ipCameraHelper`(未实现) | `Open(IP, 用户, 密码)`、`Start` / `Stop`、`PanUp` / `PanDown` / `PanLeft` / `PanRight`、`ImageRead` 事件 | 在后台线程定时推送画面;云台转动即在全景图上移动取景 |
|
||||||
|
| 控件 `Lamp` / `Alarm` / `LED` / `ShowMsg` | `OnOff(bool)`、`setLedText(文本)`、`AddText(文本)` | 窗体上的图示控件(自绘图片,尺寸与教材控件一致);LED 文字滚动。ShowMsg 尚未实现 |
|
||||||
|
|
||||||
|
教材代码示例(项目 2 任务 1,向 LED 屏发送信息):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void btn_Open_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void btn_Send_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
string res = DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text);
|
||||||
|
if (res.IndexOf("Error") >= 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show(res);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
led.setLedText(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容细节:
|
||||||
|
|
||||||
|
- 数值一律返回整数字符串(项目 7 用 `Convert.ToInt16` 转换)。
|
||||||
|
- **串口要选对**(按实训台接线):接线按教材布局图,ADAM-4017+、ADAM-4150 各经一个 485/232 转换器接串口服务器,即 COM2 4017+、COM3 4150、COM4 ZigBee 协调器、COM5 LED 屏(`shared/protocol.ts` 的 `DEFAULT_WIRING`)。实验可以在 `meta.json` 的 `wiring` 里改:项目 7 的书上代码在一个串口上打开两个模块(书上截图是用仿真版 dll 截的,选哪个口都行),这三个实验把 4150 并到 COM2 的 485 总线上(两个模块地址不同,真机上也能这样接)。
|
||||||
|
- 在没接这台设备的串口上打开它,返回值照真机版 dll(已反编译核对):`Open` 不报错;4017 的 `SetData()` 全是 null、4150 的全是 false;4150 的 `OnOff` 只管发命令、不等回应,返回 true 但继电器不动作;ZigBee 的 `OnOff` 收不到回应,返回 false;LED 的 `SendLedString` 原样返回文字但屏上不显示。真机上这只是"没反应",平台另外在实训台最上方用红字提示"程序在 COM2 上打开 ADAM-4150,它接在 COM3",评分不通过时也附上这句。
|
||||||
|
- 串口没打开(或已经 `Close`)时程序还在读数据、控制设备:返回值照教材仿真版 dll(4150 的 `SetData()` 返回各项为 false 的对象,不是真机版的 null;书上代码是照仿真版写的,返回 null 会让学生看到一个指不出原因的空引用异常),实训台同样用红字提示"ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM3)",评分说明也附上。学生忘了点"打开"时一眼能看出原因。
|
||||||
|
- 允许重复调用 `Open`(项目 7 任务 3 在定时器里每秒打开一次)。
|
||||||
|
- 串口没有打开时,`OnOff` 控制无效、返回 false。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
|
||||||
|
- 摄像头事件在非界面线程触发,与真实行为一致。
|
||||||
|
|
||||||
|
## 系统架构
|
||||||
|
|
||||||
|
学生代码在服务器上编译,并放进单独的进程运行;浏览器只负责显示和交互。三方之间只通过统一格式的 JSON 消息通信(定义见 `shared/protocol.ts`),以后如需改为在浏览器内运行,只替换 `runtime/` 即可。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Browser["浏览器(Vue 3)"]
|
||||||
|
Editor["代码编辑器 / 窗体设计器"]
|
||||||
|
Forms["虚拟窗体<br/>按控件树渲染,用户操作发回服务器"]
|
||||||
|
Board["虚拟实训台<br/>滑块、开关调节环境;显示灯、LED 屏"]
|
||||||
|
end
|
||||||
|
subgraph Server["Bun 后端(Hono)"]
|
||||||
|
Session["会话<br/>每个浏览器一个,管理每次运行"]
|
||||||
|
BoardState["实训台状态<br/>传感器输入、继电器输出、LED 文字"]
|
||||||
|
Data["账号、班级、实验、评测(SQLite)"]
|
||||||
|
end
|
||||||
|
subgraph Runtime[".NET 10 运行时(运行进程在 bubblewrap 沙箱里)"]
|
||||||
|
Compiler["编译服务(Roslyn)<br/>全服务器共用一个常驻进程"]
|
||||||
|
Runner["运行进程(每次运行一个)<br/>仿真 WinForms + 教材设备类库"]
|
||||||
|
end
|
||||||
|
Browser <-->|WebSocket| Session
|
||||||
|
Session --- BoardState
|
||||||
|
Session -->|编译请求| Compiler
|
||||||
|
Session <-->|标准输入输出,每行一条 JSON| Runner
|
||||||
|
```
|
||||||
|
|
||||||
|
数据流:
|
||||||
|
|
||||||
|
- 学生点"运行":服务器把工程写到临时目录,编译服务编译出程序集,再启动一个运行进程。
|
||||||
|
- 学生在窗体上操作:浏览器只上报"用户做了什么"(点击、输入、下拉),由运行进程按真实 WinForms 的顺序触发事件,再把控件属性变化发回浏览器。
|
||||||
|
- 学生拖动实训台滑块:服务器更新实训台状态,推给运行进程;学生程序的 `SetData()` 直接读本地副本,不需要网络往返。
|
||||||
|
- 学生程序控制灯和继电器:运行进程报告给服务器,服务器更新实训台状态并推给浏览器。
|
||||||
|
|
||||||
|
**目录结构**
|
||||||
|
|
||||||
|
```
|
||||||
|
IOT/
|
||||||
|
├─ web/ Vue 前端:编辑器、虚拟窗体、虚拟实训台
|
||||||
|
├─ server/ Bun 后端:示例接口、会话、实训台状态、编译服务和运行进程的调度
|
||||||
|
├─ runtime/ .NET:仿真 WinForms(Sim.WinForms)、教材设备类库(Sim.Devices)、运行器(Sim.Runner)
|
||||||
|
├─ shared/ 消息协议定义(TS 类型;C# 端对应实现在 Sim.WinForms/Host)
|
||||||
|
├─ samples/ 示例工程,按教材任务整理
|
||||||
|
└─ docs/ 设计方案、教材逐任务摘要
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
||||||
|
|
||||||
|
| 层 | 用途 | 选择 |
|
||||||
|
| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||||
|
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||||
|
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||||
|
| 前端 | 代码编辑器 | Monaco Editor |
|
||||||
|
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
|
||||||
|
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
|
||||||
|
| 前端 | 图表 | 计划:Chart.js |
|
||||||
|
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
||||||
|
| 后端 | 运行时 | Bun |
|
||||||
|
| 后端 | Web 框架 | Hono |
|
||||||
|
| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
|
||||||
|
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
||||||
|
| 后端 | 参数校验 | 计划:Zod |
|
||||||
|
| C# 运行时 | 平台 | .NET 10 LTS |
|
||||||
|
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
|
||||||
|
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
|
||||||
|
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
|
||||||
|
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
|
||||||
|
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
|
||||||
|
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
|
||||||
|
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
|
||||||
|
| 工程 | 仓库 | Bun workspaces 单仓库 |
|
||||||
|
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
|
||||||
|
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
|
||||||
|
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
|
||||||
|
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
|
||||||
|
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
|
||||||
|
| 部署 | 进程管理 | Docker Compose;push origin 后开发机上的 gitea-runner 测试、编好产物(运行时 dll、网页、server.js)rsync 到服务器,服务器从 git.xuyue.cc 拉代码、用现成产物构建镜像(`deploy/update.sh --prebuilt`)。服务器只有 2 核,不在上面编;不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
|
||||||
|
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
|
||||||
|
|
||||||
|
## 安全与服务器资源
|
||||||
|
|
||||||
|
在公网上运行学生代码,必须做三层防护;第 0 阶段实测,100 个学生程序同时运行合计约 1.1 GB 内存。
|
||||||
|
|
||||||
|
**安全措施**(已完成)
|
||||||
|
|
||||||
|
1. **沙箱隔离**:每次运行用 bubblewrap 放进独立的命名空间:断网,只能看到系统只读文件、运行时程序和自己的工程目录(只读),/tmp 是独立内存盘,看不到其他进程,不继承服务器的环境变量。另外限制托管堆 128 MB、累计 CPU 时间 120 秒(防死循环占满 CPU)、运行时长 60 分钟,降低优先级(nice 10),全站同时运行上限 150 个。整个平台再放在 Docker 容器里:文件系统只读、去掉全部 capabilities、限制内存和进程数。服务器启动时自检沙箱,不可用时拒绝运行学生代码。
|
||||||
|
- 没有选"每次运行一个 Docker 容器":每次多 0.3–0.5 秒,而且应用要能操作 Docker 守护进程,这本身权限过大。代价是应用容器要放开 seccomp / AppArmor,才能让 bubblewrap 创建命名空间。
|
||||||
|
2. **编译检查**:网络(System.Net)、反射(System.Reflection,AssemblyInfo.cs 里的特性除外)、启动其他程序(Process)、调用本机代码(DllImport、Marshal)、加载程序集、进程间通信、dynamic,编译时直接给出中文报错(SIM001)。文件读写由沙箱限制,不在编译时禁止(项目 7、8 要读图片)。
|
||||||
|
3. **账号管控**:不开放注册,老师上传名单批量创建学生账号;所有接口和运行程序的 WebSocket 都要先登录;按 IP 限制输错次数。登录用访问令牌(JWT,15 分钟,放在 Authorization 请求头里;WebSocket 放在网址参数里)+ 刷新令牌(30 天,每次刷新换新的,数据库只存哈希,退出登录即作废);重置密码、删除学生时用户的令牌版本加一,所有设备立即退出。学生密码由老师发放、老师可查看,按原样保存(有意的取舍)。
|
||||||
|
|
||||||
|
**资源估算**
|
||||||
|
|
||||||
|
- 编译器做成常驻进程,全服务器共用一个,约占 150 MB;学生程序运行时不加载编译器。
|
||||||
|
- 每个运行中的学生程序平均约 11 MB(按 PSS 计,共享的 .NET 运行库按进程数平摊;单看 RSS 约 47 MB)。
|
||||||
|
- 每个学生同时只运行一个程序,闲置超时自动结束。
|
||||||
|
- 两个班约 100 人同时运行,合计约 1.3 GB;8 GB 内存有充足余量。
|
||||||
|
- 2026-10-22 复测(Docker 容器限制为 2 核,加 bubblewrap 沙箱):50 人同一瞬间点"运行",4.4 秒内全部显示窗体(中位 2.3 秒),容器共 643 MB;100 人 8.4 秒(中位 4.1 秒),1.1 GB。瓶颈是 CPU(编译和启动),实际上课时点击会分散开。
|
||||||
|
- SQLite 开启 WAL 模式,定时备份。
|
||||||
|
|
||||||
|
## 实验规划
|
||||||
|
|
||||||
|
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
|
||||||
|
|
||||||
|
| 项目 | 教材内容 | 平台需要支持 | 状态 |
|
||||||
|
| ------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------ |
|
||||||
|
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
|
||||||
|
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
|
||||||
|
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
|
||||||
|
| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 已完成 |
|
||||||
|
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 |
|
||||||
|
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | 已完成 |
|
||||||
|
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | 已完成 |
|
||||||
|
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | 已完成 |
|
||||||
|
|
||||||
|
每个实验以数据形式存放(目前在 `samples/`,以后迁到 `content/`),由以下部分组成:
|
||||||
|
|
||||||
|
- **任务说明**:对应书上的任务描述和步骤,控件名以书为准。
|
||||||
|
- **参考答案**:修正后的书本代码(修正编译错误和逻辑错误,如 INSERT 列错位、漏写 `new DataSet()`、项目 7 开灯判断写反)。
|
||||||
|
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
|
||||||
|
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
|
||||||
|
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
|
||||||
|
- **场景配置**:初始环境值、设备接在哪个串口(`meta.json` 的 `wiring`,已实现)、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
||||||
|
|
||||||
|
老师不写代码也能修改或新增实验。
|
||||||
|
|
||||||
|
## 开发路线图
|
||||||
|
|
||||||
|
各阶段时长按一人兼职估算,会随实际进度调整。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||||
|
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||||
|
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
|
||||||
|
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
|
||||||
|
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
|
||||||
|
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||||
|
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
||||||
|
P2 -->|"关口:一个班试用顺利"| P3
|
||||||
|
P3 --> P4
|
||||||
|
```
|
||||||
|
|
||||||
|
并行事项:尽早办理 ICP 备案(需数周)。
|
||||||
|
|
||||||
|
**当前状态(2026-10-26)**:第 1–3 阶段的功能已全部完成,平台已部署到线上服务器(GitHub Actions 自动部署)。下一步是第 2 阶段的关口:带一个班试用,按试用中暴露的问题修改,之后再考虑第 4 阶段。
|
||||||
|
|
||||||
|
**可能的扩展:物联网设备安装与调试课**(2026-10-28 讨论,还没定开不开)。那门课的核心是接线(传感器信号线、电源、485 的 A/B 线)、用厂家工具配置设备(ADAM 地址、波特率、4017+ 量程,ZigBee 网络号)、用串口调试助手收发报文、排故,和本课"不学协议、不接线"正好相反。仿真替代不了拧螺丝,但适合预习、在家练、接错不烧设备,尤其是老师设故障让学生找的**排故练习**(和"故障注入题库"是一回事)。现在为它做的准备是把接线做成数据(`BoardState.wiring`),本课用实验预设的接线、只读显示;将来那门课在同一份数据上让学生改接线,再加上通道接线、电源、配置工具。开工前要先拿到那门课的教材和实训台资料。
|
||||||
|
|
||||||
|
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
|
||||||
|
|
||||||
|
| 指标 | 结果 |
|
||||||
|
| ---------------------- | ---------------------------------------------------- |
|
||||||
|
| 编译(编译服务预热后) | 10–300 ms |
|
||||||
|
| 点"运行"到窗体出现 | 约 100 ms |
|
||||||
|
| 单次操作往返 | 约 15 ms |
|
||||||
|
| 100 个程序同时运行 | 合计约 1.1 GB,平均每个 11 MB,另加编译服务约 150 MB |
|
||||||
|
| 100 个程序同时点击 | 往返中位数约 60 ms,P95 约 75 ms |
|
||||||
|
|
||||||
|
验证中的调整:第一版每个运行进程都加载 Roslyn,单个约 170 MB;改为常驻编译服务后,运行进程不再加载编译器,编译也从约 0.9 s 降到几十毫秒。接入沙箱后在 2 核环境下的复测见上文"资源估算"。
|
||||||
|
|
||||||
|
**第 1 阶段进展**:
|
||||||
|
|
||||||
|
- 2026-10-22:虚拟实训台(服务器统一保存状态,网页调节环境);CheckBox、RadioButton、Lamp、Alarm;教材项目 2、3 共 5 个任务的示例全部跑通。
|
||||||
|
- 2026-10-22:代码编辑器换成 Monaco:C# 语法高亮;编译错误以波浪线标在代码里,点错误信息跳到对应行;F5 运行、Shift+F5 停止(在整个页面生效,并阻止浏览器 F5 刷新);改过的代码自动保存在浏览器本地,可一键恢复原始代码。前端打包后 JS + CSS 压缩约 1.2 MB。
|
||||||
|
- 2026-10-22:窗体设计器:工具箱拖放或双击添加控件,选中、拖动、改大小、拖进拖出分组框,Delete 删除、方向键微调;属性窗口和事件窗口;双击控件或事件生成事件处理方法并跳到代码;Timer 等组件显示在组件栏。Designer.cs 在浏览器端直接解析和生成:未改动的语句保留原文,教材 25 个参考窗体和全部示例读入再写出逐字节不变;布局语句(SuspendLayout / ResumeLayout / PerformLayout)的增删规则与 VS 一致。
|
||||||
|
- 2026-10-22:设计器撤销 / 重做(撤销"生成事件处理方法"时连同插入的方法一起撤掉,不覆盖学生已写的代码);工具箱、属性、事件窗口全部显示中文,英文名放在悬停提示里;支持中文的控件名和类名。
|
||||||
|
- 2026-10-22:多窗体:新建窗体(与 VS 新建窗体相同的两个文件,沿用工程命名空间)、删除窗体(启动窗体不能删);标签页按窗体分组;运行时点哪个窗口哪个到最前。新增示例:项目 1 任务 1 登录与增加用户(两个窗体的 Designer.cs 由设计器引擎生成)。
|
||||||
|
- 2026-10-22:整理教材示例:项目 1 任务 2(计算器、多态)、项目 4 任务 1(红外对射)、项目 4 任务 3(报警灯)、项目 5 任务 1(火焰)。界面取自配套参考工程,用设计器引擎按书上的控件名改名。至此 10 个示例,全部有"能编译"检查和端到端行为测试。项目 4–8 其余任务需要的能力(摄像头、线程 API、ListBox、数据库、TrackBar、PictureBox、图表)放到第 3 阶段。
|
||||||
|
- 2026-10-22:虚拟实训台按自身宽度自适应排列,可以收起成一行状态(传感器开关仍可点),给窗体显示区腾地方。
|
||||||
|
- 2026-10-22:部署:学生程序放进 bubblewrap 沙箱(断网、只读、看不到其他进程和服务器环境变量;限制堆内存、CPU 时间、运行时长,全站并发上限);编译检查禁止网络、反射、启动进程等 API;访问口令;Bun 直接提供打包好的网页;Dockerfile + Docker Compose(Caddy 接入 NPM 的 npm_proxy 网络)+ 部署脚本,本机按服务器配置(2 核)完整验证。
|
||||||
|
|
||||||
|
**第 2 阶段进展**:
|
||||||
|
|
||||||
|
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
||||||
|
- 2026-10-22:自动评分:每个示例一份 `checks.yaml`(共 23 项,由书上评价表的功能项转来);引擎在沙箱里每项单独启动一次学生程序,按书上的控件名操作、调节实训台、核对结果,不通过时写明期望和实际。参考答案全部通过,几种典型错误各自报出对应原因。
|
||||||
|
- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。
|
||||||
|
- 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。
|
||||||
|
|
||||||
|
**第 3 阶段进展**:
|
||||||
|
|
||||||
|
- 2026-10-22:项目 5 补齐(线程、ListBox)。旧版线程 API:编译时把 `thd.Suspend()` / `Resume()` / `Abort()`、`thd.ThreadState`、`Thread.Sleep`、`new Thread(...)` 改写成平台实现(只改语义上确实是 `System.Threading.Thread` 的调用,不换行,报错和异常的行号不变;编辑器里看到的仍是学生的原始代码,也不再提示"已过时")。新版 .NET 不能从外部停住线程,平台的实现是协作式的:被挂起或终止的线程在下一个检查点停下或结束(`Thread.Sleep`、从后台线程改控件属性、调用设备类库、弹出提示框),教材的线程方法每次循环都经过这些地方;休眠中被终止的线程立即醒来结束。行为与 .NET Framework 一致的地方:线程可以挂起自己、对没启动的线程 Abort 不报错、`Resume` 没挂起的线程和直接 `Abort` 挂起的线程抛出 ThreadStateException(书上任务 2 关闭窗体时直接 `thd.Abort()`,在真实环境中也会出这个错,参考答案改为先 `Resume` 再 `Abort`,报错信息里写明这样改)。ListBox:列表项、单选、SelectedIndexChanged 先于 Click、Text 是选中项、删除选中项后取消选中;设计器工具箱加"列表框";评分规则的"包含"对列表框检查所有列表项。新增示例:项目 5 任务 2(烟雾,线程)、任务 3(推送灾情,二维数组、两个窗体、ListBox),各有评分规则和端到端测试,共 12 个示例。
|
||||||
|
|
||||||
|
- 2026-10-22:项目 4 任务 2 网络摄像机,至此教材项目 1–5 全部完成(13 个示例)。摄像头画面是自绘的扁平插画(`runtime/Sim.Devices/Camera/scene.svg`,从校园里望向东围墙,墙头装着红外对射),按云台位置(左右 7 档、上下 3 档)切成 640×480 的 JPEG 帧;`PanUp` 等每次转一格,实训台红外对射被遮挡时画面里出现翻墙的人、光束断开,为书上"发现入侵自动抓拍"的探究题准备。`ipCameraHelper` 的类名、委托名(`CameraImage`)、事件参数(`ImageReadEvenArgs.img`)与教材 dll 一致,和仿真版一样每 250 ms 在后台线程上触发 `ImageRead`,书上不用 Invoke 直接给 `pictureBox1.Image` 赋值也能显示。`Image` / `Bitmap` 保存真实的图片数据:`Image.Save` 写出能打开的 jpg,`FromFile` / `FromStream` 读回,文件夹不存在时和 GDI+ 一样报"一般性错误"。图片数据每张只发给浏览器一次(按内容哈希缓存),之后控件属性里只带编号;同一位置的画面复用同一个对象,画面不变时不产生消息。学生程序的"程序所在目录"(`AppDomain.CurrentDomain.BaseDirectory`,编译检查单独放行)和当前目录都指向可写的 bin/Debug(沙箱里在沙箱自己的临时目录),程序结束后清掉;`Image.Save` 保存的图片显示在运行区的照片列表里,点开看大图,下次运行时清空。新增 PictureBox(SizeMode 五种、BorderStyle),设计器工具箱加"图片框";评分规则加"图片框显示了图片""摄像头开启 / 关闭 / 朝某方向转过""保存了图片"三种检查。
|
||||||
|
|
||||||
|
- 2026-10-23:项目 6 数据存储(3 个任务)。**每个学生一套数据库,长期保存**(`data/databases/<用户编号>/库名.db`,每个库一个 SQLite 文件,最多 20 个库、每个约 50 MB),就像自己电脑上的 SQL Server:任务 1 建的库、任务 2 存的数据,任务 3 能查到。
|
||||||
|
- **SqlClient 仿冒**(`runtime/Sim.Data`):SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter,DataSet、DataTable、DataView 直接用 .NET 自带的。连接字符串只按 Initial Catalog 打开学生自己的同名库(服务器名、账号密码不检查,书上的长服务器名、"." 都能连上),库不存在时报 4060。
|
||||||
|
- **T-SQL 转写**:ScriptDom(SQL Server 2008 语法)解析后按白名单逐个节点转写成 SQLite 语句,不认识的语法报"平台暂不支持",生成的 SQL 里只有平台写出的关键字和 tsql_ 函数。表用 STRICT 模式保存,原始的 T-SQL 列类型记在元数据表里,读出时还原成 SQL Server 返回的 .NET 类型(int、DateTime、bool……);varchar(n) 超长、类型不对、NULL、重复主键都报和 SQL Server 一样的错误号和中文信息。`insert into 表 values (...)` 不写列名时跳过自增列(照书上写时值的顺序和列顺序不一致,数据会存进错的列,和真实情况一样);日期统一存成 yyyy-MM-dd HH:mm:ss,书上拼接的 `DateTime.Now.ToString()`(2024/1/16 9:05:03)在插入和 BETWEEN 比较时都转换;支持 TOP(含 UPDATE / DELETE TOP)、CONVERT 样式 120 等、year / getdate / dateadd / datediff、`+` 按类型连接或相加、参数化查询、@@IDENTITY、多条语句。SqlCommandBuilder 根据带中文别名的查询生成 UPDATE。
|
||||||
|
- **DataGridView**:绑定 DataView / DataTable / DataSet,自动生成列;在单元格里修改的值写回 DataTable,配合 SqlDataAdapter.Update 保存;当前行 ▶、新增行 *、选中行、CurrentRow.Cells[0].Value;类型转换失败时和 WinForms 一样触发 DataError。**DateTimePicker**:Long / Short / Time / Custom 格式与中文 Windows 一致,弹出浏览器的日期选择框。设计器工具箱加"数据表格""日期时间选择器"。
|
||||||
|
- **数据库页面**(代替 SSMS,`/database`):对象资源管理器(只有库 → 表两层,比 SSMS 简单:“+ 新建库”“+ 新建表”,点表名编辑前 200 行,⋯ 和右键是同一个菜单:查询前 1000 行、设计表结构、删除;鼠标停在表名上显示列);查询窗口(F5 执行、选中部分只执行选中的、USE 切换库,结果网格和消息栏的格式与 SSMS 一致,每批最多 5 秒);编辑前 200 行(改单元格即 UPDATE、* 行新增、右键删除,没有主键的表按所有列的原值定位);表设计器(列名、类型、允许 Null、右键设主键、标识列,"查看 SQL 脚本"显示对应的 CREATE TABLE);"恢复实验初始数据"按实验的 `database.sql` 替换实验用到的库,其他库不动。这些操作都生成 T-SQL 交给常驻的 SQL 服务执行,和学生程序走同一套转写。
|
||||||
|
- **评分**:每个实验可以带 `database.sql`,评分时每个检查项用一份新建的实验库,不碰学生自己的数据;评分规则加"点 / 修改表格单元格""在日期时间选择器里选日期""表格里有 / 没有某行"。新增示例项目 6 任务 2、3(任务 1 在数据库页面上完成),共 15 个。
|
||||||
|
- 实测:用到数据库的学生程序比不用的多约 11 MB 内存(PSS 17 → 28 MB)。另外修正了平台上的时间格式:Linux 的中文区域数据是 09:05:03,改成和中文 Windows 一样的 9:05:03。
|
||||||
|
|
||||||
|
- 2026-10-23:项目 7 情景控制(3 个任务),共 18 个示例。
|
||||||
|
- **虚拟时钟**:实训台加时钟面板,默认真实时间,可以拨到任意日期时刻、选 1×/10×/60× 倍速、一键恢复真实时间;时钟状态由服务器保存(`BoardState.clock`),运行器按它算当前时间。编译时把学生代码里的 `DateTime.Now / Today / UtcNow` 改写成 `SimClock`(和线程 API 的改写一样不换行、编辑器里看到的是原代码)。任务 2 按 6:45 起床、7:30 上课、21:00 回寝、22:00 熄灯切换,任务 3 按预约时刻开启,都靠它来测试。评分规则加 `clock` 步骤(拨到今天的某个时刻,可设倍速)。
|
||||||
|
- **项目资源**(`Properties.Resources`):只用平台素材库(自绘的开关图片、ffmpeg 合成的门铃声,名字和教材一致,`runtime/Sim.WinForms/Resources/gallery`)。实验页"项目资源"对话框勾选素材(图片看缩略图、声音可试听),平台生成 `Properties/Resources.Designer.cs`(图片是 Bitmap、声音是 byte[],用法和 VS 的强类型资源类一样,不在标签页里显示);设计器里图片框的"图片"属性可以从项目资源里选,写进 Designer.cs 的是 `global::命名空间.Properties.Resources.名字`,预览直接显示素材。学生程序的程序目录下有 Resources 文件夹指向素材库(书上用 `BaseDirectory + "//Resources//ring.mp3"`)。没添加资源就用时给中文提示。
|
||||||
|
- **Windows Media Player**(`AxWMPLib.AxWindowsMediaPlayer`):设置 URL 后和真的一样自动播放,`Ctlcontrols.play() / stop()`;声音在浏览器里播放,实训台上加"广播"喇叭,播放时亮起。VS 为它生成的 `ComponentResourceManager(...).GetObject("…OcxState")`:编译时给每个窗体嵌一份空资源,取到 null 不报错。图片、声音数据统一按内容哈希缓存(`blob` 消息),每份只传一次。
|
||||||
|
- **TrackBar**:拖动、点轨道(LargeChange)、方向键(SmallChange),先 ValueChanged 再 Scroll,竖向时最小值在下面;设计器工具箱加"滑动条""媒体播放器"。评分规则加 `value`(滑动条的值)、`media`(播放了某个文件)检查。
|
||||||
|
- 示例:书上任务 1 手动开关灯的判断写反(参考工程是对的),任务 3 漏了填充串口列表,示例里都已改正;任务 3 的"每 5 分钟整点拍照"依赖定时器正好在 0 秒触发,自动评分不检查这一项,由老师评阅。编译检查修正:`System.AppDomain.CurrentDomain.BaseDirectory`(带命名空间)也放行。
|
||||||
|
|
||||||
|
- 2026-10-23:项目 8 智慧校园管理平台(5 个任务,每个任务一个示例,代码逐个累加),至此教材 8 个项目全部完成,共 23 个示例。
|
||||||
|
- **新建类**:实验页"+ 新建类",可以带文件夹(`SQLHelpers/SQLBLL`),文件夹成为命名空间的一段,模板和 VS 2012 一样;可以删除自己建的类。
|
||||||
|
- **停靠布局**:Dock(上、下、左、右、填充)和 WinForms 一样按 Controls 倒序依次停靠,Panel 的边框、GroupBox 的标题占的地方扣掉;窗体改大小、控件显示隐藏、加入移出时重新布局。设计器里改了停靠或大小也立即按同样的规则排好。
|
||||||
|
- **背景图片**:BackgroundImage + BackgroundImageLayout(平铺、居中、拉伸、缩放),窗体、面板、图片框都可以设,设计器里从项目资源选。**背景色沿用父控件**:没设置背景色的标签、面板、分组框等画父控件的背景色(不是父控件的背景图片),和 WinForms 一样,所以书上要把放在背景图上的控件设成 Transparent。分组框的标题处边框断开、透出背景(书上分组框放在深色背景上、标题是白字)。文本框的 BackColor 生效。
|
||||||
|
- **无边框窗体**:FormBorderStyle = None 时没有标题栏;**运行区自动缩放**:窗体比运行区大时(项目 8 的窗体有 1043×791)整体按比例缩小,右上角显示缩放比例;"放大"让运行区占满整个网页(窗体在左、实训台在右),Esc 还原。
|
||||||
|
- **图表**(`System.Windows.Forms.DataVisualization.Charting.Chart`):ChartAreas、Legends、Series(AddXY、Points、ChartType 等)、DataSource + DataBind(DataTable / DataView / DataSet,按 XValueMember、YValueMembers 取列),浏览器用 Chart.js 画,配色是 WinForms 默认的 BrightPastel。设计器工具箱加"图表"(和 VS 一样带 ChartArea1、Legend1、Series1),属性窗口"系列和图例"打开对话框编辑系列(名称、图表类型、图例、图例文字、X / Y 值字段)和图例,按 VS 的格式和顺序写回 Designer.cs,对话框不管的属性原样保留。
|
||||||
|
- **路径的反斜杠**:书上写 `BaseDirectory + "Camera\\"`,Linux 上反斜杠会成为文件名的一部分。编译时把传给 System.IO(File、Directory、Path、各种 Stream)和 Image / Bitmap 读写文件方法的路径参数包上 `WindowsPaths.Fix`,反斜杠换成斜杠(和线程、时钟的改写一样不换行、编辑器里看到的是原代码)。
|
||||||
|
- **其他**:Paint 事件(VS 双击面板生成的空 `panel1_Paint` 能编译,平台不触发);图片框把鼠标按下、松开发给程序(书上方向键按下时转动摄像机、换成按下的图片);数据库里参数化的日期字符串比较时也转换格式(书上拼出的 `"2024/1/23 23:59:59"` 有两个空格)。
|
||||||
|
- **素材**:项目 8 用到的 20 多张图片(标题、校园背景、三个入口图标、亮灯 / 灭灯的路灯场景、面板背景、监控图标、方向盘和方向键、翻页箭头、关闭按钮)全部自绘,名字和尺寸与教材一致(`runtime/Sim.WinForms/Resources/project8.py` 生成 SVG,`build-gallery.sh` 渲染成 PNG)。
|
||||||
|
- **数据库 WisdomCampus**:平台按教材的表结构建(EnvironmentData、SecurityData、Users),预置 7 天环境数据,"数据分析"的图表一打开就有内容。学生第一次运行带预置数据库的实验时自动建好自己还没有的库(已有的库不动)。
|
||||||
|
- **评分规则**新增:`visible`(连同所在容器都可见)、`window`(某个窗体打开着 / 已关闭)、`exited`(程序已退出)、`image` / `background` 写素材名(如 `background: day_01`,按图片内容核对显示的是不是这张素材)、`points`(图表每个系列的数据点数);"点击"图片框时和浏览器一样先发鼠标按下、松开。
|
||||||
|
- **示例**:书上任务 3 第 23 步把"自动推送火情"的处理写到了 ch_autoLamp 上,参考工程里"推送LED信息""自动控制"是空方法、"自动推送火情"没有绑定事件,示例按书的意思补全;任务 5 每次查询前清空 Keys(书上没清空,重复查询时照片和时间对不上)。任务 2 只做界面,为了能看效果加上了打开、返回窗体。
|
||||||
|
|
||||||
|
**其他改进**:
|
||||||
|
|
||||||
|
- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。
|
||||||
|
- 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。
|
||||||
|
- 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。
|
||||||
|
- 2026-10-25:一个班试用前的准备。
|
||||||
|
- **全班模拟**(`server/test/classroom.ts`):45 个虚拟学生通过 HTTP / WebSocket 依次登录、打开作业、边写边检查、运行、点按钮、自己检查、提交,每个环节在 10 秒内陆续开始,统计每人等了多久。服务器限制在 2 核、用 Release 版运行时:登录、打开、检查、运行到窗体出现、点按钮都在 1.5 秒内(项目 8 第一次运行要建 WisdomCampus 库,约 5 秒)。
|
||||||
|
- **评分并发 6 → 16**:评分时 CPU 只用了两三成,慢在并发上限。45 人同时自测项目 8 任务 3,最后一人原来要等约 4 分钟,现在约 1.5 分钟;项目 3 任务 2 从约 1.5 分钟降到半分钟。并发 24 时 CPU 接近 80%,定时器来不及触发,参考答案被误判,所以停在 16(可用 `SIM_GRADE_CONCURRENCY` 调整)。提交评分队列同时评的份数跟着并发上限走,不再固定为 3 份。
|
||||||
|
- **检查的默认等待 2 秒 → 5 秒**:机器忙时,点"数据分析"后查库、画图表会超过 2 秒,正确的程序被判不通过。条件满足就立即往下走,放宽只让不通过的检查多等一会儿;规则里原来写的 3 秒一并去掉。
|
||||||
|
- **删除学生时删除他的数据库**:用户编号在删掉最大的编号后会重新用给下一个新学生,原来新学生会看到前一个人的库。
|
||||||
|
- **手动备份与恢复**:`deploy/backup.sh` 把平台数据库和每个学生的库(SQLite 一致读取)导出到宿主机的 `backups/<日期时间>/`;`deploy/restore.sh` 先备份现在的数据,再停平台、替换(删掉旧库的 -wal 文件)、启动。本机 Docker 实测备份、改数据、恢复后数据回到备份时的样子。
|
||||||
|
- **WebSocket 心跳与断线重连**:线上前面的 Nginx Proxy Manager 默认 60 秒没数据就断开 WebSocket(本机用默认配置的 nginx 实测),学生的窗体开着一分钟不操作,程序就被结束、点了没反应。服务器改为 30 秒没收到数据就发 ping(Bun 的 idleTimeout),经 nginx 空闲 150 秒仍连着。连接意外断开时(部署重启、网络抖动),运行区显示"正在重新连接…",清掉已经失效的窗体,按 1、2、4…最长 10 秒的间隔自动重连;连上后提示"刚才的程序已经结束,请重新运行",实训台恢复。
|
||||||
|
- **登录失败的限制**:原来按 IP 计数、任何人登录成功就清零,学生用自己的账号登录一次就能接着猜同学的 6 位数字密码。改为同一个账号 10 分钟输错 10 次只锁这个账号(同班其他人不受影响),同一 IP 10 分钟 300 次(登录成功不清零)。
|
||||||
|
- **旧浏览器提示**:网页按 Chrome / Edge 94 以上打包(机房电脑是 Chrome 94,见 2026-10-28),更旧的浏览器或 360 浏览器的兼容模式会一片空白。index.html 里用老写法的一段脚本:网页程序有语法错误时说明浏览器太旧、怎么切换极速模式,并显示浏览器版本字符串方便老师排查;15 秒还没显示出来时提示正在加载。
|
||||||
|
- **使用说明**(`/help`,没登录也能看):学生部分讲登录、自由练习和作业、设计器、运行、实训台、自己检查和提交、数据库;老师登录后多出建班导入名单、布置作业、评阅和成绩、上课前后的注意事项;最后是常见问题。登录页和各页顶栏都有入口,在新标签页打开,不打断正在做的事;可以直接打印。
|
||||||
|
- **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。
|
||||||
|
|
||||||
|
- 2026-10-26:**任务说明**。每个实验一份 `task.md`(任务目标、界面和控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试),用自己的话精简写,详细过程标出书页,不照搬书上原文。工具栏"评价项"改成"**任务**":右侧面板上面是任务说明,下面是评价项和"自己检查";面板不遮挡页面、可以拖宽,开着也能搭界面、写代码;第一次打开一个实验(或作业)时自动打开(记在浏览器里)。渲染用自己写的 Markdown 子集(标题、段落、两级列表、表格、提示框、加粗、代码),先转义全部 HTML。布置作业时任务说明随作业保存,老师可以改写;以前布置的作业用实验现在的说明。测试检查任务说明表格里的控件名都在示例的 Designer.cs 里。先写了项目 3 任务 2、项目 6 任务 2 两份样板,随后补齐其余 21 份(23 个实验全部有说明)。
|
||||||
|
- 2026-10-26:**窗体改名**。23 个示例里 11 个的主窗体不叫 Form1(Login、frm_analog、frm_LED、frm_control、Main),学生的空白工程却只有 Form1,以前属性窗口改不了窗体名(项目 8 任务 2 的评分还要找 Main 窗体)。现在选中窗体可以改"名称":和 VS 一样,类名、构造函数、各文件里的 `new X()` 和 X 类型的变量都改(跳过字符串和注释),Designer.cs 里窗体的块头注释和 `this.Name` 也改,两个文件跟着改名;事件处理方法名(如 Form1_Load)和窗体标题不变。改名不进设计器的撤销历史。任务说明里提示先改名再双击窗体生成 Load 事件。
|
||||||
|
- 2026-10-26:代码用 **bun fmt** 格式化,格式写在 `bunfig.toml`(不写分号、单引号、一行最长 160,和原来的写法一致);`docs/教材摘要/` 是书上的摘录,不格式化。
|
||||||
|
- 2026-10-28:**兼容机房的 Chrome 94**。用 Chromium 94 实测,打开 http 地址时实验页报 `crypto.randomUUID is not a function`(这个函数只在 https 和 localhost 下有);还时好时坏地一片空白:Chrome 94 在 Promise 回调里调用 `import()`(Vite 的预加载就是这样)时,有时把 `import("./X.js")` 相对于网页地址解析,请求 `/X.js` 拿到首页 HTML。改法:打包目标改成 Chrome / Edge 94;`web/src/polyfills.ts` 补上 `crypto.randomUUID`、`structuredClone`(只复制普通数据)、`findLast` / `findLastIndex`;`vite.config.ts` 的小插件在 Vite 找完预加载依赖之后把代码块之间的 `import("./X.js")` 改成 `import("/assets/X.js")`,改过的代码块换新文件名(不用缓存里的旧文件);容器查询(Chrome 105)改成 ResizeObserver 量宽度加 class(实训台的三档布局、设计器窄时隐藏提示文字);`:has()`(Chrome 105)改成 class(只读和禁用的文本框灰底、没标题的分组框)。Monaco 自带的几条 `:has()`、`color-mix()` 样式在 Chrome 94 里不生效,只影响 Markdown 悬停提示里的复选框等细节。实测 Chrome 94 经 http 打开登录、实验页(设计器改属性、代码补全、运行程序)、数据库、班级管理、使用说明,各页面直接打开 5 遍都正常。
|
||||||
|
- 2026-10-28:**运行中改了自动重新运行**。以前改了代码或设计器,运行区的窗体不变,要按 F5 才看得到。现在程序运行时(`WorkbenchPage.vue`)每次修改后等 1.5 秒没有新的改动,先用语言服务检查整个工程(和边写边检查同一个接口,不生成程序集):没有错误就重新编译运行;有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行"。检查期间又改了、程序已经停了或换了工程,这次作废。只在运行中触发,停止后改动不会自动启动程序。
|
||||||
|
- 2026-10-28:**实训台接线**。讨论"ADAM 模块和传感器的虚拟连线"后决定:本课学的是 C# 界面和事件,学生不自己接线(那是安装调试课的内容,见路线图第 4 阶段),只做两件事。①**串口要选对**:以前选哪个 COM 口都能控制设备,现在按接线判断(接线随实验,项目 7 把 4150 并到 COM2),选错了返回值照反编译的真机版 dll,设备没反应;C# 端 `TrainingBoard.Reaches` 判断,`Open` / `Close` 报告给服务器(`adam4150/open` 等,没变不发),`BoardState.opened` 记下程序打开的串口,实训台、评分说明据此提示打错的串口。打开实验时浏览器发 `experiment` 消息,服务器按示例的 `meta.json` 换接线;老师看学生提交时也按作业的实验接线。②**只读接线展示**:鼠标移到实训台的设备上,标题栏写出线路、对应通道和串口高亮;串口服务器按接线列出每个口接的设备,程序打开的口亮绿点;程序里串口下拉框的灰字也按当前接线。顺带把"4017"统一写成 ADAM-4017+(代码里的 `adam4017Helper` 是教材 dll 的名字,不改)。另外核对真机版发现:4150 串口没打开时真机版 `SetData()` 返回 null(书上代码会出空引用异常),而书上照着写的仿真版返回全 false;平台保持全 false,改为在实训台和评分说明里提示"串口没有打开,程序却在读写它"(`adam4150/unopened` 等,每次打开后最多报一次)。
|
||||||
|
- 2026-10-28:**上课前检查 8 个项目**。23 个实验逐个在浏览器里照任务说明操作、"自己检查",全部通过。修了:①日期选择框在机房 Chrome 94 上点了没反应(项目 6 任务 3、项目 8 任务 5 选不了日期):以前靠 `showPicker()` 弹出,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应;改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选日期)。②运行时的长日期多了星期(2026年10月28日星期三,框窄被截掉):Linux 的 zh-CN 长日期带星期,中文 Windows 不带,运行器改成 Windows 的写法。③评分漏洞:项目 3 任务 1、项目 4 任务 3 的"能关闭"和项目 7 任务 2 的"熄灯模式关灯",程序什么都不做也能通过,补上先确认灯亮过。④串口红字提示挪到实训台最上方(原来在底部,要往下滚)。⑤评分说明的空格("期望 6 秒内""照明灯 1 打开")。⑥任务说明按实测补充:项目 2 任务 1(事件窗口叫"单击",先复位清 LED 屏)、项目 2 任务 2 和项目 3 任务 2 的控件表、项目 2 任务 3 的 `ledHelper.Close()`、项目 3 任务 2 的"计时到"、项目 4 任务 2(IP 不能空、可以放大)、项目 7 任务 1 第 5 步、项目 7 任务 3 的预约步骤和开关第一次点没反应、项目 8 任务 5 先恢复实验数据。没改的平台限制:`Control.Focus()` 不移动焦点,密码框显示圆点不是星号。
|
||||||
|
- 2026-11-02:**导出 EXE**。原计划"导出 VS 工程",讨论后改成直接导出一个 exe,学生下载后在机房电脑上双击就能控制真的实训台,更方便。①编译:编译服务收到带 `export` 的请求时(`runtime/Sim.Runner/ExeExporter.cs`),用 Roslyn 照教材工程编译学生写的原始代码:.NET Framework 4.5 的引用程序集(NuGet 包 `Microsoft.NETFramework.ReferenceAssemblies.net45`,构建时只复制教材工程引用的 13 个到 `netfx/`,9 MB),引用真机版设备类库;平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。窗体应用、AnyCPU 优先 32 位(和教材工程一致,教材 dll 也都是 AnyCPU),带默认清单和版本信息。②教材 dll 嵌进 exe:真机版 `DeviceCtrlLibrary.dll` 和它 `API/` 下的 7 个 dll、`WinFormsDeviceControlLibrary.dll`、媒体播放器的两个 Interop dll 都是托管程序集,作为资源嵌入;加进 exe 的模块初始化器在 Main 之前注册 `AssemblyResolve`,用到时从 exe 里加载,学生的 `Program.cs` 不用改。③资源:平台生成的 `Properties.Resources` 改成从 exe 里取图片、声音;代码里按文件名用到的素材(项目 7 任务 2 的 `Resources//ring.mp3`)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样;媒体播放器的 `OcxState` 用教材 Form1.resx 里的默认状态。④书上项目 2 任务 3 的 `ledHelper.Close()` 在教材类库里没有(真机版 LED 每次发送才用串口),平台补过这个方法,导出时也补一个什么都不做的扩展方法。23 个实验全部能导出,约 0.9 MB;在 mono + Xvfb 里实测能启动,教材控件库的灯泡控件、开关图片都显示出来;真机还要在机房电脑上实测。⑤教材 dll 有版权,GitHub 仓库是公开的,不进仓库:服务器上放在 `~/IOT_SIM/textbook-libs/`,compose 只读挂进容器(`deploy/README.md`),没放时导出提示原因。⑥接口 `POST /api/export-exe`:成功返回 exe(文件名取工程的命名空间,如"教室情景模式.exe"),代码有错误时 422 和报错,实验页弹框列出前 20 条,点击跳到出错的代码。注意:项目 6、8 导出的 exe 按学生写的连接字符串连真的 SQL Server,机房电脑上要有对应的库;exe 没有签名,从网上下载后第一次运行 Windows 可能提示"已保护你的电脑",点"更多信息"→"仍要运行"。
|
||||||
|
- 2026-11-03:**导出 VS 工程**。实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。讨论后定下:机房装的是 VS 2012;代码有错不让导出(和 EXE 一样);这次不做把工程导回平台。①检查:编译服务收到 `export.kind = "project"` 的请求时(`runtime/Sim.Runner/ProjectExporter.cs`),照机房的环境编译一遍要放进 zip 的代码:**C# 5**(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库。新版 C# 的写法报 CS8026,`CompileHints` 按报错里的功能名给出改法(字符串插值改 `string.Format`、`?.` 改先判断 null、`=>` 改大括号等 12 种)。导出 EXE 仍按最新的 C# 编译,不受影响。23 个示例都是 C# 5 的写法,全部能导出。②工程照教材的样子(VS 2012 窗体应用,`.sln` + `.csproj`,ToolsVersion 4.0):平台生成的 `Properties/Resources.Designer.cs` 从素材库取图,导出时换成 VS 的 ResXFileCodeGenerator 生成的写法和 `Resources.resx`(ResXFileRef 指向 `Resources\` 下的图片);代码里按文件名用到的素材(ring.mp3)设成复制到输出目录,运行时在 `bin\Debug\Resources\`,和教材一样;每个窗体、用户控件带 `.resx`(媒体播放器的 OcxState 用教材的默认状态),`SubType`、`DependentUpon` 和 VS 一样,VS 里能打开设计器;补上 AssemblyInfo、Settings、App.config。③引用和教材工程一样只加代码直接用到的教材 dll(一般是 DeviceCtrlLibrary 和 WinFormsDeviceControlLibrary,项目 7 寝室加媒体播放器的两个 Interop),全部教材 dll 放在工程的 `lib\`,HintPath 指过去,编译时 VS 把依赖一起复制到 `bin\Debug`;只带这些引用编译不过时引用全部。书上的 `ledHelper.Close()` 用到时带上 `LedHelperCompat.cs`。④文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 的文件名用 GBK(中文 Windows 资源管理器自带的解压、老的压缩软件都按 GBK 读,UTF-8 在 Windows 7 上会乱码),另附 Info-ZIP 的 Unicode 文件名字段(0x7075),7-Zip、Linux 的 unzip 在别的语言的系统上也显示正确(`ZipWriter.cs`)。⑤23 个示例导出的工程在 mono 的 msbuild 里全部编译通过,输出目录和教材工程的 `bin\Debug` 一样,程序能启动;VS 2012 还要在机房电脑上实测。接口 `POST /api/export-project`,返回 zip,代码有错时 422,弹框说明"照机房电脑上的 VS 2012 编译"。
|
||||||
|
|
||||||
|
## 技术风险与待确认事项
|
||||||
|
|
||||||
|
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
||||||
|
|
||||||
|
| 风险 | 担心什么 | 应对 |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
||||||
|
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
||||||
|
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
||||||
|
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 已实现:ScriptDom 解析后按白名单转写;连接串只按库名打开学生自己的库;日期统一存为标准格式;STRICT 表和 CHECK 约束让类型、长度错误和 SQL Server 一样报出 |
|
||||||
|
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 |
|
||||||
|
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 |
|
||||||
|
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 已实现:实训台上的虚拟时钟可拨时间、1×/10×/60× 倍速,编译时把 DateTime.Now / Today / UtcNow 改写成虚拟时钟 |
|
||||||
|
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
|
||||||
|
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
|
||||||
|
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) |
|
||||||
|
| ICP 备案 | 办理需要数周 | 已备案 |
|
||||||
|
|
||||||
|
**待确认**
|
||||||
|
|
||||||
|
- [x] 串口要求接对:按实训台接线(COM2 ADAM-4017+、COM3 ADAM-4150、COM4 ZigBee 协调器、COM5 LED 屏,项目 7 的 4150 并在 COM2),选错和真机一样没反应,实训台上提示
|
||||||
|
- [ ] 虚拟实训台的画面细节(目前为第一版)
|
||||||
|
- [x] 第一个场景:智慧校园(和教材一致)
|
||||||
|
- [x] 设备写法:兼容教材类库
|
||||||
|
- [x] 数据库:教材用 SQL Server 2008 R2,平台用 SQLite 模拟
|
||||||
|
- [x] 项目 5 的推送方式:推送到 LED 屏
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "iot-sim",
|
"name": "iot-sim",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["shared", "server", "web"],
|
"workspaces": [
|
||||||
|
"shared",
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "bun run --cwd server dev",
|
"dev:server": "bun run --cwd server dev",
|
||||||
"dev:web": "bun run --cwd web dev",
|
"dev:web": "bun run --cwd web dev",
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
<!-- 需要 zh-CN 区域设置:日期格式要和中文 Windows 一致 -->
|
<!-- 需要 zh-CN 区域设置:日期格式要和中文 Windows 一致 -->
|
||||||
<InvariantGlobalization>false</InvariantGlobalization>
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<!-- 异常堆栈里的源文件路径不暴露服务器上的目录(StudentStackTrace 按 /runtime/Sim. 识别平台自己的代码) -->
|
||||||
|
<PathMap>$(MSBuildThisFileDirectory)=/runtime/</PathMap>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="Sim.WinForms/Sim.WinForms.csproj" />
|
<Project Path="Sim.WinForms/Sim.WinForms.csproj" />
|
||||||
<Project Path="Sim.Devices/Sim.Devices.csproj" />
|
<Project Path="Sim.Devices/Sim.Devices.csproj" />
|
||||||
|
<Project Path="Sim.Data/Sim.Data.csproj" />
|
||||||
<Project Path="Sim.Runner/Sim.Runner.csproj" />
|
<Project Path="Sim.Runner/Sim.Runner.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.SqlClient;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Sim.Data.Engine;
|
||||||
|
|
||||||
|
/// <summary>一列的定义(来自元数据表,保留建表时的 T-SQL 写法)</summary>
|
||||||
|
internal sealed record ColumnInfo(string Name, TSqlType Type, bool Nullable, bool Identity, bool PrimaryKey, string? Default);
|
||||||
|
|
||||||
|
internal sealed record TableInfo(string Name, IReadOnlyList<ColumnInfo> Columns)
|
||||||
|
{
|
||||||
|
public ColumnInfo? Find(string column) => Columns.FirstOrDefault(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
|
||||||
|
public ColumnInfo? Identity => Columns.FirstOrDefault(c => c.Identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一个学生的全部数据库:一个目录,每个库一个 SQLite 文件(库名.db)。
|
||||||
|
/// master 是默认库(连接字符串里不写 Initial Catalog 时连到它),和 SQL Server 一样也能在里面建表。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DatabaseStore
|
||||||
|
{
|
||||||
|
public const string Master = "master";
|
||||||
|
/// <summary>元数据表:每列的 T-SQL 类型、是否自增等。学生的 SQL 里不能引用它</summary>
|
||||||
|
public const string MetaTable = "__sim_columns";
|
||||||
|
private const int MaxDatabases = 20;
|
||||||
|
/// <summary>每个库最多约 50 MB(4 KB 一页)</summary>
|
||||||
|
private const int MaxPages = 12800;
|
||||||
|
|
||||||
|
public DatabaseStore(string directory) => Directory = directory;
|
||||||
|
|
||||||
|
public string Directory { get; }
|
||||||
|
|
||||||
|
/// <summary>学生程序运行时:服务器通过环境变量 SIM_DB_DIR 指定(沙箱里挂载学生自己的数据库目录)</summary>
|
||||||
|
public static DatabaseStore? FromEnvironment() =>
|
||||||
|
Environment.GetEnvironmentVariable("SIM_DB_DIR") is { Length: > 0 } dir ? new DatabaseStore(dir) : null;
|
||||||
|
|
||||||
|
public IEnumerable<string> List() =>
|
||||||
|
System.IO.Directory.Exists(Directory)
|
||||||
|
? System.IO.Directory.EnumerateFiles(Directory, "*.db").Select(f => Path.GetFileNameWithoutExtension(f)!).Where(n => n != Master).Order(StringComparer.Ordinal)
|
||||||
|
: Enumerable.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>库名大小写不敏感(和 SQL Server 一样),返回磁盘上实际的名字</summary>
|
||||||
|
public string? Resolve(string name)
|
||||||
|
{
|
||||||
|
if (string.Equals(name, Master, StringComparison.OrdinalIgnoreCase)) return Master;
|
||||||
|
return List().FirstOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqliteConnection Open(string name)
|
||||||
|
{
|
||||||
|
var resolved = Resolve(name);
|
||||||
|
if (resolved == null) throw SqlException.CannotOpenDatabase(name);
|
||||||
|
if (resolved == Master && !File.Exists(PathOf(Master))) Create(Master, allowMaster: true);
|
||||||
|
// 不用连接池:最后一个连接关闭时 WAL 里的数据写回主文件,复制、恢复数据库文件时不会丢数据
|
||||||
|
var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = PathOf(resolved), Mode = SqliteOpenMode.ReadWrite, Pooling = false }.ToString());
|
||||||
|
connection.Open();
|
||||||
|
Functions.Register(connection);
|
||||||
|
using var pragma = connection.CreateCommand();
|
||||||
|
pragma.CommandText = $"PRAGMA max_page_count = {MaxPages}; PRAGMA case_sensitive_like = OFF;";
|
||||||
|
pragma.ExecuteNonQuery();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Create(string name, bool allowMaster = false)
|
||||||
|
{
|
||||||
|
CheckName(name);
|
||||||
|
if (!allowMaster && Resolve(name) != null) throw SqlException.DatabaseExists(name);
|
||||||
|
if (List().Count() >= MaxDatabases) throw new SqlException(1105, $"最多只能建 {MaxDatabases} 个数据库,先删除不用的数据库。");
|
||||||
|
System.IO.Directory.CreateDirectory(Directory);
|
||||||
|
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = PathOf(name), Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }.ToString());
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
"PRAGMA journal_mode = WAL;" +
|
||||||
|
$"CREATE TABLE IF NOT EXISTS {MetaTable} (table_name TEXT NOT NULL COLLATE NOCASE, column_name TEXT NOT NULL COLLATE NOCASE, " +
|
||||||
|
"ordinal INTEGER NOT NULL, type_name TEXT NOT NULL, nullable INTEGER NOT NULL, is_identity INTEGER NOT NULL, " +
|
||||||
|
"is_primary_key INTEGER NOT NULL, default_text TEXT, PRIMARY KEY (table_name, column_name));";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Drop(string name)
|
||||||
|
{
|
||||||
|
var resolved = Resolve(name);
|
||||||
|
if (resolved == null || resolved == Master) throw new SqlException(3701, $"无法对 数据库 '{name}' 执行 删除,因为它不存在,或者您没有所需的权限。");
|
||||||
|
foreach (var suffix in new[] { "", "-wal", "-shm" }) File.Delete(PathOf(resolved) + suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string PathOf(string name) => Path.Combine(Directory, name + ".db");
|
||||||
|
|
||||||
|
/// <summary>库名就是文件名:不能有路径分隔符等字符</summary>
|
||||||
|
private static void CheckName(string name)
|
||||||
|
{
|
||||||
|
if (name.Length is 0 or > 64 || name.StartsWith('.') || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.IndexOfAny(['\\', ':', '*', '?', '"', '<', '>', '|']) >= 0)
|
||||||
|
throw new SqlException(102, $"数据库名 '{name}' 无效:不能为空、不能超过 64 个字,也不能包含 \\ / : * ? \" < > | 等字符。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 表结构(元数据表) ----------
|
||||||
|
|
||||||
|
public static TableInfo? GetTable(SqliteConnection connection, string name)
|
||||||
|
{
|
||||||
|
if (IsHidden(name)) return null;
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name COLLATE NOCASE";
|
||||||
|
command.Parameters.AddWithValue("$name", name);
|
||||||
|
if (command.ExecuteScalar() is not string actual) return null;
|
||||||
|
|
||||||
|
command.CommandText = $"SELECT column_name, type_name, nullable, is_identity, is_primary_key, default_text FROM {MetaTable} WHERE table_name = $name ORDER BY ordinal";
|
||||||
|
var columns = new List<ColumnInfo>();
|
||||||
|
using (var reader = command.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
columns.Add(new ColumnInfo(reader.GetString(0), TSqlType.Parse(reader.GetString(1)), reader.GetInt64(2) != 0,
|
||||||
|
reader.GetInt64(3) != 0, reader.GetInt64(4) != 0, reader.IsDBNull(5) ? null : reader.GetString(5)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new TableInfo(actual, columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> ListTables(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name";
|
||||||
|
var tables = new List<string>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var name = reader.GetString(0);
|
||||||
|
if (!IsHidden(name)) tables.Add(name);
|
||||||
|
}
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SaveColumns(SqliteConnection connection, string table, IEnumerable<ColumnInfo> columns, int firstOrdinal = 0)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = $"INSERT OR REPLACE INTO {MetaTable} VALUES ($t, $c, $o, $type, $null, $id, $pk, $def)";
|
||||||
|
var ordinal = firstOrdinal;
|
||||||
|
foreach (var column in columns)
|
||||||
|
{
|
||||||
|
command.Parameters.Clear();
|
||||||
|
command.Parameters.AddWithValue("$t", table);
|
||||||
|
command.Parameters.AddWithValue("$c", column.Name);
|
||||||
|
command.Parameters.AddWithValue("$o", ordinal++);
|
||||||
|
command.Parameters.AddWithValue("$type", column.Type.ToString());
|
||||||
|
command.Parameters.AddWithValue("$null", column.Nullable ? 1 : 0);
|
||||||
|
command.Parameters.AddWithValue("$id", column.Identity ? 1 : 0);
|
||||||
|
command.Parameters.AddWithValue("$pk", column.PrimaryKey ? 1 : 0);
|
||||||
|
command.Parameters.AddWithValue("$def", (object?)column.Default ?? DBNull.Value);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void DeleteColumns(SqliteConnection connection, string table)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = $"DELETE FROM {MetaTable} WHERE table_name = $t";
|
||||||
|
command.Parameters.AddWithValue("$t", table);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>平台内部的表和 SQLite 系统表,学生看不到也不能引用</summary>
|
||||||
|
public static bool IsHidden(string name) =>
|
||||||
|
name.StartsWith("__sim", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sqlite_", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System;
|
||||||
|
using System.Data.SqlClient;
|
||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Sim.Data.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册到 SQLite 连接上的 T-SQL 函数和运算(转写后的 SQL 调用它们,学生的 SQL 不能直接调用 tsql_ 开头的名字)。
|
||||||
|
/// 函数里抛出的 SqlException 先记在 <see cref="Pending"/>,执行语句处取出原样抛给学生(SQLite 只能传回文字信息)。
|
||||||
|
/// </summary>
|
||||||
|
internal static class Functions
|
||||||
|
{
|
||||||
|
[ThreadStatic] private static SqlException? _pending;
|
||||||
|
|
||||||
|
public static SqlException? TakePending()
|
||||||
|
{
|
||||||
|
var pending = _pending;
|
||||||
|
_pending = null;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Exception Fail(SqlException error)
|
||||||
|
{
|
||||||
|
_pending = error;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Register(SqliteConnection c)
|
||||||
|
{
|
||||||
|
c.CreateFunction<object?, object?, object?>("tsql_add", Add, isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?, object?>("tsql_div", Divide, isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?, object?>("tsql_mod", Modulo, isDeterministic: true);
|
||||||
|
c.CreateFunction("tsql_getdate", () => Values.FormatDateTime(TruncateToMilliseconds(DateTime.Now)));
|
||||||
|
c.CreateFunction<string, object?, object?>("tsql_datepart", DatePart, isDeterministic: true);
|
||||||
|
c.CreateFunction<string, object?, object?, object?>("tsql_dateadd", DateAdd, isDeterministic: true);
|
||||||
|
c.CreateFunction<string, object?, object?, object?>("tsql_datediff", DateDiff, isDeterministic: true);
|
||||||
|
c.CreateFunction<string, object?, object?, object?, object?>("tsql_convert", Convert, isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?>("tsql_len", s => s is null ? null : (long)Text(s).TrimEnd(' ').Length, isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?, object?>("tsql_left", (s, n) => s is null || n is null ? null : Left(Text(s), ToLong(n)), isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?, object?>("tsql_right", (s, n) => s is null || n is null ? null : Right(Text(s), ToLong(n)), isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?, object?>("tsql_charindex", (f, s) => f is null || s is null ? null : (long)Text(s).IndexOf(Text(f), StringComparison.OrdinalIgnoreCase) + 1, isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?>("tsql_floor", x => x is null ? null : Math.Floor(ToDouble(x)), isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?>("tsql_ceiling", x => x is null ? null : Math.Ceiling(ToDouble(x)), isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?, object?>("tsql_power", (x, y) => x is null || y is null ? null : Math.Pow(ToDouble(x), ToDouble(y)), isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?>("tsql_sqrt", x => x is null ? null : Math.Sqrt(ToDouble(x)), isDeterministic: true);
|
||||||
|
c.CreateFunction<object?, object?>("tsql_cmp", v => v is string s && Values.LooksLikeDate(s) ? Values.NormalizeDateLiteral(s) : v, isDeterministic: true);
|
||||||
|
c.CreateFunction("tsql_newid", () => Guid.NewGuid().ToString().ToUpperInvariant());
|
||||||
|
c.CreateFunction("tsql_rand", () => Random.Shared.NextDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime TruncateToMilliseconds(DateTime value) => new(value.Ticks - value.Ticks % TimeSpan.TicksPerMillisecond, value.Kind);
|
||||||
|
|
||||||
|
// ---------- 运算 ----------
|
||||||
|
|
||||||
|
/// <summary>T-SQL 的 +:两边都是字符串时连接,有数字时按数字相加(字符串转不成数字时报错)</summary>
|
||||||
|
private static object? Add(object? a, object? b)
|
||||||
|
{
|
||||||
|
if (a is null || b is null) return null;
|
||||||
|
if (a is string sa && b is string sb) return sa + sb;
|
||||||
|
if (a is byte[] || b is byte[]) throw Fail(SqlException.NotSupported("二进制数据的 + 运算"));
|
||||||
|
var x = Number(a);
|
||||||
|
var y = Number(b);
|
||||||
|
return x is long lx && y is long ly ? lx + ly : ToDouble(x) + ToDouble(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? Divide(object? a, object? b)
|
||||||
|
{
|
||||||
|
if (a is null || b is null) return null;
|
||||||
|
var x = Number(a);
|
||||||
|
var y = Number(b);
|
||||||
|
if (ToDouble(y) == 0) throw Fail(SqlException.DivideByZero());
|
||||||
|
return x is long lx && y is long ly ? lx / ly : ToDouble(x) / ToDouble(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? Modulo(object? a, object? b)
|
||||||
|
{
|
||||||
|
if (a is null || b is null) return null;
|
||||||
|
var x = Number(a);
|
||||||
|
var y = Number(b);
|
||||||
|
if (ToDouble(y) == 0) throw Fail(SqlException.DivideByZero());
|
||||||
|
return x is long lx && y is long ly ? lx % ly : ToDouble(x) % ToDouble(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>数字原样返回;字符串按 T-SQL 隐式转换成 int(带小数点时转成 float),转不了时报 245</summary>
|
||||||
|
private static object Number(object value) => value switch
|
||||||
|
{
|
||||||
|
long or double => value,
|
||||||
|
string s when long.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l,
|
||||||
|
string s when s.Contains('.') && double.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => d,
|
||||||
|
_ => throw Fail(Values.ConvertError(value, "int")),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static double ToDouble(object value) => value switch
|
||||||
|
{
|
||||||
|
long l => l,
|
||||||
|
double d => d,
|
||||||
|
_ => ToDouble(Number(value)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static long ToLong(object value) => value is long l ? l : (long)ToDouble(value);
|
||||||
|
|
||||||
|
private static string Text(object value) => value switch
|
||||||
|
{
|
||||||
|
string s => s,
|
||||||
|
double d => d.ToString(CultureInfo.InvariantCulture),
|
||||||
|
_ => System.Convert.ToString(value, CultureInfo.InvariantCulture) ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Left(string s, long n) => n <= 0 ? "" : s[..(int)Math.Min(n, s.Length)];
|
||||||
|
private static string Right(string s, long n) => n <= 0 ? "" : s[^(int)Math.Min(n, s.Length)..];
|
||||||
|
|
||||||
|
// ---------- 日期 ----------
|
||||||
|
|
||||||
|
private static DateTime ToDateTime(object value)
|
||||||
|
{
|
||||||
|
if (value is string s && Values.TryParseDateTime(s, out var d)) return d;
|
||||||
|
throw Fail(SqlException.DateConvertFailed());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? DatePart(string part, object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
var d = ToDateTime(value);
|
||||||
|
return part.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"year" or "yy" or "yyyy" => d.Year,
|
||||||
|
"quarter" or "qq" or "q" => (d.Month - 1) / 3 + 1,
|
||||||
|
"month" or "mm" or "m" => d.Month,
|
||||||
|
"dayofyear" or "dy" or "y" => d.DayOfYear,
|
||||||
|
"day" or "dd" or "d" => d.Day,
|
||||||
|
"week" or "wk" or "ww" => CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(d, CalendarWeekRule.FirstDay, DayOfWeek.Sunday),
|
||||||
|
"weekday" or "dw" or "w" => (int)d.DayOfWeek + 1,
|
||||||
|
"hour" or "hh" => d.Hour,
|
||||||
|
"minute" or "mi" or "n" => d.Minute,
|
||||||
|
"second" or "ss" or "s" => d.Second,
|
||||||
|
"millisecond" or "ms" => d.Millisecond,
|
||||||
|
_ => throw Fail(new SqlException(155, $"'{part}' 不是可以识别的 datepart 选项。")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? DateAdd(string part, object? amount, object? value)
|
||||||
|
{
|
||||||
|
if (amount is null || value is null) return null;
|
||||||
|
var d = ToDateTime(value);
|
||||||
|
var n = ToLong(amount);
|
||||||
|
var result = part.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"year" or "yy" or "yyyy" => d.AddYears((int)n),
|
||||||
|
"quarter" or "qq" or "q" => d.AddMonths((int)n * 3),
|
||||||
|
"month" or "mm" or "m" => d.AddMonths((int)n),
|
||||||
|
"day" or "dd" or "d" or "dayofyear" or "dy" or "y" or "weekday" or "dw" or "w" => d.AddDays(n),
|
||||||
|
"week" or "wk" or "ww" => d.AddDays(n * 7),
|
||||||
|
"hour" or "hh" => d.AddHours(n),
|
||||||
|
"minute" or "mi" or "n" => d.AddMinutes(n),
|
||||||
|
"second" or "ss" or "s" => d.AddSeconds(n),
|
||||||
|
"millisecond" or "ms" => d.AddMilliseconds(n),
|
||||||
|
_ => throw Fail(new SqlException(155, $"'{part}' 不是可以识别的 dateadd 选项。")),
|
||||||
|
};
|
||||||
|
return Values.FormatDateTime(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>和 SQL Server 一样数跨过的边界:DATEDIFF(day, '2024-01-01 23:00', '2024-01-02 01:00') = 1</summary>
|
||||||
|
private static object? DateDiff(string part, object? start, object? end)
|
||||||
|
{
|
||||||
|
if (start is null || end is null) return null;
|
||||||
|
var a = ToDateTime(start);
|
||||||
|
var b = ToDateTime(end);
|
||||||
|
return part.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"year" or "yy" or "yyyy" => (long)(b.Year - a.Year),
|
||||||
|
"quarter" or "qq" or "q" => (long)((b.Year - a.Year) * 4 + (b.Month - 1) / 3 - (a.Month - 1) / 3),
|
||||||
|
"month" or "mm" or "m" => (long)((b.Year - a.Year) * 12 + b.Month - a.Month),
|
||||||
|
"day" or "dd" or "d" or "dayofyear" or "dy" or "y" => (long)(b.Date - a.Date).TotalDays,
|
||||||
|
"week" or "wk" or "ww" => (long)((b.Date.AddDays(-(int)b.DayOfWeek) - a.Date.AddDays(-(int)a.DayOfWeek)).TotalDays / 7),
|
||||||
|
"hour" or "hh" => (long)(Trim(b, TimeSpan.TicksPerHour) - Trim(a, TimeSpan.TicksPerHour)).TotalHours,
|
||||||
|
"minute" or "mi" or "n" => (long)(Trim(b, TimeSpan.TicksPerMinute) - Trim(a, TimeSpan.TicksPerMinute)).TotalMinutes,
|
||||||
|
"second" or "ss" or "s" => (long)(Trim(b, TimeSpan.TicksPerSecond) - Trim(a, TimeSpan.TicksPerSecond)).TotalSeconds,
|
||||||
|
"millisecond" or "ms" => (long)(b - a).TotalMilliseconds,
|
||||||
|
_ => throw Fail(new SqlException(155, $"'{part}' 不是可以识别的 datediff 选项。")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime Trim(DateTime d, long unit) => new(d.Ticks - d.Ticks % unit);
|
||||||
|
|
||||||
|
// ---------- CONVERT / CAST,以及插入、修改时按列类型转换 ----------
|
||||||
|
|
||||||
|
/// <summary>tsql_convert('varchar', 10, 值, 120):转换成 T-SQL 类型;日期按 style 格式化</summary>
|
||||||
|
private static object? Convert(string type, object? length, object? value, object? style)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
var t = TSqlType.Parse(length is null ? type : $"{type}({(ToLong(length) < 0 ? "MAX" : ToLong(length).ToString(CultureInfo.InvariantCulture))})");
|
||||||
|
if (t.IsString)
|
||||||
|
{
|
||||||
|
var text = style is not null && value is string s && Values.TryParseDateTime(s, out var date)
|
||||||
|
? FormatStyle(date, (int)ToLong(style))
|
||||||
|
: Text(value);
|
||||||
|
return t.MaxLength is { } max && text.Length > max ? text[..max] : text;
|
||||||
|
}
|
||||||
|
if (t.IsInteger)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
long => value,
|
||||||
|
double d => (long)Math.Truncate(d),
|
||||||
|
string s when long.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l,
|
||||||
|
_ => throw Fail(Values.ConvertError(value, t.Name)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (t.IsBit)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
long l => l != 0 ? 1L : 0L,
|
||||||
|
double d => d != 0 ? 1L : 0L,
|
||||||
|
string s when s.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) => 1L,
|
||||||
|
string s when s.Trim().Equals("false", StringComparison.OrdinalIgnoreCase) => 0L,
|
||||||
|
string s when long.TryParse(s.Trim(), out var l) => l != 0 ? 1L : 0L,
|
||||||
|
_ => throw Fail(Values.ConvertError(value, "bit")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (t.IsFloat)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
long l => (double)l,
|
||||||
|
double => value,
|
||||||
|
string s when double.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => d,
|
||||||
|
_ => throw Fail(Values.ConvertError(value, t.Name)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (t.IsDateTime || t.IsDate)
|
||||||
|
{
|
||||||
|
if (value is not string s || !Values.TryParseDateTime(s, out var d)) throw Fail(SqlException.DateConvertFailed());
|
||||||
|
return t.IsDate ? Values.FormatDate(d) : Values.FormatDateTime(d);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>CONVERT 的日期样式(教材用 120:yyyy-mm-dd hh:mi:ss)</summary>
|
||||||
|
private static string FormatStyle(DateTime d, int style) => style switch
|
||||||
|
{
|
||||||
|
20 or 120 => d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
21 or 25 or 121 => d.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture),
|
||||||
|
23 => d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
101 => d.ToString("MM/dd/yyyy", CultureInfo.InvariantCulture),
|
||||||
|
102 => d.ToString("yyyy.MM.dd", CultureInfo.InvariantCulture),
|
||||||
|
103 => d.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture),
|
||||||
|
108 or 8 => d.ToString("HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
111 => d.ToString("yyyy/MM/dd", CultureInfo.InvariantCulture),
|
||||||
|
112 => d.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
|
||||||
|
126 => d.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture),
|
||||||
|
_ => d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.SqlClient;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.SqlServer.TransactSql.ScriptDom;
|
||||||
|
|
||||||
|
namespace Sim.Data.Engine;
|
||||||
|
|
||||||
|
/// <summary>结果集的一列:名字、T-SQL 类型(表达式列为 null),以及来自哪张表的哪一列(SqlCommandBuilder 生成语句时要用)</summary>
|
||||||
|
internal sealed record ResultColumn(string Name, TSqlType? Type, string? BaseTable, string? BaseColumn, bool IsKey, bool IsIdentity, bool AllowNull)
|
||||||
|
{
|
||||||
|
public Type ClrType => Type?.ClrType is { } t && t != typeof(object) ? t : typeof(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>一个结果集(读完放在内存里)</summary>
|
||||||
|
internal sealed record QueryResult(IReadOnlyList<ResultColumn> Columns, List<object[]> Rows);
|
||||||
|
|
||||||
|
/// <summary>一批语句的执行结果:各个结果集和受影响的行数(只有增删改时才算,否则为 -1)</summary>
|
||||||
|
internal sealed record BatchResult(List<QueryResult> Results, int RecordsAffected);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一个打开的数据库连接:当前库(USE 可以切换)、事务,执行一批 T-SQL。
|
||||||
|
/// SqlConnection 和数据库页面的查询窗口都用它。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Session : IDisposable
|
||||||
|
{
|
||||||
|
private SqliteTransaction? _transaction;
|
||||||
|
private long _deadline = long.MaxValue;
|
||||||
|
|
||||||
|
public Session(DatabaseStore store, string database, TimeSpan? timeLimit = null)
|
||||||
|
{
|
||||||
|
Store = store;
|
||||||
|
TimeLimit = timeLimit;
|
||||||
|
Connection = Open(database);
|
||||||
|
Database = store.Resolve(database)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>每批语句最长执行时间(数据库页面的查询窗口用;学生程序里由沙箱的 CPU 时间限制兜底)</summary>
|
||||||
|
public TimeSpan? TimeLimit { get; }
|
||||||
|
|
||||||
|
private SqliteConnection Open(string database)
|
||||||
|
{
|
||||||
|
var connection = Store.Open(database);
|
||||||
|
if (TimeLimit != null)
|
||||||
|
{
|
||||||
|
// 每执行一千条虚拟机指令检查一次,超时就中断(SQLite 报 interrupted)
|
||||||
|
SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1000, _ => Environment.TickCount64 > _deadline ? 1 : 0, null);
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DatabaseStore Store { get; }
|
||||||
|
public SqliteConnection Connection { get; private set; }
|
||||||
|
public string Database { get; private set; }
|
||||||
|
public bool InTransaction => _transaction != null;
|
||||||
|
|
||||||
|
public TableInfo? Lookup(string table) => DatabaseStore.GetTable(Connection, table);
|
||||||
|
|
||||||
|
public void ChangeDatabase(string name)
|
||||||
|
{
|
||||||
|
if (Store.Resolve(name) == null) throw SqlException.DatabaseNotFound(name);
|
||||||
|
if (_transaction != null) throw new SqlException(226, "在多语句事务内不允许使用 USE 语句。");
|
||||||
|
var next = Open(name);
|
||||||
|
Connection.Dispose();
|
||||||
|
Connection = next;
|
||||||
|
Database = Store.Resolve(name)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginTransaction()
|
||||||
|
{
|
||||||
|
if (_transaction != null) throw new InvalidOperationException("SqlConnection 不支持并行事务。");
|
||||||
|
_transaction = Connection.BeginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Commit()
|
||||||
|
{
|
||||||
|
_transaction?.Commit();
|
||||||
|
_transaction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Rollback()
|
||||||
|
{
|
||||||
|
_transaction?.Rollback();
|
||||||
|
_transaction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>执行一批语句;parameter 按名字(带不带 @ 都行)取参数值</summary>
|
||||||
|
public BatchResult Execute(string sql, Func<string, (bool Found, object? Value)> parameter)
|
||||||
|
{
|
||||||
|
var statements = Translator.Parse(sql);
|
||||||
|
if (TimeLimit is { } limit) _deadline = Environment.TickCount64 + (long)limit.TotalMilliseconds;
|
||||||
|
var results = new List<QueryResult>();
|
||||||
|
var affected = -1;
|
||||||
|
foreach (var parsed in statements)
|
||||||
|
{
|
||||||
|
var statement = Translate(parsed);
|
||||||
|
switch (statement)
|
||||||
|
{
|
||||||
|
case CreateDatabase create:
|
||||||
|
Store.Create(create.Name);
|
||||||
|
break;
|
||||||
|
case DropDatabases drop:
|
||||||
|
foreach (var name in drop.Names)
|
||||||
|
{
|
||||||
|
if (string.Equals(Store.Resolve(name), Database, StringComparison.OrdinalIgnoreCase)) throw SqlException.DatabaseInUse(name);
|
||||||
|
Store.Drop(name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case UseDatabase use:
|
||||||
|
ChangeDatabase(use.Name);
|
||||||
|
break;
|
||||||
|
case SqliteStatement run:
|
||||||
|
var (result, changes) = Run(run, parameter);
|
||||||
|
if (result != null) results.Add(result);
|
||||||
|
else if (changes >= 0) affected = Math.Max(affected, 0) + changes;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new BatchResult(results, affected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Statement Translate(TSqlStatement statement)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Translator.Translate(statement, Lookup);
|
||||||
|
}
|
||||||
|
catch (SqliteException ex)
|
||||||
|
{
|
||||||
|
throw Map(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (QueryResult? Result, int Changes) Run(SqliteStatement statement, Func<string, (bool Found, object? Value)> parameter)
|
||||||
|
{
|
||||||
|
using var command = Connection.CreateCommand();
|
||||||
|
command.Transaction = _transaction;
|
||||||
|
command.CommandText = statement.Sql;
|
||||||
|
foreach (var name in statement.Parameters)
|
||||||
|
{
|
||||||
|
var (found, value) = parameter(name);
|
||||||
|
if (!found) throw SqlException.UndeclaredVariable(name);
|
||||||
|
command.Parameters.AddWithValue(name, Values.ToStorage(value) ?? DBNull.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建表等语句和元数据一起提交;已经在学生的事务里时就用那个事务
|
||||||
|
var own = _transaction == null && statement.AfterExecute != null ? Connection.BeginTransaction() : null;
|
||||||
|
if (own != null) command.Transaction = own;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
QueryResult? result = null;
|
||||||
|
int changes;
|
||||||
|
if (statement.IsQuery)
|
||||||
|
{
|
||||||
|
result = Read(command);
|
||||||
|
changes = -1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
changes = command.ExecuteNonQuery();
|
||||||
|
statement.AfterExecute?.Invoke(Connection);
|
||||||
|
if (statement.AfterExecute != null) changes = -1;
|
||||||
|
}
|
||||||
|
own?.Commit();
|
||||||
|
return (result, changes);
|
||||||
|
}
|
||||||
|
catch (SqliteException ex)
|
||||||
|
{
|
||||||
|
own?.Rollback();
|
||||||
|
throw Map(ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
own?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private QueryResult Read(SqliteCommand command)
|
||||||
|
{
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
var schema = reader.GetSchemaTable();
|
||||||
|
var tables = new Dictionary<string, TableInfo?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var columns = new List<ResultColumn>();
|
||||||
|
for (var i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
var row = schema.Rows[i];
|
||||||
|
var baseTable = row["BaseTableName"] as string;
|
||||||
|
var baseColumn = row["BaseColumnName"] as string;
|
||||||
|
ColumnInfo? info = null;
|
||||||
|
if (!string.IsNullOrEmpty(baseTable) && !string.IsNullOrEmpty(baseColumn))
|
||||||
|
{
|
||||||
|
if (!tables.TryGetValue(baseTable, out var table)) tables[baseTable] = table = Lookup(baseTable);
|
||||||
|
info = table?.Find(baseColumn);
|
||||||
|
}
|
||||||
|
columns.Add(new ResultColumn(reader.GetName(i), info?.Type, info != null ? baseTable : null, info?.Name,
|
||||||
|
info?.PrimaryKey ?? false, info?.Identity ?? false, info?.Nullable ?? true));
|
||||||
|
}
|
||||||
|
var rows = new List<object[]>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var values = new object[reader.FieldCount];
|
||||||
|
reader.GetValues(values);
|
||||||
|
for (var i = 0; i < values.Length; i++) values[i] = Values.FromStorage(values[i], columns[i].Type);
|
||||||
|
rows.Add(values);
|
||||||
|
}
|
||||||
|
return new QueryResult(columns, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SQLite 的报错转成 SQL Server 的错误号和中文信息</summary>
|
||||||
|
private SqlException Map(SqliteException ex)
|
||||||
|
{
|
||||||
|
if (Functions.TakePending() is { } pending) return pending;
|
||||||
|
var message = ex.Message;
|
||||||
|
Match m;
|
||||||
|
if ((m = Regex.Match(message, @"no such table: (?:main\.)?(.+?)'?$")).Success) return SqlException.InvalidObject(m.Groups[1].Value);
|
||||||
|
// SQLite 对写错的列名会附带"是不是想写字符串"的提示,只取列名本身
|
||||||
|
if ((m = Regex.Match(message, @"no such column: ""?([^""\s']+)")).Success) return SqlException.InvalidColumn(m.Groups[1].Value);
|
||||||
|
if ((m = Regex.Match(message, @"ambiguous column name: (.+?)'?$")).Success) return SqlException.AmbiguousColumn(m.Groups[1].Value);
|
||||||
|
if ((m = Regex.Match(message, @"UNIQUE constraint failed: (\w+)\.")).Success) return SqlException.DuplicateKey(m.Groups[1].Value);
|
||||||
|
if ((m = Regex.Match(message, @"NOT NULL constraint failed: (.+?)\.(.+?)'?$")).Success)
|
||||||
|
return SqlException.NullNotAllowed(m.Groups[2].Value, $"{Database}.dbo.{m.Groups[1].Value}");
|
||||||
|
if (message.Contains("CHECK constraint failed: trunc:")) return SqlException.Truncated();
|
||||||
|
if ((m = Regex.Match(message, @"CHECK constraint failed: bit:(.+?)'?$")).Success) return new SqlException(245, $"列 '{m.Groups[1].Value}' 是 bit 类型,只能保存 0、1 或 NULL。");
|
||||||
|
if ((m = Regex.Match(message, @"cannot store (\w+) value in (\w+) column (.+?)\.(.+?)'?$")).Success)
|
||||||
|
{
|
||||||
|
var type = Lookup(m.Groups[3].Value)?.Find(m.Groups[4].Value)?.Type.Name ?? m.Groups[2].Value.ToLowerInvariant();
|
||||||
|
return new SqlException(245, $"将数据插入列 '{m.Groups[4].Value}' 时,无法把值转换成数据类型 {type}。");
|
||||||
|
}
|
||||||
|
if (message.Contains("database or disk is full")) return new SqlException(1105, "数据库已满:每个数据库最多约 50 MB。");
|
||||||
|
if (message.Contains("misuse of aggregate")) return new SqlException(147, "聚合不应出现在 WHERE 子句中,除非该聚合位于 HAVING 子句或选择列表所包含的子查询中。");
|
||||||
|
if (message.Contains("interrupted")) return new SqlException(-2, $"执行超时:查询超过 {TimeLimit?.TotalSeconds ?? 0} 秒,已停止。检查是否缺少 WHERE 条件或连接条件。");
|
||||||
|
if (message.Contains("database is locked")) return new SqlException(1222, "已超过了锁请求超时时段:数据库正被其他程序修改,请稍后再试。");
|
||||||
|
return new SqlException(50000, "执行出错:" + message.Replace("SQLite Error ", "").Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_transaction?.Dispose();
|
||||||
|
Connection.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Sim.Data.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一列的 T-SQL 数据类型,如 varchar(50)、int、datetime。
|
||||||
|
/// SQLite 表用 STRICT 模式保存(类型见 <see cref="StorageType"/>),原始的 T-SQL 类型记在元数据表里,
|
||||||
|
/// 读取时按它把值还原成 SQL Server 返回的 .NET 类型。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record TSqlType(string Name, int? Length = null, bool IsMax = false, int? Precision = null, int? Scale = null)
|
||||||
|
{
|
||||||
|
public static readonly TSqlType Unknown = new("sql_variant");
|
||||||
|
|
||||||
|
/// <summary>如 varchar(50)、varchar(MAX)、decimal(10, 2)</summary>
|
||||||
|
public override string ToString() => Name switch
|
||||||
|
{
|
||||||
|
_ when IsMax => $"{Name}(MAX)",
|
||||||
|
_ when Length != null => $"{Name}({Length})",
|
||||||
|
"decimal" or "numeric" when Precision != null => Scale != null ? $"{Name}({Precision}, {Scale})" : $"{Name}({Precision})",
|
||||||
|
_ => Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static TSqlType Parse(string text)
|
||||||
|
{
|
||||||
|
var m = Regex.Match(text.Trim(), @"^(\w+)\s*(?:\(\s*(MAX|\d+)\s*(?:,\s*(\d+)\s*)?\))?$", RegexOptions.IgnoreCase);
|
||||||
|
if (!m.Success) return Unknown;
|
||||||
|
var name = m.Groups[1].Value.ToLowerInvariant();
|
||||||
|
if (!m.Groups[2].Success) return new TSqlType(name);
|
||||||
|
if (m.Groups[2].Value.Equals("MAX", StringComparison.OrdinalIgnoreCase)) return new TSqlType(name, IsMax: true);
|
||||||
|
var first = int.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||||
|
if (name is "decimal" or "numeric")
|
||||||
|
return new TSqlType(name, Precision: first, Scale: m.Groups[3].Success ? int.Parse(m.Groups[3].Value, CultureInfo.InvariantCulture) : null);
|
||||||
|
return new TSqlType(name, Length: first);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsString => Name is "char" or "varchar" or "nchar" or "nvarchar" or "text" or "ntext";
|
||||||
|
public bool IsDateTime => Name is "datetime" or "datetime2" or "smalldatetime";
|
||||||
|
public bool IsDate => Name == "date";
|
||||||
|
public bool IsInteger => Name is "int" or "bigint" or "smallint" or "tinyint";
|
||||||
|
public bool IsBit => Name == "bit";
|
||||||
|
public bool IsFloat => Name is "float" or "real" or "decimal" or "numeric" or "money" or "smallmoney";
|
||||||
|
public bool IsBinary => Name is "binary" or "varbinary" or "image";
|
||||||
|
|
||||||
|
/// <summary>SQLite STRICT 表的列类型</summary>
|
||||||
|
public string StorageType => this switch
|
||||||
|
{
|
||||||
|
{ IsInteger: true } or { IsBit: true } => "INTEGER",
|
||||||
|
{ IsFloat: true } => "REAL",
|
||||||
|
{ IsBinary: true } => "BLOB",
|
||||||
|
{ IsString: true } or { IsDateTime: true } or { IsDate: true } or { Name: "time" or "uniqueidentifier" } => "TEXT",
|
||||||
|
_ => "ANY",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>字符串列的长度上限(超出时和 SQL Server 一样报"将截断字符串")</summary>
|
||||||
|
public int? MaxLength => IsString && !IsMax && Name is not ("text" or "ntext") ? Length ?? 1 : null;
|
||||||
|
|
||||||
|
/// <summary>SQL Server 返回这一列时的 .NET 类型(DataTable 的列类型、DataGridView 的显示方式由它决定)</summary>
|
||||||
|
public Type ClrType => Name switch
|
||||||
|
{
|
||||||
|
"int" => typeof(int),
|
||||||
|
"bigint" => typeof(long),
|
||||||
|
"smallint" => typeof(short),
|
||||||
|
"tinyint" => typeof(byte),
|
||||||
|
"bit" => typeof(bool),
|
||||||
|
"float" => typeof(double),
|
||||||
|
"real" => typeof(float),
|
||||||
|
"decimal" or "numeric" or "money" or "smallmoney" => typeof(decimal),
|
||||||
|
"date" or "datetime" or "datetime2" or "smalldatetime" => typeof(DateTime),
|
||||||
|
"time" => typeof(TimeSpan),
|
||||||
|
"uniqueidentifier" => typeof(Guid),
|
||||||
|
"binary" or "varbinary" or "image" => typeof(byte[]),
|
||||||
|
"char" or "varchar" or "nchar" or "nvarchar" or "text" or "ntext" => typeof(string),
|
||||||
|
_ => typeof(object),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>平台支持的 T-SQL 类型(建表时检查)</summary>
|
||||||
|
public static bool IsSupported(string name) => name is
|
||||||
|
"int" or "bigint" or "smallint" or "tinyint" or "bit" or "float" or "real" or "decimal" or "numeric" or "money" or "smallmoney"
|
||||||
|
or "date" or "datetime" or "datetime2" or "smalldatetime" or "time" or "uniqueidentifier"
|
||||||
|
or "binary" or "varbinary" or "image" or "char" or "varchar" or "nchar" or "nvarchar" or "text" or "ntext";
|
||||||
|
}
|
||||||
@@ -0,0 +1,925 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.SqlClient;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.SqlServer.TransactSql.ScriptDom;
|
||||||
|
|
||||||
|
namespace Sim.Data.Engine;
|
||||||
|
|
||||||
|
/// <summary>翻译好的一条语句</summary>
|
||||||
|
internal abstract record Statement;
|
||||||
|
|
||||||
|
/// <summary>在 SQLite 上执行的语句;IsQuery 表示有结果集;AfterExecute 在同一事务里更新元数据</summary>
|
||||||
|
internal sealed record SqliteStatement(string Sql, bool IsQuery, IReadOnlyList<string> Parameters, Action<Microsoft.Data.Sqlite.SqliteConnection>? AfterExecute = null) : Statement;
|
||||||
|
|
||||||
|
internal sealed record CreateDatabase(string Name) : Statement;
|
||||||
|
internal sealed record DropDatabases(IReadOnlyList<string> Names) : Statement;
|
||||||
|
internal sealed record UseDatabase(string Name) : Statement;
|
||||||
|
/// <summary>SET NOCOUNT ON 之类不影响结果的语句</summary>
|
||||||
|
internal sealed record IgnoredStatement : Statement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把 T-SQL(SQL Server 2008 语法)转写成 SQLite 语句。只转写认识的语法节点,其余一律报"平台暂不支持",
|
||||||
|
/// 生成的 SQL 里只会出现这里写出的关键字和 tsql_ 函数,学生的 SQL 无法借此执行 SQLite 特有的命令。
|
||||||
|
/// 一批语句先整体解析(语法错误和 SQL Server 一样在执行前报出),执行时再逐条转写(后面的语句能用到前面刚建的表)。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Translator
|
||||||
|
{
|
||||||
|
private readonly Func<string, TableInfo?> _lookup;
|
||||||
|
private readonly StringBuilder _sql = new();
|
||||||
|
private readonly List<string> _parameters = new();
|
||||||
|
|
||||||
|
private Translator(Func<string, TableInfo?> lookup) => _lookup = lookup;
|
||||||
|
|
||||||
|
/// <summary>解析整批语句;有语法错误时抛出 SqlException 102</summary>
|
||||||
|
public static IReadOnlyList<TSqlStatement> Parse(string sql)
|
||||||
|
{
|
||||||
|
var parser = new TSql100Parser(initialQuotedIdentifiers: true);
|
||||||
|
var fragment = parser.Parse(new StringReader(sql), out var errors);
|
||||||
|
if (errors.Count > 0) throw SyntaxError(sql, errors[0]);
|
||||||
|
return ((TSqlScript)fragment).Batches.SelectMany(b => b.Statements).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>转写一条语句;lookup 按表名取表结构(INSERT 跳过自增列、按列类型转换值时要用)</summary>
|
||||||
|
public static Statement Translate(TSqlStatement statement, Func<string, TableInfo?> lookup) => new Translator(lookup).Statement(statement);
|
||||||
|
|
||||||
|
private static SqlException SyntaxError(string sql, ParseError error)
|
||||||
|
{
|
||||||
|
// ParseError 的信息是英文,这里取出错位置的词,按 SQL Server 中文版的格式报
|
||||||
|
var rest = error.Offset < sql.Length ? sql[error.Offset..] : "";
|
||||||
|
var token = new string(rest.TakeWhile(c => !char.IsWhiteSpace(c) && c is not (',' or ')' or '(' or ';')).ToArray());
|
||||||
|
if (token.Length == 0 && rest.Length > 0) token = rest[..1];
|
||||||
|
if (token.Length == 0) return new SqlException(102, "语句不完整,结尾附近有语法错误。", error.Line);
|
||||||
|
if (token.StartsWith('\'')) return new SqlException(105, $"字符串 '{token.Trim('\'')}' 后的引号不完整。", error.Line);
|
||||||
|
return SqlException.Syntax(token, error.Line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 语句 ----------
|
||||||
|
|
||||||
|
private Statement Statement(TSqlStatement statement)
|
||||||
|
{
|
||||||
|
switch (statement)
|
||||||
|
{
|
||||||
|
case SelectStatement select:
|
||||||
|
if (select.Into != null) throw SqlException.NotSupported("SELECT ... INTO");
|
||||||
|
if (select.WithCtesAndXmlNamespaces != null) throw SqlException.NotSupported("WITH(公用表表达式)");
|
||||||
|
Query(select.QueryExpression);
|
||||||
|
return Done(isQuery: true);
|
||||||
|
case InsertStatement insert:
|
||||||
|
Insert(insert.InsertSpecification);
|
||||||
|
return Done(isQuery: false);
|
||||||
|
case UpdateStatement update:
|
||||||
|
Update(update.UpdateSpecification);
|
||||||
|
return Done(isQuery: false);
|
||||||
|
case DeleteStatement delete:
|
||||||
|
Delete(delete.DeleteSpecification);
|
||||||
|
return Done(isQuery: false);
|
||||||
|
case CreateTableStatement create:
|
||||||
|
return CreateTable(create);
|
||||||
|
case DropTableStatement drop:
|
||||||
|
return DropTable(drop);
|
||||||
|
case AlterTableAddTableElementStatement alter:
|
||||||
|
return AddColumns(alter);
|
||||||
|
case TruncateTableStatement truncate:
|
||||||
|
{
|
||||||
|
var table = RequireTable(truncate.TableName);
|
||||||
|
_sql.Append("DELETE FROM ").Append(Values.QuoteName(table.Name));
|
||||||
|
return Done(isQuery: false);
|
||||||
|
}
|
||||||
|
case CreateDatabaseStatement create:
|
||||||
|
return new CreateDatabase(create.DatabaseName.Value);
|
||||||
|
case DropDatabaseStatement drop:
|
||||||
|
return new DropDatabases(drop.Databases.Select(d => d.Value).ToList());
|
||||||
|
case UseStatement use:
|
||||||
|
return new UseDatabase(use.DatabaseName.Value);
|
||||||
|
case PredicateSetStatement:
|
||||||
|
return new IgnoredStatement();
|
||||||
|
default:
|
||||||
|
throw SqlException.NotSupported(StatementName(statement));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteStatement Done(bool isQuery, Action<Microsoft.Data.Sqlite.SqliteConnection>? after = null) =>
|
||||||
|
new(_sql.ToString(), isQuery, _parameters.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), after);
|
||||||
|
|
||||||
|
private static string StatementName(TSqlStatement statement) => statement.GetType().Name.Replace("Statement", "") switch
|
||||||
|
{
|
||||||
|
"CreateProcedure" or "AlterProcedure" => "存储过程",
|
||||||
|
"CreateView" or "AlterView" => "视图",
|
||||||
|
"CreateTrigger" => "触发器",
|
||||||
|
"CreateIndex" => "CREATE INDEX",
|
||||||
|
"DeclareVariable" => "DECLARE 变量",
|
||||||
|
"If" => "IF 语句",
|
||||||
|
"While" => "WHILE 循环",
|
||||||
|
"BeginEndBlock" => "BEGIN ... END",
|
||||||
|
"Execute" => "EXEC",
|
||||||
|
var name => name + " 语句",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 查询 ----------
|
||||||
|
|
||||||
|
private void Query(QueryExpression query)
|
||||||
|
{
|
||||||
|
switch (query)
|
||||||
|
{
|
||||||
|
case QuerySpecification spec:
|
||||||
|
QuerySpec(spec);
|
||||||
|
break;
|
||||||
|
case BinaryQueryExpression binary:
|
||||||
|
Query(binary.FirstQueryExpression);
|
||||||
|
_sql.Append(binary.BinaryQueryExpressionType switch
|
||||||
|
{
|
||||||
|
BinaryQueryExpressionType.Union => binary.All ? " UNION ALL " : " UNION ",
|
||||||
|
BinaryQueryExpressionType.Except => " EXCEPT ",
|
||||||
|
_ => " INTERSECT ",
|
||||||
|
});
|
||||||
|
Query(binary.SecondQueryExpression);
|
||||||
|
OrderBy(binary.OrderByClause);
|
||||||
|
break;
|
||||||
|
case QueryParenthesisExpression paren:
|
||||||
|
_sql.Append("SELECT * FROM (");
|
||||||
|
Query(paren.QueryExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
OrderBy(paren.OrderByClause);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw SqlException.NotSupported(query.GetType().Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QuerySpec(QuerySpecification spec)
|
||||||
|
{
|
||||||
|
_sql.Append("SELECT ");
|
||||||
|
if (spec.UniqueRowFilter == UniqueRowFilter.Distinct) _sql.Append("DISTINCT ");
|
||||||
|
for (var i = 0; i < spec.SelectElements.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
SelectElement(spec.SelectElements[i]);
|
||||||
|
}
|
||||||
|
if (spec.FromClause != null)
|
||||||
|
{
|
||||||
|
_sql.Append(" FROM ");
|
||||||
|
for (var i = 0; i < spec.FromClause.TableReferences.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
TableReference(spec.FromClause.TableReferences[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Where(spec.WhereClause);
|
||||||
|
if (spec.GroupByClause != null)
|
||||||
|
{
|
||||||
|
_sql.Append(" GROUP BY ");
|
||||||
|
for (var i = 0; i < spec.GroupByClause.GroupingSpecifications.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
if (spec.GroupByClause.GroupingSpecifications[i] is not ExpressionGroupingSpecification group) throw SqlException.NotSupported("这种 GROUP BY 写法");
|
||||||
|
Expression(group.Expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (spec.HavingClause != null)
|
||||||
|
{
|
||||||
|
_sql.Append(" HAVING ");
|
||||||
|
Boolean(spec.HavingClause.SearchCondition);
|
||||||
|
}
|
||||||
|
OrderBy(spec.OrderByClause);
|
||||||
|
Top(spec.TopRowFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectElement(SelectElement element)
|
||||||
|
{
|
||||||
|
switch (element)
|
||||||
|
{
|
||||||
|
case SelectStarExpression star:
|
||||||
|
if (star.Qualifier != null) _sql.Append(Values.QuoteName(star.Qualifier.Identifiers[^1].Value)).Append('.');
|
||||||
|
_sql.Append('*');
|
||||||
|
break;
|
||||||
|
case SelectScalarExpression scalar:
|
||||||
|
Expression(scalar.Expression);
|
||||||
|
if (scalar.ColumnName != null)
|
||||||
|
{
|
||||||
|
// as '编号' 和 as 编号 都按列名处理
|
||||||
|
_sql.Append(" AS ").Append(Values.QuoteName(scalar.ColumnName.Value));
|
||||||
|
}
|
||||||
|
else if (scalar.Expression is not ColumnReferenceExpression)
|
||||||
|
{
|
||||||
|
// 和 SQL Server 一样,没有起别名的表达式列没有列名(DataTable 里叫 Column1)
|
||||||
|
_sql.Append(" AS \"\"");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw SqlException.NotSupported("SELECT @变量 = ...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TableReference(TableReference reference)
|
||||||
|
{
|
||||||
|
switch (reference)
|
||||||
|
{
|
||||||
|
case NamedTableReference named:
|
||||||
|
var table = RequireTable(named.SchemaObject);
|
||||||
|
_sql.Append(Values.QuoteName(table.Name));
|
||||||
|
if (named.Alias != null) _sql.Append(" AS ").Append(Values.QuoteName(named.Alias.Value));
|
||||||
|
break;
|
||||||
|
case QualifiedJoin join:
|
||||||
|
TableReference(join.FirstTableReference);
|
||||||
|
_sql.Append(join.QualifiedJoinType switch
|
||||||
|
{
|
||||||
|
QualifiedJoinType.Inner => " INNER JOIN ",
|
||||||
|
QualifiedJoinType.LeftOuter => " LEFT JOIN ",
|
||||||
|
QualifiedJoinType.RightOuter => " RIGHT JOIN ",
|
||||||
|
_ => " FULL JOIN ",
|
||||||
|
});
|
||||||
|
TableReference(join.SecondTableReference);
|
||||||
|
_sql.Append(" ON ");
|
||||||
|
Boolean(join.SearchCondition);
|
||||||
|
break;
|
||||||
|
case UnqualifiedJoin cross when cross.UnqualifiedJoinType == UnqualifiedJoinType.CrossJoin:
|
||||||
|
TableReference(cross.FirstTableReference);
|
||||||
|
_sql.Append(" CROSS JOIN ");
|
||||||
|
TableReference(cross.SecondTableReference);
|
||||||
|
break;
|
||||||
|
case QueryDerivedTable derived:
|
||||||
|
_sql.Append('(');
|
||||||
|
Query(derived.QueryExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
if (derived.Alias != null) _sql.Append(" AS ").Append(Values.QuoteName(derived.Alias.Value));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw SqlException.NotSupported("这种 FROM 写法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Where(WhereClause? where)
|
||||||
|
{
|
||||||
|
if (where == null) return;
|
||||||
|
if (where.Cursor != null) throw SqlException.NotSupported("游标");
|
||||||
|
_sql.Append(" WHERE ");
|
||||||
|
Boolean(where.SearchCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OrderBy(OrderByClause? orderBy)
|
||||||
|
{
|
||||||
|
if (orderBy == null) return;
|
||||||
|
_sql.Append(" ORDER BY ");
|
||||||
|
for (var i = 0; i < orderBy.OrderByElements.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
Expression(orderBy.OrderByElements[i].Expression);
|
||||||
|
if (orderBy.OrderByElements[i].SortOrder == SortOrder.Descending) _sql.Append(" DESC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Top(TopRowFilter? top)
|
||||||
|
{
|
||||||
|
if (top == null) return;
|
||||||
|
if (top.Percent) throw SqlException.NotSupported("TOP ... PERCENT");
|
||||||
|
if (top.WithTies) throw SqlException.NotSupported("TOP ... WITH TIES");
|
||||||
|
_sql.Append(" LIMIT ");
|
||||||
|
Expression(top.Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 增删改 ----------
|
||||||
|
|
||||||
|
private void Insert(InsertSpecification spec)
|
||||||
|
{
|
||||||
|
if (spec.TopRowFilter != null) throw SqlException.NotSupported("INSERT TOP");
|
||||||
|
if (spec.Target is not NamedTableReference target) throw SqlException.NotSupported("这种 INSERT 写法");
|
||||||
|
var table = RequireTable(target.SchemaObject);
|
||||||
|
|
||||||
|
// 列:写了列名就按列名;没写就是除自增列以外的全部列(和 SQL Server 一样)
|
||||||
|
List<ColumnInfo> columns;
|
||||||
|
if (spec.Columns.Count > 0)
|
||||||
|
{
|
||||||
|
columns = spec.Columns.Select(c => RequireColumn(table, c.MultiPartIdentifier.Identifiers[^1].Value)).ToList();
|
||||||
|
if (columns.FirstOrDefault(c => c.Identity) != null) throw SqlException.IdentityInsert(table.Name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
columns = table.Columns.Where(c => !c.Identity).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
_sql.Append("INSERT INTO ").Append(Values.QuoteName(table.Name));
|
||||||
|
switch (spec.InsertSource)
|
||||||
|
{
|
||||||
|
case ValuesInsertSource { IsDefaultValues: true }:
|
||||||
|
_sql.Append(" DEFAULT VALUES");
|
||||||
|
break;
|
||||||
|
case ValuesInsertSource values:
|
||||||
|
_sql.Append(" (").Append(string.Join(", ", columns.Select(c => Values.QuoteName(c.Name)))).Append(") VALUES ");
|
||||||
|
for (var r = 0; r < values.RowValues.Count; r++)
|
||||||
|
{
|
||||||
|
var row = values.RowValues[r].ColumnValues;
|
||||||
|
if (row.Count != columns.Count) throw SqlException.ColumnCountMismatch();
|
||||||
|
if (r > 0) _sql.Append(", ");
|
||||||
|
_sql.Append('(');
|
||||||
|
for (var i = 0; i < row.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
ValueFor(columns[i], row[i]);
|
||||||
|
}
|
||||||
|
_sql.Append(')');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SelectInsertSource select:
|
||||||
|
_sql.Append(" (").Append(string.Join(", ", columns.Select(c => Values.QuoteName(c.Name)))).Append(") ");
|
||||||
|
Query(select.Select);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw SqlException.NotSupported("INSERT ... EXEC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update(UpdateSpecification spec)
|
||||||
|
{
|
||||||
|
if (spec.FromClause != null) throw SqlException.NotSupported("UPDATE ... FROM");
|
||||||
|
if (spec.Target is not NamedTableReference target) throw SqlException.NotSupported("这种 UPDATE 写法");
|
||||||
|
var table = RequireTable(target.SchemaObject);
|
||||||
|
_sql.Append("UPDATE ").Append(Values.QuoteName(table.Name)).Append(" SET ");
|
||||||
|
for (var i = 0; i < spec.SetClauses.Count; i++)
|
||||||
|
{
|
||||||
|
if (spec.SetClauses[i] is not AssignmentSetClause { Column: not null } set || set.AssignmentKind != AssignmentKind.Equals)
|
||||||
|
throw SqlException.NotSupported("这种 SET 写法");
|
||||||
|
var column = RequireColumn(table, set.Column.MultiPartIdentifier.Identifiers[^1].Value);
|
||||||
|
if (column.Identity) throw new SqlException(8102, $"无法更新标识列 '{column.Name}'。");
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
_sql.Append(Values.QuoteName(column.Name)).Append(" = ");
|
||||||
|
ValueFor(column, set.NewValue);
|
||||||
|
}
|
||||||
|
LimitedWhere(table, spec.WhereClause, spec.TopRowFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Delete(DeleteSpecification spec)
|
||||||
|
{
|
||||||
|
if (spec.FromClause != null) throw SqlException.NotSupported("DELETE ... FROM ... JOIN");
|
||||||
|
if (spec.Target is not NamedTableReference target) throw SqlException.NotSupported("这种 DELETE 写法");
|
||||||
|
var table = RequireTable(target.SchemaObject);
|
||||||
|
_sql.Append("DELETE FROM ").Append(Values.QuoteName(table.Name));
|
||||||
|
LimitedWhere(table, spec.WhereClause, spec.TopRowFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>UPDATE TOP (n) / DELETE TOP (n):SQLite 不支持,改成按 rowid 限定前 n 行</summary>
|
||||||
|
private void LimitedWhere(TableInfo table, WhereClause? where, TopRowFilter? top)
|
||||||
|
{
|
||||||
|
if (top == null)
|
||||||
|
{
|
||||||
|
Where(where);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sql.Append(" WHERE rowid IN (SELECT rowid FROM ").Append(Values.QuoteName(table.Name));
|
||||||
|
Where(where);
|
||||||
|
Top(top);
|
||||||
|
_sql.Append(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>插入、修改的值按目标列的类型转换(日期统一格式、字符串转数字失败时报和 SQL Server 一样的错)</summary>
|
||||||
|
private void ValueFor(ColumnInfo column, ScalarExpression value)
|
||||||
|
{
|
||||||
|
if (value is NullLiteral || value is DefaultLiteral)
|
||||||
|
{
|
||||||
|
_sql.Append(value is NullLiteral ? "NULL" : throw SqlException.NotSupported("VALUES 里的 DEFAULT"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var type = column.Type;
|
||||||
|
if (type.IsString || type.IsBinary || type.StorageType == "ANY")
|
||||||
|
{
|
||||||
|
Expression(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sql.Append("tsql_convert(").Append(Values.Quote(type.Name)).Append(", NULL, ");
|
||||||
|
Expression(value);
|
||||||
|
_sql.Append(", NULL)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 建表、删表、加列 ----------
|
||||||
|
|
||||||
|
private Statement CreateTable(CreateTableStatement create)
|
||||||
|
{
|
||||||
|
var name = TableName(create.SchemaObjectName);
|
||||||
|
if (DatabaseStore.IsHidden(name)) throw new SqlException(102, $"表名 '{name}' 是平台保留的名字,请换一个。");
|
||||||
|
if (_lookup(name) != null) throw SqlException.ObjectExists(name);
|
||||||
|
var definition = create.Definition;
|
||||||
|
|
||||||
|
var primaryKey = definition.TableConstraints.OfType<UniqueConstraintDefinition>().FirstOrDefault(c => c.IsPrimaryKey);
|
||||||
|
var keyColumns = primaryKey?.Columns.Select(c => c.Column.MultiPartIdentifier.Identifiers[^1].Value).ToList() ?? new List<string>();
|
||||||
|
var columns = definition.ColumnDefinitions.Select(c => Column(c, keyColumns)).ToList();
|
||||||
|
if (columns.Count(c => c.Identity) > 1) throw new SqlException(2744, $"为表 '{name}' 指定了多个标识列。只允许为每个表指定一个标识列。");
|
||||||
|
foreach (var key in keyColumns)
|
||||||
|
if (columns.All(c => !string.Equals(c.Name, key, StringComparison.OrdinalIgnoreCase))) throw SqlException.InvalidColumn(key);
|
||||||
|
|
||||||
|
_sql.Append("CREATE TABLE ").Append(Values.QuoteName(name)).Append(" (");
|
||||||
|
_sql.Append(string.Join(", ", columns.Select(c => ColumnSql(c, single: keyColumns.Count <= 1 && c.PrimaryKey))));
|
||||||
|
if (keyColumns.Count > 1) _sql.Append(", PRIMARY KEY (").Append(string.Join(", ", keyColumns.Select(Values.QuoteName))).Append(')');
|
||||||
|
_sql.Append(") STRICT");
|
||||||
|
return Done(isQuery: false, after: c => DatabaseStore.SaveColumns(c, name, columns));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Statement AddColumns(AlterTableAddTableElementStatement alter)
|
||||||
|
{
|
||||||
|
var table = RequireTable(alter.SchemaObjectName);
|
||||||
|
if (alter.Definition.TableConstraints.Count > 0) throw SqlException.NotSupported("ALTER TABLE 添加约束");
|
||||||
|
var columns = alter.Definition.ColumnDefinitions.Select(c => Column(c, new List<string>())).ToList();
|
||||||
|
foreach (var column in columns)
|
||||||
|
{
|
||||||
|
if (table.Find(column.Name) != null) throw new SqlException(2705, $"各表中的列名必须唯一。在表 '{table.Name}' 中多次指定了列名 '{column.Name}'。");
|
||||||
|
if (column.Identity || column.PrimaryKey) throw SqlException.NotSupported("ALTER TABLE 添加主键或标识列");
|
||||||
|
if (_sql.Length > 0) _sql.Append("; ");
|
||||||
|
_sql.Append("ALTER TABLE ").Append(Values.QuoteName(table.Name)).Append(" ADD COLUMN ").Append(ColumnSql(column, single: false));
|
||||||
|
}
|
||||||
|
return Done(isQuery: false, after: c => DatabaseStore.SaveColumns(c, table.Name, columns, table.Columns.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Statement DropTable(DropTableStatement drop)
|
||||||
|
{
|
||||||
|
var tables = drop.Objects.Select(o => _lookup(TableName(o)) ?? throw SqlException.CannotDropTable(TableName(o))).ToList();
|
||||||
|
_sql.Append(string.Join("; ", tables.Select(t => "DROP TABLE " + Values.QuoteName(t.Name))));
|
||||||
|
return Done(isQuery: false, after: c =>
|
||||||
|
{
|
||||||
|
foreach (var table in tables) DatabaseStore.DeleteColumns(c, table.Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ColumnInfo Column(ColumnDefinition definition, List<string> keyColumns)
|
||||||
|
{
|
||||||
|
var name = definition.ColumnIdentifier.Value;
|
||||||
|
if (definition.DataType is not SqlDataTypeReference dataType) throw new SqlException(2715, $"第 1 个列、参数或变量: 找不到数据类型 {definition.DataType?.Name?.BaseIdentifier?.Value}。");
|
||||||
|
var type = TypeOf(dataType);
|
||||||
|
var identity = definition.IdentityOptions != null;
|
||||||
|
if (identity && !type.IsInteger) throw new SqlException(2749, $"标识列 '{name}' 的数据类型必须是 int、bigint、smallint 或 tinyint。");
|
||||||
|
var primaryKey = keyColumns.Any(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
|| definition.Constraints.OfType<UniqueConstraintDefinition>().Any(c => c.IsPrimaryKey);
|
||||||
|
var nullable = !identity && !primaryKey && definition.Constraints.OfType<NullableConstraintDefinition>().All(c => c.Nullable);
|
||||||
|
if (definition.Constraints.Any(c => c is not (NullableConstraintDefinition or UniqueConstraintDefinition { IsPrimaryKey: true })))
|
||||||
|
throw SqlException.NotSupported("PRIMARY KEY、NULL / NOT NULL 以外的列约束");
|
||||||
|
|
||||||
|
string? defaultText = null;
|
||||||
|
if (definition.DefaultConstraint != null) defaultText = DefaultSql(definition.DefaultConstraint.Expression);
|
||||||
|
if (identity && definition.IdentityOptions!.IdentitySeed is IntegerLiteral seed && seed.Value != "1")
|
||||||
|
throw SqlException.NotSupported("从 1 以外的数开始的 IDENTITY");
|
||||||
|
return new ColumnInfo(name, type, nullable, identity, primaryKey, defaultText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TSqlType TypeOf(SqlDataTypeReference dataType)
|
||||||
|
{
|
||||||
|
var name = dataType.SqlDataTypeOption.ToString().ToLowerInvariant();
|
||||||
|
if (!TSqlType.IsSupported(name)) throw SqlException.NotSupported($"数据类型 {name}");
|
||||||
|
var parameters = dataType.Parameters;
|
||||||
|
if (parameters.Count == 0) return new TSqlType(name);
|
||||||
|
if (parameters[0] is MaxLiteral) return new TSqlType(name, IsMax: true);
|
||||||
|
var first = int.Parse(parameters[0].Value, CultureInfo.InvariantCulture);
|
||||||
|
if (name is "decimal" or "numeric")
|
||||||
|
return new TSqlType(name, Precision: first, Scale: parameters.Count > 1 ? int.Parse(parameters[1].Value, CultureInfo.InvariantCulture) : null);
|
||||||
|
return new TSqlType(name, Length: first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SQLite 的列定义:STRICT 类型 + 约束;字符串长度和 bit 取值用 CHECK 实现(约束名用来认出是哪种错误)</summary>
|
||||||
|
private static string ColumnSql(ColumnInfo column, bool single)
|
||||||
|
{
|
||||||
|
var sql = new StringBuilder(Values.QuoteName(column.Name));
|
||||||
|
if (column.Identity && single)
|
||||||
|
{
|
||||||
|
sql.Append(" INTEGER PRIMARY KEY AUTOINCREMENT");
|
||||||
|
return sql.ToString();
|
||||||
|
}
|
||||||
|
if (column.Identity) throw SqlException.NotSupported("标识列不是唯一主键的表");
|
||||||
|
sql.Append(' ').Append(column.Type.StorageType);
|
||||||
|
if (single && column.PrimaryKey) sql.Append(" PRIMARY KEY");
|
||||||
|
if (!column.Nullable) sql.Append(" NOT NULL");
|
||||||
|
if (column.Type.StorageType == "TEXT") sql.Append(" COLLATE NOCASE");
|
||||||
|
if (column.Default != null) sql.Append(" DEFAULT ").Append(column.Default);
|
||||||
|
if (column.Type.MaxLength is { } max)
|
||||||
|
sql.Append(" CONSTRAINT ").Append(Values.QuoteName("trunc:" + column.Name)).Append(" CHECK (length(").Append(Values.QuoteName(column.Name)).Append(") <= ").Append(max).Append(')');
|
||||||
|
if (column.Type.IsBit)
|
||||||
|
sql.Append(" CONSTRAINT ").Append(Values.QuoteName("bit:" + column.Name)).Append(" CHECK (").Append(Values.QuoteName(column.Name)).Append(" IN (0, 1))");
|
||||||
|
return sql.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DEFAULT 只支持常量和 getdate()</summary>
|
||||||
|
private static string DefaultSql(ScalarExpression expression) => Unparen(expression) switch
|
||||||
|
{
|
||||||
|
IntegerLiteral i => i.Value,
|
||||||
|
NumericLiteral n => n.Value,
|
||||||
|
RealLiteral r => r.Value,
|
||||||
|
StringLiteral s => Values.Quote(s.Value),
|
||||||
|
NullLiteral => "NULL",
|
||||||
|
UnaryExpression { UnaryExpressionType: UnaryExpressionType.Negative, Expression: IntegerLiteral or NumericLiteral } u => "-" + ((Literal)u.Expression).Value,
|
||||||
|
FunctionCall f when f.FunctionName.Value.Equals("getdate", StringComparison.OrdinalIgnoreCase) => "(strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime'))",
|
||||||
|
_ => throw SqlException.NotSupported("这种 DEFAULT 默认值"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ScalarExpression Unparen(ScalarExpression expression) =>
|
||||||
|
expression is ParenthesisExpression p ? Unparen(p.Expression) : expression;
|
||||||
|
|
||||||
|
// ---------- 表名、列名 ----------
|
||||||
|
|
||||||
|
/// <summary>dbo.表名、库名.dbo.表名 都取最后一段(跨库访问不支持)</summary>
|
||||||
|
private static string TableName(SchemaObjectName name)
|
||||||
|
{
|
||||||
|
if (name.ServerIdentifier != null) throw SqlException.NotSupported("访问其他服务器上的表");
|
||||||
|
if (name.SchemaIdentifier != null && !name.SchemaIdentifier.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw SqlException.NotSupported($"架构 {name.SchemaIdentifier.Value}(只有 dbo)");
|
||||||
|
return name.BaseIdentifier.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TableInfo RequireTable(SchemaObjectName name)
|
||||||
|
{
|
||||||
|
if (name.DatabaseIdentifier != null) throw SqlException.NotSupported("在一条语句里访问其他数据库的表,请先用 USE 切换数据库");
|
||||||
|
var table = TableName(name);
|
||||||
|
return _lookup(table) ?? throw SqlException.InvalidObject(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ColumnInfo RequireColumn(TableInfo table, string column) => table.Find(column) ?? throw SqlException.InvalidColumn(column);
|
||||||
|
|
||||||
|
// ---------- 表达式 ----------
|
||||||
|
|
||||||
|
private void Expression(ScalarExpression expression)
|
||||||
|
{
|
||||||
|
switch (expression)
|
||||||
|
{
|
||||||
|
case ColumnReferenceExpression column:
|
||||||
|
ColumnReference(column);
|
||||||
|
break;
|
||||||
|
case IntegerLiteral or NumericLiteral or RealLiteral or MoneyLiteral:
|
||||||
|
_sql.Append(((Literal)expression).Value.TrimStart('$', '¥'));
|
||||||
|
break;
|
||||||
|
case StringLiteral s:
|
||||||
|
_sql.Append(Values.Quote(s.Value));
|
||||||
|
break;
|
||||||
|
case NullLiteral:
|
||||||
|
_sql.Append("NULL");
|
||||||
|
break;
|
||||||
|
case BinaryLiteral b:
|
||||||
|
_sql.Append("X'").Append(b.Value[2..]).Append('\'');
|
||||||
|
break;
|
||||||
|
case VariableReference variable:
|
||||||
|
_parameters.Add(variable.Name);
|
||||||
|
_sql.Append(variable.Name);
|
||||||
|
break;
|
||||||
|
case GlobalVariableExpression global when global.Name.Equals("@@IDENTITY", StringComparison.OrdinalIgnoreCase):
|
||||||
|
_sql.Append("last_insert_rowid()");
|
||||||
|
break;
|
||||||
|
case ParenthesisExpression paren:
|
||||||
|
_sql.Append('(');
|
||||||
|
Expression(paren.Expression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
case UnaryExpression unary:
|
||||||
|
_sql.Append(unary.UnaryExpressionType switch
|
||||||
|
{
|
||||||
|
UnaryExpressionType.Negative => "-",
|
||||||
|
UnaryExpressionType.Positive => "+",
|
||||||
|
_ => "~",
|
||||||
|
});
|
||||||
|
Expression(unary.Expression);
|
||||||
|
break;
|
||||||
|
case BinaryExpression binary:
|
||||||
|
Binary(binary);
|
||||||
|
break;
|
||||||
|
case FunctionCall call:
|
||||||
|
Function(call);
|
||||||
|
break;
|
||||||
|
case LeftFunctionCall left:
|
||||||
|
Call("tsql_left", left.Parameters);
|
||||||
|
break;
|
||||||
|
case RightFunctionCall right:
|
||||||
|
Call("tsql_right", right.Parameters);
|
||||||
|
break;
|
||||||
|
case CastCall cast:
|
||||||
|
Conversion(cast.DataType, cast.Parameter, null);
|
||||||
|
break;
|
||||||
|
case ConvertCall convert:
|
||||||
|
Conversion(convert.DataType, convert.Parameter, convert.Style);
|
||||||
|
break;
|
||||||
|
case CoalesceExpression coalesce:
|
||||||
|
Call("coalesce", coalesce.Expressions);
|
||||||
|
break;
|
||||||
|
case NullIfExpression nullIf:
|
||||||
|
Call("nullif", new[] { nullIf.FirstExpression, nullIf.SecondExpression });
|
||||||
|
break;
|
||||||
|
case SearchedCaseExpression searched:
|
||||||
|
_sql.Append("CASE");
|
||||||
|
foreach (var when in searched.WhenClauses)
|
||||||
|
{
|
||||||
|
_sql.Append(" WHEN ");
|
||||||
|
Boolean(when.WhenExpression);
|
||||||
|
_sql.Append(" THEN ");
|
||||||
|
Expression(when.ThenExpression);
|
||||||
|
}
|
||||||
|
Else(searched.ElseExpression);
|
||||||
|
break;
|
||||||
|
case SimpleCaseExpression simple:
|
||||||
|
_sql.Append("CASE ");
|
||||||
|
Expression(simple.InputExpression);
|
||||||
|
foreach (var when in simple.WhenClauses)
|
||||||
|
{
|
||||||
|
_sql.Append(" WHEN ");
|
||||||
|
Expression(when.WhenExpression);
|
||||||
|
_sql.Append(" THEN ");
|
||||||
|
Expression(when.ThenExpression);
|
||||||
|
}
|
||||||
|
Else(simple.ElseExpression);
|
||||||
|
break;
|
||||||
|
case ScalarSubquery subquery:
|
||||||
|
_sql.Append('(');
|
||||||
|
Query(subquery.QueryExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw SqlException.NotSupported(expression.GetType().Name.Replace("Expression", "") + " 表达式");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Else(ScalarExpression? otherwise)
|
||||||
|
{
|
||||||
|
if (otherwise != null)
|
||||||
|
{
|
||||||
|
_sql.Append(" ELSE ");
|
||||||
|
Expression(otherwise);
|
||||||
|
}
|
||||||
|
_sql.Append(" END");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ColumnReference(ColumnReferenceExpression column)
|
||||||
|
{
|
||||||
|
if (column.ColumnType == ColumnType.Wildcard)
|
||||||
|
{
|
||||||
|
_sql.Append('*');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (column.ColumnType != ColumnType.Regular) throw SqlException.NotSupported(column.ColumnType.ToString());
|
||||||
|
var parts = column.MultiPartIdentifier.Identifiers;
|
||||||
|
// 表名.列名 保留表名(或别名);库名.dbo.表名.列名 只取最后两段
|
||||||
|
var take = Math.Min(parts.Count, 2);
|
||||||
|
_sql.Append(string.Join(".", parts.Skip(parts.Count - take).Select(p => Values.QuoteName(p.Value))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Binary(BinaryExpression binary)
|
||||||
|
{
|
||||||
|
switch (binary.BinaryExpressionType)
|
||||||
|
{
|
||||||
|
// + 要在运行时看两边的类型:字符串连接还是数字相加
|
||||||
|
case BinaryExpressionType.Add:
|
||||||
|
Call("tsql_add", new[] { binary.FirstExpression, binary.SecondExpression });
|
||||||
|
return;
|
||||||
|
case BinaryExpressionType.Divide:
|
||||||
|
Call("tsql_div", new[] { binary.FirstExpression, binary.SecondExpression });
|
||||||
|
return;
|
||||||
|
case BinaryExpressionType.Modulo:
|
||||||
|
Call("tsql_mod", new[] { binary.FirstExpression, binary.SecondExpression });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sql.Append('(');
|
||||||
|
Expression(binary.FirstExpression);
|
||||||
|
_sql.Append(binary.BinaryExpressionType switch
|
||||||
|
{
|
||||||
|
BinaryExpressionType.Subtract => " - ",
|
||||||
|
BinaryExpressionType.Multiply => " * ",
|
||||||
|
BinaryExpressionType.BitwiseAnd => " & ",
|
||||||
|
BinaryExpressionType.BitwiseOr => " | ",
|
||||||
|
_ => throw SqlException.NotSupported("^ 运算"),
|
||||||
|
});
|
||||||
|
Expression(binary.SecondExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Call(string name, IList<ScalarExpression> arguments)
|
||||||
|
{
|
||||||
|
_sql.Append(name).Append('(');
|
||||||
|
for (var i = 0; i < arguments.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
Expression(arguments[i]);
|
||||||
|
}
|
||||||
|
_sql.Append(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Conversion(DataTypeReference dataType, ScalarExpression value, ScalarExpression? style)
|
||||||
|
{
|
||||||
|
if (dataType is not SqlDataTypeReference sqlType) throw SqlException.NotSupported($"转换成 {dataType.Name.BaseIdentifier.Value}");
|
||||||
|
var type = TypeOf(sqlType);
|
||||||
|
_sql.Append("tsql_convert(").Append(Values.Quote(type.Name)).Append(", ")
|
||||||
|
.Append(type.IsMax ? "-1" : type.Length?.ToString(CultureInfo.InvariantCulture) ?? (type.IsString ? "30" : "NULL")).Append(", ");
|
||||||
|
Expression(value);
|
||||||
|
_sql.Append(", ");
|
||||||
|
if (style != null) Expression(style);
|
||||||
|
else _sql.Append("NULL");
|
||||||
|
_sql.Append(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Function(FunctionCall call)
|
||||||
|
{
|
||||||
|
if (call.CallTarget != null) throw SqlException.NotSupported("这种函数调用");
|
||||||
|
if (call.OverClause != null) throw SqlException.NotSupported("OVER(开窗函数)");
|
||||||
|
var name = call.FunctionName.Value.ToLowerInvariant();
|
||||||
|
var args = call.Parameters;
|
||||||
|
var distinct = call.UniqueRowFilter == UniqueRowFilter.Distinct ? "DISTINCT " : "";
|
||||||
|
|
||||||
|
void Expect(int min, int max)
|
||||||
|
{
|
||||||
|
if (args.Count < min || args.Count > max)
|
||||||
|
throw new SqlException(174, $"{call.FunctionName.Value} 函数要求有 {(min == max ? min.ToString(CultureInfo.InvariantCulture) : $"{min} 到 {max}")} 个参数。");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name)
|
||||||
|
{
|
||||||
|
case "count" or "sum" or "avg" or "min" or "max":
|
||||||
|
Expect(1, 1);
|
||||||
|
_sql.Append(name).Append('(').Append(distinct);
|
||||||
|
if (args[0] is ColumnReferenceExpression { ColumnType: ColumnType.Wildcard }) _sql.Append('*');
|
||||||
|
else Expression(args[0]);
|
||||||
|
_sql.Append(')');
|
||||||
|
return;
|
||||||
|
case "getdate" or "current_timestamp" or "sysdatetime":
|
||||||
|
Expect(0, 0);
|
||||||
|
_sql.Append("tsql_getdate()");
|
||||||
|
return;
|
||||||
|
case "year" or "month" or "day":
|
||||||
|
Expect(1, 1);
|
||||||
|
_sql.Append("tsql_datepart('").Append(name).Append("', ");
|
||||||
|
Expression(args[0]);
|
||||||
|
_sql.Append(')');
|
||||||
|
return;
|
||||||
|
case "datepart" or "datename":
|
||||||
|
Expect(2, 2);
|
||||||
|
_sql.Append("tsql_datepart(").Append(Values.Quote(DatePartName(args[0]))).Append(", ");
|
||||||
|
Expression(args[1]);
|
||||||
|
_sql.Append(')');
|
||||||
|
return;
|
||||||
|
case "dateadd" or "datediff":
|
||||||
|
Expect(3, 3);
|
||||||
|
_sql.Append("tsql_").Append(name).Append('(').Append(Values.Quote(DatePartName(args[0]))).Append(", ");
|
||||||
|
Expression(args[1]);
|
||||||
|
_sql.Append(", ");
|
||||||
|
Expression(args[2]);
|
||||||
|
_sql.Append(')');
|
||||||
|
return;
|
||||||
|
case "scope_identity" or "ident_current":
|
||||||
|
_sql.Append("last_insert_rowid()");
|
||||||
|
return;
|
||||||
|
case "len":
|
||||||
|
Expect(1, 1);
|
||||||
|
Call("tsql_len", args);
|
||||||
|
return;
|
||||||
|
case "datalength":
|
||||||
|
Expect(1, 1);
|
||||||
|
Call("length", args);
|
||||||
|
return;
|
||||||
|
case "substring":
|
||||||
|
Expect(3, 3);
|
||||||
|
Call("substr", args);
|
||||||
|
return;
|
||||||
|
case "charindex":
|
||||||
|
Expect(2, 2);
|
||||||
|
Call("tsql_charindex", args);
|
||||||
|
return;
|
||||||
|
case "upper" or "lower" or "ltrim" or "rtrim" or "replace" or "abs" or "round" or "sign":
|
||||||
|
Expect(1, 3);
|
||||||
|
Call(name, name == "round" ? args.Take(2).ToList() : args);
|
||||||
|
return;
|
||||||
|
case "isnull":
|
||||||
|
Expect(2, 2);
|
||||||
|
Call("ifnull", args);
|
||||||
|
return;
|
||||||
|
case "floor" or "ceiling" or "sqrt":
|
||||||
|
Expect(1, 1);
|
||||||
|
Call("tsql_" + name, args);
|
||||||
|
return;
|
||||||
|
case "power":
|
||||||
|
Expect(2, 2);
|
||||||
|
Call("tsql_power", args);
|
||||||
|
return;
|
||||||
|
case "newid":
|
||||||
|
Expect(0, 0);
|
||||||
|
_sql.Append("tsql_newid()");
|
||||||
|
return;
|
||||||
|
case "rand":
|
||||||
|
_sql.Append("tsql_rand()");
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw SqlException.UnknownFunction(call.FunctionName.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DATEADD(day, ...) 的第一个参数是关键字:ScriptDom 解析成 IdentifierLiteral(旧版本是列名)</summary>
|
||||||
|
private static string DatePartName(ScalarExpression expression) => expression switch
|
||||||
|
{
|
||||||
|
IdentifierLiteral i => i.Value,
|
||||||
|
ColumnReferenceExpression c => c.MultiPartIdentifier.Identifiers[^1].Value,
|
||||||
|
StringLiteral s => s.Value,
|
||||||
|
_ => throw SqlException.NotSupported("这种 datepart 写法"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 条件 ----------
|
||||||
|
|
||||||
|
private void Boolean(BooleanExpression expression)
|
||||||
|
{
|
||||||
|
switch (expression)
|
||||||
|
{
|
||||||
|
case BooleanComparisonExpression comparison:
|
||||||
|
_sql.Append('(');
|
||||||
|
Comparand(comparison.FirstExpression);
|
||||||
|
_sql.Append(comparison.ComparisonType switch
|
||||||
|
{
|
||||||
|
BooleanComparisonType.Equals => " = ",
|
||||||
|
BooleanComparisonType.GreaterThan => " > ",
|
||||||
|
BooleanComparisonType.LessThan => " < ",
|
||||||
|
BooleanComparisonType.GreaterThanOrEqualTo or BooleanComparisonType.NotLessThan => " >= ",
|
||||||
|
BooleanComparisonType.LessThanOrEqualTo or BooleanComparisonType.NotGreaterThan => " <= ",
|
||||||
|
BooleanComparisonType.NotEqualToBrackets or BooleanComparisonType.NotEqualToExclamation => " <> ",
|
||||||
|
_ => throw SqlException.NotSupported(comparison.ComparisonType.ToString()),
|
||||||
|
});
|
||||||
|
Comparand(comparison.SecondExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
case BooleanBinaryExpression binary:
|
||||||
|
_sql.Append('(');
|
||||||
|
Boolean(binary.FirstExpression);
|
||||||
|
_sql.Append(binary.BinaryExpressionType == BooleanBinaryExpressionType.And ? " AND " : " OR ");
|
||||||
|
Boolean(binary.SecondExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
case BooleanNotExpression not:
|
||||||
|
_sql.Append("NOT (");
|
||||||
|
Boolean(not.Expression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
case BooleanParenthesisExpression paren:
|
||||||
|
_sql.Append('(');
|
||||||
|
Boolean(paren.Expression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
case BooleanIsNullExpression isNull:
|
||||||
|
_sql.Append('(');
|
||||||
|
Expression(isNull.Expression);
|
||||||
|
_sql.Append(isNull.IsNot ? " IS NOT NULL)" : " IS NULL)");
|
||||||
|
break;
|
||||||
|
case LikePredicate like:
|
||||||
|
_sql.Append('(');
|
||||||
|
Expression(like.FirstExpression);
|
||||||
|
_sql.Append(like.NotDefined ? " NOT LIKE " : " LIKE ");
|
||||||
|
Expression(like.SecondExpression);
|
||||||
|
if (like.EscapeExpression != null)
|
||||||
|
{
|
||||||
|
_sql.Append(" ESCAPE ");
|
||||||
|
Expression(like.EscapeExpression);
|
||||||
|
}
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
case InPredicate inPredicate:
|
||||||
|
_sql.Append('(');
|
||||||
|
Comparand(inPredicate.Expression);
|
||||||
|
_sql.Append(inPredicate.NotDefined ? " NOT IN (" : " IN (");
|
||||||
|
if (inPredicate.Subquery != null)
|
||||||
|
{
|
||||||
|
Query(inPredicate.Subquery.QueryExpression);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (var i = 0; i < inPredicate.Values.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) _sql.Append(", ");
|
||||||
|
Comparand(inPredicate.Values[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_sql.Append("))");
|
||||||
|
break;
|
||||||
|
case BooleanTernaryExpression between:
|
||||||
|
_sql.Append('(');
|
||||||
|
Comparand(between.FirstExpression);
|
||||||
|
_sql.Append(between.TernaryExpressionType == BooleanTernaryExpressionType.NotBetween ? " NOT BETWEEN " : " BETWEEN ");
|
||||||
|
Comparand(between.SecondExpression);
|
||||||
|
_sql.Append(" AND ");
|
||||||
|
Comparand(between.ThirdExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
case ExistsPredicate exists:
|
||||||
|
_sql.Append("EXISTS (");
|
||||||
|
Query(exists.Subquery.QueryExpression);
|
||||||
|
_sql.Append(')');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw SqlException.NotSupported(expression.GetType().Name.Replace("Expression", "") + " 条件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>比较里的日期字符串转成标准格式,日期列按文本比较才是按时间先后(BETWEEN '2024/1/16 9:05:03' AND ...)</summary>
|
||||||
|
private void Comparand(ScalarExpression expression)
|
||||||
|
{
|
||||||
|
if (expression is StringLiteral s && Values.LooksLikeDate(s.Value))
|
||||||
|
{
|
||||||
|
_sql.Append(Values.Quote(Values.NormalizeDateLiteral(s.Value)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 参数的值执行时才知道:是日期样子的字符串时同样转成标准格式(教材 BETWEEN @time_start AND @time_end 传的是字符串)
|
||||||
|
if (expression is VariableReference)
|
||||||
|
{
|
||||||
|
_sql.Append("tsql_cmp(");
|
||||||
|
Expression(expression);
|
||||||
|
_sql.Append(')');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Expression(expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Data.SqlClient;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Sim.Data.Engine;
|
||||||
|
|
||||||
|
/// <summary>值在 .NET、SQLite、T-SQL 之间的转换。日期一律以 "yyyy-MM-dd HH:mm:ss" 文本保存,按文本比较即按时间先后。</summary>
|
||||||
|
internal static class Values
|
||||||
|
{
|
||||||
|
private static readonly string[] DateFormats =
|
||||||
|
{
|
||||||
|
"yyyy-M-d", "yyyy-M-d H:m", "yyyy-M-d H:m:s", "yyyy-M-d H:m:s.FFFFFFF", "yyyy-M-dTH:m:s", "yyyy-M-dTH:m:s.FFFFFFF",
|
||||||
|
"yyyy/M/d", "yyyy/M/d H:m", "yyyy/M/d H:m:s", "yyyy/M/d H:m:s.FFFFFFF",
|
||||||
|
"yyyyMMdd", "yyyyMMdd H:m:s",
|
||||||
|
"yyyy.M.d", "yyyy.M.d H:m:s",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>形如日期的字符串字面量(比较时转成标准格式,例如 BETWEEN '2024/1/16 9:05:03' AND ...)</summary>
|
||||||
|
private static readonly Regex DateLike = new(@"^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}((\s+|T)\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)?$");
|
||||||
|
|
||||||
|
/// <summary>日期和时间之间多余的空格(教材拼出的 "2024/1/23 23:59:59")</summary>
|
||||||
|
private static readonly Regex Spaces = new(@"\s+");
|
||||||
|
|
||||||
|
public static bool LooksLikeDate(string text) => DateLike.IsMatch(text.Trim());
|
||||||
|
|
||||||
|
/// <summary>按 SQL Server 接受的写法解析日期(包括中文 Windows 上 DateTime.ToString() 的 2024/1/16 9:05:03)</summary>
|
||||||
|
public static bool TryParseDateTime(string text, out DateTime value) =>
|
||||||
|
DateTime.TryParseExact(Spaces.Replace(text.Trim(), " "), DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out value);
|
||||||
|
|
||||||
|
public static string FormatDateTime(DateTime value) =>
|
||||||
|
value.Millisecond == 0 && value.Ticks % TimeSpan.TicksPerMillisecond == 0
|
||||||
|
? value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
: value.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
public static string FormatDate(DateTime value) => value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>字符串里的日期改成标准格式;只有日期没有时间的保持只有日期(与 datetime 比较时相当于当天 0 点)</summary>
|
||||||
|
public static string NormalizeDateLiteral(string text)
|
||||||
|
{
|
||||||
|
if (!TryParseDateTime(text, out var value)) return text;
|
||||||
|
return text.Contains(':') ? FormatDateTime(value) : FormatDate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>C# 传进来的参数值 → SQLite 能保存的值</summary>
|
||||||
|
public static object? ToStorage(object? value) => value switch
|
||||||
|
{
|
||||||
|
null or DBNull => null,
|
||||||
|
DateTime d => FormatDateTime(d),
|
||||||
|
DateTimeOffset d => FormatDateTime(d.LocalDateTime),
|
||||||
|
TimeSpan t => t.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||||
|
bool b => b ? 1L : 0L,
|
||||||
|
Guid g => g.ToString().ToUpperInvariant(),
|
||||||
|
decimal m => (double)m,
|
||||||
|
Enum e => Convert.ToInt64(e, CultureInfo.InvariantCulture),
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>SQLite 读出的值 → SQL Server 对这个类型返回的 .NET 值</summary>
|
||||||
|
public static object FromStorage(object value, TSqlType? type)
|
||||||
|
{
|
||||||
|
if (value is DBNull) return value;
|
||||||
|
if (type == null || type.ClrType == typeof(object)) return FromExpression(value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return type.ClrType switch
|
||||||
|
{
|
||||||
|
var t when t == typeof(int) => Convert.ToInt32(value, CultureInfo.InvariantCulture),
|
||||||
|
var t when t == typeof(long) => Convert.ToInt64(value, CultureInfo.InvariantCulture),
|
||||||
|
var t when t == typeof(short) => Convert.ToInt16(value, CultureInfo.InvariantCulture),
|
||||||
|
var t when t == typeof(byte) => Convert.ToByte(value, CultureInfo.InvariantCulture),
|
||||||
|
var t when t == typeof(bool) => Convert.ToInt64(value, CultureInfo.InvariantCulture) != 0,
|
||||||
|
var t when t == typeof(double) => Convert.ToDouble(value, CultureInfo.InvariantCulture),
|
||||||
|
var t when t == typeof(float) => Convert.ToSingle(value, CultureInfo.InvariantCulture),
|
||||||
|
var t when t == typeof(decimal) => Convert.ToDecimal(value, CultureInfo.InvariantCulture),
|
||||||
|
var t when t == typeof(DateTime) => value is string s && TryParseDateTime(s, out var d) ? d : value,
|
||||||
|
var t when t == typeof(TimeSpan) => value is string s && TimeSpan.TryParse(s, CultureInfo.InvariantCulture, out var ts) ? ts : value,
|
||||||
|
var t when t == typeof(Guid) => value is string s && Guid.TryParse(s, out var g) ? g : value,
|
||||||
|
var t when t == typeof(string) => Convert.ToString(value, CultureInfo.InvariantCulture)!,
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or OverflowException or InvalidCastException)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>表达式列(如 count(*)、getdate())没有声明类型:整数按 int 返回,日期样子的文本按 DateTime 返回</summary>
|
||||||
|
public static object FromExpression(object value) => value switch
|
||||||
|
{
|
||||||
|
long l when l is >= int.MinValue and <= int.MaxValue => (int)l,
|
||||||
|
string s when DateLike.IsMatch(s) && s.Contains(':') && TryParseDateTime(s, out var d) => d,
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>T-SQL 字符串字面量</summary>
|
||||||
|
public static string Quote(string text) => "'" + text.Replace("'", "''") + "'";
|
||||||
|
|
||||||
|
/// <summary>SQLite 标识符</summary>
|
||||||
|
public static string QuoteName(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
||||||
|
|
||||||
|
public static SqlException ConvertError(object? value, string type) =>
|
||||||
|
SqlException.ConvertFailed(Convert.ToString(value, CultureInfo.InvariantCulture) ?? "", type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<!-- 仿真的 System.Data.SqlClient:教材用 SQL Server,平台底层用 SQLite(每个学生一套数据库文件),
|
||||||
|
T-SQL 语句用 ScriptDom 解析后转写成 SQLite 语句。学生代码编译时引用本程序集。 -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<AssemblyName>Sim.Data</AssemblyName>
|
||||||
|
<RootNamespace>System.Data.SqlClient</RootNamespace>
|
||||||
|
<NoWarn>$(NoWarn);CS1591;CS8618</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 运行器的 SQL 服务(数据库页面、准备实验数据)直接使用执行层 -->
|
||||||
|
<InternalsVisibleTo Include="Sim.Runner" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.13" />
|
||||||
|
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.140.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Linq;
|
||||||
|
using Sim.Data.Engine;
|
||||||
|
|
||||||
|
namespace System.Data.SqlClient;
|
||||||
|
|
||||||
|
/// <summary>执行 T-SQL 语句。一条命令里可以有多条语句(用分号或换行分开)</summary>
|
||||||
|
public sealed class SqlCommand : DbCommand, ICloneable
|
||||||
|
{
|
||||||
|
private SqlConnection? _connection;
|
||||||
|
private string _commandText = "";
|
||||||
|
private CommandType _commandType = CommandType.Text;
|
||||||
|
|
||||||
|
public SqlCommand() { }
|
||||||
|
|
||||||
|
public SqlCommand(string cmdText) => CommandText = cmdText;
|
||||||
|
|
||||||
|
public SqlCommand(string cmdText, SqlConnection connection) : this(cmdText) => Connection = connection;
|
||||||
|
|
||||||
|
public SqlCommand(string cmdText, SqlConnection connection, SqlTransaction transaction) : this(cmdText, connection) => Transaction = transaction;
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.AllowNull]
|
||||||
|
public override string CommandText
|
||||||
|
{
|
||||||
|
get => _commandText;
|
||||||
|
set => _commandText = value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int CommandTimeout { get; set; } = 30;
|
||||||
|
|
||||||
|
public override CommandType CommandType
|
||||||
|
{
|
||||||
|
get => _commandType;
|
||||||
|
set => _commandType = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlConnection? Connection
|
||||||
|
{
|
||||||
|
get => _connection;
|
||||||
|
set => _connection = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override DbConnection? DbConnection
|
||||||
|
{
|
||||||
|
get => _connection;
|
||||||
|
set => _connection = (SqlConnection?)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlParameterCollection Parameters { get; } = new();
|
||||||
|
protected override DbParameterCollection DbParameterCollection => Parameters;
|
||||||
|
|
||||||
|
public new SqlTransaction? Transaction { get; set; }
|
||||||
|
|
||||||
|
protected override DbTransaction? DbTransaction
|
||||||
|
{
|
||||||
|
get => Transaction;
|
||||||
|
set => Transaction = (SqlTransaction?)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool DesignTimeVisible { get; set; } = true;
|
||||||
|
public override UpdateRowSource UpdatedRowSource { get; set; } = UpdateRowSource.Both;
|
||||||
|
|
||||||
|
public override int ExecuteNonQuery() => Run(nameof(ExecuteNonQuery)).RecordsAffected;
|
||||||
|
|
||||||
|
public override object? ExecuteScalar()
|
||||||
|
{
|
||||||
|
var result = Run(nameof(ExecuteScalar));
|
||||||
|
return result.Results.Count > 0 && result.Results[0].Rows.Count > 0 ? result.Results[0].Rows[0][0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlDataReader ExecuteReader() => ExecuteReader(CommandBehavior.Default);
|
||||||
|
|
||||||
|
public new SqlDataReader ExecuteReader(CommandBehavior behavior) =>
|
||||||
|
new(Run(nameof(ExecuteReader)), behavior.HasFlag(CommandBehavior.CloseConnection) ? _connection : null);
|
||||||
|
|
||||||
|
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => ExecuteReader(behavior);
|
||||||
|
|
||||||
|
public override void Cancel() { }
|
||||||
|
public override void Prepare() { }
|
||||||
|
|
||||||
|
public new SqlParameter CreateParameter() => new();
|
||||||
|
protected override DbParameter CreateDbParameter() => CreateParameter();
|
||||||
|
|
||||||
|
public SqlCommand Clone()
|
||||||
|
{
|
||||||
|
var clone = new SqlCommand(_commandText, _connection!) { CommandType = _commandType, CommandTimeout = CommandTimeout, Transaction = Transaction };
|
||||||
|
foreach (SqlParameter parameter in Parameters) clone.Parameters.Add(parameter.Clone());
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
object ICloneable.Clone() => Clone();
|
||||||
|
|
||||||
|
private BatchResult Run(string operation)
|
||||||
|
{
|
||||||
|
if (_connection == null) throw new InvalidOperationException($"{operation}: Connection 属性尚未初始化。");
|
||||||
|
var session = _connection.Session(operation);
|
||||||
|
if (string.IsNullOrWhiteSpace(_commandText)) throw new InvalidOperationException($"{operation}: CommandText 属性尚未初始化");
|
||||||
|
if (_commandType == CommandType.StoredProcedure) throw SqlException.NotSupported("存储过程");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return session.Execute(_commandText, name =>
|
||||||
|
{
|
||||||
|
var parameter = Parameters.Find(name);
|
||||||
|
return parameter == null ? (false, null) : (true, parameter.Value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (SqlException ex)
|
||||||
|
{
|
||||||
|
throw ex.Rethrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SqlParameter : DbParameter, ICloneable
|
||||||
|
{
|
||||||
|
private SqlDbType _sqlDbType = SqlDbType.NVarChar;
|
||||||
|
private bool _typeSet;
|
||||||
|
private string _parameterName = "";
|
||||||
|
private string _sourceColumn = "";
|
||||||
|
|
||||||
|
public SqlParameter() { }
|
||||||
|
|
||||||
|
public SqlParameter(string parameterName, object? value)
|
||||||
|
{
|
||||||
|
ParameterName = parameterName;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqlParameter(string parameterName, SqlDbType dbType)
|
||||||
|
{
|
||||||
|
ParameterName = parameterName;
|
||||||
|
SqlDbType = dbType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqlParameter(string parameterName, SqlDbType dbType, int size) : this(parameterName, dbType) => Size = size;
|
||||||
|
|
||||||
|
public SqlParameter(string parameterName, SqlDbType dbType, int size, string sourceColumn) : this(parameterName, dbType, size) => SourceColumn = sourceColumn;
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.AllowNull]
|
||||||
|
public override string ParameterName
|
||||||
|
{
|
||||||
|
get => _parameterName;
|
||||||
|
set => _parameterName = value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object? Value { get; set; }
|
||||||
|
|
||||||
|
public SqlDbType SqlDbType
|
||||||
|
{
|
||||||
|
get => _sqlDbType;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_sqlDbType = value;
|
||||||
|
_typeSet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override DbType DbType
|
||||||
|
{
|
||||||
|
get => _sqlDbType switch
|
||||||
|
{
|
||||||
|
SqlDbType.Int => DbType.Int32,
|
||||||
|
SqlDbType.BigInt => DbType.Int64,
|
||||||
|
SqlDbType.SmallInt => DbType.Int16,
|
||||||
|
SqlDbType.TinyInt => DbType.Byte,
|
||||||
|
SqlDbType.Bit => DbType.Boolean,
|
||||||
|
SqlDbType.Float => DbType.Double,
|
||||||
|
SqlDbType.Real => DbType.Single,
|
||||||
|
SqlDbType.Decimal or SqlDbType.Money => DbType.Decimal,
|
||||||
|
SqlDbType.DateTime or SqlDbType.SmallDateTime or SqlDbType.Date => DbType.DateTime,
|
||||||
|
SqlDbType.VarBinary or SqlDbType.Image or SqlDbType.Binary => DbType.Binary,
|
||||||
|
SqlDbType.UniqueIdentifier => DbType.Guid,
|
||||||
|
SqlDbType.VarChar or SqlDbType.Char or SqlDbType.Text => DbType.AnsiString,
|
||||||
|
_ => DbType.String,
|
||||||
|
};
|
||||||
|
set => SqlDbType = value switch
|
||||||
|
{
|
||||||
|
DbType.Int32 => SqlDbType.Int,
|
||||||
|
DbType.Int64 => SqlDbType.BigInt,
|
||||||
|
DbType.Int16 => SqlDbType.SmallInt,
|
||||||
|
DbType.Byte => SqlDbType.TinyInt,
|
||||||
|
DbType.Boolean => SqlDbType.Bit,
|
||||||
|
DbType.Double => SqlDbType.Float,
|
||||||
|
DbType.Single => SqlDbType.Real,
|
||||||
|
DbType.Decimal or DbType.Currency => SqlDbType.Decimal,
|
||||||
|
DbType.DateTime or DbType.Date => SqlDbType.DateTime,
|
||||||
|
DbType.Binary => SqlDbType.VarBinary,
|
||||||
|
DbType.Guid => SqlDbType.UniqueIdentifier,
|
||||||
|
DbType.AnsiString => SqlDbType.VarChar,
|
||||||
|
_ => SqlDbType.NVarChar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ParameterDirection Direction { get; set; } = ParameterDirection.Input;
|
||||||
|
public override bool IsNullable { get; set; }
|
||||||
|
public override int Size { get; set; }
|
||||||
|
public new byte Precision { get; set; }
|
||||||
|
public new byte Scale { get; set; }
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.AllowNull]
|
||||||
|
public override string SourceColumn
|
||||||
|
{
|
||||||
|
get => _sourceColumn;
|
||||||
|
set => _sourceColumn = value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool SourceColumnNullMapping { get; set; }
|
||||||
|
public override DataRowVersion SourceVersion { get; set; } = DataRowVersion.Current;
|
||||||
|
|
||||||
|
public override void ResetDbType()
|
||||||
|
{
|
||||||
|
_sqlDbType = SqlDbType.NVarChar;
|
||||||
|
_typeSet = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TypeSet => _typeSet;
|
||||||
|
|
||||||
|
public SqlParameter Clone() => new(_parameterName, Value)
|
||||||
|
{
|
||||||
|
_sqlDbType = _sqlDbType,
|
||||||
|
_typeSet = _typeSet,
|
||||||
|
Direction = Direction,
|
||||||
|
Size = Size,
|
||||||
|
SourceColumn = SourceColumn,
|
||||||
|
SourceVersion = SourceVersion,
|
||||||
|
SourceColumnNullMapping = SourceColumnNullMapping,
|
||||||
|
IsNullable = IsNullable,
|
||||||
|
};
|
||||||
|
|
||||||
|
object ICloneable.Clone() => Clone();
|
||||||
|
|
||||||
|
public override string ToString() => _parameterName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SqlParameterCollection : DbParameterCollection
|
||||||
|
{
|
||||||
|
private readonly List<SqlParameter> _items = new();
|
||||||
|
|
||||||
|
public override int Count => _items.Count;
|
||||||
|
public override object SyncRoot => this;
|
||||||
|
|
||||||
|
public new SqlParameter this[int index]
|
||||||
|
{
|
||||||
|
get => _items[index];
|
||||||
|
set => _items[index] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlParameter this[string parameterName]
|
||||||
|
{
|
||||||
|
get => Find(parameterName) ?? throw new IndexOutOfRangeException($"此 SqlParameterCollection 中未包含带有 ParameterName“{parameterName}”的 SqlParameter。");
|
||||||
|
set => _items[IndexOf(parameterName)] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按名字找参数,"@id" 和 "id" 视为同一个(和 SqlClient 一样不区分大小写)</summary>
|
||||||
|
internal SqlParameter? Find(string name)
|
||||||
|
{
|
||||||
|
var bare = name.TrimStart('@');
|
||||||
|
return _items.FirstOrDefault(p => string.Equals(p.ParameterName.TrimStart('@'), bare, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqlParameter Add(SqlParameter value)
|
||||||
|
{
|
||||||
|
_items.Add(value ?? throw new ArgumentNullException(nameof(value)));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Add(object value)
|
||||||
|
{
|
||||||
|
Add(value as SqlParameter ?? throw new InvalidCastException("SqlParameterCollection 只接受非空的 SqlParameter 类型对象。"));
|
||||||
|
return _items.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqlParameter Add(string parameterName, SqlDbType sqlDbType) => Add(new SqlParameter(parameterName, sqlDbType));
|
||||||
|
public SqlParameter Add(string parameterName, SqlDbType sqlDbType, int size) => Add(new SqlParameter(parameterName, sqlDbType, size));
|
||||||
|
public SqlParameter Add(string parameterName, SqlDbType sqlDbType, int size, string sourceColumn) => Add(new SqlParameter(parameterName, sqlDbType, size, sourceColumn));
|
||||||
|
public SqlParameter AddWithValue(string parameterName, object? value) => Add(new SqlParameter(parameterName, value));
|
||||||
|
|
||||||
|
public override void AddRange(Array values)
|
||||||
|
{
|
||||||
|
foreach (var value in values) Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddRange(SqlParameter[] values) => AddRange((Array)values);
|
||||||
|
|
||||||
|
public override void Clear() => _items.Clear();
|
||||||
|
public override bool Contains(object value) => value is SqlParameter p && _items.Contains(p);
|
||||||
|
public override bool Contains(string value) => Find(value) != null;
|
||||||
|
public override void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index);
|
||||||
|
public override IEnumerator GetEnumerator() => _items.GetEnumerator();
|
||||||
|
public override int IndexOf(object value) => value is SqlParameter p ? _items.IndexOf(p) : -1;
|
||||||
|
public override int IndexOf(string parameterName) => Find(parameterName) is { } p ? _items.IndexOf(p) : -1;
|
||||||
|
public override void Insert(int index, object value) => _items.Insert(index, (SqlParameter)value);
|
||||||
|
public override void Remove(object value) => _items.Remove((SqlParameter)value);
|
||||||
|
public override void RemoveAt(int index) => _items.RemoveAt(index);
|
||||||
|
public override void RemoveAt(string parameterName) => _items.RemoveAt(IndexOf(parameterName));
|
||||||
|
protected override DbParameter GetParameter(int index) => _items[index];
|
||||||
|
protected override DbParameter GetParameter(string parameterName) => this[parameterName];
|
||||||
|
protected override void SetParameter(int index, DbParameter value) => _items[index] = (SqlParameter)value;
|
||||||
|
protected override void SetParameter(string parameterName, DbParameter value) => this[parameterName] = (SqlParameter)value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.Common;
|
||||||
|
using Sim.Data.Engine;
|
||||||
|
|
||||||
|
namespace System.Data.SqlClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据库连接。连接字符串里的服务器名、账号、密码都不检查(书上的长服务器名、"." 都能连上),
|
||||||
|
/// 只按 Initial Catalog(或 Database)打开这个学生自己的同名数据库;不写时连到 master。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlConnection : DbConnection, ICloneable
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> KnownKeywords = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"data source", "server", "address", "addr", "network address", "initial catalog", "database", "user id", "uid", "user",
|
||||||
|
"password", "pwd", "integrated security", "trusted_connection", "persist security info", "persistsecurityinfo",
|
||||||
|
"connect timeout", "connection timeout", "timeout", "pooling", "max pool size", "min pool size", "multipleactiveresultsets",
|
||||||
|
"encrypt", "trustservercertificate", "application name", "app", "workstation id", "wsid", "packet size",
|
||||||
|
"asynchronous processing", "async", "user instance", "attachdbfilename", "connection lifetime", "load balance timeout", "current language", "language",
|
||||||
|
};
|
||||||
|
|
||||||
|
private string _connectionString = "";
|
||||||
|
private ConnectionState _state = ConnectionState.Closed;
|
||||||
|
private Session? _session;
|
||||||
|
|
||||||
|
public SqlConnection() { }
|
||||||
|
|
||||||
|
public SqlConnection(string connectionString) => ConnectionString = connectionString;
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.AllowNull]
|
||||||
|
public override string ConnectionString
|
||||||
|
{
|
||||||
|
get => _connectionString;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_state != ConnectionState.Closed) throw new InvalidOperationException("不允许更改“ConnectionString”属性。连接的当前状态为打开。");
|
||||||
|
_connectionString = value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string Database => _session?.Database ?? Setting("initial catalog", "database") ?? DatabaseStore.Master;
|
||||||
|
public override string DataSource => Setting("data source", "server", "address", "addr", "network address") ?? "";
|
||||||
|
public override string ServerVersion => "10.50.1600";
|
||||||
|
public override ConnectionState State => _state;
|
||||||
|
public override int ConnectionTimeout => 15;
|
||||||
|
public string WorkstationId => Environment.MachineName;
|
||||||
|
|
||||||
|
/// <summary>连接打开时的数据库会话(平台内部使用)</summary>
|
||||||
|
internal Session Session(string operation) =>
|
||||||
|
_state == ConnectionState.Open && _session != null
|
||||||
|
? _session
|
||||||
|
: throw new InvalidOperationException($"{operation} 要求已打开且可用的连接。连接的当前状态为已关闭。");
|
||||||
|
|
||||||
|
public override void Open()
|
||||||
|
{
|
||||||
|
if (_state == ConnectionState.Open) throw new InvalidOperationException("连接未关闭。连接的当前状态为打开。");
|
||||||
|
CheckKeywords();
|
||||||
|
var store = DatabaseStore.FromEnvironment()
|
||||||
|
?? throw new SqlException(-1, "在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误:这次运行没有准备数据库。");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_session = new Session(store, Database);
|
||||||
|
}
|
||||||
|
catch (SqlException ex)
|
||||||
|
{
|
||||||
|
throw ex.Rethrow();
|
||||||
|
}
|
||||||
|
SetState(ConnectionState.Open);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Close()
|
||||||
|
{
|
||||||
|
if (_session == null) return;
|
||||||
|
_session.Dispose();
|
||||||
|
_session = null;
|
||||||
|
SetState(ConnectionState.Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void ChangeDatabase(string databaseName) => Session("ChangeDatabase").ChangeDatabase(databaseName);
|
||||||
|
|
||||||
|
public new SqlCommand CreateCommand() => new() { Connection = this };
|
||||||
|
|
||||||
|
protected override DbCommand CreateDbCommand() => CreateCommand();
|
||||||
|
|
||||||
|
public new SqlTransaction BeginTransaction() => BeginTransaction(IsolationLevel.ReadCommitted);
|
||||||
|
|
||||||
|
public new SqlTransaction BeginTransaction(IsolationLevel iso)
|
||||||
|
{
|
||||||
|
Session("BeginTransaction").BeginTransaction();
|
||||||
|
return new SqlTransaction(this, iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => BeginTransaction(isolationLevel);
|
||||||
|
|
||||||
|
public object Clone() => new SqlConnection(_connectionString);
|
||||||
|
|
||||||
|
public static void ClearAllPools() { }
|
||||||
|
public static void ClearPool(SqlConnection connection) { }
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) Close();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetState(ConnectionState state)
|
||||||
|
{
|
||||||
|
var original = _state;
|
||||||
|
_state = state;
|
||||||
|
OnStateChange(new StateChangeEventArgs(original, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? Setting(params string[] keys)
|
||||||
|
{
|
||||||
|
var builder = Parse();
|
||||||
|
foreach (var key in keys)
|
||||||
|
if (builder.TryGetValue(key, out var value) && value?.ToString() is { Length: > 0 } text) return text.Trim();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DbConnectionStringBuilder Parse()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new DbConnectionStringBuilder { ConnectionString = _connectionString };
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("初始化字符串的格式不符合从索引 0 处开始的规范。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckKeywords()
|
||||||
|
{
|
||||||
|
foreach (string key in Parse().Keys)
|
||||||
|
if (!KnownKeywords.Contains(key)) throw new ArgumentException($"不支持关键字:“{key}”。");
|
||||||
|
if (Setting("attachdbfilename") != null) throw new SqlException(-1, "平台不支持 AttachDbFilename:请用 Initial Catalog 指定数据库名。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SqlTransaction : DbTransaction
|
||||||
|
{
|
||||||
|
private readonly SqlConnection _connection;
|
||||||
|
private bool _done;
|
||||||
|
|
||||||
|
internal SqlTransaction(SqlConnection connection, IsolationLevel isolationLevel)
|
||||||
|
{
|
||||||
|
_connection = connection;
|
||||||
|
IsolationLevel = isolationLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlConnection Connection => _connection;
|
||||||
|
protected override DbConnection DbConnection => _connection;
|
||||||
|
public override IsolationLevel IsolationLevel { get; }
|
||||||
|
|
||||||
|
public override void Commit()
|
||||||
|
{
|
||||||
|
if (_done) throw new InvalidOperationException("此 SqlTransaction 已完成;它再也无法使用。");
|
||||||
|
_done = true;
|
||||||
|
_connection.Session("Commit").Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Rollback()
|
||||||
|
{
|
||||||
|
if (_done) throw new InvalidOperationException("此 SqlTransaction 已完成;它再也无法使用。");
|
||||||
|
_done = true;
|
||||||
|
_connection.Session("Rollback").Rollback();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
|
||||||
|
namespace System.Data.SqlClient;
|
||||||
|
|
||||||
|
public delegate void SqlRowUpdatingEventHandler(object sender, SqlRowUpdatingEventArgs e);
|
||||||
|
public delegate void SqlRowUpdatedEventHandler(object sender, SqlRowUpdatedEventArgs e);
|
||||||
|
|
||||||
|
public sealed class SqlRowUpdatingEventArgs : RowUpdatingEventArgs
|
||||||
|
{
|
||||||
|
public SqlRowUpdatingEventArgs(DataRow row, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping)
|
||||||
|
: base(row, command, statementType, tableMapping) { }
|
||||||
|
|
||||||
|
public new SqlCommand? Command
|
||||||
|
{
|
||||||
|
get => (SqlCommand?)base.Command;
|
||||||
|
set => base.Command = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SqlRowUpdatedEventArgs : RowUpdatedEventArgs
|
||||||
|
{
|
||||||
|
public SqlRowUpdatedEventArgs(DataRow row, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping)
|
||||||
|
: base(row, command, statementType, tableMapping) { }
|
||||||
|
|
||||||
|
public new SqlCommand? Command => (SqlCommand?)base.Command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>在数据库和 DataSet 之间搬运数据:Fill 读出来,Update 把 DataTable 里改过的行写回去</summary>
|
||||||
|
public sealed class SqlDataAdapter : DbDataAdapter, ICloneable
|
||||||
|
{
|
||||||
|
public SqlDataAdapter() { }
|
||||||
|
|
||||||
|
public SqlDataAdapter(SqlCommand selectCommand) => SelectCommand = selectCommand;
|
||||||
|
|
||||||
|
public SqlDataAdapter(string selectCommandText, SqlConnection selectConnection) => SelectCommand = new SqlCommand(selectCommandText, selectConnection);
|
||||||
|
|
||||||
|
public SqlDataAdapter(string selectCommandText, string selectConnectionString) =>
|
||||||
|
SelectCommand = new SqlCommand(selectCommandText, new SqlConnection(selectConnectionString));
|
||||||
|
|
||||||
|
public new SqlCommand? SelectCommand
|
||||||
|
{
|
||||||
|
get => (SqlCommand?)base.SelectCommand;
|
||||||
|
set => base.SelectCommand = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlCommand? InsertCommand
|
||||||
|
{
|
||||||
|
get => (SqlCommand?)base.InsertCommand;
|
||||||
|
set => base.InsertCommand = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlCommand? UpdateCommand
|
||||||
|
{
|
||||||
|
get => (SqlCommand?)base.UpdateCommand;
|
||||||
|
set => base.UpdateCommand = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlCommand? DeleteCommand
|
||||||
|
{
|
||||||
|
get => (SqlCommand?)base.DeleteCommand;
|
||||||
|
set => base.DeleteCommand = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event SqlRowUpdatingEventHandler? RowUpdating;
|
||||||
|
public event SqlRowUpdatedEventHandler? RowUpdated;
|
||||||
|
|
||||||
|
protected override RowUpdatingEventArgs CreateRowUpdatingEvent(DataRow dataRow, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping) =>
|
||||||
|
new SqlRowUpdatingEventArgs(dataRow, command, statementType, tableMapping);
|
||||||
|
|
||||||
|
protected override RowUpdatedEventArgs CreateRowUpdatedEvent(DataRow dataRow, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping) =>
|
||||||
|
new SqlRowUpdatedEventArgs(dataRow, command, statementType, tableMapping);
|
||||||
|
|
||||||
|
protected override void OnRowUpdating(RowUpdatingEventArgs value) => RowUpdating?.Invoke(this, (SqlRowUpdatingEventArgs)value);
|
||||||
|
|
||||||
|
protected override void OnRowUpdated(RowUpdatedEventArgs value) => RowUpdated?.Invoke(this, (SqlRowUpdatedEventArgs)value);
|
||||||
|
|
||||||
|
object ICloneable.Clone() => new SqlDataAdapter { SelectCommand = SelectCommand, InsertCommand = InsertCommand, UpdateCommand = UpdateCommand, DeleteCommand = DeleteCommand };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按 SqlDataAdapter 的查询语句自动生成 INSERT / UPDATE / DELETE(要求查询包含主键列)</summary>
|
||||||
|
public sealed class SqlCommandBuilder : DbCommandBuilder
|
||||||
|
{
|
||||||
|
public SqlCommandBuilder()
|
||||||
|
{
|
||||||
|
QuotePrefix = "[";
|
||||||
|
QuoteSuffix = "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqlCommandBuilder(SqlDataAdapter adapter) : this() => DataAdapter = adapter;
|
||||||
|
|
||||||
|
public new SqlDataAdapter? DataAdapter
|
||||||
|
{
|
||||||
|
get => (SqlDataAdapter?)base.DataAdapter;
|
||||||
|
set => base.DataAdapter = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public new SqlCommand GetInsertCommand() => (SqlCommand)base.GetInsertCommand();
|
||||||
|
public new SqlCommand GetInsertCommand(bool useColumnsForParameterNames) => (SqlCommand)base.GetInsertCommand(useColumnsForParameterNames);
|
||||||
|
public new SqlCommand GetUpdateCommand() => (SqlCommand)base.GetUpdateCommand();
|
||||||
|
public new SqlCommand GetUpdateCommand(bool useColumnsForParameterNames) => (SqlCommand)base.GetUpdateCommand(useColumnsForParameterNames);
|
||||||
|
public new SqlCommand GetDeleteCommand() => (SqlCommand)base.GetDeleteCommand();
|
||||||
|
public new SqlCommand GetDeleteCommand(bool useColumnsForParameterNames) => (SqlCommand)base.GetDeleteCommand(useColumnsForParameterNames);
|
||||||
|
|
||||||
|
protected override void ApplyParameterInfo(DbParameter parameter, DataRow row, StatementType statementType, bool whereClause) { }
|
||||||
|
|
||||||
|
protected override string GetParameterName(int parameterOrdinal) => "@p" + parameterOrdinal.ToString(Globalization.CultureInfo.InvariantCulture);
|
||||||
|
protected override string GetParameterName(string parameterName) => "@" + parameterName;
|
||||||
|
protected override string GetParameterPlaceholder(int parameterOrdinal) => GetParameterName(parameterOrdinal);
|
||||||
|
|
||||||
|
public override string QuoteIdentifier(string unquotedIdentifier) => "[" + unquotedIdentifier.Replace("]", "]]") + "]";
|
||||||
|
|
||||||
|
protected override void SetRowUpdatingHandler(DbDataAdapter adapter)
|
||||||
|
{
|
||||||
|
if (adapter is not SqlDataAdapter sql) throw new InvalidOperationException("SqlCommandBuilder 只能用于 SqlDataAdapter。");
|
||||||
|
if (adapter == base.DataAdapter) sql.RowUpdating += Handler;
|
||||||
|
else sql.RowUpdating -= Handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Handler(object sender, SqlRowUpdatingEventArgs e) => RowUpdatingHandler(e);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using Sim.Data.Engine;
|
||||||
|
|
||||||
|
namespace System.Data.SqlClient;
|
||||||
|
|
||||||
|
/// <summary>按行读取查询结果(结果已在内存里,读的时候不再访问数据库)</summary>
|
||||||
|
public sealed class SqlDataReader : DbDataReader
|
||||||
|
{
|
||||||
|
private readonly BatchResult _batch;
|
||||||
|
private readonly SqlConnection? _closeConnection;
|
||||||
|
private int _resultIndex;
|
||||||
|
private int _row = -1;
|
||||||
|
private bool _closed;
|
||||||
|
|
||||||
|
internal SqlDataReader(BatchResult batch, SqlConnection? closeConnection)
|
||||||
|
{
|
||||||
|
_batch = batch;
|
||||||
|
_closeConnection = closeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private QueryResult? Current => _resultIndex < _batch.Results.Count ? _batch.Results[_resultIndex] : null;
|
||||||
|
|
||||||
|
private object[] Row
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_closed) throw new InvalidOperationException("阅读器关闭时尝试调用 Read 无效。");
|
||||||
|
var current = Current;
|
||||||
|
if (current == null || _row < 0 || _row >= current.Rows.Count) throw new InvalidOperationException("不存在数据时进行无效的读取尝试。");
|
||||||
|
return current.Rows[_row];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int FieldCount => Current?.Columns.Count ?? 0;
|
||||||
|
public override bool HasRows => Current?.Rows.Count > 0;
|
||||||
|
public override bool IsClosed => _closed;
|
||||||
|
public override int RecordsAffected => _batch.RecordsAffected;
|
||||||
|
public override int Depth => 0;
|
||||||
|
|
||||||
|
public override object this[int ordinal] => GetValue(ordinal);
|
||||||
|
public override object this[string name] => GetValue(GetOrdinal(name));
|
||||||
|
|
||||||
|
public override bool Read()
|
||||||
|
{
|
||||||
|
if (_closed) throw new InvalidOperationException("阅读器关闭时尝试调用 Read 无效。");
|
||||||
|
var current = Current;
|
||||||
|
if (current == null) return false;
|
||||||
|
_row++;
|
||||||
|
return _row < current.Rows.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool NextResult()
|
||||||
|
{
|
||||||
|
_resultIndex++;
|
||||||
|
_row = -1;
|
||||||
|
return Current != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Close()
|
||||||
|
{
|
||||||
|
if (_closed) return;
|
||||||
|
_closed = true;
|
||||||
|
_closeConnection?.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string GetName(int ordinal) => Column(ordinal).Name;
|
||||||
|
|
||||||
|
public override int GetOrdinal(string name)
|
||||||
|
{
|
||||||
|
var columns = Current?.Columns ?? throw new IndexOutOfRangeException(name);
|
||||||
|
for (var i = 0; i < columns.Count; i++)
|
||||||
|
if (string.Equals(columns[i].Name, name, StringComparison.OrdinalIgnoreCase)) return i;
|
||||||
|
throw new IndexOutOfRangeException(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Type GetFieldType(int ordinal) => Column(ordinal).ClrType;
|
||||||
|
public override string GetDataTypeName(int ordinal) => Column(ordinal).Type?.Name ?? "sql_variant";
|
||||||
|
|
||||||
|
public override object GetValue(int ordinal)
|
||||||
|
{
|
||||||
|
Column(ordinal);
|
||||||
|
return Row[ordinal];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetValues(object[] values)
|
||||||
|
{
|
||||||
|
var row = Row;
|
||||||
|
var count = Math.Min(values.Length, row.Length);
|
||||||
|
Array.Copy(row, values, count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool IsDBNull(int ordinal) => GetValue(ordinal) is DBNull;
|
||||||
|
|
||||||
|
public override bool GetBoolean(int ordinal) => Cast<bool>(ordinal);
|
||||||
|
public override byte GetByte(int ordinal) => Cast<byte>(ordinal);
|
||||||
|
public override char GetChar(int ordinal) => throw new NotSupportedException("SqlDataReader 不支持 GetChar。");
|
||||||
|
public override DateTime GetDateTime(int ordinal) => Cast<DateTime>(ordinal);
|
||||||
|
public override decimal GetDecimal(int ordinal) => Cast<decimal>(ordinal);
|
||||||
|
public override double GetDouble(int ordinal) => Cast<double>(ordinal);
|
||||||
|
public override float GetFloat(int ordinal) => Cast<float>(ordinal);
|
||||||
|
public override Guid GetGuid(int ordinal) => Cast<Guid>(ordinal);
|
||||||
|
public override short GetInt16(int ordinal) => Cast<short>(ordinal);
|
||||||
|
public override int GetInt32(int ordinal) => Cast<int>(ordinal);
|
||||||
|
public override long GetInt64(int ordinal) => Cast<long>(ordinal);
|
||||||
|
public override string GetString(int ordinal) => Cast<string>(ordinal);
|
||||||
|
|
||||||
|
/// <summary>和 SqlClient 一样严格:列是 varchar 时 GetInt32 报"指定的转换无效",是 NULL 时报"数据为空"</summary>
|
||||||
|
private T Cast<T>(int ordinal)
|
||||||
|
{
|
||||||
|
var value = GetValue(ordinal);
|
||||||
|
if (value is DBNull) throw new InvalidCastException("数据为空。不能对空值调用此方法或属性。");
|
||||||
|
if (value is T typed) return typed;
|
||||||
|
// 表达式列(如 count(*))没有声明类型,数字之间可以互相转换
|
||||||
|
if (Column(ordinal).Type == null && value is IConvertible && typeof(T) != typeof(string))
|
||||||
|
return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
|
||||||
|
throw new InvalidCastException("指定的转换无效。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
|
||||||
|
{
|
||||||
|
var bytes = Cast<byte[]>(ordinal);
|
||||||
|
if (buffer == null) return bytes.Length;
|
||||||
|
var count = (int)Math.Min(length, bytes.Length - dataOffset);
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
Array.Copy(bytes, dataOffset, buffer, bufferOffset, count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
|
||||||
|
{
|
||||||
|
var text = Cast<string>(ordinal);
|
||||||
|
if (buffer == null) return text.Length;
|
||||||
|
var count = (int)Math.Min(length, text.Length - dataOffset);
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
text.CopyTo((int)dataOffset, buffer, bufferOffset, count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IEnumerator GetEnumerator() => new DbEnumerator(this, closeReader: false);
|
||||||
|
|
||||||
|
/// <summary>列的结构信息:DbDataAdapter 填充、SqlCommandBuilder 生成增删改语句时要用(主键、自增、来自哪张表的哪一列)</summary>
|
||||||
|
public override DataTable GetSchemaTable()
|
||||||
|
{
|
||||||
|
var table = new DataTable("SchemaTable");
|
||||||
|
table.Columns.Add(SchemaTableColumn.ColumnName, typeof(string));
|
||||||
|
table.Columns.Add(SchemaTableColumn.ColumnOrdinal, typeof(int));
|
||||||
|
table.Columns.Add(SchemaTableColumn.ColumnSize, typeof(int));
|
||||||
|
table.Columns.Add(SchemaTableColumn.NumericPrecision, typeof(short));
|
||||||
|
table.Columns.Add(SchemaTableColumn.NumericScale, typeof(short));
|
||||||
|
table.Columns.Add(SchemaTableColumn.IsUnique, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableColumn.IsKey, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableOptionalColumn.BaseServerName, typeof(string));
|
||||||
|
table.Columns.Add(SchemaTableOptionalColumn.BaseCatalogName, typeof(string));
|
||||||
|
table.Columns.Add(SchemaTableColumn.BaseColumnName, typeof(string));
|
||||||
|
table.Columns.Add(SchemaTableColumn.BaseSchemaName, typeof(string));
|
||||||
|
table.Columns.Add(SchemaTableColumn.BaseTableName, typeof(string));
|
||||||
|
table.Columns.Add(SchemaTableColumn.DataType, typeof(Type));
|
||||||
|
table.Columns.Add(SchemaTableColumn.AllowDBNull, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableColumn.ProviderType, typeof(int));
|
||||||
|
table.Columns.Add(SchemaTableColumn.IsAliased, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableColumn.IsExpression, typeof(bool));
|
||||||
|
table.Columns.Add("IsIdentity", typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableOptionalColumn.IsAutoIncrement, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableOptionalColumn.IsRowVersion, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableOptionalColumn.IsHidden, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableColumn.IsLong, typeof(bool));
|
||||||
|
table.Columns.Add(SchemaTableOptionalColumn.IsReadOnly, typeof(bool));
|
||||||
|
table.Columns.Add("DataTypeName", typeof(string));
|
||||||
|
|
||||||
|
var columns = Current?.Columns ?? Array.Empty<ResultColumn>();
|
||||||
|
for (var i = 0; i < columns.Count; i++)
|
||||||
|
{
|
||||||
|
var c = columns[i];
|
||||||
|
var row = table.NewRow();
|
||||||
|
row[SchemaTableColumn.ColumnName] = c.Name;
|
||||||
|
row[SchemaTableColumn.ColumnOrdinal] = i;
|
||||||
|
row[SchemaTableColumn.ColumnSize] = c.Type?.MaxLength ?? (c.Type?.IsMax == true ? int.MaxValue : 0);
|
||||||
|
row[SchemaTableColumn.NumericPrecision] = (short)(c.Type?.Precision ?? 0);
|
||||||
|
row[SchemaTableColumn.NumericScale] = (short)(c.Type?.Scale ?? 0);
|
||||||
|
row[SchemaTableColumn.IsUnique] = c.IsKey;
|
||||||
|
row[SchemaTableColumn.IsKey] = c.IsKey;
|
||||||
|
row[SchemaTableColumn.BaseColumnName] = (object?)c.BaseColumn ?? DBNull.Value;
|
||||||
|
row[SchemaTableColumn.BaseSchemaName] = c.BaseTable != null ? "dbo" : DBNull.Value;
|
||||||
|
row[SchemaTableColumn.BaseTableName] = (object?)c.BaseTable ?? DBNull.Value;
|
||||||
|
row[SchemaTableColumn.DataType] = c.ClrType;
|
||||||
|
row[SchemaTableColumn.AllowDBNull] = c.AllowNull;
|
||||||
|
row[SchemaTableColumn.ProviderType] = 0;
|
||||||
|
row[SchemaTableColumn.IsAliased] = c.BaseColumn != null && !string.Equals(c.BaseColumn, c.Name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
row[SchemaTableColumn.IsExpression] = c.BaseColumn == null;
|
||||||
|
row["IsIdentity"] = c.IsIdentity;
|
||||||
|
row[SchemaTableOptionalColumn.IsAutoIncrement] = c.IsIdentity;
|
||||||
|
row[SchemaTableOptionalColumn.IsRowVersion] = false;
|
||||||
|
row[SchemaTableOptionalColumn.IsHidden] = false;
|
||||||
|
row[SchemaTableColumn.IsLong] = c.Type is { IsMax: true } or { Name: "text" or "ntext" or "image" };
|
||||||
|
row[SchemaTableOptionalColumn.IsReadOnly] = c.IsIdentity || c.BaseColumn == null;
|
||||||
|
row["DataTypeName"] = c.Type?.Name ?? "sql_variant";
|
||||||
|
table.Rows.Add(row);
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultColumn Column(int ordinal)
|
||||||
|
{
|
||||||
|
var columns = Current?.Columns;
|
||||||
|
if (columns == null || ordinal < 0 || ordinal >= columns.Count) throw new IndexOutOfRangeException("索引超出了数组界限。");
|
||||||
|
return columns[ordinal];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
|
||||||
|
namespace System.Data.SqlClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据库报错。Number 和 Message 与中文版 SQL Server 一致(如 208"对象名 'X' 无效。"),
|
||||||
|
/// 学生在网上搜报错信息能找到对应的说明。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlException : DbException
|
||||||
|
{
|
||||||
|
internal SqlException(int number, string message, int lineNumber = 1) : base(message, unchecked((int)0x80131904))
|
||||||
|
{
|
||||||
|
Number = number;
|
||||||
|
LineNumber = lineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Number { get; }
|
||||||
|
public int LineNumber { get; }
|
||||||
|
public byte Class => 16;
|
||||||
|
public byte State => 1;
|
||||||
|
public string Server => ".";
|
||||||
|
public string Procedure => "";
|
||||||
|
|
||||||
|
/// <summary>在 SqlClient 的公开方法里重新抛出:学生看到的调用栈从 SqlCommand.ExecuteNonQuery 等开始,不含平台内部的实现</summary>
|
||||||
|
internal SqlException Rethrow() => new(Number, Message, LineNumber);
|
||||||
|
|
||||||
|
public override string ToString() => $"{GetType().FullName} (0x80131904): {Message}{(StackTrace != null ? Environment.NewLine + StackTrace : "")}";
|
||||||
|
|
||||||
|
// ---------- 常见报错 ----------
|
||||||
|
|
||||||
|
internal static SqlException Syntax(string near, int line = 1) =>
|
||||||
|
new(102, IsKeyword(near) ? $"关键字 '{near}' 附近有语法错误。" : $"'{near}' 附近有语法错误。", line);
|
||||||
|
|
||||||
|
internal static SqlException InvalidObject(string name) => new(208, $"对象名 '{name}' 无效。");
|
||||||
|
internal static SqlException InvalidColumn(string name) => new(207, $"列名 '{name}' 无效。");
|
||||||
|
internal static SqlException AmbiguousColumn(string name) => new(209, $"列名 '{name}' 不明确。");
|
||||||
|
internal static SqlException ColumnCountMismatch() => new(213, "列名或所提供值的数目与表定义不匹配。");
|
||||||
|
internal static SqlException IdentityInsert(string table) => new(544, $"当 IDENTITY_INSERT 设置为 OFF 时,不能为表 '{table}' 中的标识列插入显式值。");
|
||||||
|
internal static SqlException NullNotAllowed(string column, string table) => new(515, $"不能将值 NULL 插入列 '{column}',表 '{table}';列不允许有 Null 值。INSERT 失败。");
|
||||||
|
internal static SqlException DuplicateKey(string table) => new(2627, $"违反了 PRIMARY KEY 约束。不能在对象 'dbo.{table}' 中插入重复键。");
|
||||||
|
internal static SqlException Truncated() => new(8152, "将截断字符串或二进制数据。");
|
||||||
|
internal static SqlException ConvertFailed(string value, string type) => new(245, $"在将 varchar 值 '{value}' 转换成数据类型 {type} 时失败。");
|
||||||
|
internal static SqlException DateConvertFailed() => new(241, "从字符串转换日期和/或时间时,转换失败。");
|
||||||
|
internal static SqlException ObjectExists(string name) => new(2714, $"数据库中已存在名为 '{name}' 的对象。");
|
||||||
|
internal static SqlException DatabaseExists(string name) => new(1801, $"数据库 '{name}' 已存在。请选择其他数据库名称。");
|
||||||
|
internal static SqlException CannotOpenDatabase(string name) => new(4060, $"无法打开登录所请求的数据库 \"{name}\"。登录失败。");
|
||||||
|
internal static SqlException DatabaseNotFound(string name) => new(911, $"数据库 '{name}' 不存在。请确保正确地输入了该名称。");
|
||||||
|
internal static SqlException DatabaseInUse(string name) => new(3702, $"无法删除数据库 \"{name}\",因为该数据库当前正在使用。");
|
||||||
|
internal static SqlException CannotDropTable(string name) => new(3701, $"无法对 表 '{name}' 执行 删除,因为它不存在,或者您没有所需的权限。");
|
||||||
|
internal static SqlException UnknownFunction(string name) => new(195, $"'{name}' 不是可以识别的 内置函数名称。");
|
||||||
|
internal static SqlException UndeclaredVariable(string name) => new(137, $"必须声明标量变量 \"{name}\"。");
|
||||||
|
internal static SqlException DivideByZero() => new(8134, "遇到以零作除数错误。");
|
||||||
|
internal static SqlException NotSupported(string what) => new(40000, $"平台暂不支持 {what}。");
|
||||||
|
|
||||||
|
private static bool IsKeyword(string word) => word.ToUpperInvariant() is
|
||||||
|
"SELECT" or "FROM" or "WHERE" or "INSERT" or "INTO" or "VALUES" or "UPDATE" or "SET" or "DELETE" or "CREATE" or "TABLE"
|
||||||
|
or "DROP" or "ALTER" or "AND" or "OR" or "NOT" or "NULL" or "ORDER" or "GROUP" or "BY" or "HAVING" or "AS" or "ON"
|
||||||
|
or "JOIN" or "INNER" or "LEFT" or "RIGHT" or "OUTER" or "IS" or "IN" or "LIKE" or "BETWEEN" or "TOP" or "DISTINCT"
|
||||||
|
or "PRIMARY" or "KEY" or "IDENTITY" or "DEFAULT" or "DATABASE" or "USE" or "CASE" or "WHEN" or "THEN" or "ELSE" or "END";
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// 生成网络摄像机的画面帧:把 scene.svg 渲染成全景图(有人翻墙 / 没有人两种),按云台位置切成 640×480 的 JPEG。
|
||||||
|
// 帧文件提交在仓库里,改了 scene.svg 后重新运行:bun runtime/Sim.Devices/Camera/build-frames.ts
|
||||||
|
// 需要 rsvg-convert(librsvg)和 magick(ImageMagick)。
|
||||||
|
import { mkdir, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
/** 与 IPCameraHelper.cs 中的常量一致 */
|
||||||
|
const VIEW = { width: 640, height: 480 }
|
||||||
|
const STEPS = { x: 7, y: 3 }
|
||||||
|
const STRIDE = { x: 200, y: 160 }
|
||||||
|
|
||||||
|
const dir = import.meta.dir
|
||||||
|
const framesDir = join(dir, 'frames')
|
||||||
|
const work = join(tmpdir(), `camera-frames-${process.pid}`)
|
||||||
|
|
||||||
|
async function run(cmd: string[]) {
|
||||||
|
const proc = Bun.spawn(cmd, { stderr: 'inherit' })
|
||||||
|
if ((await proc.exited) !== 0) throw new Error(`${cmd[0]} 失败`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = await Bun.file(join(dir, 'scene.svg')).text()
|
||||||
|
const intruder = scene
|
||||||
|
.replace('id="intruder" display="none"', 'id="intruder"')
|
||||||
|
.replace('<path id="beam" ', '<path id="beam" display="none" ')
|
||||||
|
.replace('id="beam-broken" display="none"', 'id="beam-broken"')
|
||||||
|
if (intruder === scene) throw new Error('scene.svg 里找不到 #intruder')
|
||||||
|
|
||||||
|
await rm(framesDir, { recursive: true, force: true })
|
||||||
|
await mkdir(framesDir, { recursive: true })
|
||||||
|
await mkdir(work, { recursive: true })
|
||||||
|
try {
|
||||||
|
for (const [flag, svg] of [
|
||||||
|
[0, scene],
|
||||||
|
[1, intruder],
|
||||||
|
] as const) {
|
||||||
|
const svgPath = join(work, `scene-${flag}.svg`)
|
||||||
|
const pngPath = join(work, `scene-${flag}.png`)
|
||||||
|
await Bun.write(svgPath, svg)
|
||||||
|
await run(['rsvg-convert', svgPath, '-o', pngPath])
|
||||||
|
for (let x = 0; x < STEPS.x; x++) {
|
||||||
|
for (let y = 0; y < STEPS.y; y++) {
|
||||||
|
const crop = `${VIEW.width}x${VIEW.height}+${x * STRIDE.x}+${y * STRIDE.y}`
|
||||||
|
await run(['magick', pngPath, '-crop', crop, '+repage', '-strip', '-quality', '80', join(framesDir, `${x}-${y}-${flag}.jpg`)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
console.log(`已生成 ${STEPS.x * STEPS.y * 2} 帧:${framesDir}`)
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,151 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1840" height="800" viewBox="0 0 1840 800">
|
||||||
|
<!-- 网络摄像机看到的场景:从校园里望向东围墙。摄像机视野 640×480,左右 7 档、上下 3 档(见 build-frames.ts)。
|
||||||
|
#intruder 是翻墙的人,实训台红外对射被遮挡时显示,同时对射光束断开(#beam)。 -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7cb9e8"/>
|
||||||
|
<stop offset="1" stop-color="#d4ecfa"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lawn" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#8cc66d"/>
|
||||||
|
<stop offset="1" stop-color="#6aa84f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="bricks" width="60" height="30" patternUnits="userSpaceOnUse">
|
||||||
|
<rect width="60" height="30" fill="#c96f4a"/>
|
||||||
|
<path d="M0 15h60M0 30h60M30 0v15M0 15v15M60 15v15" stroke="#a65638" stroke-width="2"/>
|
||||||
|
</pattern>
|
||||||
|
<g id="tree">
|
||||||
|
<rect x="-7" y="-10" width="14" height="70" fill="#7a5230"/>
|
||||||
|
<circle cx="0" cy="-50" r="48" fill="#4f9a45"/>
|
||||||
|
<circle cx="-30" cy="-25" r="34" fill="#5aa84f"/>
|
||||||
|
<circle cx="30" cy="-25" r="34" fill="#468c3e"/>
|
||||||
|
</g>
|
||||||
|
<g id="pine">
|
||||||
|
<rect x="-6" y="-10" width="12" height="40" fill="#6b4a2b"/>
|
||||||
|
<path d="M0 -130 L45 -10 H-45 Z" fill="#3f7f45"/>
|
||||||
|
<path d="M0 -150 L35 -60 H-35 Z" fill="#4a8f4f"/>
|
||||||
|
</g>
|
||||||
|
<g id="cloud" fill="#ffffff" opacity="0.92">
|
||||||
|
<ellipse cx="0" cy="0" rx="70" ry="26"/>
|
||||||
|
<ellipse cx="-40" cy="8" rx="45" ry="20"/>
|
||||||
|
<ellipse cx="45" cy="6" rx="50" ry="22"/>
|
||||||
|
<ellipse cx="5" cy="-18" rx="40" ry="24"/>
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 天空和云 -->
|
||||||
|
<rect width="1840" height="460" fill="url(#sky)"/>
|
||||||
|
<circle cx="1620" cy="110" r="46" fill="#ffe27a"/>
|
||||||
|
<use href="#cloud" x="210" y="110"/>
|
||||||
|
<use href="#cloud" x="760" y="70" transform="scale(1)"/>
|
||||||
|
<use href="#cloud" x="1250" y="150"/>
|
||||||
|
<use href="#cloud" x="1700" y="250"/>
|
||||||
|
<use href="#cloud" x="420" y="260"/>
|
||||||
|
|
||||||
|
<!-- 墙外远处的楼房 -->
|
||||||
|
<g fill="#b9c7d6">
|
||||||
|
<rect x="40" y="250" width="150" height="220"/>
|
||||||
|
<rect x="230" y="300" width="110" height="170"/>
|
||||||
|
<rect x="1450" y="230" width="170" height="240"/>
|
||||||
|
<rect x="1660" y="290" width="130" height="180"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#e6eef6">
|
||||||
|
<rect x="60" y="270" width="24" height="18"/><rect x="104" y="270" width="24" height="18"/><rect x="148" y="270" width="24" height="18"/>
|
||||||
|
<rect x="60" y="310" width="24" height="18"/><rect x="104" y="310" width="24" height="18"/><rect x="148" y="310" width="24" height="18"/>
|
||||||
|
<rect x="60" y="350" width="24" height="18"/><rect x="104" y="350" width="24" height="18"/><rect x="148" y="350" width="24" height="18"/>
|
||||||
|
<rect x="1470" y="250" width="26" height="20"/><rect x="1520" y="250" width="26" height="20"/><rect x="1570" y="250" width="26" height="20"/>
|
||||||
|
<rect x="1470" y="295" width="26" height="20"/><rect x="1520" y="295" width="26" height="20"/><rect x="1570" y="295" width="26" height="20"/>
|
||||||
|
<rect x="1470" y="340" width="26" height="20"/><rect x="1520" y="340" width="26" height="20"/><rect x="1570" y="340" width="26" height="20"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 墙外的教学楼(对面学校) -->
|
||||||
|
<rect x="560" y="200" width="620" height="270" fill="#f2e3c9"/>
|
||||||
|
<rect x="540" y="185" width="660" height="22" fill="#c98b5a"/>
|
||||||
|
<rect x="820" y="140" width="100" height="50" fill="#f2e3c9"/>
|
||||||
|
<rect x="810" y="130" width="120" height="14" fill="#c98b5a"/>
|
||||||
|
<circle cx="870" cy="166" r="16" fill="#ffffff" stroke="#8a6a4a" stroke-width="3"/>
|
||||||
|
<path d="M870 166 V156 M870 166 H878" stroke="#8a6a4a" stroke-width="3"/>
|
||||||
|
<g fill="#7fa6c8">
|
||||||
|
<rect x="590" y="225" width="50" height="40"/><rect x="670" y="225" width="50" height="40"/><rect x="750" y="225" width="50" height="40"/>
|
||||||
|
<rect x="940" y="225" width="50" height="40"/><rect x="1020" y="225" width="50" height="40"/><rect x="1100" y="225" width="50" height="40"/>
|
||||||
|
<rect x="590" y="295" width="50" height="40"/><rect x="670" y="295" width="50" height="40"/><rect x="750" y="295" width="50" height="40"/>
|
||||||
|
<rect x="940" y="295" width="50" height="40"/><rect x="1020" y="295" width="50" height="40"/><rect x="1100" y="295" width="50" height="40"/>
|
||||||
|
<rect x="590" y="365" width="50" height="40"/><rect x="670" y="365" width="50" height="40"/><rect x="750" y="365" width="50" height="40"/>
|
||||||
|
<rect x="940" y="365" width="50" height="40"/><rect x="1020" y="365" width="50" height="40"/><rect x="1100" y="365" width="50" height="40"/>
|
||||||
|
</g>
|
||||||
|
<rect x="835" y="380" width="70" height="90" fill="#8a6a4a"/>
|
||||||
|
|
||||||
|
<!-- 墙外的树 -->
|
||||||
|
<use href="#pine" x="420" y="470"/>
|
||||||
|
<use href="#pine" x="1300" y="470"/>
|
||||||
|
<use href="#pine" x="1380" y="475"/>
|
||||||
|
|
||||||
|
<!-- 东围墙 -->
|
||||||
|
<rect x="0" y="440" width="1840" height="140" fill="url(#bricks)"/>
|
||||||
|
<rect x="0" y="428" width="1840" height="16" fill="#e8d8c4"/>
|
||||||
|
<g fill="#b5603f">
|
||||||
|
<rect x="0" y="420" width="40" height="160"/>
|
||||||
|
<rect x="460" y="420" width="40" height="160"/>
|
||||||
|
<rect x="920" y="420" width="40" height="160"/>
|
||||||
|
<rect x="1380" y="420" width="40" height="160"/>
|
||||||
|
<rect x="1800" y="420" width="40" height="160"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#e8d8c4">
|
||||||
|
<rect x="-6" y="412" width="52" height="12"/><rect x="454" y="412" width="52" height="12"/>
|
||||||
|
<rect x="914" y="412" width="52" height="12"/><rect x="1374" y="412" width="52" height="12"/>
|
||||||
|
<rect x="1794" y="412" width="52" height="12"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 墙头的红外对射:两个探头和红色光束 -->
|
||||||
|
<g>
|
||||||
|
<rect x="474" y="384" width="16" height="28" rx="3" fill="#3a3f47"/>
|
||||||
|
<rect x="478" y="390" width="8" height="6" fill="#e04040"/>
|
||||||
|
<rect x="1384" y="384" width="16" height="28" rx="3" fill="#3a3f47"/>
|
||||||
|
<rect x="1388" y="390" width="8" height="6" fill="#e04040"/>
|
||||||
|
<path id="beam" d="M490 393 H1384" stroke="#ff3b3b" stroke-width="2" stroke-dasharray="10 6" opacity="0.8"/>
|
||||||
|
<g id="beam-broken" display="none" stroke="#ff3b3b" stroke-width="2" stroke-dasharray="10 6" opacity="0.8">
|
||||||
|
<path d="M490 393 H880"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 翻墙的人 -->
|
||||||
|
<g id="intruder" display="none">
|
||||||
|
<!-- 腿:一条跨在墙头,一条垂在墙里侧 -->
|
||||||
|
<path d="M905 430 L870 470 L880 520" stroke="#2f3b55" stroke-width="18" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M915 430 L955 455 L985 440" stroke="#2f3b55" stroke-width="18" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<ellipse cx="882" cy="526" rx="16" ry="8" fill="#222"/>
|
||||||
|
<!-- 身体 -->
|
||||||
|
<path d="M890 360 Q910 350 930 362 L925 435 H895 Z" fill="#2c2c2c"/>
|
||||||
|
<!-- 手臂撑在墙头 -->
|
||||||
|
<path d="M895 372 L860 410 L870 428" stroke="#2c2c2c" stroke-width="14" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M928 372 L955 405 L945 428" stroke="#2c2c2c" stroke-width="14" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- 头和帽子 -->
|
||||||
|
<circle cx="910" cy="332" r="22" fill="#e9b88f"/>
|
||||||
|
<path d="M887 330 Q910 296 934 330 Z" fill="#1d1d1d"/>
|
||||||
|
<rect x="895" y="328" width="30" height="7" fill="#1d1d1d"/>
|
||||||
|
<!-- 背包 -->
|
||||||
|
<rect x="922" y="372" width="22" height="34" rx="5" fill="#8b3a3a"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 墙里侧:草地、小路、路灯、树、灌木 -->
|
||||||
|
<rect x="0" y="580" width="1840" height="220" fill="url(#lawn)"/>
|
||||||
|
<path d="M0 640 Q600 610 1000 650 T1840 630 V700 Q1300 720 900 700 T0 720 Z" fill="#d9c9a8"/>
|
||||||
|
<g fill="#3f8a3a">
|
||||||
|
<ellipse cx="120" cy="590" rx="90" ry="30"/>
|
||||||
|
<ellipse cx="700" cy="592" rx="110" ry="28"/>
|
||||||
|
<ellipse cx="1160" cy="590" rx="100" ry="30"/>
|
||||||
|
<ellipse cx="1640" cy="592" rx="120" ry="30"/>
|
||||||
|
</g>
|
||||||
|
<use href="#tree" x="300" y="640"/>
|
||||||
|
<use href="#tree" x="1560" y="650"/>
|
||||||
|
<g>
|
||||||
|
<rect x="1236" y="470" width="10" height="200" fill="#505a66"/>
|
||||||
|
<path d="M1241 476 Q1241 450 1275 450 H1290" stroke="#505a66" stroke-width="8" fill="none"/>
|
||||||
|
<rect x="1280" y="446" width="34" height="12" rx="4" fill="#3d4652"/>
|
||||||
|
<ellipse cx="1297" cy="460" rx="14" ry="4" fill="#fff6c4"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#6aa84f">
|
||||||
|
<circle cx="40" cy="770" r="6"/><circle cx="520" cy="760" r="5"/><circle cx="980" cy="775" r="6"/>
|
||||||
|
<circle cx="1420" cy="765" r="5"/><circle cx="1780" cy="772" r="6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DeviceCtrlLibrary.Model;
|
using DeviceCtrlLibrary.Model;
|
||||||
using Sim.Host;
|
using Sim.Host;
|
||||||
@@ -7,7 +8,8 @@ namespace DeviceCtrlLibrary;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
|
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
|
||||||
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数。
|
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数,
|
||||||
|
/// 并且和真机版一样,设备要在它接的那个串口上打开才有反应(接线见 TrainingBoard)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DeviceCtrlHelper
|
public class DeviceCtrlHelper
|
||||||
{
|
{
|
||||||
@@ -17,6 +19,39 @@ public class DeviceCtrlHelper
|
|||||||
public static ADAM4017Helper adam4017Helper = new();
|
public static ADAM4017Helper adam4017Helper = new();
|
||||||
public static ADAM4150Helper adam4150Helper = new();
|
public static ADAM4150Helper adam4150Helper = new();
|
||||||
public static ZigBeeHelper zigBeeHelper = new();
|
public static ZigBeeHelper zigBeeHelper = new();
|
||||||
|
public static IPCameraHelper ipCameraHelper = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 告诉实训台程序在哪个串口上打开、关闭了设备,以及串口没打开时还在读写设备(网页上据此提示)。
|
||||||
|
/// 没变就不发:书上有在定时器里反复 Open 的写法;没打开时的读写每次打开之后最多报一次
|
||||||
|
/// </summary>
|
||||||
|
internal static class PortReport
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> Unopened = new();
|
||||||
|
|
||||||
|
public static string? Open(string device, string? current, string? portName)
|
||||||
|
{
|
||||||
|
lock (Unopened) Unopened.Remove(device);
|
||||||
|
if (current != portName) SimHost.SendDevice(device, "open", portName ?? "");
|
||||||
|
return portName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Close(string device, string? current)
|
||||||
|
{
|
||||||
|
if (current is not null) SimHost.SendDevice(device, "close", null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>串口没打开(portName 为空)时返回 true,并报告一次</summary>
|
||||||
|
public static bool NotOpen(string device, string? portName)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(portName)) return false;
|
||||||
|
bool first;
|
||||||
|
lock (Unopened) first = Unopened.Add(device);
|
||||||
|
if (first) SimHost.SendDevice(device, "unopened", null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SerialPortHelper
|
public class SerialPortHelper
|
||||||
@@ -47,18 +82,21 @@ public class LEDHelper
|
|||||||
private const string SendFailed = "Error:发送失败";
|
private const string SendFailed = "Error:发送失败";
|
||||||
private string? _portName;
|
private string? _portName;
|
||||||
|
|
||||||
public void OpenLedCom(string portName) => _portName = portName;
|
public void OpenLedCom(string portName) => _portName = PortReport.Open("led", _portName, portName);
|
||||||
|
|
||||||
/// <summary>成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"</summary>
|
/// <summary>
|
||||||
|
/// 成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"。
|
||||||
|
/// 打开的串口上没接 LED 屏时和真机一样也原样返回(真机只管发出去、不等回应),只是屏上不显示
|
||||||
|
/// </summary>
|
||||||
public string SendLedString(string sendString)
|
public string SendLedString(string sendString)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_portName)) return SendFailed;
|
if (PortReport.NotOpen("led", _portName)) return SendFailed;
|
||||||
SimHost.SendDevice("led", "text", sendString);
|
if (TrainingBoard.Reaches("led", _portName)) SimHost.SendDevice("led", "text", sendString);
|
||||||
return sendString;
|
return sendString;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留空方法让书上代码能编译</summary>
|
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留它让书上代码能编译</summary>
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("led", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ADAM4017Helper
|
public class ADAM4017Helper
|
||||||
@@ -69,18 +107,19 @@ public class ADAM4017Helper
|
|||||||
public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2,
|
public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2,
|
||||||
int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6)
|
int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6)
|
||||||
{
|
{
|
||||||
_portName = PortName;
|
_portName = PortReport.Open("adam4017", _portName, PortName);
|
||||||
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
|
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
|
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
|
||||||
/// 串口未打开时所有属性为 null,与教材仿真版一致。
|
/// 串口未打开时所有属性为 null,与教材仿真版一致;打开的串口上没接 4017+ 时收不到回应,和真机版一样也全是 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ADAM4017ResModel SetData()
|
public ADAM4017ResModel SetData()
|
||||||
{
|
{
|
||||||
|
LegacyThreads.CheckPoint();
|
||||||
var model = new ADAM4017ResModel();
|
var model = new ADAM4017ResModel();
|
||||||
if (string.IsNullOrWhiteSpace(_portName)) return model;
|
if (PortReport.NotOpen("adam4017", _portName) || !TrainingBoard.Reaches("adam4017", _portName)) return model;
|
||||||
model.temperatureValue = Read(0);
|
model.temperatureValue = Read(0);
|
||||||
model.humidityValue = Read(1);
|
model.humidityValue = Read(1);
|
||||||
model.lightValue = Read(2);
|
model.lightValue = Read(2);
|
||||||
@@ -93,7 +132,7 @@ public class ADAM4017Helper
|
|||||||
|
|
||||||
private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString();
|
private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString();
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("adam4017", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ADAM4150Helper
|
public class ADAM4150Helper
|
||||||
@@ -103,15 +142,16 @@ public class ADAM4150Helper
|
|||||||
|
|
||||||
public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3)
|
public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3)
|
||||||
{
|
{
|
||||||
_portName = PortName;
|
_portName = PortReport.Open("adam4150", _portName, PortName);
|
||||||
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
|
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary>
|
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开、或打开的串口上没接 4150(收不到回应)时全部为 false</summary>
|
||||||
public ADAM4150ResModel SetData()
|
public ADAM4150ResModel SetData()
|
||||||
{
|
{
|
||||||
|
LegacyThreads.CheckPoint();
|
||||||
var model = new ADAM4150ResModel();
|
var model = new ADAM4150ResModel();
|
||||||
if (string.IsNullOrWhiteSpace(_portName)) return model;
|
if (PortReport.NotOpen("adam4150", _portName) || !TrainingBoard.Reaches("adam4150", _portName)) return model;
|
||||||
model.bodyInfraredValue = Read(0);
|
model.bodyInfraredValue = Read(0);
|
||||||
model.fireValue = Read(1);
|
model.fireValue = Read(1);
|
||||||
model.smokeValue = Read(2);
|
model.smokeValue = Read(2);
|
||||||
@@ -123,31 +163,35 @@ public class ADAM4150Helper
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
|
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
|
||||||
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物)。
|
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物);
|
||||||
|
/// 真机版只管发出命令、不等回应,所以打开的串口上没接 4150 时也返回 true,只是继电器不动作。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool OnOff(int DO, bool IsOpen)
|
public bool OnOff(int DO, bool IsOpen)
|
||||||
{
|
{
|
||||||
if (DO < 0 || DO > 4 || string.IsNullOrWhiteSpace(_portName)) return false;
|
if (DO < 0 || DO > 4 || PortReport.NotOpen("adam4150", _portName)) return false;
|
||||||
SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
|
if (TrainingBoard.Reaches("adam4150", _portName)) SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("adam4150", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ZigBeeHelper
|
public class ZigBeeHelper
|
||||||
{
|
{
|
||||||
private string? _portName;
|
private string? _portName;
|
||||||
|
|
||||||
public void Open(string PortName) => _portName = PortName;
|
public void Open(string PortName) => _portName = PortReport.Open("zigbee", _portName, PortName);
|
||||||
|
|
||||||
/// <summary>控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false</summary>
|
/// <summary>
|
||||||
|
/// 控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false。
|
||||||
|
/// 真机版要等协调器回应,打开的串口上没接协调器时收不到回应,也返回 false
|
||||||
|
/// </summary>
|
||||||
public bool OnOff(int RelayNum, bool IsOpen)
|
public bool OnOff(int RelayNum, bool IsOpen)
|
||||||
{
|
{
|
||||||
if (RelayNum < 0 || RelayNum > 1 || string.IsNullOrWhiteSpace(_portName)) return false;
|
if (RelayNum < 0 || RelayNum > 1 || PortReport.NotOpen("zigbee", _portName) || !TrainingBoard.Reaches("zigbee", _portName)) return false;
|
||||||
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
|
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("zigbee", _portName);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using Sim.Host;
|
||||||
|
|
||||||
|
namespace DeviceCtrlLibrary;
|
||||||
|
|
||||||
|
/// <summary>摄像头每拍到一帧画面时触发(教材 dll 里的委托名就是 CameraImage)</summary>
|
||||||
|
public delegate void CameraImage(ImageReadEvenArgs e);
|
||||||
|
|
||||||
|
public class ImageEventArgs : EventArgs { }
|
||||||
|
|
||||||
|
/// <summary>摄像头画面事件的参数;类名拼写与教材 dll 一致(EvenArgs)</summary>
|
||||||
|
public class ImageReadEvenArgs
|
||||||
|
{
|
||||||
|
public ImageEventArgs ImageEventArg { get; set; }
|
||||||
|
public Image img { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 网络摄像机。画面是自绘的东围墙场景(Camera/scene.svg 切出的帧):Pan 方法每次转动一格,
|
||||||
|
/// 实训台的红外对射被遮挡时画面里出现翻墙的人。和教材仿真版一样,ImageRead 在后台线程上定时触发。
|
||||||
|
/// </summary>
|
||||||
|
public class IPCameraHelper
|
||||||
|
{
|
||||||
|
// 与 Camera/build-frames.ts 一致:左右 7 档、上下 3 档,开始时在正中
|
||||||
|
private const int StepsX = 7;
|
||||||
|
private const int StepsY = 3;
|
||||||
|
private const int FrameIntervalMs = 250;
|
||||||
|
|
||||||
|
private static readonly string FramesDir = Path.Combine(Path.GetDirectoryName(typeof(IPCameraHelper).Assembly.Location)!, "Camera", "frames");
|
||||||
|
private static readonly Image?[] Frames = new Image?[StepsX * StepsY * 2];
|
||||||
|
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private string? _ip;
|
||||||
|
private Timer? _timer;
|
||||||
|
private int _x = StepsX / 2;
|
||||||
|
private int _y = StepsY / 2;
|
||||||
|
private int _ticking;
|
||||||
|
private bool _errorReported;
|
||||||
|
|
||||||
|
public event CameraImage ImageRead;
|
||||||
|
|
||||||
|
/// <summary>连接摄像机。任何非空的 IP 都能连上(教材用 admin、空密码)</summary>
|
||||||
|
public void Open(string IpAdderss, string Uid, string Pwd) => _ip = IpAdderss;
|
||||||
|
|
||||||
|
/// <summary>开始传送画面;没有 Open 时不传</summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_ip)) return;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_timer ??= new Timer(_ => Tick());
|
||||||
|
_timer.Change(0, FrameIntervalMs);
|
||||||
|
}
|
||||||
|
SimHost.SendDevice("camera", "on", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
lock (_lock) _timer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
SimHost.SendDevice("camera", "on", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
_ip = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PanLeft() => Pan(-1, 0);
|
||||||
|
public void PanRight() => Pan(1, 0);
|
||||||
|
public void PanUp() => Pan(0, -1);
|
||||||
|
public void PanDown() => Pan(0, 1);
|
||||||
|
|
||||||
|
/// <summary>云台转动一格,到头后不再转;没有 Open 时无效</summary>
|
||||||
|
private void Pan(int dx, int dy)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_ip)) return;
|
||||||
|
int x, y;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_x = Math.Clamp(_x + dx, 0, StepsX - 1);
|
||||||
|
_y = Math.Clamp(_y + dy, 0, StepsY - 1);
|
||||||
|
(x, y) = (_x, _y);
|
||||||
|
}
|
||||||
|
SimHost.SendDevice("camera", "pan", new[] { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tick()
|
||||||
|
{
|
||||||
|
// 上一帧的处理方法还没执行完时跳过这一帧
|
||||||
|
if (Interlocked.Exchange(ref _ticking, 1) == 1) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handler = ImageRead;
|
||||||
|
if (handler == null) return;
|
||||||
|
int x, y;
|
||||||
|
lock (_lock) (x, y) = (_x, _y);
|
||||||
|
handler(new ImageReadEvenArgs { ImageEventArg = new ImageEventArgs(), img = Frame(x, y, TrainingBoard.ReadDigital(3)) });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 教材仿真版用 System.Timers.Timer,处理方法里的异常不会让程序退出;这里报告一次,之后照常传画面
|
||||||
|
if (!_errorReported)
|
||||||
|
{
|
||||||
|
_errorReported = true;
|
||||||
|
SimHost.ReportException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _ticking, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>同一位置的画面复用同一个 Image 对象:赋给 PictureBox 的是同一张图时不用重新发送</summary>
|
||||||
|
private static Image Frame(int x, int y, bool intruder)
|
||||||
|
{
|
||||||
|
var index = (y * StepsX + x) * 2 + (intruder ? 1 : 0);
|
||||||
|
lock (Frames)
|
||||||
|
{
|
||||||
|
return Frames[index] ??= Image.FromStream(new MemoryStream(File.ReadAllBytes(Path.Combine(FramesDir, $"{x}-{y}-{(intruder ? 1 : 0)}.jpg"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace DeviceCtrlLibrary;
|
namespace DeviceCtrlLibrary;
|
||||||
@@ -15,6 +16,14 @@ public static class TrainingBoard
|
|||||||
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
|
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
|
||||||
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
|
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
|
||||||
private static bool[] _digital = new bool[7];
|
private static bool[] _digital = new bool[7];
|
||||||
|
// 接线:设备 → 串口(教材布局图的接法,随实验而定,见 shared/protocol.ts 的 DEFAULT_WIRING)
|
||||||
|
private static readonly Dictionary<string, string> Wiring = new()
|
||||||
|
{
|
||||||
|
["adam4017"] = "COM2",
|
||||||
|
["adam4150"] = "COM3",
|
||||||
|
["zigbee"] = "COM4",
|
||||||
|
["led"] = "COM5",
|
||||||
|
};
|
||||||
|
|
||||||
static TrainingBoard()
|
static TrainingBoard()
|
||||||
{
|
{
|
||||||
@@ -22,18 +31,32 @@ public static class TrainingBoard
|
|||||||
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
|
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…]}</summary>
|
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…],"clock":…,"wiring":{…}}(clock、wiring 不带时表示不变)</summary>
|
||||||
public static void Update(JsonObject json)
|
public static void Update(JsonObject json)
|
||||||
{
|
{
|
||||||
|
if (json.ContainsKey("clock")) Sim.Host.SimClock.Update(json["clock"]);
|
||||||
lock (Lock)
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (json["analog"] is JsonArray analog)
|
if (json["analog"] is JsonArray analog)
|
||||||
for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0;
|
for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0;
|
||||||
if (json["digital"] is JsonArray digital)
|
if (json["digital"] is JsonArray digital)
|
||||||
for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false;
|
for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false;
|
||||||
|
if (json["wiring"] is JsonObject wiring)
|
||||||
|
foreach (var (device, port) in wiring)
|
||||||
|
if (Wiring.ContainsKey(device) && port is not null) Wiring[device] = (string)port!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 程序打开的串口上有没有接这台设备。和真机一样,串口打开错了不报错,只是设备收不到命令、也不回应。
|
||||||
|
/// Windows 的串口名不分大小写
|
||||||
|
/// </summary>
|
||||||
|
internal static bool Reaches(string device, string? portName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(portName)) return false;
|
||||||
|
lock (Lock) return Wiring.TryGetValue(device, out var port) && string.Equals(port, portName.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
internal static int ReadAnalog(int channel)
|
internal static int ReadAnalog(int channel)
|
||||||
{
|
{
|
||||||
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
|
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
<RootNamespace>DeviceCtrlLibrary</RootNamespace>
|
<RootNamespace>DeviceCtrlLibrary</RootNamespace>
|
||||||
<NoWarn>$(NoWarn);CS1591;CS8618;IDE1006</NoWarn>
|
<NoWarn>$(NoWarn);CS1591;CS8618;IDE1006</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 网络摄像机的画面帧(由 Camera/build-frames.ts 生成),随运行器一起输出 -->
|
||||||
|
<None Include="Camera/frames/*.jpg" CopyToOutputDirectory="PreserveNewest" LinkBase="Camera/frames" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编译时检查学生代码用到的 API:网络、启动外部程序、反射、调用本机代码等课程用不到、只会带来风险的功能,
|
||||||
|
/// 直接给出中文报错。真正的隔离靠运行时沙箱,这里是第二道防线,也让学生看到明确的原因。
|
||||||
|
/// </summary>
|
||||||
|
internal static class ApiPolicy
|
||||||
|
{
|
||||||
|
#pragma warning disable RS2008 // 不是分析器,不需要发布跟踪
|
||||||
|
private static readonly DiagnosticDescriptor NotAllowed = new(
|
||||||
|
"SIM001", "平台不允许使用的功能", "平台不允许使用 {0}({1})", "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true);
|
||||||
|
#pragma warning restore RS2008
|
||||||
|
|
||||||
|
/// <summary>整个命名空间(含子命名空间)都不允许</summary>
|
||||||
|
private static readonly (string Namespace, string Reason)[] DeniedNamespaces =
|
||||||
|
{
|
||||||
|
("System.Net", "网络访问"),
|
||||||
|
("System.Reflection", "反射"),
|
||||||
|
("System.Runtime.Loader", "加载程序集"),
|
||||||
|
("System.IO.Pipes", "进程间通信"),
|
||||||
|
("System.IO.MemoryMappedFiles", "进程间通信"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>单个类型不允许(所在命名空间的其他类型可以用)</summary>
|
||||||
|
private static readonly Dictionary<string, string> DeniedTypes = new()
|
||||||
|
{
|
||||||
|
["System.Diagnostics.Process"] = "启动其他程序",
|
||||||
|
["System.Diagnostics.ProcessStartInfo"] = "启动其他程序",
|
||||||
|
["System.AppDomain"] = "加载程序集",
|
||||||
|
["System.Runtime.InteropServices.Marshal"] = "调用本机代码",
|
||||||
|
["System.Runtime.InteropServices.DllImportAttribute"] = "调用本机代码",
|
||||||
|
["System.Runtime.InteropServices.LibraryImportAttribute"] = "调用本机代码",
|
||||||
|
["System.Runtime.InteropServices.NativeLibrary"] = "调用本机代码",
|
||||||
|
["System.Runtime.InteropServices.NativeMemory"] = "调用本机代码",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 不允许的类型里单独放行的成员:教材用 AppDomain.CurrentDomain.BaseDirectory 拼出保存照片的路径;
|
||||||
|
/// 类型的名字(GetType().Name、DataColumn.DataType.Name)只是读名字,没有风险
|
||||||
|
/// </summary>
|
||||||
|
private static readonly HashSet<string> AllowedMembers = new()
|
||||||
|
{
|
||||||
|
"System.AppDomain.CurrentDomain",
|
||||||
|
"System.AppDomain.BaseDirectory",
|
||||||
|
"System.Reflection.MemberInfo.Name",
|
||||||
|
"System.Type.FullName",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IEnumerable<Diagnostic> Check(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
foreach (var name in tree.GetRoot().DescendantNodes().OfType<SimpleNameSyntax>())
|
||||||
|
{
|
||||||
|
// A.B.C 只检查最右边的名字,避免同一处报好几次
|
||||||
|
if (name.Parent is QualifiedNameSyntax q && q.Left == name) continue;
|
||||||
|
// var 推断出的类型在初始化表达式里已经检查过
|
||||||
|
if (name is IdentifierNameSyntax { IsVar: true }) continue;
|
||||||
|
// 类型名只是用来限定成员(AppDomain.CurrentDomain、System.AppDomain.CurrentDomain):检查后面的成员就够了
|
||||||
|
ExpressionSyntax qualifier = name.Parent is MemberAccessExpressionSyntax inner && inner.Name == name ? inner : name;
|
||||||
|
if (qualifier.Parent is MemberAccessExpressionSyntax m && m.Expression == qualifier && model.GetSymbolInfo(name).Symbol is INamespaceOrTypeSymbol) continue;
|
||||||
|
|
||||||
|
var symbol = model.GetSymbolInfo(name).Symbol;
|
||||||
|
if (symbol == null) continue;
|
||||||
|
if (Reason(symbol) is { } reason)
|
||||||
|
{
|
||||||
|
yield return Diagnostic.Create(NotAllowed, name.GetLocation(), Display(symbol), reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Reason(ISymbol symbol)
|
||||||
|
{
|
||||||
|
if (symbol is IDynamicTypeSymbol) return "dynamic 动态调用";
|
||||||
|
// using 指令里的命名空间不算:VS 生成的 AssemblyInfo.cs 会 using System.Runtime.InteropServices
|
||||||
|
if (symbol is INamespaceSymbol) return null;
|
||||||
|
if (symbol is IPropertySymbol && AllowedMembers.Contains($"{symbol.ContainingType.ToDisplayString()}.{symbol.Name}")) return null;
|
||||||
|
var type = symbol as INamedTypeSymbol ?? symbol.ContainingType;
|
||||||
|
// 方法的返回值、参数类型由调用处检查;这里看符号本身属于哪个类型
|
||||||
|
for (var t = type; t != null; t = t.ContainingType)
|
||||||
|
{
|
||||||
|
var fullName = t.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "");
|
||||||
|
if (DeniedTypes.TryGetValue(fullName, out var reason)) return reason;
|
||||||
|
}
|
||||||
|
if (type == null) return null;
|
||||||
|
// AssemblyInfo.cs 里的 [assembly: AssemblyTitle(...)] 等特性可以用
|
||||||
|
if (type.Name.EndsWith("Attribute") && type.ContainingNamespace.ToDisplayString() == "System.Reflection") return null;
|
||||||
|
var ns = type.ContainingNamespace.ToDisplayString();
|
||||||
|
foreach (var (denied, reason) in DeniedNamespaces)
|
||||||
|
{
|
||||||
|
if (ns == denied || ns.StartsWith(denied + ".")) return reason;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Display(ISymbol symbol) => symbol switch
|
||||||
|
{
|
||||||
|
IDynamicTypeSymbol => "dynamic",
|
||||||
|
INamedTypeSymbol type => type.ToDisplayString(),
|
||||||
|
_ => $"{symbol.ContainingType.ToDisplayString()}.{symbol.Name}",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编译前把学生代码里的 DateTime.Now / Today / UtcNow 改写成虚拟时钟 Sim.Host.SimClock(实训台上可以拨时间、调倍速,
|
||||||
|
/// 教材项目 7 按 6:45 起床、22:00 熄灯等时刻切换情景模式)。只改写语义上确实是 System.DateTime 这三个属性的地方;
|
||||||
|
/// 改写不换行,报错和异常里的行号不变。学生在编辑器里看到的仍是原来的代码。
|
||||||
|
/// </summary>
|
||||||
|
internal static class ClockRewriter
|
||||||
|
{
|
||||||
|
private const string Target = "global::Sim.Host.SimClock";
|
||||||
|
|
||||||
|
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
var result = compilation;
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var text = tree.GetText().ToString();
|
||||||
|
if (!text.Contains("Now") && !text.Contains("Today")) continue;
|
||||||
|
var rewriter = new Rewriter(compilation.GetSemanticModel(tree));
|
||||||
|
var root = rewriter.Visit(tree.GetRoot());
|
||||||
|
if (!rewriter.Changed) continue;
|
||||||
|
result = result.ReplaceSyntaxTree(tree,
|
||||||
|
CSharpSyntaxTree.ParseText(SourceText.From(root.ToFullString(), Encoding.UTF8), (CSharpParseOptions)tree.Options, tree.FilePath));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Replacement(ISymbol? symbol) =>
|
||||||
|
symbol is IPropertySymbol { IsStatic: true, Name: "Now" or "Today" or "UtcNow" } property
|
||||||
|
&& property.ContainingType.SpecialType == SpecialType.System_DateTime
|
||||||
|
? $"{Target}.{property.Name}"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private sealed class Rewriter(SemanticModel model) : CSharpSyntaxRewriter
|
||||||
|
{
|
||||||
|
public bool Changed { get; private set; }
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
|
||||||
|
{
|
||||||
|
// DateTime.Now、System.DateTime.Now
|
||||||
|
if (Replacement(model.GetSymbolInfo(node).Symbol) is { } replacement)
|
||||||
|
{
|
||||||
|
Changed = true;
|
||||||
|
return SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(node);
|
||||||
|
}
|
||||||
|
return base.VisitMemberAccessExpression(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node)
|
||||||
|
{
|
||||||
|
// using static System.DateTime; 之后直接写 Now
|
||||||
|
if (node.Parent is not MemberAccessExpressionSyntax access || access.Expression == node)
|
||||||
|
{
|
||||||
|
if (Replacement(model.GetSymbolInfo(node).Symbol) is { } replacement)
|
||||||
|
{
|
||||||
|
Changed = true;
|
||||||
|
return SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base.VisitIdentifierName(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
namespace Sim.Runner;
|
namespace Sim.Runner;
|
||||||
@@ -17,11 +18,38 @@ internal static class CompileHints
|
|||||||
("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。",
|
("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。",
|
||||||
("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") =>
|
("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") =>
|
||||||
$"“{name}”像是类型和名字之间少了一个空格。",
|
$"“{name}”像是类型和名字之间少了一个空格。",
|
||||||
|
("CS0234", var s) when s.Contains("Properties") => "要用 Properties.Resources,先点代码上方的“项目资源”,添加需要的图片或声音。",
|
||||||
|
("CS0117", var s) when s.Contains("Resources.") => $"项目资源里还没有“{s[(s.LastIndexOf('.') + 1)..]}”:点代码上方的“项目资源”添加(名字要和代码里一致)。",
|
||||||
("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。",
|
("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。",
|
||||||
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
|
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
|
||||||
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
|
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
|
||||||
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
|
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
|
||||||
|
("CS8026", _) => Vs2012(diagnostic),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>导出工程时照机房的 VS 2012 只能用 C# 5,新版 C# 的写法(报错里给出功能名)说明怎么改</summary>
|
||||||
|
private static readonly (string Feature, string Fix)[] NewerFeatures =
|
||||||
|
{
|
||||||
|
("内插字符串", "$\"…{x}…\" 改成 string.Format(\"…{0}…\", x),或者用 + 把字符串连起来"),
|
||||||
|
("空传播运算符", "a?.b 改成先判断是否为 null,例如 a != null ? a.b : null"),
|
||||||
|
("nameof", "nameof(x) 直接写成字符串 \"x\""),
|
||||||
|
("expression-bodied", "=> 改成大括号,方法写成 { return …; },属性写成 { get { return …; } }"),
|
||||||
|
("自动属性初始值", "属性后面的 = 初始值 去掉,改到构造函数里赋值"),
|
||||||
|
("化出变量", "out var x 改成先声明变量(如 int x;),再写 out x"),
|
||||||
|
("字典初始值", "[\"键\"] = 值 改成 { \"键\", 值 }"),
|
||||||
|
("模式匹配", "x is 类型 y 改成先用 as 转换,再判断是否为 null"),
|
||||||
|
("本地函数", "方法里的方法挪到类里,写成一个 private 方法"),
|
||||||
|
("default 字面量", "default 写成 default(类型),例如 default(int)"),
|
||||||
|
("元组", "改成定义一个类,或者用 out 参数返回多个值"),
|
||||||
|
("异常筛选器", "catch (…) when (条件) 改成在 catch 里用 if 判断"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Vs2012(Diagnostic diagnostic)
|
||||||
|
{
|
||||||
|
var message = diagnostic.GetMessage(System.Globalization.CultureInfo.GetCultureInfo("zh-Hans"));
|
||||||
|
var fix = NewerFeatures.FirstOrDefault(f => message.Contains(f.Feature)).Fix;
|
||||||
|
return "机房的 VS 2012 只支持 C# 5,不认这种写法" + (fix == null ? ",换成老的写法。" : $":{fix}。");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,16 @@ namespace Sim.Runner;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
|
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
|
||||||
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
|
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
|
||||||
|
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe;
|
||||||
|
/// 带 "export":{"kind":"project",…} 时导出 VS 工程(见 ProjectExporter),写到同一目录的 export.zip。
|
||||||
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class CompileServer
|
internal static class CompileServer
|
||||||
{
|
{
|
||||||
public const string AssemblyFile = "StudentApp.dll";
|
public const string AssemblyFile = "StudentApp.dll";
|
||||||
public const string PdbFile = "StudentApp.pdb";
|
public const string PdbFile = "StudentApp.pdb";
|
||||||
|
public const string ExportFile = "export.exe";
|
||||||
|
public const string ExportProjectFile = "export.zip";
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||||
|
|
||||||
@@ -36,7 +40,10 @@ internal static class CompileServer
|
|||||||
{
|
{
|
||||||
var request = JsonNode.Parse(line)!.AsObject();
|
var request = JsonNode.Parse(line)!.AsObject();
|
||||||
id = request["id"]?.DeepClone();
|
id = request["id"]?.DeepClone();
|
||||||
reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir"));
|
var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir");
|
||||||
|
reply = request["export"] is JsonObject export
|
||||||
|
? Export(dir, (string?)export["kind"] == "project", (string?)export["libs"] ?? "", (string?)export["name"] ?? "")
|
||||||
|
: Compile(dir);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -66,6 +73,38 @@ internal static class CompileServer
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile;
|
||||||
|
/// 导出工程(project):照机房的 VS 2012 检查后打包成 VS 工程,成功时写到 ExportProjectFile。
|
||||||
|
/// name 是程序集名(exe 的名字、工程名),由服务器按工程取好。
|
||||||
|
/// </summary>
|
||||||
|
private static JsonObject Export(string dir, bool project, string libs, string name)
|
||||||
|
{
|
||||||
|
var watch = Stopwatch.StartNew();
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) name = "上位机程序";
|
||||||
|
bool ok;
|
||||||
|
JsonArray diagnostics;
|
||||||
|
if (project)
|
||||||
|
{
|
||||||
|
var result = ProjectExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
|
||||||
|
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportProjectFile), result.Zip);
|
||||||
|
(ok, diagnostics) = (result.Success, result.Diagnostics);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
|
||||||
|
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe);
|
||||||
|
(ok, diagnostics) = (result.Success, result.Diagnostics);
|
||||||
|
}
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "compiled",
|
||||||
|
["ok"] = ok,
|
||||||
|
["diagnostics"] = diagnostics,
|
||||||
|
["ms"] = watch.ElapsedMilliseconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
|
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
|
||||||
private static void WarmUp()
|
private static void WarmUp()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace Sim.Runner;
|
|||||||
/// <summary>把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用)</summary>
|
/// <summary>把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用)</summary>
|
||||||
internal sealed class ConsoleForwarder : TextWriter
|
internal sealed class ConsoleForwarder : TextWriter
|
||||||
{
|
{
|
||||||
|
/// <summary>一直不换行的输出(如教材的 while (true) Console.Write("A"))攒到这么多字符就先发出去</summary>
|
||||||
|
private const int MaxBuffered = 4096;
|
||||||
|
|
||||||
private readonly StringBuilder _buffer = new();
|
private readonly StringBuilder _buffer = new();
|
||||||
|
|
||||||
public override Encoding Encoding => Encoding.UTF8;
|
public override Encoding Encoding => Encoding.UTF8;
|
||||||
@@ -16,7 +19,7 @@ internal sealed class ConsoleForwarder : TextWriter
|
|||||||
lock (_buffer)
|
lock (_buffer)
|
||||||
{
|
{
|
||||||
_buffer.Append(value);
|
_buffer.Append(value);
|
||||||
if (value == '\n') FlushBuffer();
|
if (value == '\n' || _buffer.Length >= MaxBuffered) FlushBuffer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +29,7 @@ internal sealed class ConsoleForwarder : TextWriter
|
|||||||
lock (_buffer)
|
lock (_buffer)
|
||||||
{
|
{
|
||||||
_buffer.Append(value);
|
_buffer.Append(value);
|
||||||
if (value.Contains('\n')) FlushBuffer();
|
if (value.Contains('\n') || _buffer.Length >= MaxBuffered) FlushBuffer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Emit;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出 EXE:把学生工程照教材工程的样子(.NET Framework 4.5 窗体应用,引用真机版设备类库)编译成一个 exe,
|
||||||
|
/// 在机房的 Windows 上双击就能运行,控制真的实训台。
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>编译的是学生写的原始代码:平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。</item>
|
||||||
|
/// <item>教材 dll(设备类库、设备控件库、媒体播放器的 Interop)嵌进 exe,启动时由模块初始化器从 exe 里加载,学生的 Program.cs 不用改。</item>
|
||||||
|
/// <item>Properties.Resources 的图片、声音嵌进 exe;代码里按文件名用到的素材(如 Resources\ring.mp3)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样。</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
internal static class ExeExporter
|
||||||
|
{
|
||||||
|
public sealed record Result(bool Success, byte[] Exe, JsonArray Diagnostics);
|
||||||
|
|
||||||
|
private const string LibPrefix = "IotSimExport.lib.";
|
||||||
|
private const string FilePrefix = "IotSimExport.file.";
|
||||||
|
private const string MediaPrefix = "IotSimExport.media.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
|
||||||
|
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
|
||||||
|
/// </summary>
|
||||||
|
internal const string MediaPlayerOcxState =
|
||||||
|
"AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" +
|
||||||
|
"LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" +
|
||||||
|
"ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" +
|
||||||
|
"AAAAAQAAAAAAAAAAAAAAAKIAAAAAAwAACAAAAAAABQAAAAAAAADwPwMAAAAAAAUAAAAAAAAAAAAIAAIA" +
|
||||||
|
"AAAAAAMAAQAAAAsA//8DAAAAAAALAP//CAACAAAAAAADADIAAAALAAAACAAKAAAAZgB1AGwAbAAAAAsA" +
|
||||||
|
"AAALAAAACwD//wsA//8LAAAACAACAAAAAAAIAAIAAAAAAAgAAgAAAAAACAACAAAAAAALAAAANAYAAOcB" +
|
||||||
|
"AAAL";
|
||||||
|
|
||||||
|
private const string AxHostStateType =
|
||||||
|
"System.Windows.Forms.AxHost+State, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||||
|
|
||||||
|
/// <summary>平台生成的 Properties/Resources.Designer.cs 从素材库取图片,导出时改成从 exe 里取</summary>
|
||||||
|
private const string PlatformResources = "global::Sim.Host.ProjectResources.";
|
||||||
|
private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles.";
|
||||||
|
|
||||||
|
private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)");
|
||||||
|
internal static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
|
||||||
|
|
||||||
|
internal static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
|
||||||
|
.WithPlatform(Platform.AnyCpu32BitPreferred)
|
||||||
|
.WithOptimizationLevel(OptimizationLevel.Release)
|
||||||
|
.WithNullableContextOptions(NullableContextOptions.Disable)
|
||||||
|
.WithAllowUnsafe(false)
|
||||||
|
.WithConcurrentBuild(true);
|
||||||
|
|
||||||
|
/// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary>
|
||||||
|
internal static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
|
||||||
|
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
|
||||||
|
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary>
|
||||||
|
public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName)
|
||||||
|
{
|
||||||
|
var libs = TextbookLibs(libsDir, "导出 EXE");
|
||||||
|
|
||||||
|
var sources = StudentCompiler.SourceFiles(projectDir)
|
||||||
|
.Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 平台生成的资源类改成从 exe 里取,用到的素材文件嵌进去
|
||||||
|
var files = new SortedSet<string>(StringComparer.Ordinal);
|
||||||
|
for (var i = 0; i < sources.Count; i++)
|
||||||
|
{
|
||||||
|
if (!sources[i].Text.Contains(PlatformResources)) continue;
|
||||||
|
foreach (Match m in ResourceFile.Matches(sources[i].Text)) files.Add(Path.GetFileName(m.Groups[1].Value));
|
||||||
|
sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources));
|
||||||
|
}
|
||||||
|
// 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹
|
||||||
|
var media = ReferencedMedia(galleryDir, sources.Select(s => s.Text));
|
||||||
|
|
||||||
|
var trees = sources
|
||||||
|
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path))
|
||||||
|
.Append(CSharpSyntaxTree.ParseText(SourceText.From(Bootstrap(media), Encoding.UTF8), StudentCompiler.ParseOptions, path: "IotSimExport.cs"))
|
||||||
|
.ToList();
|
||||||
|
var references = Framework.Value.Concat(libs.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))).ToList();
|
||||||
|
var compilation = CSharpCompilation.Create(assemblyName, trees, references, Options);
|
||||||
|
|
||||||
|
var resources = new List<ResourceDescription>();
|
||||||
|
foreach (var lib in libs)
|
||||||
|
{
|
||||||
|
var path = lib;
|
||||||
|
resources.Add(new ResourceDescription(LibPrefix + Path.GetFileName(path), () => File.OpenRead(path), isPublic: false));
|
||||||
|
}
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(galleryDir, file);
|
||||||
|
if (File.Exists(path)) resources.Add(new ResourceDescription(FilePrefix + file, () => File.OpenRead(path), isPublic: false));
|
||||||
|
}
|
||||||
|
foreach (var file in media)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(galleryDir, file);
|
||||||
|
resources.Add(new ResourceDescription(MediaPrefix + file, () => File.OpenRead(path), isPublic: false));
|
||||||
|
}
|
||||||
|
resources.AddRange(ControlResources(compilation, sources.SelectMany(s => OcxStateKey.Matches(s.Text).Select(m => m.Groups[1].Value)).ToHashSet()));
|
||||||
|
|
||||||
|
using var pe = new MemoryStream();
|
||||||
|
using var win32 = compilation.CreateDefaultWin32Resources(versionResource: true, noManifest: false, manifestContents: null, iconInIcoFormat: null);
|
||||||
|
var emit = compilation.Emit(pe, win32Resources: win32, manifestResources: resources,
|
||||||
|
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded));
|
||||||
|
|
||||||
|
var diagnostics = new JsonArray();
|
||||||
|
// 和平台运行时一样按编译器给出的顺序:语法错误在前,真正的出错原因一般是第一条
|
||||||
|
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
|
||||||
|
{
|
||||||
|
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
|
||||||
|
}
|
||||||
|
return new Result(emit.Success, pe.ToArray(), diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>代码里按文件名用到的素材库文件(如 BaseDirectory + "//Resources//ring.mp3"),按文件名排序</summary>
|
||||||
|
internal static List<string> ReferencedMedia(string galleryDir, IEnumerable<string> texts)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(galleryDir)) return new List<string>();
|
||||||
|
var all = texts.ToList();
|
||||||
|
return Directory.GetFiles(galleryDir)
|
||||||
|
.Select(Path.GetFileName)
|
||||||
|
.OfType<string>()
|
||||||
|
.Where(name => !name.Contains(".2x.") && name != "index.json")
|
||||||
|
.Where(name => all.Any(text => text.Contains(name + "\"")))
|
||||||
|
.OrderBy(name => name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时
|
||||||
|
/// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。
|
||||||
|
/// </summary>
|
||||||
|
internal static string[] TextbookLibs(string libsDir, string what)
|
||||||
|
{
|
||||||
|
var libs = ReadableLibs(libsDir, what);
|
||||||
|
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
|
||||||
|
throw new InvalidOperationException($"服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能{what},请联系老师");
|
||||||
|
return libs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ReadableLibs(string libsDir, string what)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(libsDir)) return Array.Empty<string>();
|
||||||
|
string? current = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var libs = Directory.GetFiles(libsDir, "*.dll").OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
||||||
|
foreach (var lib in libs)
|
||||||
|
{
|
||||||
|
current = lib;
|
||||||
|
using var _ = File.OpenRead(lib);
|
||||||
|
}
|
||||||
|
return libs;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
var item = current == null ? "目录" : Path.GetFileName(current);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"服务器上的教材设备类库读不了({item} 的权限不够),不能{what},请联系老师(服务器上执行 chmod 755 textbook-libs 和 chmod 644 textbook-libs/*.dll)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每个窗体、控件类的 .resources:VS 生成的 Designer.cs 用 ComponentResourceManager 读窗体的 .resx,
|
||||||
|
/// 平台没有 .resx。媒体播放器的 OcxState 放默认状态,其他取到 null(和运行时一样)。
|
||||||
|
/// </summary>
|
||||||
|
private static List<ResourceDescription> ControlResources(CSharpCompilation compilation, HashSet<string> ocxKeys)
|
||||||
|
{
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
using (var writer = new System.Resources.ResourceWriter(stream))
|
||||||
|
{
|
||||||
|
var state = Convert.FromBase64String(MediaPlayerOcxState);
|
||||||
|
foreach (var key in ocxKeys.OrderBy(k => k, StringComparer.Ordinal)) writer.AddResourceData(key, AxHostStateType, state);
|
||||||
|
writer.Generate();
|
||||||
|
}
|
||||||
|
var data = stream.ToArray();
|
||||||
|
|
||||||
|
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
|
||||||
|
var resources = new List<ResourceDescription>();
|
||||||
|
if (control == null) return resources;
|
||||||
|
var names = new HashSet<string>();
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
|
||||||
|
{
|
||||||
|
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
|
||||||
|
var derives = false;
|
||||||
|
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
|
||||||
|
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
|
||||||
|
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(data), isPublic: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加进 exe 的启动代码:模块初始化器在 Main 之前运行(.NET Framework 也支持),
|
||||||
|
/// 注册 AssemblyResolve,用到教材 dll 时从 exe 里加载;把代码里用到的素材文件放到 exe 旁边。
|
||||||
|
/// </summary>
|
||||||
|
private static string Bootstrap(IReadOnlyList<string> media) => $$"""
|
||||||
|
[assembly: global::System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.5", FrameworkDisplayName = ".NET Framework 4.5")]
|
||||||
|
|
||||||
|
namespace System.Runtime.CompilerServices
|
||||||
|
{
|
||||||
|
[global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]
|
||||||
|
internal sealed class ModuleInitializerAttribute : global::System.Attribute { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
|
||||||
|
/// 平台补上了它;导出时也补一个,书上的代码照样能编译。放在全局命名空间,不用 using 就能找到;类库自己有 Close 时用类库的。
|
||||||
|
/// </summary>
|
||||||
|
internal static class IotSimExportLedCompat
|
||||||
|
{
|
||||||
|
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace IotSimExport
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
internal static class Bootstrap
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, Assembly> Loaded = new Dictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
[global::System.Runtime.CompilerServices.ModuleInitializer]
|
||||||
|
internal static void Init()
|
||||||
|
{
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve += Resolve;
|
||||||
|
ExtractMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Assembly Resolve(object sender, ResolveEventArgs args)
|
||||||
|
{
|
||||||
|
var name = new AssemblyName(args.Name).Name;
|
||||||
|
lock (Loaded)
|
||||||
|
{
|
||||||
|
Assembly assembly;
|
||||||
|
if (Loaded.TryGetValue(name, out assembly)) return assembly;
|
||||||
|
var bytes = EmbeddedFiles.Read("{{LibPrefix}}" + name + ".dll");
|
||||||
|
if (bytes == null) return null;
|
||||||
|
assembly = Assembly.Load(bytes);
|
||||||
|
Loaded[name] = assembly;
|
||||||
|
return assembly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] Media = { {{string.Join(", ", media.Select(m => SymbolDisplay.FormatLiteral(m, true)))}} };
|
||||||
|
|
||||||
|
private static void ExtractMedia()
|
||||||
|
{
|
||||||
|
foreach (var file in Media)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources");
|
||||||
|
var path = Path.Combine(dir, file);
|
||||||
|
if (File.Exists(path)) continue;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllBytes(path, EmbeddedFiles.Read("{{MediaPrefix}}" + file));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// 文件夹不能写(如放在只读的 U 盘上)时不影响程序运行,只是用不到这个文件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class EmbeddedFiles
|
||||||
|
{
|
||||||
|
internal static byte[] Read(string name)
|
||||||
|
{
|
||||||
|
using (var stream = typeof(EmbeddedFiles).Assembly.GetManifestResourceStream(name))
|
||||||
|
{
|
||||||
|
if (stream == null) return null;
|
||||||
|
var bytes = new byte[stream.Length];
|
||||||
|
var read = 0;
|
||||||
|
while (read < bytes.Length) read += stream.Read(bytes, read, bytes.Length - read);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static global::System.Drawing.Bitmap Image(string file)
|
||||||
|
{
|
||||||
|
return new global::System.Drawing.Bitmap(new MemoryStream(Bytes(file)));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] Bytes(string file)
|
||||||
|
{
|
||||||
|
return Read("{{FilePrefix}}" + Path.GetFileName(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.Completion;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Host.Mef;
|
||||||
|
using Microsoft.CodeAnalysis.QuickInfo;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 常驻语言服务:给浏览器里的代码编辑器提供代码补全、悬停提示、参数提示和边写边检查的编译错误。
|
||||||
|
/// 和 VS 的 IntelliSense 用同一套 Roslyn 引擎。从标准输入读取 {"id":…,"type":…,"session":…,"files":[…],"file":…,"line":…,"column":…},
|
||||||
|
/// 回复 {"id":…,…}。请求并行处理(同一个会话内按顺序),学生在不同标签页里互不等待。
|
||||||
|
/// 每个浏览器标签页一个会话,保存一份工程;请求带上全部文件,只更新有变化的文件,Roslyn 能复用没变的部分。
|
||||||
|
/// 和编译服务分开成两个进程:编译不会被补全请求拖慢,反之亦然。
|
||||||
|
/// </summary>
|
||||||
|
internal static class LanguageServer
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||||
|
private static readonly object OutputLock = new();
|
||||||
|
private static readonly ConcurrentDictionary<string, Session> Sessions = new();
|
||||||
|
private const int MaxSessions = 300;
|
||||||
|
private const int MaxCompletionItems = 2000;
|
||||||
|
|
||||||
|
public static int Run()
|
||||||
|
{
|
||||||
|
using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
|
||||||
|
var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true };
|
||||||
|
|
||||||
|
WarmUp();
|
||||||
|
Write(output, new JsonObject { ["type"] = "ready" });
|
||||||
|
|
||||||
|
while (input.ReadLine() is { } line)
|
||||||
|
{
|
||||||
|
_ = Task.Run(() => HandleAsync(line, output));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Write(StreamWriter output, JsonObject message)
|
||||||
|
{
|
||||||
|
var text = message.ToJsonString(JsonOptions);
|
||||||
|
lock (OutputLock) output.WriteLine(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleAsync(string line, StreamWriter output)
|
||||||
|
{
|
||||||
|
JsonNode? id = null;
|
||||||
|
JsonObject reply;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = JsonNode.Parse(line)!.AsObject();
|
||||||
|
id = request["id"]?.DeepClone();
|
||||||
|
reply = await DispatchAsync(request);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
reply = new JsonObject { ["type"] = "error", ["message"] = ex.Message };
|
||||||
|
}
|
||||||
|
reply["id"] = id;
|
||||||
|
Write(output, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonObject> DispatchAsync(JsonObject request)
|
||||||
|
{
|
||||||
|
var session = GetSession((string?)request["session"] ?? "default");
|
||||||
|
await session.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session.LastUsed = DateTime.UtcNow;
|
||||||
|
if (request["files"] is JsonArray files) session.Update(files);
|
||||||
|
var type = (string?)request["type"];
|
||||||
|
if (type == "diagnostics") return await DiagnosticsAsync(session);
|
||||||
|
if (type == "completionDetail") return await CompletionDetailAsync(session, (int?)request["index"] ?? -1);
|
||||||
|
|
||||||
|
var document = session.Document((string?)request["file"] ?? "");
|
||||||
|
if (document == null) return new JsonObject { ["type"] = "empty" };
|
||||||
|
var text = await document.GetTextAsync();
|
||||||
|
var line = Math.Clamp(((int?)request["line"] ?? 1) - 1, 0, text.Lines.Count - 1);
|
||||||
|
var column = Math.Clamp(((int?)request["column"] ?? 1) - 1, 0, text.Lines[line].End - text.Lines[line].Start);
|
||||||
|
var position = text.Lines.GetPosition(new LinePosition(line, column));
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
"complete" => await CompleteAsync(session, document, text, position),
|
||||||
|
"hover" => await HoverAsync(document, text, position),
|
||||||
|
"signature" => await SignatureAsync(document, position),
|
||||||
|
_ => new JsonObject { ["type"] = "error", ["message"] = $"不认识的请求 {type}" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
session.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Session GetSession(string id)
|
||||||
|
{
|
||||||
|
if (Sessions.TryGetValue(id, out var existing)) return existing;
|
||||||
|
// 会话太多时丢掉最久没用的(浏览器关了也不会通知这里)
|
||||||
|
if (Sessions.Count >= MaxSessions)
|
||||||
|
{
|
||||||
|
foreach (var old in Sessions.OrderBy(s => s.Value.LastUsed).Take(Sessions.Count - MaxSessions + 1)) Sessions.TryRemove(old.Key, out _);
|
||||||
|
}
|
||||||
|
return Sessions.GetOrAdd(id, _ => new Session());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 代码补全 ----------
|
||||||
|
|
||||||
|
private static async Task<JsonObject> CompleteAsync(Session session, Document document, SourceText text, int position)
|
||||||
|
{
|
||||||
|
var service = CompletionService.GetService(document)!;
|
||||||
|
var list = await service.GetCompletionsAsync(document, position);
|
||||||
|
session.LastCompletion = (document, list);
|
||||||
|
var items = new JsonArray();
|
||||||
|
foreach (var item in list.ItemsList.Take(MaxCompletionItems))
|
||||||
|
{
|
||||||
|
items.Add(new JsonObject
|
||||||
|
{
|
||||||
|
["label"] = item.DisplayTextPrefix + item.DisplayText + item.DisplayTextSuffix,
|
||||||
|
["insert"] = item.DisplayText,
|
||||||
|
["filter"] = item.FilterText,
|
||||||
|
["sort"] = item.SortText,
|
||||||
|
["kind"] = item.Tags.FirstOrDefault() ?? "",
|
||||||
|
["detail"] = item.InlineDescription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new JsonObject { ["type"] = "completions", ["range"] = Range(text, list.Span), ["items"] = items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>补全列表里选中某一项时,取它的说明(签名和文档)</summary>
|
||||||
|
private static async Task<JsonObject> CompletionDetailAsync(Session session, int index)
|
||||||
|
{
|
||||||
|
if (session.LastCompletion is not var (document, list) || index < 0 || index >= list.ItemsList.Count)
|
||||||
|
{
|
||||||
|
return new JsonObject { ["type"] = "detail", ["text"] = null };
|
||||||
|
}
|
||||||
|
var description = await CompletionService.GetService(document)!.GetDescriptionAsync(document, list.ItemsList[index]);
|
||||||
|
return new JsonObject { ["type"] = "detail", ["text"] = WithoutNullable(description?.Text) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 悬停提示 ----------
|
||||||
|
|
||||||
|
private static async Task<JsonObject> HoverAsync(Document document, SourceText text, int position)
|
||||||
|
{
|
||||||
|
var info = await QuickInfoService.GetService(document)!.GetQuickInfoAsync(document, position);
|
||||||
|
if (info == null) return new JsonObject { ["type"] = "hover", ["text"] = null };
|
||||||
|
var sections = info.Sections.Select(s => s.Text).Where(t => !string.IsNullOrWhiteSpace(t));
|
||||||
|
return new JsonObject { ["type"] = "hover", ["text"] = WithoutNullable(string.Join("\n\n", sections)), ["range"] = Range(text, info.Span) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 参数提示 ----------
|
||||||
|
|
||||||
|
/// <summary>不显示可空注解:仿真类库开启了可空检查,学生代码没有开,VS 里看到的也是 string 而不是 string?</summary>
|
||||||
|
private static readonly SymbolDisplayFormat TypeFormat = SymbolDisplayFormat.MinimallyQualifiedFormat
|
||||||
|
.RemoveMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
|
||||||
|
|
||||||
|
/// <summary>Roslyn 生成的说明文字里去掉可空注解(如 string? → string)</summary>
|
||||||
|
private static string? WithoutNullable(string? text) =>
|
||||||
|
text == null ? null : Regex.Replace(text, @"(?<=[\w>\]])\?(?=[\s,)\]>]|$)", "");
|
||||||
|
|
||||||
|
/// <summary>光标在方法调用或 new 的括号里时,列出各个重载,标出正在填的是第几个参数</summary>
|
||||||
|
private static async Task<JsonObject> SignatureAsync(Document document, int position)
|
||||||
|
{
|
||||||
|
var empty = new JsonObject { ["type"] = "signature", ["signatures"] = new JsonArray() };
|
||||||
|
var root = await document.GetSyntaxRootAsync();
|
||||||
|
var model = await document.GetSemanticModelAsync();
|
||||||
|
if (root == null || model == null) return empty;
|
||||||
|
|
||||||
|
var argumentList = root.FindToken(Math.Max(position - 1, 0)).Parent?
|
||||||
|
.AncestorsAndSelf()
|
||||||
|
.OfType<ArgumentListSyntax>()
|
||||||
|
.FirstOrDefault(a => position > a.OpenParenToken.SpanStart && (a.CloseParenToken.IsMissing || position <= a.CloseParenToken.SpanStart));
|
||||||
|
if (argumentList == null) return empty;
|
||||||
|
|
||||||
|
IEnumerable<IMethodSymbol> methods;
|
||||||
|
ISymbol? chosen;
|
||||||
|
switch (argumentList.Parent)
|
||||||
|
{
|
||||||
|
case InvocationExpressionSyntax invocation:
|
||||||
|
methods = model.GetMemberGroup(invocation.Expression).OfType<IMethodSymbol>();
|
||||||
|
chosen = model.GetSymbolInfo(invocation).Symbol;
|
||||||
|
break;
|
||||||
|
case BaseObjectCreationExpressionSyntax creation when model.GetTypeInfo(creation).Type is INamedTypeSymbol type:
|
||||||
|
methods = type.InstanceConstructors.Where(c => c.DeclaredAccessibility == Accessibility.Public);
|
||||||
|
chosen = model.GetSymbolInfo(creation).Symbol;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeParameter = argumentList.Arguments.GetSeparators().Count(comma => comma.SpanStart < position);
|
||||||
|
var signatures = new JsonArray();
|
||||||
|
var list = methods.ToList();
|
||||||
|
foreach (var method in list)
|
||||||
|
{
|
||||||
|
var label = new StringBuilder(method.MethodKind == MethodKind.Constructor ? method.ContainingType.Name : method.Name).Append('(');
|
||||||
|
var parameters = new JsonArray();
|
||||||
|
foreach (var p in method.Parameters)
|
||||||
|
{
|
||||||
|
if (p.Ordinal > 0) label.Append(", ");
|
||||||
|
var start = label.Length;
|
||||||
|
label.Append(p.Type.ToDisplayString(TypeFormat)).Append(' ').Append(p.Name);
|
||||||
|
parameters.Add(new JsonArray(start, label.Length));
|
||||||
|
}
|
||||||
|
label.Append(')');
|
||||||
|
if (method.MethodKind != MethodKind.Constructor) label.Insert(0, method.ReturnType.ToDisplayString(TypeFormat) + " ");
|
||||||
|
// 返回类型插在前面,参数位置要跟着后移
|
||||||
|
var offset = method.MethodKind != MethodKind.Constructor ? method.ReturnType.ToDisplayString(TypeFormat).Length + 1 : 0;
|
||||||
|
foreach (var range in parameters.OfType<JsonArray>())
|
||||||
|
{
|
||||||
|
range[0] = (int)range[0]! + offset;
|
||||||
|
range[1] = (int)range[1]! + offset;
|
||||||
|
}
|
||||||
|
signatures.Add(new JsonObject { ["label"] = label.ToString(), ["parameters"] = parameters });
|
||||||
|
}
|
||||||
|
// 已经能确定是哪个重载时选它,否则选参数够多的第一个
|
||||||
|
var active = list.FindIndex(m => SymbolEqualityComparer.Default.Equals(m, chosen));
|
||||||
|
if (active < 0) active = Math.Max(list.FindIndex(m => m.Parameters.Length > activeParameter), 0);
|
||||||
|
return new JsonObject { ["type"] = "signature", ["signatures"] = signatures, ["activeSignature"] = active, ["activeParameter"] = activeParameter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 边写边检查 ----------
|
||||||
|
|
||||||
|
/// <summary>整个工程的编译错误和警告(不生成程序集),提示和真正编译时一样</summary>
|
||||||
|
private static async Task<JsonObject> DiagnosticsAsync(Session session)
|
||||||
|
{
|
||||||
|
var project = session.Workspace.CurrentSolution.GetProject(session.ProjectId)!;
|
||||||
|
var compilation = (CSharpCompilation)(await project.GetCompilationAsync())!;
|
||||||
|
var diagnostics = new JsonArray();
|
||||||
|
foreach (var diagnostic in compilation.GetDiagnostics()
|
||||||
|
.Concat(ApiPolicy.Check(compilation))
|
||||||
|
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning && d.Location.IsInSource)
|
||||||
|
.Where(d => !LegacyThreadRewriter.IsSupportedObsoleteWarning(d))
|
||||||
|
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
|
||||||
|
{
|
||||||
|
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
|
||||||
|
}
|
||||||
|
return new JsonObject { ["type"] = "diagnostics", ["diagnostics"] = diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject Range(SourceText text, TextSpan span)
|
||||||
|
{
|
||||||
|
var lines = text.Lines.GetLinePositionSpan(span);
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["startLine"] = lines.Start.Line + 1,
|
||||||
|
["startColumn"] = lines.Start.Character + 1,
|
||||||
|
["endLine"] = lines.End.Line + 1,
|
||||||
|
["endColumn"] = lines.End.Character + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>启动时做一遍补全和检查,让 MEF 组合和 JIT 提前完成,学生第一次按"."时不用等几秒</summary>
|
||||||
|
private static void WarmUp()
|
||||||
|
{
|
||||||
|
var session = new Session();
|
||||||
|
session.Update(new JsonArray(new JsonObject
|
||||||
|
{
|
||||||
|
["path"] = "Program.cs",
|
||||||
|
["content"] = "using System.Windows.Forms; static class P { static void Main() { var f = new Form(); f.Text = \"预热\"; MessageBox.Show(\"\"); } }",
|
||||||
|
}));
|
||||||
|
var document = session.Document("Program.cs")!;
|
||||||
|
var text = document.GetTextAsync().Result;
|
||||||
|
var position = text.ToString().IndexOf("f.Text", StringComparison.Ordinal) + 2;
|
||||||
|
CompleteAsync(session, document, text, position).Wait();
|
||||||
|
HoverAsync(document, text, position).Wait();
|
||||||
|
SignatureAsync(document, text.ToString().IndexOf("Show(", StringComparison.Ordinal) + 5).Wait();
|
||||||
|
DiagnosticsAsync(session).Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>一个浏览器标签页的工程</summary>
|
||||||
|
private sealed class Session
|
||||||
|
{
|
||||||
|
public readonly AdhocWorkspace Workspace = new(MefHostServices.DefaultHost);
|
||||||
|
public readonly ProjectId ProjectId = ProjectId.CreateNewId();
|
||||||
|
public readonly SemaphoreSlim Lock = new(1, 1);
|
||||||
|
public DateTime LastUsed = DateTime.UtcNow;
|
||||||
|
/// <summary>最近一次补全的结果,选中某项时据此取说明</summary>
|
||||||
|
public (Document, CompletionList)? LastCompletion;
|
||||||
|
private readonly Dictionary<string, (DocumentId Id, string Content)> documents = new();
|
||||||
|
|
||||||
|
public Session()
|
||||||
|
{
|
||||||
|
Workspace.AddProject(ProjectInfo.Create(
|
||||||
|
ProjectId, VersionStamp.Create(), "StudentApp", "StudentApp", LanguageNames.CSharp,
|
||||||
|
compilationOptions: StudentCompiler.CompilationOptions,
|
||||||
|
parseOptions: StudentCompiler.ParseOptions,
|
||||||
|
metadataReferences: StudentCompiler.References.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>同步浏览器里的文件:只更新有变化的,删掉已经没有的</summary>
|
||||||
|
public void Update(JsonArray files)
|
||||||
|
{
|
||||||
|
var solution = Workspace.CurrentSolution;
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
foreach (var file in files.OfType<JsonObject>())
|
||||||
|
{
|
||||||
|
var path = (string?)file["path"];
|
||||||
|
var content = (string?)file["content"] ?? "";
|
||||||
|
if (path == null || !path.EndsWith(".cs", StringComparison.Ordinal)) continue;
|
||||||
|
seen.Add(path);
|
||||||
|
if (documents.TryGetValue(path, out var doc))
|
||||||
|
{
|
||||||
|
if (doc.Content == content) continue;
|
||||||
|
solution = solution.WithDocumentText(doc.Id, SourceText.From(content));
|
||||||
|
documents[path] = (doc.Id, content);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var id = DocumentId.CreateNewId(ProjectId);
|
||||||
|
solution = solution.AddDocument(id, path, SourceText.From(content), filePath: path);
|
||||||
|
documents[path] = (id, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var path in documents.Keys.Where(p => !seen.Contains(p)).ToList())
|
||||||
|
{
|
||||||
|
solution = solution.RemoveDocument(documents[path].Id);
|
||||||
|
documents.Remove(path);
|
||||||
|
}
|
||||||
|
Workspace.TryApplyChanges(solution);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Document? Document(string path) =>
|
||||||
|
documents.TryGetValue(path, out var doc) ? Workspace.CurrentSolution.GetDocument(doc.Id) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编译前改写学生代码里的旧版线程 API,让教材项目 5 的 Suspend / Resume / Abort 能在新版 .NET 上运行:
|
||||||
|
/// <code>
|
||||||
|
/// new Thread(GetData) → new Thread(LegacyThreads.Wrap(new ThreadStart(GetData)))
|
||||||
|
/// thd.Suspend() / Resume() / Abort() → LegacyThreads.Suspend(thd) …
|
||||||
|
/// thd.ThreadState → LegacyThreads.GetThreadState(thd)
|
||||||
|
/// Thread.Sleep(2000) → LegacyThreads.Sleep(2000)
|
||||||
|
/// </code>
|
||||||
|
/// 只改写语义上确实是 System.Threading.Thread 成员的调用;改写不换行,报错和异常堆栈里的行号不变。
|
||||||
|
/// 学生在编辑器里看到的仍是原来的代码。
|
||||||
|
/// </summary>
|
||||||
|
internal static class LegacyThreadRewriter
|
||||||
|
{
|
||||||
|
private const string Target = "global::Sim.Host.LegacyThreads";
|
||||||
|
|
||||||
|
/// <summary>改写整个编译;没有需要改写的地方时原样返回</summary>
|
||||||
|
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
var result = compilation;
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
// 用到线程的文件一定会出现 Thread 这个词,其他文件不用做语义分析
|
||||||
|
if (!tree.GetText().ToString().Contains("Thread")) continue;
|
||||||
|
var rewriter = new Rewriter(compilation.GetSemanticModel(tree));
|
||||||
|
var root = rewriter.Visit(tree.GetRoot());
|
||||||
|
if (!rewriter.Changed) continue;
|
||||||
|
var text = SourceText.From(root.ToFullString(), Encoding.UTF8);
|
||||||
|
result = result.ReplaceSyntaxTree(tree, CSharpSyntaxTree.ParseText(text, (CSharpParseOptions)tree.Options, tree.FilePath));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>平台支持的过时线程 API 不再提示"已过时"(边写边检查时用)</summary>
|
||||||
|
public static bool IsSupportedObsoleteWarning(Diagnostic diagnostic)
|
||||||
|
{
|
||||||
|
if (diagnostic.Id is not ("CS0618" or "SYSLIB0006")) return false;
|
||||||
|
// 警告标在整个调用上,如 thd.Suspend()
|
||||||
|
var source = diagnostic.Location.SourceTree?.GetText().ToString(diagnostic.Location.SourceSpan) ?? "";
|
||||||
|
return Regex.IsMatch(source, @"\b(Suspend|Resume|Abort)\b");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsThread(ITypeSymbol? type) =>
|
||||||
|
type is { Name: "Thread", ContainingNamespace: { Name: "Threading", ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } };
|
||||||
|
|
||||||
|
private sealed class Rewriter(SemanticModel model) : CSharpSyntaxRewriter
|
||||||
|
{
|
||||||
|
public bool Changed { get; private set; }
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||||
|
{
|
||||||
|
var method = model.GetSymbolInfo(node).Symbol as IMethodSymbol;
|
||||||
|
var visited = (InvocationExpressionSyntax)base.VisitInvocationExpression(node)!;
|
||||||
|
if (method == null || !IsThread(method.ContainingType)) return visited;
|
||||||
|
|
||||||
|
switch (method.Name)
|
||||||
|
{
|
||||||
|
case "Suspend" or "Resume" or "Abort" when !method.IsStatic && visited.Expression is MemberAccessExpressionSyntax access:
|
||||||
|
// thd.Abort(x) → LegacyThreads.Abort(thd, x)
|
||||||
|
var arguments = visited.ArgumentList.Arguments.Insert(0, SyntaxFactory.Argument(access.Expression.WithoutTrivia()));
|
||||||
|
Changed = true;
|
||||||
|
return SyntaxFactory.InvocationExpression(Member(method.Name), SyntaxFactory.ArgumentList(arguments))
|
||||||
|
.WithTriviaFrom(visited);
|
||||||
|
case "Sleep" when method.IsStatic:
|
||||||
|
Changed = true;
|
||||||
|
return visited.WithExpression(Member("Sleep").WithTriviaFrom(visited.Expression));
|
||||||
|
default:
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
|
||||||
|
{
|
||||||
|
var symbol = model.GetSymbolInfo(node).Symbol;
|
||||||
|
var visited = (MemberAccessExpressionSyntax)base.VisitMemberAccessExpression(node)!;
|
||||||
|
if (symbol is not IPropertySymbol { Name: "ThreadState", IsStatic: false } property || !IsThread(property.ContainingType)) return visited;
|
||||||
|
Changed = true;
|
||||||
|
return SyntaxFactory.InvocationExpression(
|
||||||
|
Member("GetThreadState"),
|
||||||
|
SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(visited.Expression.WithoutTrivia()))))
|
||||||
|
.WithTriviaFrom(visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitObjectCreationExpression(ObjectCreationExpressionSyntax node) =>
|
||||||
|
WrapThreadStart(node, base.VisitObjectCreationExpression(node));
|
||||||
|
|
||||||
|
public override SyntaxNode? VisitImplicitObjectCreationExpression(ImplicitObjectCreationExpressionSyntax node) =>
|
||||||
|
WrapThreadStart(node, base.VisitImplicitObjectCreationExpression(node));
|
||||||
|
|
||||||
|
/// <summary>new Thread(start) 的第一个参数包一层 LegacyThreads.Wrap,线程被 Abort 时安静地结束</summary>
|
||||||
|
private SyntaxNode? WrapThreadStart(BaseObjectCreationExpressionSyntax original, SyntaxNode? visitedNode)
|
||||||
|
{
|
||||||
|
if (visitedNode is not BaseObjectCreationExpressionSyntax visited
|
||||||
|
|| model.GetSymbolInfo(original).Symbol is not IMethodSymbol { MethodKind: MethodKind.Constructor } constructor
|
||||||
|
|| !IsThread(constructor.ContainingType)
|
||||||
|
|| constructor.Parameters.Length == 0
|
||||||
|
|| visited.ArgumentList is not { Arguments.Count: > 0 } argumentList)
|
||||||
|
{
|
||||||
|
return visitedNode;
|
||||||
|
}
|
||||||
|
var first = argumentList.Arguments[0];
|
||||||
|
if (first.Expression.IsKind(SyntaxKind.NullLiteralExpression)) return visited;
|
||||||
|
var delegateType = constructor.Parameters[0].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||||
|
// new ThreadStart(方法名或 lambda):明确委托类型,Wrap 的重载就不会有歧义
|
||||||
|
var wrapped = SyntaxFactory.ParseExpression($"{Target}.Wrap(new {delegateType}({first.Expression.WithoutTrivia().ToFullString()}))");
|
||||||
|
Changed = true;
|
||||||
|
return visited.WithArgumentList(argumentList.WithArguments(
|
||||||
|
argumentList.Arguments.Replace(first, first.WithExpression(wrapped.WithTriviaFrom(first.Expression)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpressionSyntax Member(string name) => SyntaxFactory.ParseExpression($"{Target}.{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Operations;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编译前把传给 System.IO(File、Directory、Path、FileStream、StreamWriter 等)和 Image / Bitmap 读写文件方法的路径参数
|
||||||
|
/// 包上 Sim.Host.WindowsPaths.Fix(...):教材按 Windows 写 AppDomain.CurrentDomain.BaseDirectory + "Camera\\",
|
||||||
|
/// 在 Linux 上反斜杠要换成斜杠。只包语义上确实是这些方法里名字带 path / file / dir 的字符串参数;
|
||||||
|
/// 改写不换行,报错和异常里的行号不变。学生在编辑器里看到的仍是原来的代码。
|
||||||
|
/// </summary>
|
||||||
|
internal static class PathRewriter
|
||||||
|
{
|
||||||
|
private const string Fix = "global::Sim.Host.WindowsPaths.Fix";
|
||||||
|
|
||||||
|
public static CSharpCompilation Rewrite(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
var result = compilation;
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
var root = tree.GetRoot();
|
||||||
|
var targets = root.DescendantNodes()
|
||||||
|
.Where(n => n is InvocationExpressionSyntax or BaseObjectCreationExpressionSyntax)
|
||||||
|
.SelectMany(n => PathArguments(model.GetOperation(n)))
|
||||||
|
.ToHashSet();
|
||||||
|
if (targets.Count == 0) continue;
|
||||||
|
var rewritten = root.ReplaceNodes(targets, (_, node) =>
|
||||||
|
SyntaxFactory.ParseExpression($"{Fix}({node.WithoutTrivia().ToFullString()})").WithTriviaFrom(node));
|
||||||
|
result = result.ReplaceSyntaxTree(tree,
|
||||||
|
CSharpSyntaxTree.ParseText(SourceText.From(rewritten.ToFullString(), Encoding.UTF8), (CSharpParseOptions)tree.Options, tree.FilePath));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>调用里要包起来的路径参数(只取学生直接写的表达式;不含反斜杠的字符串常量不用包)</summary>
|
||||||
|
private static IEnumerable<ExpressionSyntax> PathArguments(IOperation? operation)
|
||||||
|
{
|
||||||
|
var (method, arguments) = operation switch
|
||||||
|
{
|
||||||
|
IInvocationOperation invocation => (invocation.TargetMethod, invocation.Arguments),
|
||||||
|
IObjectCreationOperation creation when creation.Constructor != null => (creation.Constructor, creation.Arguments),
|
||||||
|
_ => (null, default),
|
||||||
|
};
|
||||||
|
if (method == null || !HandlesFiles(method.ContainingType)) yield break;
|
||||||
|
foreach (var argument in arguments)
|
||||||
|
{
|
||||||
|
if (argument.Parameter is not { } parameter || !IsPathName(parameter.Name)) continue;
|
||||||
|
var values = argument.ArgumentKind == ArgumentKind.ParamArray && argument.Value is IArrayCreationOperation { Initializer: { } init }
|
||||||
|
? init.ElementValues
|
||||||
|
: [argument.Value];
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (value.IsImplicit || value.Type?.SpecialType != SpecialType.System_String) continue;
|
||||||
|
if (value.ConstantValue is { HasValue: true, Value: string constant } && !constant.Contains('\\')) continue;
|
||||||
|
if (value.Syntax is ExpressionSyntax expression) yield return expression;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HandlesFiles(INamedTypeSymbol type)
|
||||||
|
{
|
||||||
|
var ns = type.ContainingNamespace?.ToDisplayString();
|
||||||
|
return ns == "System.IO" || (ns == "System.Drawing" && type.Name is "Image" or "Bitmap");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPathName(string name) =>
|
||||||
|
name.Contains("path", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("file", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("dir", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -2,20 +2,24 @@ using System;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using Sim.Runner;
|
using Sim.Runner;
|
||||||
|
|
||||||
// 两种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
|
// 三种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
|
||||||
// dotnet Sim.Runner.dll compile-server 常驻编译服务,全服务器共用一个,编译学生工程并把程序集写回工程目录
|
// dotnet Sim.Runner.dll compile-server 常驻编译服务,全服务器共用一个,编译学生工程并把程序集写回工程目录
|
||||||
|
// dotnet Sim.Runner.dll language-server 常驻语言服务,全服务器共用一个:代码补全、悬停提示、参数提示、边写边检查
|
||||||
|
// dotnet Sim.Runner.dll sql-server 常驻 SQL 服务:数据库页面的查询、对象资源管理器,评分前准备实验数据库
|
||||||
// dotnet Sim.Runner.dll run <工程目录> 运行已编译的学生程序,每次运行一个进程
|
// dotnet Sim.Runner.dll run <工程目录> 运行已编译的学生程序,每次运行一个进程
|
||||||
// 运行模式不加载 Roslyn,单个学生程序的内存占用因此小得多。
|
// 运行模式不加载 Roslyn,单个学生程序的内存占用因此小得多。
|
||||||
|
|
||||||
return args switch
|
return args switch
|
||||||
{
|
{
|
||||||
["compile-server"] => CompileServer.Run(),
|
["compile-server"] => CompileServer.Run(),
|
||||||
|
["language-server"] => LanguageServer.Run(),
|
||||||
|
["sql-server"] => SqlService.Run(),
|
||||||
["run", var dir] when Directory.Exists(dir) => StudentProgramHost.Run(dir),
|
["run", var dir] when Directory.Exists(dir) => StudentProgramHost.Run(dir),
|
||||||
_ => Usage(),
|
_ => Usage(),
|
||||||
};
|
};
|
||||||
|
|
||||||
static int Usage()
|
static int Usage()
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner run <工程目录>");
|
Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner language-server | Sim.Runner sql-server | Sim.Runner run <工程目录>");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出 VS 工程:把学生工程打包成教材那样的 VS 2012 窗体应用工程(zip),在机房电脑上用 VS 打开接着改、按 F5 运行。
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>先照机房的环境检查:C# 5(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库,有错误就不导出。</item>
|
||||||
|
/// <item>平台生成的 Properties/Resources.Designer.cs 从素材库取图,导出时换成 VS 生成的写法和 Resources.resx,图片放在 Resources 文件夹;
|
||||||
|
/// 代码里按文件名用到的素材(如 ring.mp3)设成复制到输出目录,运行时在 bin\Debug\Resources 下,和教材一样。</item>
|
||||||
|
/// <item>教材 dll 放在工程的 lib 文件夹,用到的才加引用(和教材工程一样只引用设备类库、控件库),编译时 VS 把它们和依赖复制到 bin\Debug。</item>
|
||||||
|
/// <item>文本文件用带 BOM 的 UTF-8 和 CRLF 换行(VS 2012 打开不带 BOM 的文件会把中文当成 GBK)。</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProjectExporter
|
||||||
|
{
|
||||||
|
public sealed record Result(bool Success, byte[] Zip, JsonArray Diagnostics);
|
||||||
|
|
||||||
|
/// <summary>VS 2012 的编译器支持到 C# 5</summary>
|
||||||
|
internal static readonly CSharpParseOptions Vs2012 = new(LanguageVersion.CSharp5);
|
||||||
|
|
||||||
|
private const string PlatformResourcesFile = "Properties/Resources.Designer.cs";
|
||||||
|
private const string LedCompatFile = "LedHelperCompat.cs";
|
||||||
|
private const string LedCompatClass = "LedHelperCompat";
|
||||||
|
|
||||||
|
private static readonly Regex ResourceMember = new(
|
||||||
|
@"internal static (System\.Drawing\.Bitmap|byte\[\]) ([\p{L}\p{N}_]+) \{\s*get \{ return global::Sim\.Host\.ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\); \}");
|
||||||
|
private static readonly Regex ResourcesNamespace = new(@"namespace\s+([\p{L}\p{N}_.]+)\.Properties\b");
|
||||||
|
private static readonly Regex FirstNamespace = new(@"^\s*namespace\s+([\p{L}\p{N}_.]+)", RegexOptions.Multiline);
|
||||||
|
|
||||||
|
/// <summary>VS 2012 窗体应用模板默认的引用(教材工程都有这些)</summary>
|
||||||
|
private static readonly string[] DefaultReferences =
|
||||||
|
{
|
||||||
|
"System", "System.Core", "System.Xml.Linq", "System.Data.DataSetExtensions", "Microsoft.CSharp",
|
||||||
|
"System.Data", "System.Deployment", "System.Drawing", "System.Windows.Forms", "System.Xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string BitmapType = "System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a";
|
||||||
|
private const string BytesType = "System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||||
|
|
||||||
|
private sealed record ResourceItem(string Name, bool Image, string File);
|
||||||
|
|
||||||
|
private sealed record Source(string Path, string Text);
|
||||||
|
|
||||||
|
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库;name:工程名(也是程序集名、文件夹名)</summary>
|
||||||
|
public static Result Export(string projectDir, string libsDir, string galleryDir, string name)
|
||||||
|
{
|
||||||
|
var libs = ExeExporter.TextbookLibs(libsDir, "导出工程");
|
||||||
|
var sources = StudentCompiler.SourceFiles(projectDir)
|
||||||
|
.Select(path => new Source(Path.GetRelativePath(projectDir, path).Replace('\\', '/'), File.ReadAllText(path)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var platformResources = sources.FirstOrDefault(s => s.Path == PlatformResourcesFile)?.Text;
|
||||||
|
var items = platformResources == null
|
||||||
|
? new List<ResourceItem>()
|
||||||
|
: ResourceMember.Matches(platformResources)
|
||||||
|
.Select(m => new ResourceItem(m.Groups[2].Value, m.Groups[1].Value != "byte[]", Path.GetFileName(m.Groups[3].Value)))
|
||||||
|
.ToList();
|
||||||
|
var rootNamespace = RootNamespace(sources, platformResources, name);
|
||||||
|
sources.RemoveAll(s => s.Path == PlatformResourcesFile);
|
||||||
|
|
||||||
|
// VS 模板里有、平台上没有的文件(Properties 文件夹由平台管理,学生建不了同名文件)
|
||||||
|
var generated = new List<Source>
|
||||||
|
{
|
||||||
|
new("Properties/AssemblyInfo.cs", AssemblyInfo(name)),
|
||||||
|
new("Properties/Resources.Designer.cs", ResourcesDesigner(rootNamespace, items)),
|
||||||
|
new("Properties/Settings.Designer.cs", SettingsDesigner(rootNamespace)),
|
||||||
|
};
|
||||||
|
var compat = new Source(LedCompatFile, LedCompat);
|
||||||
|
|
||||||
|
// 照机房的环境编译一遍:C# 5、.NET Framework 4.5、真机版设备类库
|
||||||
|
var trees = sources.Concat(generated).Append(compat)
|
||||||
|
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), Vs2012, path: s.Path))
|
||||||
|
.ToList();
|
||||||
|
var libReferences = libs.ToDictionary(p => (MetadataReference)MetadataReference.CreateFromFile(p), p => p);
|
||||||
|
var compilation = CSharpCompilation.Create(name, trees, ExeExporter.Framework.Value.Concat(libReferences.Keys), ExeExporter.Options);
|
||||||
|
using (var pe = new MemoryStream())
|
||||||
|
{
|
||||||
|
var emit = compilation.Emit(pe);
|
||||||
|
var diagnostics = new JsonArray();
|
||||||
|
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
|
||||||
|
{
|
||||||
|
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
|
||||||
|
}
|
||||||
|
if (!emit.Success) return new Result(false, Array.Empty<byte>(), diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
var studentTrees = trees.Take(sources.Count).ToList();
|
||||||
|
var code = sources.Concat(generated).ToList();
|
||||||
|
if (UsesLedCompat(compilation, studentTrees))
|
||||||
|
{
|
||||||
|
code.Add(compat);
|
||||||
|
studentTrees.Add(trees[^1]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
compilation = compilation.RemoveSyntaxTrees(trees[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用:和教材工程一样,教材 dll 只引用代码里直接用到的(其他的作为依赖由 VS 复制到输出目录),
|
||||||
|
// 框架引用在 VS 模板默认的之外加上用到的(如图表控件)。只带这些引用编译不过时(用到的类型的基类在别的 dll 里等)引用全部教材 dll
|
||||||
|
var direct = DirectlyUsedAssemblies(compilation, studentTrees);
|
||||||
|
var frameworkReferences = DefaultReferences
|
||||||
|
.Concat(ExeExporter.Framework.Value.OfType<PortableExecutableReference>()
|
||||||
|
.Select(r => Path.GetFileNameWithoutExtension(r.FilePath)).OfType<string>()
|
||||||
|
.Where(n => direct.Contains(n) && n != "mscorlib" && !DefaultReferences.Contains(n))
|
||||||
|
.OrderBy(n => n, StringComparer.Ordinal))
|
||||||
|
.ToList();
|
||||||
|
var textbookReferences = libs.Where(p => direct.Contains(Path.GetFileNameWithoutExtension(p))).ToList();
|
||||||
|
var reduced = compilation.WithReferences(ExeExporter.Framework.Value.Concat(libReferences.Where(r => textbookReferences.Contains(r.Value)).Select(r => r.Key)));
|
||||||
|
if (reduced.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error)) textbookReferences = libs.ToList();
|
||||||
|
textbookReferences = textbookReferences.Select(p => Path.GetFileName(p)).ToList();
|
||||||
|
|
||||||
|
var forms = Forms(compilation, sources, studentTrees);
|
||||||
|
var media = ExeExporter.ReferencedMedia(galleryDir, sources.Select(s => s.Text));
|
||||||
|
var images = items.Select(i => i.File).Where(f => File.Exists(Path.Combine(galleryDir, f))).Distinct().ToList();
|
||||||
|
|
||||||
|
var zip = new ZipWriter(DateTime.UtcNow.AddHours(8));
|
||||||
|
var root = $"{name}/";
|
||||||
|
var dir = $"{name}/{name}/";
|
||||||
|
zip.Add(root + name + ".sln", Text(Solution(name)));
|
||||||
|
zip.Add(dir + name + ".csproj", Text(Project(name, rootNamespace, code, forms, frameworkReferences, textbookReferences, images, media)));
|
||||||
|
zip.Add(dir + "App.config", Text(AppConfig));
|
||||||
|
zip.Add(dir + "Properties/Settings.settings", Text(SettingsFile));
|
||||||
|
zip.Add(dir + "Properties/Resources.resx", Text(Resx(items.Select(ResourceEntry))));
|
||||||
|
foreach (var source in code) zip.Add(dir + source.Path, Text(source.Text));
|
||||||
|
foreach (var form in forms.Where(f => f.Resx))
|
||||||
|
{
|
||||||
|
zip.Add(dir + Path.ChangeExtension(form.Path, ".resx"), Text(Resx(form.OcxKeys.Select(OcxEntry))));
|
||||||
|
}
|
||||||
|
foreach (var file in images.Union(media).OrderBy(f => f, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
zip.Add(dir + "Resources/" + file, File.ReadAllBytes(Path.Combine(galleryDir, file)));
|
||||||
|
}
|
||||||
|
foreach (var lib in libs) zip.Add(dir + "lib/" + Path.GetFileName(lib), File.ReadAllBytes(lib));
|
||||||
|
return new Result(true, zip.Finish(), new JsonArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>工程的默认命名空间:平台的资源类所在的命名空间,否则取 Program.cs(再其次其他文件)里的第一个命名空间</summary>
|
||||||
|
private static string RootNamespace(List<Source> sources, string? platformResources, string name)
|
||||||
|
{
|
||||||
|
if (platformResources != null && ResourcesNamespace.Match(platformResources) is { Success: true } r) return r.Groups[1].Value;
|
||||||
|
foreach (var source in sources.OrderBy(s => s.Path == "Program.cs" ? 0 : 1).ThenBy(s => s.Path, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var m = FirstNamespace.Match(source.Text);
|
||||||
|
if (m.Success) return m.Groups[1].Value;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>代码里直接用到的类型、成员所在的程序集(程序集名)</summary>
|
||||||
|
private static HashSet<string> DirectlyUsedAssemblies(CSharpCompilation compilation, List<SyntaxTree> trees)
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
void Add(ISymbol? symbol)
|
||||||
|
{
|
||||||
|
while (symbol is IArrayTypeSymbol array) symbol = array.ElementType;
|
||||||
|
if (symbol?.ContainingAssembly is { } assembly) names.Add(assembly.Identity.Name);
|
||||||
|
if (symbol is INamedTypeSymbol { IsGenericType: true } generic)
|
||||||
|
{
|
||||||
|
foreach (var argument in generic.TypeArguments) Add(argument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var tree in trees)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
foreach (var node in tree.GetRoot().DescendantNodes())
|
||||||
|
{
|
||||||
|
Add(model.GetSymbolInfo(node).Symbol);
|
||||||
|
Add(model.GetTypeInfo(node).Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>学生代码是否用到了补上的 ledHelper.Close()(教材类库里没有)</summary>
|
||||||
|
private static bool UsesLedCompat(CSharpCompilation compilation, List<SyntaxTree> trees)
|
||||||
|
{
|
||||||
|
foreach (var tree in trees)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
foreach (var invocation in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||||
|
{
|
||||||
|
if (model.GetSymbolInfo(invocation).Symbol is IMethodSymbol { ReducedFrom.ContainingType.Name: LedCompatClass }) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>窗体、用户控件:VS 里显示成可以打开设计器的文件,Designer.cs 和 .resx 挂在它下面</summary>
|
||||||
|
private sealed record Form(string Path, string SubType, bool Resx, List<string> OcxKeys);
|
||||||
|
|
||||||
|
private static List<Form> Forms(CSharpCompilation compilation, List<Source> sources, List<SyntaxTree> trees)
|
||||||
|
{
|
||||||
|
var form = compilation.GetTypeByMetadataName("System.Windows.Forms.Form");
|
||||||
|
var userControl = compilation.GetTypeByMetadataName("System.Windows.Forms.UserControl");
|
||||||
|
var result = new List<Form>();
|
||||||
|
for (var i = 0; i < sources.Count; i++)
|
||||||
|
{
|
||||||
|
var path = sources[i].Path;
|
||||||
|
if (path.EndsWith(".Designer.cs")) continue;
|
||||||
|
var designer = sources.FirstOrDefault(s => s.Path == path[..^3] + ".Designer.cs");
|
||||||
|
var model = compilation.GetSemanticModel(trees[i]);
|
||||||
|
var type = trees[i].GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>()
|
||||||
|
.Select(c => model.GetDeclaredSymbol(c)).FirstOrDefault(t => t != null);
|
||||||
|
string? subType = null;
|
||||||
|
for (var t = type?.BaseType; t != null && subType == null; t = t.BaseType)
|
||||||
|
{
|
||||||
|
if (SymbolEqualityComparer.Default.Equals(t, form)) subType = "Form";
|
||||||
|
else if (SymbolEqualityComparer.Default.Equals(t, userControl)) subType = "UserControl";
|
||||||
|
}
|
||||||
|
if (subType == null) continue;
|
||||||
|
var keys = designer == null
|
||||||
|
? new List<string>()
|
||||||
|
: ExeExporter.OcxStateKey.Matches(designer.Text).Select(m => m.Groups[1].Value).Distinct().OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||||
|
result.Add(new Form(path, subType, designer != null, keys));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Project(string name, string rootNamespace, List<Source> code, List<Form> forms,
|
||||||
|
List<string> frameworkReferences, List<string> textbookReferences, List<string> images, List<string> media)
|
||||||
|
{
|
||||||
|
static string Win(string path) => path.Replace('/', '\\');
|
||||||
|
static string X(string text) => SecurityElement.Escape(text);
|
||||||
|
|
||||||
|
var s = new StringBuilder();
|
||||||
|
s.Append($"""
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||||
|
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||||
|
<ProjectGuid>{ProjectGuid(name)}</ProjectGuid>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<AppDesignerFolder>Properties</AppDesignerFolder>
|
||||||
|
<RootNamespace>{X(rootNamespace)}</RootNamespace>
|
||||||
|
<AssemblyName>{X(name)}</AssemblyName>
|
||||||
|
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
|
||||||
|
<FileAlignment>512</FileAlignment>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
<DebugType>full</DebugType>
|
||||||
|
<Optimize>false</Optimize>
|
||||||
|
<OutputPath>bin\Debug\</OutputPath>
|
||||||
|
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||||
|
<ErrorReport>prompt</ErrorReport>
|
||||||
|
<WarningLevel>4</WarningLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<DebugType>pdbonly</DebugType>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
<OutputPath>bin\Release\</OutputPath>
|
||||||
|
<DefineConstants>TRACE</DefineConstants>
|
||||||
|
<ErrorReport>prompt</ErrorReport>
|
||||||
|
<WarningLevel>4</WarningLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
|
||||||
|
""");
|
||||||
|
foreach (var lib in textbookReferences)
|
||||||
|
{
|
||||||
|
s.Append($"""
|
||||||
|
<Reference Include="{X(Path.GetFileNameWithoutExtension(lib))}">
|
||||||
|
<HintPath>lib\{X(lib)}</HintPath>
|
||||||
|
</Reference>
|
||||||
|
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
foreach (var reference in frameworkReferences) s.Append($" <Reference Include=\"{reference}\" />\n");
|
||||||
|
s.Append(" </ItemGroup>\n <ItemGroup>\n");
|
||||||
|
|
||||||
|
var formPaths = forms.ToDictionary(f => f.Path);
|
||||||
|
// 和 VS 一样 Form1.cs 在 Form1.Designer.cs 前面
|
||||||
|
static string Order(string path) => path.EndsWith(".Designer.cs") ? path[..^12] + ".cs\u0001" : path;
|
||||||
|
foreach (var source in code.Where(c => !c.Path.StartsWith("Properties/")).OrderBy(c => Order(c.Path), StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var path = source.Path;
|
||||||
|
if (formPaths.TryGetValue(path, out var form))
|
||||||
|
{
|
||||||
|
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <SubType>{form.SubType}</SubType>\n </Compile>\n");
|
||||||
|
}
|
||||||
|
else if (path.EndsWith(".Designer.cs") && formPaths.ContainsKey(path[..^12] + ".cs"))
|
||||||
|
{
|
||||||
|
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <DependentUpon>{X(Path.GetFileName(path[..^12] + ".cs"))}</DependentUpon>\n </Compile>\n");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
s.Append($" <Compile Include=\"{X(Win(path))}\" />\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Append(" <Compile Include=\"Properties\\AssemblyInfo.cs\" />\n");
|
||||||
|
foreach (var form in forms.Where(f => f.Resx).OrderBy(f => f.Path, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
s.Append($" <EmbeddedResource Include=\"{X(Win(Path.ChangeExtension(form.Path, ".resx")))}\">\n <DependentUpon>{X(Path.GetFileName(form.Path))}</DependentUpon>\n </EmbeddedResource>\n");
|
||||||
|
}
|
||||||
|
s.Append("""
|
||||||
|
<EmbeddedResource Include="Properties\Resources.resx">
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<Compile Include="Properties\Resources.Designer.cs">
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>Resources.resx</DependentUpon>
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
</Compile>
|
||||||
|
<None Include="Properties\Settings.settings">
|
||||||
|
<Generator>SettingsSingleFileGenerator</Generator>
|
||||||
|
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
||||||
|
</None>
|
||||||
|
<Compile Include="Properties\Settings.Designer.cs">
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>Settings.settings</DependentUpon>
|
||||||
|
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="App.config" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
""");
|
||||||
|
var files = images.Union(media).OrderBy(f => f, StringComparer.Ordinal).ToList();
|
||||||
|
if (files.Count > 0)
|
||||||
|
{
|
||||||
|
s.Append(" <ItemGroup>\n");
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
// 代码里按文件名用的素材复制到 bin\Debug\Resources(和教材工程的输出目录一样);Properties.Resources 的图片编译进程序
|
||||||
|
s.Append(media.Contains(file)
|
||||||
|
? $" <Content Include=\"Resources\\{X(file)}\">\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n </Content>\n"
|
||||||
|
: $" <None Include=\"Resources\\{X(file)}\" />\n");
|
||||||
|
}
|
||||||
|
s.Append(" </ItemGroup>\n");
|
||||||
|
}
|
||||||
|
s.Append("""
|
||||||
|
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||||
|
</Project>
|
||||||
|
""");
|
||||||
|
return s.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Solution(string name) => $$"""
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio 2012
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "{{name}}", "{{name}}\{{name}}.csproj", "{{ProjectGuid(name)}}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{{ProjectGuid(name)}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{{ProjectGuid(name)}}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{{ProjectGuid(name)}}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{{ProjectGuid(name)}}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>同一个工程名每次导出的 GUID 相同(重新导出后覆盖旧的,VS 认成同一个工程)</summary>
|
||||||
|
private static string ProjectGuid(string name) => "{" + NameGuid("project:" + name).ToString().ToUpperInvariant() + "}";
|
||||||
|
|
||||||
|
private static Guid NameGuid(string text) => new(MD5.HashData(Encoding.UTF8.GetBytes(text)));
|
||||||
|
|
||||||
|
private static string AssemblyInfo(string name) => $$"""
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
// 有关程序集的常规信息通过以下
|
||||||
|
// 特性集控制。更改这些特性值可修改
|
||||||
|
// 与程序集关联的信息。
|
||||||
|
[assembly: AssemblyTitle({{SymbolDisplay.FormatLiteral(name, true)}})]
|
||||||
|
[assembly: AssemblyDescription("")]
|
||||||
|
[assembly: AssemblyConfiguration("")]
|
||||||
|
[assembly: AssemblyCompany("")]
|
||||||
|
[assembly: AssemblyProduct({{SymbolDisplay.FormatLiteral(name, true)}})]
|
||||||
|
[assembly: AssemblyCopyright("Copyright © {{DateTime.UtcNow.AddHours(8).Year}}")]
|
||||||
|
[assembly: AssemblyTrademark("")]
|
||||||
|
[assembly: AssemblyCulture("")]
|
||||||
|
|
||||||
|
// 将 ComVisible 设置为 false 使此程序集中的类型
|
||||||
|
// 对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型,
|
||||||
|
// 则将该类型上的 ComVisible 特性设置为 true。
|
||||||
|
[assembly: ComVisible(false)]
|
||||||
|
|
||||||
|
// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID
|
||||||
|
[assembly: Guid("{{NameGuid("assembly:" + name)}}")]
|
||||||
|
|
||||||
|
// 程序集的版本信息由下面四个值组成:
|
||||||
|
//
|
||||||
|
// 主版本
|
||||||
|
// 次版本
|
||||||
|
// 生成号
|
||||||
|
// 修订号
|
||||||
|
//
|
||||||
|
// 可以指定所有这些值,也可以使用“生成号”和“修订号”的默认值,
|
||||||
|
// 方法是按如下所示使用“*”:
|
||||||
|
// [assembly: AssemblyVersion("1.0.*")]
|
||||||
|
[assembly: AssemblyVersion("1.0.0.0")]
|
||||||
|
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>和 VS 2012 的 ResXFileCodeGenerator 生成的一样(中文版 VS 的注释)</summary>
|
||||||
|
private static string ResourcesDesigner(string ns, List<ResourceItem> items)
|
||||||
|
{
|
||||||
|
var members = string.Concat(items.Select(item =>
|
||||||
|
{
|
||||||
|
var type = item.Image ? "System.Drawing.Bitmap" : "byte[]";
|
||||||
|
var what = item.Image ? "System.Drawing.Bitmap" : "System.Byte[]";
|
||||||
|
return $$"""
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找 {{what}} 类型的本地化资源。
|
||||||
|
/// </summary>
|
||||||
|
internal static {{type}} {{item.Name}} {
|
||||||
|
get {
|
||||||
|
object obj = ResourceManager.GetObject("{{item.Name}}", resourceCulture);
|
||||||
|
return (({{type}})(obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
""";
|
||||||
|
}));
|
||||||
|
return $$"""
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// 此代码由工具生成。
|
||||||
|
// 运行时版本:4.0.30319.42000
|
||||||
|
//
|
||||||
|
// 对此文件的更改可能会导致不正确的行为,并且如果
|
||||||
|
// 重新生成代码,这些更改将会丢失。
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace {{ns}}.Properties {
|
||||||
|
using System;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一个强类型的资源类,用于查找本地化的字符串等。
|
||||||
|
/// </summary>
|
||||||
|
// 此类是由 StronglyTypedResourceBuilder
|
||||||
|
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
|
||||||
|
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
|
||||||
|
// (以 /str 作为命令选项),或重新生成 VS 项目。
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
|
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||||
|
internal class Resources {
|
||||||
|
|
||||||
|
private static global::System.Resources.ResourceManager resourceMan;
|
||||||
|
|
||||||
|
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||||
|
|
||||||
|
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||||
|
internal Resources() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 返回此类使用的缓存的 ResourceManager 实例。
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||||
|
get {
|
||||||
|
if (object.ReferenceEquals(resourceMan, null)) {
|
||||||
|
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("{{ns}}.Properties.Resources", typeof(Resources).Assembly);
|
||||||
|
resourceMan = temp;
|
||||||
|
}
|
||||||
|
return resourceMan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用此强类型资源类,为所有资源查找
|
||||||
|
/// 重写当前线程的 CurrentUICulture 属性。
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
internal static global::System.Globalization.CultureInfo Culture {
|
||||||
|
get {
|
||||||
|
return resourceCulture;
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
resourceCulture = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{members}} }
|
||||||
|
}
|
||||||
|
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SettingsDesigner(string ns) => $$"""
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
// Runtime Version:4.0.30319.42000
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace {{ns}}.Properties
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
|
||||||
|
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
|
||||||
|
{
|
||||||
|
|
||||||
|
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
|
||||||
|
|
||||||
|
public static Settings Default
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return defaultInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SettingsFile = """
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
|
||||||
|
<Profiles>
|
||||||
|
<Profile Name="(Default)" />
|
||||||
|
</Profiles>
|
||||||
|
<Settings />
|
||||||
|
</SettingsFile>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string AppConfig = """
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<configuration>
|
||||||
|
<startup>
|
||||||
|
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
|
||||||
|
</startup>
|
||||||
|
</configuration>
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
|
||||||
|
/// 平台补上了它。用到时导出工程里也带上这个文件,书上的代码照样能编译。
|
||||||
|
/// </summary>
|
||||||
|
private const string LedCompat = """
|
||||||
|
// 教材的设备类库里 LEDHelper 没有 Close 方法(LED 屏每次发送时才打开串口,发完就关),
|
||||||
|
// 书上的代码调用了 ledHelper.Close(),这里补一个什么都不做的方法,让代码能编译。
|
||||||
|
internal static class LedHelperCompat
|
||||||
|
{
|
||||||
|
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static readonly Lazy<string> ResxHeader = new(() =>
|
||||||
|
{
|
||||||
|
using var stream = typeof(ProjectExporter).Assembly.GetManifestResourceStream("ResxHeader.xml")!;
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
return reader.ReadToEnd().TrimEnd('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
private static string Resx(IEnumerable<string> entries)
|
||||||
|
{
|
||||||
|
var list = entries.ToList();
|
||||||
|
var s = new StringBuilder(ResxHeader.Value).Append('\n');
|
||||||
|
if (list.Any(e => e.Contains("ResXFileRef")))
|
||||||
|
{
|
||||||
|
s.Append(" <assembly alias=\"System.Windows.Forms\" name=\"System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\" />\n");
|
||||||
|
}
|
||||||
|
foreach (var entry in list) s.Append(entry);
|
||||||
|
return s.Append("</root>").ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResourceEntry(ResourceItem item) =>
|
||||||
|
$" <data name=\"{SecurityElement.Escape(item.Name)}\" type=\"System.Resources.ResXFileRef, System.Windows.Forms\">\n" +
|
||||||
|
$" <value>..\\Resources\\{SecurityElement.Escape(item.File)};{(item.Image ? BitmapType : BytesType)}</value>\n" +
|
||||||
|
" </data>\n";
|
||||||
|
|
||||||
|
/// <summary>媒体播放器的 OcxState:教材 Form1.resx 里的默认状态,和 VS 一样每行 80 个字符</summary>
|
||||||
|
private static string OcxEntry(string key)
|
||||||
|
{
|
||||||
|
var data = ExeExporter.MediaPlayerOcxState;
|
||||||
|
var lines = Enumerable.Range(0, (data.Length + 79) / 80).Select(i => " " + data.Substring(i * 80, Math.Min(80, data.Length - i * 80)));
|
||||||
|
return $" <data name=\"{SecurityElement.Escape(key)}\" mimetype=\"application/x-microsoft.net.object.binary.base64\">\n" +
|
||||||
|
" <value>\n" + string.Join("\n", lines) + "\n</value>\n </data>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>文本文件:带 BOM 的 UTF-8,CRLF 换行</summary>
|
||||||
|
private static byte[] Text(string text)
|
||||||
|
{
|
||||||
|
var crlf = text.Replace("\r\n", "\n").Replace("\n", "\r\n");
|
||||||
|
return Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(crlf)).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
Microsoft ResX Schema
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
The primary goals of this format is to allow a simple XML format
|
||||||
|
that is mostly human readable. The generation and parsing of the
|
||||||
|
various data types are done through the TypeConverter classes
|
||||||
|
associated with the data types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
... ado.net/XML headers & schema ...
|
||||||
|
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||||
|
<resheader name="version">2.0</resheader>
|
||||||
|
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||||
|
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||||
|
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||||
|
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||||
|
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||||
|
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||||
|
</data>
|
||||||
|
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
|
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||||
|
<comment>This is a comment</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
There are any number of "resheader" rows that contain simple
|
||||||
|
name/value pairs.
|
||||||
|
|
||||||
|
Each data row contains a name, and value. The row also contains a
|
||||||
|
type or mimetype. Type corresponds to a .NET class that support
|
||||||
|
text/value conversion through the TypeConverter architecture.
|
||||||
|
Classes that don't support this are serialized and stored with the
|
||||||
|
mimetype set.
|
||||||
|
|
||||||
|
The mimetype is used for serialized objects, and tells the
|
||||||
|
ResXResourceReader how to depersist the object. This is currently not
|
||||||
|
extensible. For a given mimetype the value must be set accordingly:
|
||||||
|
|
||||||
|
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||||
|
that the ResXResourceWriter will generate, however the reader can
|
||||||
|
read any of the formats listed below.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.binary.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.soap.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||||
|
value : The object must be serialized into a byte array
|
||||||
|
: using a System.ComponentModel.TypeConverter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
@@ -11,9 +11,30 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||||
|
<!-- 语言服务(代码补全、悬停提示):和 VS 的 IntelliSense 用同一套引擎;只有 language-server 模式会加载 -->
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.14.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<!-- 导出 EXE:照教材工程(.NET Framework 4.5)编译学生代码,要用 4.5 的引用程序集(只供编译,不含实现)。
|
||||||
|
只复制教材工程引用的那些(VS 窗体应用模板默认的引用加图表控件),放到输出目录的 netfx/ -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageDownload Include="Microsoft.NETFramework.ReferenceAssemblies.net45" Version="[1.0.3]" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Target Name="CopyNetFxReferences" AfterTargets="Build">
|
||||||
|
<PropertyGroup>
|
||||||
|
<NetFxDir>$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net45/1.0.3/build/.NETFramework/v4.5/</NetFxDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<NetFxReference Include="$(NetFxDir)mscorlib.dll;$(NetFxDir)System.dll;$(NetFxDir)System.Core.dll;$(NetFxDir)System.Data.dll;$(NetFxDir)System.Data.DataSetExtensions.dll;$(NetFxDir)System.Deployment.dll;$(NetFxDir)System.Drawing.dll;$(NetFxDir)System.Net.Http.dll;$(NetFxDir)System.Windows.Forms.dll;$(NetFxDir)System.Windows.Forms.DataVisualization.dll;$(NetFxDir)System.Xml.dll;$(NetFxDir)System.Xml.Linq.dll;$(NetFxDir)Microsoft.CSharp.dll" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
<!-- 导出 VS 工程:.resx 文件开头的标准内容(VS 生成的架构说明和 resheader) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="ProjectTemplate/ResxHeader.xml" LogicalName="ResxHeader.xml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||||
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
||||||
|
<ProjectReference Include="../Sim.Data/Sim.Data.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.SqlClient;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Sim.Data.Engine;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 常驻 SQL 服务:数据库页面(代替 SSMS)的查询、对象资源管理器,以及评分前按实验预置脚本准备数据库。
|
||||||
|
/// 请求里的目录由服务器给出(学生自己的数据库目录或评分用的临时目录),学生的 SQL 经转写后只能操作这个目录里的库。
|
||||||
|
/// 每行一个请求 {"id":…,"type":"exec"|"script"|"explorer",…},回复带同样的 id;请求可以并发处理。
|
||||||
|
/// </summary>
|
||||||
|
internal static class SqlService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||||
|
private static readonly TimeSpan TimeLimit = TimeSpan.FromSeconds(5);
|
||||||
|
/// <summary>查询窗口最多显示的行数</summary>
|
||||||
|
private const int MaxRows = 1000;
|
||||||
|
|
||||||
|
public static int Run()
|
||||||
|
{
|
||||||
|
// 日期等按中文 Windows 上 SSMS 的样子显示
|
||||||
|
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo("zh-CN");
|
||||||
|
using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
|
||||||
|
var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true };
|
||||||
|
var writeLock = new object();
|
||||||
|
using var slots = new SemaphoreSlim(4);
|
||||||
|
|
||||||
|
output.WriteLine(new JsonObject { ["type"] = "ready" }.ToJsonString(JsonOptions));
|
||||||
|
while (input.ReadLine() is { } line)
|
||||||
|
{
|
||||||
|
slots.Wait();
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
JsonObject reply;
|
||||||
|
JsonNode? id = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = JsonNode.Parse(line)!.AsObject();
|
||||||
|
id = request["id"]?.DeepClone();
|
||||||
|
reply = Handle(request);
|
||||||
|
}
|
||||||
|
catch (SqlException ex)
|
||||||
|
{
|
||||||
|
reply = Error(ex);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
reply = new JsonObject { ["type"] = "error", ["number"] = 0, ["message"] = ex.Message, ["line"] = 1 };
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
slots.Release();
|
||||||
|
}
|
||||||
|
reply["id"] = id;
|
||||||
|
lock (writeLock) output.WriteLine(reply.ToJsonString(JsonOptions));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject Handle(JsonObject request)
|
||||||
|
{
|
||||||
|
var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir");
|
||||||
|
var store = new DatabaseStore(dir);
|
||||||
|
return (string?)request["type"] switch
|
||||||
|
{
|
||||||
|
"exec" => Exec(store, (string?)request["database"] ?? DatabaseStore.Master, (string?)request["sql"] ?? ""),
|
||||||
|
"script" => Script(store, (string?)request["sql"] ?? ""),
|
||||||
|
"explorer" => Explorer(store),
|
||||||
|
var type => throw new ArgumentException($"不认识的请求 {type}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>查询窗口执行一批语句:返回各个结果集(最多 1000 行)、受影响的行数,以及执行后的当前库(USE 可能切换了)</summary>
|
||||||
|
private static JsonObject Exec(DatabaseStore store, string database, string sql)
|
||||||
|
{
|
||||||
|
var started = Environment.TickCount64;
|
||||||
|
using var session = new Session(store, database, TimeLimit);
|
||||||
|
var batch = session.Execute(sql, name => (false, null));
|
||||||
|
var results = new JsonArray();
|
||||||
|
foreach (var result in batch.Results)
|
||||||
|
{
|
||||||
|
var columns = new JsonArray();
|
||||||
|
foreach (var column in result.Columns) columns.Add(new JsonObject { ["name"] = column.Name, ["type"] = column.Type?.ToString() });
|
||||||
|
var rows = new JsonArray();
|
||||||
|
foreach (var row in result.Rows.Take(MaxRows))
|
||||||
|
{
|
||||||
|
var cells = new JsonArray();
|
||||||
|
for (var i = 0; i < row.Length; i++) cells.Add(Display(row[i], result.Columns[i].Type?.IsDate == true));
|
||||||
|
rows.Add(cells);
|
||||||
|
}
|
||||||
|
results.Add(new JsonObject { ["columns"] = columns, ["rows"] = rows, ["total"] = result.Rows.Count });
|
||||||
|
}
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "result",
|
||||||
|
["database"] = session.Database,
|
||||||
|
["results"] = results,
|
||||||
|
["affected"] = batch.RecordsAffected,
|
||||||
|
["ms"] = Environment.TickCount64 - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>执行一个脚本(多批语句,用单独一行的 GO 分隔),从 master 开始,USE 对后面的批次有效</summary>
|
||||||
|
private static JsonObject Script(DatabaseStore store, string sql)
|
||||||
|
{
|
||||||
|
var batches = Regex.Split(sql, @"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase).Where(b => !string.IsNullOrWhiteSpace(b));
|
||||||
|
using var session = new Session(store, DatabaseStore.Master, TimeSpan.FromSeconds(30));
|
||||||
|
foreach (var batch in batches) session.Execute(batch, name => (false, null));
|
||||||
|
return new JsonObject { ["type"] = "done" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>对象资源管理器:所有库、库里的表、表的列</summary>
|
||||||
|
private static JsonObject Explorer(DatabaseStore store)
|
||||||
|
{
|
||||||
|
var databases = new JsonArray();
|
||||||
|
foreach (var name in store.List())
|
||||||
|
{
|
||||||
|
var tables = new JsonArray();
|
||||||
|
using (var connection = store.Open(name))
|
||||||
|
{
|
||||||
|
foreach (var table in DatabaseStore.ListTables(connection))
|
||||||
|
{
|
||||||
|
var info = DatabaseStore.GetTable(connection, table);
|
||||||
|
var columns = new JsonArray();
|
||||||
|
foreach (var column in info?.Columns ?? Array.Empty<ColumnInfo>())
|
||||||
|
{
|
||||||
|
columns.Add(new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = column.Name,
|
||||||
|
["type"] = column.Type.ToString(),
|
||||||
|
["nullable"] = column.Nullable,
|
||||||
|
["identity"] = column.Identity,
|
||||||
|
["primaryKey"] = column.PrimaryKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tables.Add(new JsonObject { ["name"] = table, ["columns"] = columns });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
databases.Add(new JsonObject { ["name"] = name, ["tables"] = tables });
|
||||||
|
}
|
||||||
|
return new JsonObject { ["type"] = "explorer", ["databases"] = databases };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>结果网格里显示的样子,和 SSMS 一致:日期 2024-01-16 09:05:03.000,bit 显示 1 / 0,二进制显示 0x…</summary>
|
||||||
|
private static JsonNode? Display(object value, bool dateOnly) => value switch
|
||||||
|
{
|
||||||
|
DBNull => null,
|
||||||
|
DateTime d when dateOnly => d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
DateTime d => d.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture),
|
||||||
|
bool b => b ? 1 : 0,
|
||||||
|
byte[] bytes => "0x" + Convert.ToHexString(bytes.AsSpan(0, Math.Min(bytes.Length, 32))) + (bytes.Length > 32 ? "…" : ""),
|
||||||
|
int i => i,
|
||||||
|
long l => l,
|
||||||
|
double x => x,
|
||||||
|
decimal m => m,
|
||||||
|
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject Error(SqlException ex) =>
|
||||||
|
new() { ["type"] = "error", ["number"] = ex.Number, ["message"] = ex.Message, ["line"] = ex.LineNumber };
|
||||||
|
}
|
||||||
@@ -27,46 +27,83 @@ internal static class StudentCompiler
|
|||||||
"System.Drawing.dll",
|
"System.Drawing.dll",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly string[] SimAssemblies = { "Sim.WinForms.dll", "Sim.Devices.dll" };
|
private static readonly string[] SimAssemblies = { "Sim.WinForms.dll", "Sim.Devices.dll", "Sim.Data.dll" };
|
||||||
|
|
||||||
public sealed record Result(bool Success, byte[] Assembly, byte[] Pdb, JsonArray Diagnostics);
|
public sealed record Result(bool Success, byte[] Assembly, byte[] Pdb, JsonArray Diagnostics);
|
||||||
|
|
||||||
|
/// <summary>编译和语言服务(代码补全等)共用同一套选项</summary>
|
||||||
|
internal static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.Latest);
|
||||||
|
|
||||||
|
internal static readonly CSharpCompilationOptions CompilationOptions = new CSharpCompilationOptions(OutputKind.WindowsApplication)
|
||||||
|
.WithOptimizationLevel(OptimizationLevel.Debug)
|
||||||
|
.WithNullableContextOptions(NullableContextOptions.Disable)
|
||||||
|
.WithAllowUnsafe(false)
|
||||||
|
.WithConcurrentBuild(true);
|
||||||
|
|
||||||
public static Result Compile(string projectDir)
|
public static Result Compile(string projectDir)
|
||||||
{
|
{
|
||||||
var parseOptions = new CSharpParseOptions(LanguageVersion.Latest);
|
var parseOptions = ParseOptions;
|
||||||
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
var trees = SourceFiles(projectDir)
|
||||||
.Where(path => !IsBuildOutput(projectDir, path))
|
|
||||||
.OrderBy(path => path, StringComparer.Ordinal)
|
|
||||||
.Select(path => CSharpSyntaxTree.ParseText(
|
.Select(path => CSharpSyntaxTree.ParseText(
|
||||||
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
|
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
|
||||||
parseOptions,
|
parseOptions,
|
||||||
path: Path.GetRelativePath(projectDir, path)))
|
path: Path.GetRelativePath(projectDir, path)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
|
var compilation = CSharpCompilation.Create("StudentApp", trees, References.Value, CompilationOptions);
|
||||||
.WithOptimizationLevel(OptimizationLevel.Debug)
|
|
||||||
.WithNullableContextOptions(NullableContextOptions.Disable)
|
|
||||||
.WithAllowUnsafe(false)
|
|
||||||
.WithConcurrentBuild(true);
|
|
||||||
|
|
||||||
var compilation = CSharpCompilation.Create("StudentApp", trees, References.Value, options);
|
|
||||||
|
|
||||||
using var peStream = new MemoryStream();
|
using var peStream = new MemoryStream();
|
||||||
using var pdbStream = new MemoryStream();
|
using var pdbStream = new MemoryStream();
|
||||||
var emit = compilation.Emit(peStream, pdbStream,
|
// 编译改写后的代码(旧版线程 API 换成平台实现,DateTime.Now 换成虚拟时钟),API 检查仍针对学生写的原始代码
|
||||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
|
var emit = PathRewriter.Rewrite(ClockRewriter.Rewrite(LegacyThreadRewriter.Rewrite(compilation))).Emit(peStream, pdbStream,
|
||||||
|
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb),
|
||||||
|
manifestResources: EmptyControlResources(compilation));
|
||||||
|
var denied = ApiPolicy.Check(compilation).ToList();
|
||||||
|
|
||||||
var diagnostics = new JsonArray();
|
var diagnostics = new JsonArray();
|
||||||
foreach (var diagnostic in emit.Diagnostics
|
foreach (var diagnostic in emit.Diagnostics.Concat(denied)
|
||||||
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning)
|
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning)
|
||||||
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
|
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
|
||||||
{
|
{
|
||||||
diagnostics.Add(ToJson(diagnostic));
|
diagnostics.Add(ToJson(diagnostic));
|
||||||
}
|
}
|
||||||
return new Result(emit.Success, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
return new Result(emit.Success && denied.Count == 0, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JsonObject ToJson(Diagnostic diagnostic)
|
private static readonly Lazy<byte[]> EmptyResources = new(() =>
|
||||||
|
{
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
using (var writer = new System.Resources.ResourceWriter(stream)) writer.Generate();
|
||||||
|
return stream.ToArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给学生工程里的每个窗体、控件类嵌一份空的 .resources:VS 生成的 Designer.cs 会用
|
||||||
|
/// ComponentResourceManager(typeof(窗体)).GetObject("…OcxState") 读窗体的 .resx(如 Windows Media Player 控件),
|
||||||
|
/// 平台没有 .resx,有了空资源时取到 null,而不是报"找不到资源"。
|
||||||
|
/// </summary>
|
||||||
|
private static List<ResourceDescription> EmptyControlResources(CSharpCompilation compilation)
|
||||||
|
{
|
||||||
|
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
|
||||||
|
var resources = new List<ResourceDescription>();
|
||||||
|
if (control == null) return resources;
|
||||||
|
var names = new HashSet<string>();
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>())
|
||||||
|
{
|
||||||
|
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
|
||||||
|
var derives = false;
|
||||||
|
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
|
||||||
|
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
|
||||||
|
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(EmptyResources.Value), isPublic: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static JsonObject ToJson(Diagnostic diagnostic)
|
||||||
{
|
{
|
||||||
var span = diagnostic.Location.GetMappedLineSpan();
|
var span = diagnostic.Location.GetMappedLineSpan();
|
||||||
var message = diagnostic.GetMessage(Chinese);
|
var message = diagnostic.GetMessage(Chinese);
|
||||||
@@ -84,6 +121,12 @@ internal static class StudentCompiler
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>学生工程里的源文件(不含 bin、obj 下的),按路径排序</summary>
|
||||||
|
internal static IEnumerable<string> SourceFiles(string projectDir) =>
|
||||||
|
Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
||||||
|
.Where(path => !IsBuildOutput(projectDir, path))
|
||||||
|
.OrderBy(path => path, StringComparer.Ordinal);
|
||||||
|
|
||||||
private static bool IsBuildOutput(string projectDir, string path)
|
private static bool IsBuildOutput(string projectDir, string path)
|
||||||
{
|
{
|
||||||
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
|
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
|
||||||
@@ -91,7 +134,7 @@ internal static class StudentCompiler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>学生代码可引用的程序集:.NET 框架(去掉冲突的门面)+ 仿真库</summary>
|
/// <summary>学生代码可引用的程序集:.NET 框架(去掉冲突的门面)+ 仿真库</summary>
|
||||||
private static readonly Lazy<IReadOnlyList<MetadataReference>> References = new(() =>
|
internal static readonly Lazy<IReadOnlyList<MetadataReference>> References = new(() =>
|
||||||
{
|
{
|
||||||
var frameworkDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
|
var frameworkDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
|
||||||
var framework = Directory.EnumerateFiles(frameworkDir, "*.dll")
|
var framework = Directory.EnumerateFiles(frameworkDir, "*.dll")
|
||||||
|
|||||||
@@ -27,13 +27,22 @@ internal static class StudentProgramHost
|
|||||||
Console.SetOut(console);
|
Console.SetOut(console);
|
||||||
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
|
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
|
||||||
|
|
||||||
// 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出
|
// 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出。
|
||||||
var zhCN = CultureInfo.GetCultureInfo("zh-CN");
|
// Linux 上的区域数据把时间写成 09:05:03,中文 Windows 是 9:05:03(书上的截图也是);长日期后面多了星期(2024年1月16日星期二),
|
||||||
|
// 中文 Windows 的长日期不带星期(日期选择框的长日期格式窄,带上星期会被截掉)。这里都改成 Windows 的写法
|
||||||
|
var zhCN = (CultureInfo)CultureInfo.GetCultureInfo("zh-CN").Clone();
|
||||||
|
zhCN.DateTimeFormat.LongTimePattern = "H:mm:ss";
|
||||||
|
zhCN.DateTimeFormat.ShortTimePattern = "H:mm";
|
||||||
|
zhCN.DateTimeFormat.LongDatePattern = "yyyy'年'M'月'd'日'";
|
||||||
|
zhCN.DateTimeFormat.FullDateTimePattern = "yyyy'年'M'月'd'日' H:mm:ss";
|
||||||
|
zhCN = CultureInfo.ReadOnly(zhCN);
|
||||||
CultureInfo.DefaultThreadCurrentCulture = zhCN;
|
CultureInfo.DefaultThreadCurrentCulture = zhCN;
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = zhCN;
|
CultureInfo.DefaultThreadCurrentUICulture = zhCN;
|
||||||
CultureInfo.CurrentCulture = zhCN;
|
CultureInfo.CurrentCulture = zhCN;
|
||||||
CultureInfo.CurrentUICulture = zhCN;
|
CultureInfo.CurrentUICulture = zhCN;
|
||||||
|
|
||||||
|
UseWritableBaseDirectory(dir);
|
||||||
|
|
||||||
var assembly = AssemblyLoadContext.Default.LoadFromStream(
|
var assembly = AssemblyLoadContext.Default.LoadFromStream(
|
||||||
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.AssemblyFile))),
|
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.AssemblyFile))),
|
||||||
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.PdbFile))));
|
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.PdbFile))));
|
||||||
@@ -81,6 +90,31 @@ internal static class StudentProgramHost
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 学生程序的"程序所在目录":和在 VS 里按 F5 一样是工程下的 bin/Debug/,也是当前目录,可以写文件
|
||||||
|
/// (教材把摄像头照片存到 AppDomain.CurrentDomain.BaseDirectory + "Camera/")。
|
||||||
|
/// 沙箱里工程目录只读,改用沙箱自己的临时目录;两种情况下文件都在程序结束后清掉。
|
||||||
|
/// </summary>
|
||||||
|
private static void UseWritableBaseDirectory(string projectDir)
|
||||||
|
{
|
||||||
|
string baseDir;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
baseDir = Directory.CreateDirectory(Path.Combine(projectDir, "bin", "Debug")).FullName;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
baseDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "StudentApp", "bin", "Debug")).FullName;
|
||||||
|
}
|
||||||
|
baseDir = Path.TrimEndingDirectorySeparator(baseDir) + Path.DirectorySeparatorChar;
|
||||||
|
// 程序目录下的 Resources 文件夹指向素材库:教材用 BaseDirectory + "//Resources//ring.mp3" 播放铃声
|
||||||
|
var resources = Path.Combine(baseDir, "Resources");
|
||||||
|
if (!Path.Exists(resources)) Directory.CreateSymbolicLink(resources, ProjectResources.Directory);
|
||||||
|
AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDir);
|
||||||
|
Directory.SetCurrentDirectory(baseDir);
|
||||||
|
SimHost.AppBaseDirectory = baseDir;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>读取浏览器发来的操作,排队到界面线程执行</summary>
|
/// <summary>读取浏览器发来的操作,排队到界面线程执行</summary>
|
||||||
private static void ReadActions()
|
private static void ReadActions()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Sim.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写 zip 文件。文件名照中文 Windows 的习惯用 GBK 编码(资源管理器自带的解压、老版本的压缩软件都按 GBK 读,
|
||||||
|
/// 用 UTF-8 时 Windows 7 等会显示乱码),另附 Info-ZIP 的 Unicode 文件名扩展字段(0x7075),
|
||||||
|
/// 7-Zip、Linux 的 unzip 等认这个字段的软件在别的语言的系统上也能显示正确。
|
||||||
|
/// GBK 表示不了的文件名改用 UTF-8 并设置 UTF-8 标志位。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ZipWriter
|
||||||
|
{
|
||||||
|
private static readonly Encoding Gbk;
|
||||||
|
private static readonly uint[] CrcTable = new uint[256];
|
||||||
|
|
||||||
|
static ZipWriter()
|
||||||
|
{
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
Gbk = Encoding.GetEncoding(936, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
|
||||||
|
for (uint n = 0; n < 256; n++)
|
||||||
|
{
|
||||||
|
var c = n;
|
||||||
|
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
|
||||||
|
CrcTable[n] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Entry(byte[] Name, byte[] Extra, ushort Flags, ushort Method, uint Crc, uint CompressedSize, uint Size, uint Offset);
|
||||||
|
|
||||||
|
private readonly MemoryStream output = new();
|
||||||
|
private readonly List<Entry> entries = new();
|
||||||
|
private readonly ushort dosTime;
|
||||||
|
private readonly ushort dosDate;
|
||||||
|
|
||||||
|
/// <param name="time">文件的修改时间(机房电脑上解压后看到的时间)</param>
|
||||||
|
public ZipWriter(DateTime time)
|
||||||
|
{
|
||||||
|
dosTime = (ushort)((time.Hour << 11) | (time.Minute << 5) | (time.Second / 2));
|
||||||
|
dosDate = (ushort)(((time.Year - 1980) << 9) | (time.Month << 5) | time.Day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>添加文件,path 用 / 分隔</summary>
|
||||||
|
public void Add(string path, byte[] data)
|
||||||
|
{
|
||||||
|
byte[] name;
|
||||||
|
byte[] extra = Array.Empty<byte>();
|
||||||
|
ushort flags = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
name = Gbk.GetBytes(path);
|
||||||
|
var utf8 = Encoding.UTF8.GetBytes(path);
|
||||||
|
if (utf8.Length != name.Length || !utf8.AsSpan().SequenceEqual(name))
|
||||||
|
{
|
||||||
|
// Unicode Path Extra Field:版本 1、GBK 文件名的 CRC、UTF-8 文件名
|
||||||
|
extra = new byte[4 + 1 + 4 + utf8.Length];
|
||||||
|
BitConverter.TryWriteBytes(extra.AsSpan(0), (ushort)0x7075);
|
||||||
|
BitConverter.TryWriteBytes(extra.AsSpan(2), (ushort)(1 + 4 + utf8.Length));
|
||||||
|
extra[4] = 1;
|
||||||
|
BitConverter.TryWriteBytes(extra.AsSpan(5), Crc32(name));
|
||||||
|
utf8.CopyTo(extra, 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (EncoderFallbackException)
|
||||||
|
{
|
||||||
|
name = Encoding.UTF8.GetBytes(path);
|
||||||
|
flags = 1 << 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
var crc = Crc32(data);
|
||||||
|
var compressed = Deflate(data);
|
||||||
|
var method = (ushort)8;
|
||||||
|
if (compressed.Length >= data.Length)
|
||||||
|
{
|
||||||
|
compressed = data;
|
||||||
|
method = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new Entry(name, extra, flags, method, crc, (uint)compressed.Length, (uint)data.Length, (uint)output.Position);
|
||||||
|
entries.Add(entry);
|
||||||
|
using var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true);
|
||||||
|
w.Write(0x04034b50u);
|
||||||
|
w.Write((ushort)20);
|
||||||
|
w.Write(entry.Flags);
|
||||||
|
w.Write(entry.Method);
|
||||||
|
w.Write(dosTime);
|
||||||
|
w.Write(dosDate);
|
||||||
|
w.Write(entry.Crc);
|
||||||
|
w.Write(entry.CompressedSize);
|
||||||
|
w.Write(entry.Size);
|
||||||
|
w.Write((ushort)entry.Name.Length);
|
||||||
|
w.Write((ushort)entry.Extra.Length);
|
||||||
|
w.Write(entry.Name);
|
||||||
|
w.Write(entry.Extra);
|
||||||
|
w.Write(compressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>写中央目录,返回整个 zip</summary>
|
||||||
|
public byte[] Finish()
|
||||||
|
{
|
||||||
|
var start = (uint)output.Position;
|
||||||
|
using (var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true))
|
||||||
|
{
|
||||||
|
foreach (var e in entries)
|
||||||
|
{
|
||||||
|
w.Write(0x02014b50u);
|
||||||
|
w.Write((ushort)20); // 由 MS-DOS 兼容的系统生成:解压时不设置 Unix 权限
|
||||||
|
w.Write((ushort)20);
|
||||||
|
w.Write(e.Flags);
|
||||||
|
w.Write(e.Method);
|
||||||
|
w.Write(dosTime);
|
||||||
|
w.Write(dosDate);
|
||||||
|
w.Write(e.Crc);
|
||||||
|
w.Write(e.CompressedSize);
|
||||||
|
w.Write(e.Size);
|
||||||
|
w.Write((ushort)e.Name.Length);
|
||||||
|
w.Write((ushort)e.Extra.Length);
|
||||||
|
w.Write((ushort)0); // 注释长度
|
||||||
|
w.Write((ushort)0); // 磁盘号
|
||||||
|
w.Write((ushort)0); // 内部属性
|
||||||
|
w.Write(0u); // 外部属性
|
||||||
|
w.Write(e.Offset);
|
||||||
|
w.Write(e.Name);
|
||||||
|
w.Write(e.Extra);
|
||||||
|
}
|
||||||
|
var size = (uint)output.Position - start;
|
||||||
|
w.Write(0x06054b50u);
|
||||||
|
w.Write((ushort)0);
|
||||||
|
w.Write((ushort)0);
|
||||||
|
w.Write((ushort)entries.Count);
|
||||||
|
w.Write((ushort)entries.Count);
|
||||||
|
w.Write(size);
|
||||||
|
w.Write(start);
|
||||||
|
w.Write((ushort)0);
|
||||||
|
}
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Deflate(byte[] data)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using (var deflate = new DeflateStream(stream, CompressionLevel.Optimal, leaveOpen: true)) deflate.Write(data);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint Crc32(byte[] data)
|
||||||
|
{
|
||||||
|
var c = 0xFFFFFFFFu;
|
||||||
|
foreach (var b in data) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||||
|
return c ^ 0xFFFFFFFFu;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace System.Drawing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 绘图面。平台的控件由浏览器绘制,学生程序不能自己画(没有 DrawLine 等方法);
|
||||||
|
/// 只为让 VS 生成的空 Paint 事件处理程序(panel1_Paint)能编译。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Graphics : IDisposable
|
||||||
|
{
|
||||||
|
private Graphics()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Sim.Host;
|
||||||
|
|
||||||
|
namespace System.Drawing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图片。System.Drawing 在 Linux 上不受支持,这里只保存图片文件的原始数据(JPEG、PNG、GIF、BMP)和尺寸:
|
||||||
|
/// 能显示在 PictureBox 上、保存成文件、从文件或流读回,不能逐像素绘制。
|
||||||
|
/// </summary>
|
||||||
|
public abstract class Image : IDisposable, ICloneable
|
||||||
|
{
|
||||||
|
private string? _simKey;
|
||||||
|
|
||||||
|
private protected Image(byte[] data)
|
||||||
|
{
|
||||||
|
Data = data;
|
||||||
|
(RawFormat, Width, Height) = ReadHeader(data) ?? throw new ArgumentException("参数无效:不是 JPEG、PNG、GIF 或 BMP 格式的图片。");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal byte[] Data { get; }
|
||||||
|
|
||||||
|
public int Width { get; }
|
||||||
|
public int Height { get; }
|
||||||
|
public Size Size => new(Width, Height);
|
||||||
|
public ImageFormat RawFormat { get; }
|
||||||
|
public object? Tag { get; set; }
|
||||||
|
|
||||||
|
/// <summary>平台内部使用:图片内容的哈希,浏览器按它缓存图片,同一张图只传一次</summary>
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public string SimKey => _simKey ??= Convert.ToHexString(SHA1.HashData(Data)).ToLowerInvariant();
|
||||||
|
|
||||||
|
internal string MimeType => RawFormat.MimeType;
|
||||||
|
|
||||||
|
public static Image FromFile(string filename) => new Bitmap(filename);
|
||||||
|
|
||||||
|
public static Image FromStream(Stream stream) => new Bitmap(stream);
|
||||||
|
|
||||||
|
public void Save(string filename) => Save(filename, RawFormat);
|
||||||
|
|
||||||
|
/// <summary>按原格式写出图片数据(不做格式转换)。目录不存在时和 GDI+ 一样报"一般性错误"</summary>
|
||||||
|
public void Save(string filename, ImageFormat format)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(filename, Data);
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
throw new ExternalException("GDI+ 中发生一般性错误。(保存图片的文件夹不存在,先用 Directory.CreateDirectory 创建)");
|
||||||
|
}
|
||||||
|
SimHost.ImageSaved(Path.GetFullPath(filename), this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(Stream stream, ImageFormat format) => stream.Write(Data, 0, Data.Length);
|
||||||
|
|
||||||
|
public object Clone() => new Bitmap(this);
|
||||||
|
|
||||||
|
public void Dispose() => GC.SuppressFinalize(this);
|
||||||
|
|
||||||
|
private static (ImageFormat, int, int)? ReadHeader(byte[] d)
|
||||||
|
{
|
||||||
|
if (d.Length >= 24 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G')
|
||||||
|
return (ImageFormat.Png, BigEndian(d, 16), BigEndian(d, 20));
|
||||||
|
if (d.Length >= 10 && d[0] == 'G' && d[1] == 'I' && d[2] == 'F')
|
||||||
|
return (ImageFormat.Gif, d[6] | d[7] << 8, d[8] | d[9] << 8);
|
||||||
|
if (d.Length >= 26 && d[0] == 'B' && d[1] == 'M')
|
||||||
|
return (ImageFormat.Bmp, BitConverter.ToInt32(d, 18), Math.Abs(BitConverter.ToInt32(d, 22)));
|
||||||
|
if (d.Length >= 4 && d[0] == 0xFF && d[1] == 0xD8)
|
||||||
|
{
|
||||||
|
// 依次跳过各段,找到记录尺寸的 SOF 段
|
||||||
|
var i = 2;
|
||||||
|
while (i + 9 < d.Length && d[i] == 0xFF)
|
||||||
|
{
|
||||||
|
var marker = d[i + 1];
|
||||||
|
var length = d[i + 2] << 8 | d[i + 3];
|
||||||
|
if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC)
|
||||||
|
return (ImageFormat.Jpeg, d[i + 7] << 8 | d[i + 8], d[i + 5] << 8 | d[i + 6]);
|
||||||
|
i += 2 + length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int BigEndian(byte[] d, int offset) => d[offset] << 24 | d[offset + 1] << 16 | d[offset + 2] << 8 | d[offset + 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Bitmap : Image
|
||||||
|
{
|
||||||
|
public Bitmap(string filename) : base(File.ReadAllBytes(filename)) { }
|
||||||
|
|
||||||
|
public Bitmap(Stream stream) : base(ReadAll(stream)) { }
|
||||||
|
|
||||||
|
public Bitmap(Image original) : base(original.Data) { }
|
||||||
|
|
||||||
|
private static byte[] ReadAll(Stream stream)
|
||||||
|
{
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
stream.CopyTo(buffer);
|
||||||
|
return buffer.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||