Merge branch 'master' into editor-agent-right-click
This commit is contained in:
@@ -14,7 +14,10 @@ permissions:
|
||||
env:
|
||||
CI: 'true'
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_HTTP_MULTIPLEXING: 'false'
|
||||
CARGO_NET_RETRY: '10'
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTUP_MAX_RETRIES: '10'
|
||||
RUSTC_WRAPPER: ''
|
||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||
|
||||
@@ -37,12 +40,16 @@ jobs:
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=''
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo_command='sudo'
|
||||
sudo_command=()
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || {
|
||||
echo 'non-root runner user requires sudo for system dependencies.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=(sudo -E)
|
||||
fi
|
||||
${sudo_command} apt-get update
|
||||
${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
"${sudo_command[@]}" apt-get update
|
||||
"${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl
|
||||
|
||||
@@ -80,14 +87,33 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none
|
||||
for attempt in $(seq 1 10); do
|
||||
if curl --retry 3 --retry-all-errors --retry-delay 2 \
|
||||
--proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 10 ]]; then
|
||||
echo 'rustup bootstrap failed after 10 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
fi
|
||||
echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
|
||||
test -n "${toolchain}"
|
||||
rustup toolchain install "${toolchain}" --profile minimal --component rustfmt
|
||||
for attempt in $(seq 1 10); do
|
||||
if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 10 ]]; then
|
||||
echo 'Rust toolchain installation failed after 10 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
@@ -151,12 +177,16 @@ jobs:
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=''
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo_command='sudo'
|
||||
sudo_command=()
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || {
|
||||
echo 'non-root runner user requires sudo for system dependencies.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=(sudo -E)
|
||||
fi
|
||||
${sudo_command} apt-get update
|
||||
${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
"${sudo_command[@]}" apt-get update
|
||||
"${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
@@ -203,14 +233,33 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none
|
||||
for attempt in $(seq 1 10); do
|
||||
if curl --retry 3 --retry-all-errors --retry-delay 2 \
|
||||
--proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 10 ]]; then
|
||||
echo 'rustup bootstrap failed after 10 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
fi
|
||||
echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
|
||||
test -n "${toolchain}"
|
||||
rustup toolchain install "${toolchain}" --profile minimal --component rustfmt
|
||||
for attempt in $(seq 1 10); do
|
||||
if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 10 ]]; then
|
||||
echo 'Rust toolchain installation failed after 10 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
@@ -248,12 +297,16 @@ jobs:
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=''
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo_command='sudo'
|
||||
sudo_command=()
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || {
|
||||
echo 'non-root runner user requires sudo for system dependencies.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=(sudo -E)
|
||||
fi
|
||||
${sudo_command} apt-get update
|
||||
${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
"${sudo_command[@]}" apt-get update
|
||||
"${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
@@ -280,14 +333,33 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none
|
||||
for attempt in $(seq 1 10); do
|
||||
if curl --retry 3 --retry-all-errors --retry-delay 2 \
|
||||
--proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 10 ]]; then
|
||||
echo 'rustup bootstrap failed after 10 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
fi
|
||||
echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
|
||||
test -n "${toolchain}"
|
||||
rustup toolchain install "${toolchain}" --profile minimal --component rustfmt
|
||||
for attempt in $(seq 1 10); do
|
||||
if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 10 ]]; then
|
||||
echo 'Rust toolchain installation failed after 10 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
|
||||
@@ -4373,3 +4373,9 @@
|
||||
- 回退边界:明确模型不存在 / 不支持、408、非内容拒绝类 429、5xx、响应解析失败或非拒绝类缺图可以切模型;401 / 403、普通参数 / 内容安全拒绝、本地配置与参考图错误、发送 / 连接错误、request budget 耗尽和已生成图片下载失败不切模型。一次业务请求总发送上限仍为 5 次,两个模型共享同一 worker provider deadline 和 attempt 预算。
|
||||
- 观测边界:审计 `image_model` 记录实际 provider attempt;首选 `gpt-image-2` 失败但兜底 `gpt-image-2-c` 恢复成功时,首选失败仍写入 `external_api_call_failure`,最终成功运行摘要记录 `recoveredFailureCount`。日志用 `fallback_from_model` / `fallback_to_model` 标识切换,不改变业务模型、扣费、素材 metadata 或终态语义。
|
||||
- 脚本边界:仓库 `gpt-image-2-apimart` skill 的现役生成脚本采用同一首选 / 回退顺序;认证、请求发送不确定错误和下载失败不重新生图,避免重复上游成本。
|
||||
|
||||
## 2026-07-21 图片画布滚轮与中键平移统一为二维视口移动
|
||||
|
||||
- 背景:画布中键拖拽的平移模型已同时计算 X / Y,但普通滚轮分支只消费 `deltaY`,横向滚轮或触控板的 `deltaX` 被丢弃,且缺少中键横向拖动的状态机回归覆盖。
|
||||
- 决策:普通滚轮原样消费设备上报的 `deltaX / deltaY` 二维平移 viewport;当按住 Shift 且设备上报 `deltaX = 0` 时,输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型不感知修饰键。`Ctrl / Cmd + 滚轮` 继续只负责围绕指针缩放;中键和抓手拖拽继续同时更新 X / Y。
|
||||
- 验证:交互模型单测覆盖原始 `deltaX / deltaY` 和缩放边界;viewport hook 单测覆盖二维滚轮、Shift 横向适配与 Ctrl 缩放;stage 状态机单测覆盖中键水平、垂直同时移动。
|
||||
|
||||
@@ -269,7 +269,8 @@ npm run check:server-rs-ddd
|
||||
- 四个 job 共同覆盖 `npm run check`,并追加 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。后端 runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支的原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check` 和 release build smoke。
|
||||
- checkout 必须使用完整历史。PR 将 base SHA 写入 `SPACETIME_SCHEMA_BASE_REF`,直接推送 `master` 使用 before SHA;事件基线不可解析时直接失败。Gitea 检查的是 PR head 而非预合并 commit,workflow 必须拒绝不包含最新 base commit 的过期 PR,分支保护同时保持“PR 过期禁止合并”。
|
||||
- 普通 PR job 不读取业务 secret,不运行真实 API/SpacetimeDB/OSS/支付/生成/live smoke,也不执行会修改外部状态的维护、迁移、发布或备份命令。
|
||||
- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发和 crates.io;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Gitea 1.26 的任务超时由 runner 全局配置控制;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。
|
||||
- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发和 crates.io;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Runner 2.0.0 支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。
|
||||
- `genarrative-station` 当前使用 Gitea `1.26.4` + 基于 Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像:只修复 `systempaths=unconfined` 的空 slice 被 `mergo` 丢失,真实 job 必须保持 `MaskedPaths=[]`、`ReadonlyPaths=[]`;外层仍非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 只监听 Unix socket,`docker_host: "-"` 阻止 socket 进入 job。job 只在 `gitea-actions` internal network,通过 `/git` reverse gateway 访问 Gitea,通过拒绝私网、保留地址和 metadata 的 80/443 proxy 访问公共依赖;直连公网和 Gitea 数据网必须失败。内层 bwrap 所需 namespace/proc 选项只能用于该 rootless DinD,不能放宽宿主 rootful runner。系统依赖步骤在 root job 中不调用 sudo,非 root 时用 `sudo -E` 保留受控 proxy;Cargo 关闭 HTTP multiplexing 并设置 10 次网络重试,rustup bootstrap/toolchain 安装也按有界次数重试。AI 原生壳 job 把 Node 发行目录与 rustup proxy 安装到 `/usr/local` 的受信任只读路径,并在测试前执行完整 bwrap canary。宿主 compose helper 必须把 `/opt/gitea-stack` 挂到同名绝对路径,避免相对 volume 错误落到 `/stack` 空目录。
|
||||
|
||||
## 后端相关默认验证
|
||||
|
||||
|
||||
@@ -3262,3 +3262,12 @@
|
||||
- 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。
|
||||
- 原因:单 attempt timeout、重试退避、图片下载与 worker / lease 分别使用独立的相对计时,没有共享同一绝对 deadline;只抬高 worker timeout 或单独压低 provider timeout 都无法保证留出终态写回窗口。
|
||||
- 处理:实际调用 VectorEngine 的四类图片 job 使用 `1800s` long 预算;从 job 开始的同一起点派生 provider deadline,常规提前 `60s`、短预算提前一半。每次 attempt、退避、下一次 attempt 和图片下载都必须在该 deadline 内;普通 HTTP / `inline` 不伪造 worker deadline。修复时不改动 lease fencing、迟到写回仲裁和原子退款语义。
|
||||
|
||||
## Gitea PR runner 不能把宿主 Docker socket 当普通 volume
|
||||
|
||||
- 现象:`act_runner` 的 `container.docker_host` 留空时,job inspect 会出现 `/var/run/docker.sock:/var/run/docker.sock`;PR 脚本即使 `valid_volumes: []`,仍可直接调用 Docker API 读取其它容器、挂宿主目录或取得 runner 配置。另一类迁移故障是 rootless daemon 可以启动,但 bwrap 在 job 内报 `No permissions to create new namespace`、`Failed to make / slave` 或 `Mount too revealing`。
|
||||
- 原因:空 `docker_host` 会自动发现并把控制 socket 传播到 job;rootful Docker 的 seccomp、AppArmor 与 system-path masks 又不支持完整 nested bwrap。Runner 2.0.0 还有一处独立合并缺陷:`parseSystemPaths` 把 `systempaths=unconfined` 转成显式空 slice 后,`mergo.WithOverride` 不覆盖 empty value,真实 job 又恢复 Docker 默认 masks,表现为配置文件写了 unconfined、手工 `docker run` canary 也成功,但 Actions job 仍在 `--proc /proc` 返回 EPERM。直接使用 `--privileged`、外层 `CAP_SYS_ADMIN` 或 host executor 会把测试跑绿建立在破坏 PR 隔离的前提上。宿主启用 Clash fake-IP 时,简单按 DNS 的 `198.18.0.0/15` 判断公网还会误拒所有公共依赖。
|
||||
- 处理:先把 Gitea 升到至少 `1.26.4`,再用固定 digest 的 Runner 2.0.0 rootless DinD;外层保持非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 仅 Unix socket,runner 固定 `docker_host: "-"`。若版本仍有上述 empty-slice 缺陷,只做 merge 后保留 `MaskedPaths=[]` / `ReadonlyPaths=[]` 的最小补丁并固定自有镜像 digest,不对整个 HostConfig 启用 overwrite-empty。job 使用独立 internal network,Gitea 经 reverse gateway,公共 80/443 经使用公共 DoH 验证真实 IP、拒绝私网/保留地址/metadata 的 proxy;DoH 要缓存并合并同域并发,长下载 timeout 不能只有 60 秒。rootless job 内的 bwrap namespace/proc 选项不能复制到宿主 rootful runner。apt 步骤在 root 时直接执行,非 root 时用 `sudo -E`,否则 sudo `env_reset` 会让 apt 丢失 proxy。
|
||||
- 运维陷阱:从容器内运行 Compose 时,宿主 `/opt/gitea-stack` 必须挂到容器同名绝对路径;挂成 `/stack` 会让相对 bind source 被 daemon 解析为宿主 `/stack/...`,表现为 Gitea 进入空安装页、gateway 脚本“缺失”。发现后不要迁移空库,立即用同路径 mount 重建并核对原数据大小、installed 日志、仓库数和 API。切换前保留冷数据 tar、pg_dumpall 和原 compose/env/runner 配置,备份与 token 不提交 Git。
|
||||
- 验证:同时检查 Gitea 版本、runner declare、外层 `Privileged=false`/无 CapAdd/无宿主 socket、inner job `Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]`、固定 image digest、`/var/run/docker.sock` 不存在、公共 proxy 可用、直连公网/Postgres/metadata 失败,以及完整 bwrap canary。最后重跑四个 CI job;checkout 成功但 apt/rustup/npm 同时失败时,先排 proxy/env,而不是改测试。
|
||||
- 关联:`.gitea/workflows/project-ci.yml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.md`。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
|
||||
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额优先展示画板按扣费、退回、到账和页面恢复链路刷新的 `profileDashboard.walletBalance`,充值中心 `mudPointBalance` 仅用于展开面板的账户明细,不覆盖已刷新的总余额。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
|
||||
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
|
||||
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||
- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
|
||||
- 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。
|
||||
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
|
||||
@@ -149,7 +149,7 @@
|
||||
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
|
||||
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
|
||||
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
|
||||
- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。
|
||||
- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。
|
||||
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
|
||||
- “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=<projectId>` 并按 query 加载该工程。
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
## 第六阶段模块
|
||||
|
||||
- `ImageCanvasInteractionModel.ts`
|
||||
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
|
||||
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时由输入适配层把 `deltaY` 映射为横向位移、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
|
||||
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。
|
||||
- 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
## 第十七阶段模块
|
||||
|
||||
- `useImageCanvasViewportControls.ts`
|
||||
- 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
|
||||
- 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时的横向位移适配、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
|
||||
- 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。
|
||||
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
|
||||
|
||||
|
||||
@@ -211,7 +211,13 @@ npm run check
|
||||
|
||||
PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF`。`check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA,手工触发时回退到 `origin/master`。
|
||||
|
||||
启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 必须提供 `ubuntu-latest` 标签,并将其映射到经验证且固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像;禁止使用浮动镜像 tag,禁止将该标签映射到 host 执行器,禁止向 job 暴露 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 会安装 Node 22、仓库 `rust-toolchain.toml` 固定的 Rust 1.96.0,以及 clang/lld 和 Tauri Linux 依赖;受控 runner 镜像应预装 rustup,fallback 下载只用于首次引导。runner 仍需能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm registry、Rust 分发和 crates.io。workflow 中的 `actions/checkout` / `actions/setup-node` 固定到完整 commit;内网 runner 不允许访问 GitHub 时,先把对应 commit 镜像到当前 Gitea 并把 workflow 改为绝对 action URL。首版不使用 Actions cache,避免未配置 runner cache 网络时把缓存恢复错误变成 PR 失败。Gitea 1.26 不执行 workflow 的 `timeout-minutes`,任务最长运行时间在 runner 全局配置收口,不能只在 YAML 写一个不会生效的超时值。
|
||||
启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 必须提供 `ubuntu-latest` 标签,并将其映射到经验证且固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像;禁止使用浮动镜像 tag,禁止将该标签映射到 host 执行器,禁止向 job 暴露 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 会安装 Node 22、仓库 `rust-toolchain.toml` 固定的 Rust 1.96.0,以及 clang/lld 和 Tauri Linux 依赖;受控 runner 镜像应预装 rustup,fallback 下载只用于首次引导。runner 仍需能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm registry、Rust 分发和 crates.io。workflow 中的 `actions/checkout` / `actions/setup-node` 固定到完整 commit;内网 runner 不允许访问 GitHub 时,先把对应 commit 镜像到当前 Gitea 并把 workflow 改为绝对 action URL。首版不使用 Actions cache,避免未配置 runner cache 网络时把缓存恢复错误变成 PR 失败。当前 Runner 2.0.0 已支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限;若 workflow 以后新增更短 timeout,不能删除全局兜底。
|
||||
|
||||
当前 `genarrative-station` 已于 2026-07-21 升级到 Gitea `1.26.4`,并使用基于 Gitea Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像与固定 digest 的 Ubuntu 24.04 job image。Runner 2.0.0 会先把 `systempaths=unconfined` 解析为空 `MaskedPaths` / `ReadonlyPaths`,再被 `mergo.WithOverride` 当成 empty value 丢失;站点修补只在 merge 后保留这两个显式空 slice,不改其它 runner 行为。真实 job inspect 必须看到 `MaskedPaths=[]`、`ReadonlyPaths=[]`、`SecurityOpt=[seccomp=unconfined]`、`Privileged=false`、无 CapAdd 且 `Binds=[]`。外层 runner 以 `rootless` 用户运行,`privileged=false`、不增加 `CAP_SYS_ADMIN`,只映射 `/dev/net/tun`,内部 Docker 只监听私有 Unix socket;runner 配置必须保持 `docker_host: "-"`、`valid_volumes: []` 和 `bind_workdir: false`,防止内部 Docker socket 或宿主 bind mount 进入 job。job 只连接 `gitea-actions` internal network:`genarrative-station` 由只转发 `/git` 到 Gitea 的内部 gateway 解析,公网依赖只经拒绝私网、保留地址和 metadata 的 80/443 egress proxy;绕过 proxy 的公网和 Postgres/Redis 数据网都必须不可达。完整 bwrap canary 需要 rootless DinD 外层的 rootlesskit AppArmor/userns 边界,以及内层 job 的 namespace/proc 挂载支持;相关 `seccomp/systempaths` 放宽只允许存在于这个无宿主 socket 的 rootless DinD 内层,禁止复制回控制宿主 rootful Docker 的 runner。
|
||||
|
||||
workflow 的系统依赖步骤在 UID 0 时不再调用 sudo;非 root runner 必须使用 `sudo -E` 保留受控 proxy 环境,避免 `sudo` 的 `env_reset` 让 apt 绕过 gateway。Cargo 通过 proxy 下载 sparse index/crate 时固定关闭 HTTP multiplexing 并设置 `CARGO_NET_RETRY=10`;rustup bootstrap 与 toolchain 安装也执行有界重试,降低代理链瞬时 TLS EOF 对后端测试的影响,但不能用重试掩盖持续不可达。AI 原生壳 job 不能把 `$HOME/.cargo/bin` 或 setup-node toolcache 直接加入 `command.exec` 的受信任 PATH;应把完整 Node 发行目录复制到 `/usr/local/lib`,把 root-owned rustup proxy 安装到 `/usr/local/bin`,并通过 `RUSTUP_HOME` 只读挂载工具链。测试前先运行与应用一致的完整 bwrap canary,失败时停止测试,不允许跳过 sandbox 用例。
|
||||
|
||||
站点 stack 仍由宿主 `/opt/gitea-stack` 管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到同一个绝对路径再执行;若挂成 `/stack`,相对 bind source 会被 Docker daemon 误解析为宿主 `/stack/...` 并启动空数据目录。升级或 runner 迁移前先停止 Gitea 写入并保留 `data/gitea` 冷快照、`pg_dumpall`、compose/.env 与 runner config/.runner;本次可回滚快照位于 `backups/gitea-ci-migration-20260721-211018`。备份文件、绝对宿主配置和注册 token 不得提交 Git。
|
||||
|
||||
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)` 四个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `ubuntu-latest` 标签的 runner。
|
||||
|
||||
|
||||
@@ -2320,15 +2320,24 @@ describe('ImageCanvasEditorView', () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
|
||||
it('uses normal wheel for two-axis canvas pan and ctrl wheel for zoom', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
const world = screen.getByTestId('image-canvas-world') as HTMLElement;
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 });
|
||||
fireEvent.wheel(viewport, {
|
||||
deltaX: 80,
|
||||
deltaY: 120,
|
||||
clientX: 400,
|
||||
clientY: 280,
|
||||
});
|
||||
expect(world.style.transform).toBe(
|
||||
'translate(-80px, -120px) scale(0.5)',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
moveViewportFromMinimapDrag,
|
||||
moveViewportFromMinimapPointer,
|
||||
moveViewportFromPan,
|
||||
panViewportFromWheel,
|
||||
scaleViewportFromScreenPoint,
|
||||
scrollViewportVertically,
|
||||
selectCanvasObjectsInsideMarquee,
|
||||
selectLayersInsideMarquee,
|
||||
zoomViewportFromWheel,
|
||||
@@ -133,11 +133,13 @@ describe('ImageCanvasInteractionModel', () => {
|
||||
).toEqual({ x: 100, y: 60 });
|
||||
});
|
||||
|
||||
it('scrolls vertically and zooms around a screen point', () => {
|
||||
it('pans from two-axis wheel input and zooms around a screen point', () => {
|
||||
const viewport = { x: 10, y: 20, scale: 1 };
|
||||
|
||||
expect(scrollViewportVertically(viewport, 120)).toEqual({
|
||||
x: 10,
|
||||
expect(
|
||||
panViewportFromWheel({ viewport, deltaX: 40, deltaY: 120 }),
|
||||
).toEqual({
|
||||
x: -30,
|
||||
y: -100,
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
@@ -212,12 +212,18 @@ export function scaleViewportFromScreenPoint({
|
||||
};
|
||||
}
|
||||
|
||||
export function scrollViewportVertically(
|
||||
viewport: CanvasViewport,
|
||||
deltaY: number,
|
||||
) {
|
||||
export function panViewportFromWheel({
|
||||
viewport,
|
||||
deltaX,
|
||||
deltaY,
|
||||
}: {
|
||||
viewport: CanvasViewport;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
}) {
|
||||
return {
|
||||
...viewport,
|
||||
x: viewport.x - deltaX,
|
||||
y: viewport.y - deltaY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [
|
||||
{
|
||||
title: '视图',
|
||||
items: [
|
||||
{ action: '纵向滚动画布', keys: ['鼠标滚轮'], status: 'ready' },
|
||||
{ action: '平移画布', keys: ['鼠标滚轮 / 触控板'], status: 'ready' },
|
||||
{ action: '横向平移画布', keys: ['Shift', '鼠标滚轮'], status: 'ready' },
|
||||
{ action: '以鼠标位置缩放', keys: ['Ctrl', '鼠标滚轮'], status: 'ready' },
|
||||
{ action: '显示全部元素', keys: ['Ctrl', '0'], status: 'new' },
|
||||
{ action: '缩放至 100%', keys: ['Ctrl', '1'], status: 'new' },
|
||||
|
||||
@@ -1083,6 +1083,22 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
});
|
||||
expect(screen.getByTestId('panning').textContent).toBe('true');
|
||||
|
||||
act(() => {
|
||||
fireEvent(
|
||||
viewport,
|
||||
new MouseEvent('pointermove', {
|
||||
bubbles: true,
|
||||
clientX: 140,
|
||||
clientY: 70,
|
||||
button: 1,
|
||||
buttons: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('viewport-state').textContent).toBe(
|
||||
'40.0,-30.0,1',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent(
|
||||
viewport,
|
||||
|
||||
@@ -158,19 +158,12 @@ describe('useImageCanvasViewportControls', () => {
|
||||
expect(captureCanvasHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles vertical wheel scroll, ctrl wheel zoom and minimap movement', () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback);
|
||||
return rafCallbacks.length;
|
||||
},
|
||||
);
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
it('keeps ordinary mouse wheel vertical and maps Shift wheel to horizontal', () => {
|
||||
const { result, viewportElement } = renderViewportControls();
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, {
|
||||
deltaX: 0,
|
||||
deltaY: 120,
|
||||
clientX: 260,
|
||||
clientY: 190,
|
||||
@@ -182,6 +175,47 @@ describe('useImageCanvasViewportControls', () => {
|
||||
scale: 0.5,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, {
|
||||
shiftKey: true,
|
||||
deltaX: 0,
|
||||
deltaY: 80,
|
||||
clientX: 260,
|
||||
clientY: 190,
|
||||
});
|
||||
});
|
||||
expectViewport(result.current.viewport, {
|
||||
x: -340,
|
||||
y: -50,
|
||||
scale: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles native two-axis wheel pan, ctrl wheel zoom and minimap movement', () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback);
|
||||
return rafCallbacks.length;
|
||||
},
|
||||
);
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
const { result, viewportElement } = renderViewportControls();
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, {
|
||||
deltaX: 40,
|
||||
deltaY: 120,
|
||||
clientX: 260,
|
||||
clientY: 190,
|
||||
});
|
||||
});
|
||||
expectViewport(result.current.viewport, {
|
||||
x: -300,
|
||||
y: -50,
|
||||
scale: 0.5,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, {
|
||||
ctrlKey: true,
|
||||
@@ -191,7 +225,7 @@ describe('useImageCanvasViewportControls', () => {
|
||||
});
|
||||
});
|
||||
expectViewport(result.current.viewport, {
|
||||
x: -312,
|
||||
x: -356,
|
||||
y: -74,
|
||||
scale: 0.55,
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
getWorldPointFromClient,
|
||||
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
|
||||
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
|
||||
panViewportFromWheel,
|
||||
scaleViewportFromScreenPoint,
|
||||
scrollViewportVertically,
|
||||
zoomViewportFromWheel,
|
||||
} from './ImageCanvasInteractionModel';
|
||||
|
||||
@@ -260,8 +260,16 @@ export function useImageCanvasViewportControls({
|
||||
}
|
||||
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
const useShiftWheelHorizontalFallback =
|
||||
event.shiftKey && event.deltaX === 0;
|
||||
setViewport((currentViewport) =>
|
||||
scrollViewportVertically(currentViewport, event.deltaY),
|
||||
panViewportFromWheel({
|
||||
viewport: currentViewport,
|
||||
deltaX: useShiftWheelHorizontalFallback
|
||||
? event.deltaY
|
||||
: event.deltaX,
|
||||
deltaY: useShiftWheelHorizontalFallback ? 0 : event.deltaY,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user