Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f410d942 | ||
|
|
a8732f51be | ||
|
|
4a63ccd10f | ||
|
|
865d8b0516 |
+36
-66
@@ -165,88 +165,58 @@ docker compose restart xiaobai-review
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 镜像构建的唯一安全入口(2026-08 HEL-235 起)
|
||||
### 服务器本地目录更新与构建(日常推荐)
|
||||
|
||||
生产机 `192.168.200.11` 上的 `/opt/1panel/docker/compose/xiaobaifupan` 只是历史文件树:
|
||||
不是 Git 仓库、内容停在旧提交、与线上镜像不一致,且其 `compose.yaml` 会把构建结果打进
|
||||
`xiaobai-review:latest`。**禁止在该目录(或任何服务器工作树)里 `docker build` /
|
||||
`docker compose build`**,否则会把已上线功能悄悄打回旧版。
|
||||
|
||||
唯一安全构建方式是在有仓库检出、能免密 SSH 到部署机的机器上运行:
|
||||
|
||||
```bash
|
||||
tools/build_image.sh <提交号> <镜像tag>
|
||||
# 示例:tools/build_image.sh cefc86917d89 verify-hel235-cefc869
|
||||
```
|
||||
|
||||
该脚本的行为约束:
|
||||
|
||||
- 先 `git fetch`,再把提交号解析为完整 SHA,解析失败立即中止,绝不使用本地脏状态或服务器旧目录;
|
||||
- 构建前读取当前线上容器镜像的 `org.opencontainers.image.revision`,用 Git 祖先关系确认候选提交包含线上全部历史;落后 `main`、旁支或错误提交会直接退出,并打印线上提交、候选提交、文件差异和将丢失的提交;
|
||||
- 镜像 tag 必须以 `-<提交短号7位>` 结尾(如 `hel234-cefc869`),禁止 `latest`、`rollback-*`;
|
||||
- 通过 `git archive <提交> | ssh 部署机 docker build -` 流式构建,服务器上不存在构建用工作树;
|
||||
- 构建后回读镜像 label 里的 `org.opencontainers.image.revision`,与预期提交不一致则删除镜像并中止;
|
||||
- 每次构建在部署机 `~/xiaobai-build/BUILD_LOG.tsv` 留痕,可追溯每个镜像的来源提交。
|
||||
|
||||
构建只产出镜像,不启动、不替换任何容器;换版用新 tag 起新容器,回滚用既有镜像 tag 重跑。
|
||||
|
||||
### 使用 Gitea 更新程序(旧方式,生产机禁用)
|
||||
|
||||
代码仓库为:
|
||||
生产机 `192.168.200.11` 的 `/opt/1panel/docker/compose/xiaobaifupan` 自 2026-08-29(HEL-235B)
|
||||
起已是受 Git 管理的工作目录,只跟踪 Gitea `main`(仓库
|
||||
`http://192.168.200.36:3200/leefer/xiaobai-review.git`)。由于目录顶层归 root,
|
||||
`.git` 存放在部署账号家目录(外部 Git 目录方案):
|
||||
|
||||
```text
|
||||
http://192.168.200.36:3200/leefer/xiaobaifupan.git
|
||||
~/xiaobai-build/repos/xiaobai-review.git Git 元数据(分支/历史/索引)
|
||||
/opt/1panel/docker/compose/xiaobaifupan 工作目录(程序文件本体)
|
||||
~/xiaobai-build/update-from-main.sh 一键更新+构建入口
|
||||
~/xiaobai-git 便捷查看(status/log/diff)
|
||||
```
|
||||
|
||||
首次在服务器部署代码时,可以直接克隆到目标目录:
|
||||
日常更新只需要在服务器上执行一条命令:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/xiaobai-review
|
||||
sudo chown "$USER":"$USER" /opt/xiaobai-review
|
||||
git clone http://192.168.200.36:3200/leefer/xiaobaifupan.git /opt/xiaobai-review
|
||||
cd /opt/xiaobai-review
|
||||
~/xiaobai-build/update-from-main.sh # 更新到 main 并构建 main-<短号> 镜像
|
||||
~/xiaobai-build/update-from-main.sh verify-tag main-a8732f5 # 部署前复核镜像与 main 一致
|
||||
```
|
||||
|
||||
私有仓库会提示输入 Gitea 用户名和密码或访问令牌。不要把密码写入仓库 URL、
|
||||
`compose.yaml` 或脚本。然后把原 `.env` 与 `data/` 放回该目录;这两项已被 Git
|
||||
忽略,后续拉取代码不会覆盖数据库与密钥。
|
||||
脚本在构建前强制完成五道校验,任一不符立即停止、不产出镜像:
|
||||
|
||||
如需部署管理员私有问师,通过 NAS 文件管理器将本地
|
||||
`data/private-mentor-skills/` 复制到服务器项目的同名 `data` 目录,并保持目录仅由
|
||||
部署账号和容器运行用户读取。该内容不会通过 Gitea 同步。
|
||||
1. `git fetch` 成功(连不上 Gitea 即停);
|
||||
2. 必须在 `main` 分支(智能体不得用功能分支直接当正式线);
|
||||
3. 工作区无未提交改动、无多余文件;
|
||||
4. 只允许快进合并到 `origin/main`(分叉即停);main 新增/删除顶层文件时会给出
|
||||
需管理员执行的精确清单(目录顶层归 root);
|
||||
5. 构建后回读镜像 `org.opencontainers.image.revision`,与 `main` 提交不一致则删除镜像。
|
||||
|
||||
每次更新前先创建 SQLite 一致性备份,再拉取并重建容器(注意:`docker compose up -d --build`
|
||||
从服务器本地工作树构建,仅适用于来源可信的全新环境;生产机 `192.168.200.11` 禁用,
|
||||
请用 `tools/build_image.sh` 构建后换容器):
|
||||
镜像 tag 固定为 `main-<提交短号7位>`(不带提交号的模糊 tag 一律禁止);每次构建在
|
||||
`~/xiaobai-build/BUILD_LOG.tsv` 留痕。构建只产出镜像,不启动、不替换容器;换版与
|
||||
回滚步骤见 `~/xiaobai-build/README.md`。
|
||||
|
||||
```bash
|
||||
cd /opt/xiaobai-review
|
||||
docker compose exec -T xiaobai-review python -c "import sqlite3; s=sqlite3.connect('/app/data/review.db'); d=sqlite3.connect('/app/data/review-before-update.db'); s.backup(d); d.close(); s.close()"
|
||||
git pull --ff-only origin main
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
curl --fail http://127.0.0.1:8765/api/health
|
||||
```
|
||||
`compose.yaml` 的镜像名与 revision 标签同样做了强校验:直接 `docker compose up -d --build`
|
||||
会因缺少 `XIAOBAI_GIT_REV` / `XIAOBAI_GIT_SHORT` 变量而拒绝执行,避免再出现构建进
|
||||
`latest` 的模糊版本。需要用 compose 时先 `export` 这两个变量(值以
|
||||
`~/xiaobai-git rev-parse HEAD` 为准),或直接用上面的脚本。
|
||||
|
||||
`docker compose up -d --build` 会原地替换应用容器,不删除宿主机的 `data` 目录。
|
||||
数据库迁移会在新容器启动时自动执行。若 `git pull --ff-only` 提示本地代码有修改,
|
||||
先用 `git status` 查明原因,不要用强制重置覆盖 `.env` 或 `data`。
|
||||
### 智能体高级入口:Git 归档流式构建
|
||||
|
||||
### 不使用 Git 时更新(生产机禁用)
|
||||
有仓库检出、能免密 SSH 到部署机的智能体可以用 `tools/build_image.sh <提交号> <镜像tag>`
|
||||
从任意明确提交流式构建(`git archive | ssh docker build`),tag 同样必须以
|
||||
`-<提交短号7位>` 结尾,构建后回读 revision 校验并留痕。用于在服务器不便拉取时的
|
||||
应急构建;日常正式线仍应走 `main`。
|
||||
|
||||
`docker compose build` 会从服务器本地目录构建,来源提交不可追溯。生产机
|
||||
`192.168.200.11` 上禁止使用本节方式,一律改用上一节的 `tools/build_image.sh`。
|
||||
### 历史方式(已废弃)
|
||||
|
||||
重新上传代码后执行:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose build --pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker compose down` 不会删除宿主机的 `data` 目录。不要使用带有手工删除
|
||||
`data` 目录的清理命令。
|
||||
早期文档建议在服务器重新 `git clone` 一份或手工上传代码后 `docker compose up --build`。
|
||||
这两条路径已废弃:服务器上**只允许存在一个受管工作目录**(上述
|
||||
`/opt/1panel/docker/compose/xiaobaifupan`),任何脱离 Git 校验的本地构建都会把
|
||||
来源提交变成不可追溯状态,禁止使用。
|
||||
|
||||
## 7. 备份与恢复
|
||||
|
||||
|
||||
+3
-1
@@ -3,7 +3,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: xiaobai-review:latest
|
||||
labels:
|
||||
org.opencontainers.image.revision: "${XIAOBAI_GIT_REV:?必须先设置 XIAOBAI_GIT_REV=当前 main 完整提交号(或改用 tools/update_from_main.sh)}"
|
||||
image: xiaobai-review:main-${XIAOBAI_GIT_SHORT:?必须先设置 XIAOBAI_GIT_SHORT=7位提交短号(或改用 tools/update_from_main.sh)}
|
||||
container_name: xiaobai-review
|
||||
ports:
|
||||
- "0.0.0.0:8765:8765/tcp"
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel240">
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel251">
|
||||
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
|
||||
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel243">
|
||||
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel251">
|
||||
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
|
||||
</head>
|
||||
<body class="login-portal">
|
||||
@@ -33,6 +33,73 @@
|
||||
<p class="login-brand-subtitle">A股个人复盘工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-mascots" id="loginMascots" aria-hidden="true" data-mood="idle">
|
||||
<div class="login-mascots-stage">
|
||||
<div class="login-mascot is-red">
|
||||
<div class="login-mascot-breathe">
|
||||
<div class="login-mascot-lean">
|
||||
<svg class="login-mascot-figure" viewBox="0 0 96 210" focusable="false">
|
||||
<ellipse class="login-mascot-shadow" cx="48" cy="200" rx="26" ry="5.5"></ellipse>
|
||||
<line class="login-mascot-wick" x1="48" y1="10" x2="48" y2="50"></line>
|
||||
<rect class="login-mascot-body" x="16" y="50" width="64" height="126" rx="14"></rect>
|
||||
<line class="login-mascot-wick" x1="48" y1="176" x2="48" y2="190"></line>
|
||||
<g class="login-mascot-face">
|
||||
<circle class="login-mascot-eye" cx="36" cy="94" r="10"></circle>
|
||||
<circle class="login-mascot-eye" cx="60" cy="94" r="10"></circle>
|
||||
<circle class="login-mascot-pupil" cx="36" cy="94" r="4.2"></circle>
|
||||
<circle class="login-mascot-pupil" cx="60" cy="94" r="4.2"></circle>
|
||||
<rect class="login-mascot-lid" x="24" y="82" width="48" height="24" rx="10"></rect>
|
||||
<path class="login-mascot-happy" d="M28 96 Q36 88 44 96"></path>
|
||||
<path class="login-mascot-happy" d="M52 96 Q60 88 68 96"></path>
|
||||
<circle class="login-mascot-sad" cx="36" cy="96" r="2.2"></circle>
|
||||
<circle class="login-mascot-sad" cx="60" cy="96" r="2.2"></circle>
|
||||
</g>
|
||||
<ellipse class="login-mascot-arm is-left" cx="16" cy="128" rx="8" ry="5.5"></ellipse>
|
||||
<ellipse class="login-mascot-arm is-right" cx="80" cy="128" rx="8" ry="5.5"></ellipse>
|
||||
<ellipse class="login-mascot-hand is-left" cx="16" cy="128" rx="9" ry="7"></ellipse>
|
||||
<ellipse class="login-mascot-hand is-right" cx="80" cy="128" rx="9" ry="7"></ellipse>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-mascot is-green">
|
||||
<div class="login-mascot-breathe">
|
||||
<div class="login-mascot-lean">
|
||||
<div class="login-mascot-figure">
|
||||
<svg class="login-mascot-front" viewBox="0 0 84 175" focusable="false">
|
||||
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
|
||||
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
|
||||
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
|
||||
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
|
||||
<g class="login-mascot-face">
|
||||
<circle class="login-mascot-eye" cx="32" cy="76" r="8.5"></circle>
|
||||
<circle class="login-mascot-eye" cx="52" cy="76" r="8.5"></circle>
|
||||
<circle class="login-mascot-pupil" cx="32" cy="76" r="3.6"></circle>
|
||||
<circle class="login-mascot-pupil" cx="52" cy="76" r="3.6"></circle>
|
||||
<rect class="login-mascot-lid" x="22" y="66" width="40" height="20" rx="8"></rect>
|
||||
<path class="login-mascot-happy" d="M25 78 Q32 71 39 78"></path>
|
||||
<path class="login-mascot-happy" d="M45 78 Q52 71 59 78"></path>
|
||||
<circle class="login-mascot-sad" cx="32" cy="78" r="1.9"></circle>
|
||||
<circle class="login-mascot-sad" cx="52" cy="78" r="1.9"></circle>
|
||||
</g>
|
||||
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
|
||||
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
|
||||
</svg>
|
||||
<svg class="login-mascot-back" viewBox="0 0 84 175" focusable="false">
|
||||
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
|
||||
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
|
||||
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
|
||||
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
|
||||
<rect class="login-mascot-slit" x="40.5" y="58" width="3" height="72" rx="1.5"></rect>
|
||||
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
|
||||
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-brand-copy">
|
||||
<p class="login-brand-kicker">收盘之后 · 复盘开始</p>
|
||||
<h1 class="login-brand-title">看懂情绪周期,把复盘变成下一次的先手。</h1>
|
||||
@@ -90,6 +157,6 @@
|
||||
<section class="login-card" id="loginCard" aria-live="polite"></section>
|
||||
</main>
|
||||
<script src="/shared/api.js?v=20260803-2"></script>
|
||||
<script src="/login/page.js?v=20260829-hel243"></script>
|
||||
<script src="/login/page.js?v=20260829-hel251"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+246
-1
@@ -15,6 +15,7 @@
|
||||
error: "",
|
||||
username: "",
|
||||
password: "",
|
||||
passwordVisible: false,
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -102,6 +103,8 @@
|
||||
const lead = options.lead;
|
||||
const hint = options.hint;
|
||||
const invalid = state.error ? " is-invalid" : "";
|
||||
const passwordType = state.passwordVisible ? "text" : "password";
|
||||
const passwordToggle = state.passwordVisible ? "隐藏" : "显示";
|
||||
return [
|
||||
options.back
|
||||
? '<button class="login-back" type="button" data-login-action="picker">返回账号列表</button>'
|
||||
@@ -114,7 +117,7 @@
|
||||
"</div>",
|
||||
'<form class="login-form" id="loginForm">',
|
||||
`<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
|
||||
`<label class="form-field"><span>密码</span><input id="loginPassword" class="${invalid.trim()}" type="password" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required></label>`,
|
||||
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
|
||||
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
|
||||
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
|
||||
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
|
||||
@@ -214,6 +217,7 @@
|
||||
card.innerHTML = pickerMarkup();
|
||||
}
|
||||
bindCard();
|
||||
if (mascots) mascots.sync();
|
||||
}
|
||||
|
||||
function bindCard() {
|
||||
@@ -227,6 +231,10 @@
|
||||
card.querySelectorAll("[data-login-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const action = button.dataset.loginAction;
|
||||
if (action === "toggle-password") {
|
||||
togglePasswordVisible();
|
||||
return;
|
||||
}
|
||||
if (action === "resume") {
|
||||
resumeCurrentAccount();
|
||||
return;
|
||||
@@ -293,11 +301,13 @@
|
||||
render();
|
||||
try {
|
||||
await api.request(`/api/auth/${state.mode}`, "POST", { username, password });
|
||||
await celebrateLogin();
|
||||
enterApp();
|
||||
} catch (error) {
|
||||
state.loading = false;
|
||||
setError(error.message || "账号操作失败");
|
||||
render();
|
||||
mascots.fail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +360,241 @@
|
||||
}
|
||||
}
|
||||
|
||||
function togglePasswordVisible() {
|
||||
state.passwordVisible = !state.passwordVisible;
|
||||
const input = document.querySelector("#loginPassword");
|
||||
const button = document.querySelector(".login-password-toggle");
|
||||
if (input) {
|
||||
input.type = state.passwordVisible ? "text" : "password";
|
||||
input.focus();
|
||||
}
|
||||
if (button) {
|
||||
button.textContent = state.passwordVisible ? "隐藏" : "显示";
|
||||
button.setAttribute("aria-pressed", state.passwordVisible ? "true" : "false");
|
||||
button.setAttribute("aria-label", state.passwordVisible ? "隐藏密码" : "显示密码");
|
||||
}
|
||||
mascots.sync();
|
||||
}
|
||||
|
||||
function reducedMotion() {
|
||||
return Boolean(global.matchMedia && global.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
}
|
||||
|
||||
function finePointer() {
|
||||
return Boolean(global.matchMedia && global.matchMedia("(pointer: fine)").matches);
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => global.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function celebrateLogin() {
|
||||
mascots.succeed();
|
||||
if (!reducedMotion()) await wait(720);
|
||||
}
|
||||
|
||||
const mascots = (() => {
|
||||
const root = document.querySelector("#loginMascots");
|
||||
const red = root && root.querySelector(".login-mascot.is-red");
|
||||
const green = root && root.querySelector(".login-mascot.is-green");
|
||||
const motion = {
|
||||
pupilX: 0,
|
||||
pupilY: 0,
|
||||
targetX: 0,
|
||||
targetY: 0,
|
||||
leanRed: 0,
|
||||
leanGreen: 0,
|
||||
targetLeanRed: 0,
|
||||
targetLeanGreen: 0,
|
||||
};
|
||||
let mood = "idle";
|
||||
let locked = "";
|
||||
let blinkTimer = 0;
|
||||
let failTimer = 0;
|
||||
let raf = 0;
|
||||
|
||||
function setVars() {
|
||||
if (!red || !green) return;
|
||||
const pupilX = `${motion.pupilX.toFixed(2)}px`;
|
||||
const pupilY = `${motion.pupilY.toFixed(2)}px`;
|
||||
red.style.setProperty("--pupil-x", pupilX);
|
||||
red.style.setProperty("--pupil-y", pupilY);
|
||||
green.style.setProperty("--pupil-x", pupilX);
|
||||
green.style.setProperty("--pupil-y", pupilY);
|
||||
red.style.setProperty("--lean", `${motion.leanRed.toFixed(2)}deg`);
|
||||
green.style.setProperty("--lean", `${motion.leanGreen.toFixed(2)}deg`);
|
||||
}
|
||||
|
||||
function poseFor(next) {
|
||||
if (next === "account" || next === "busy") {
|
||||
motion.targetX = 3.2;
|
||||
motion.targetY = 0.8;
|
||||
motion.targetLeanRed = 7;
|
||||
motion.targetLeanGreen = 5.6;
|
||||
return;
|
||||
}
|
||||
if (next === "password") {
|
||||
motion.targetX = 0;
|
||||
motion.targetY = 0;
|
||||
motion.targetLeanRed = 0;
|
||||
motion.targetLeanGreen = 0;
|
||||
return;
|
||||
}
|
||||
if (next === "fail") {
|
||||
motion.targetX = 0;
|
||||
motion.targetY = 2.8;
|
||||
motion.targetLeanRed = 8;
|
||||
motion.targetLeanGreen = 8;
|
||||
return;
|
||||
}
|
||||
if (next === "success") {
|
||||
motion.targetX = 0;
|
||||
motion.targetY = 0;
|
||||
motion.targetLeanRed = 0;
|
||||
motion.targetLeanGreen = 0;
|
||||
return;
|
||||
}
|
||||
if (!finePointer()) {
|
||||
motion.targetX = 2.4;
|
||||
motion.targetY = 0.4;
|
||||
motion.targetLeanRed = 4;
|
||||
motion.targetLeanGreen = 3.2;
|
||||
return;
|
||||
}
|
||||
motion.targetLeanRed = 0;
|
||||
motion.targetLeanGreen = 0;
|
||||
}
|
||||
|
||||
function applyMood(next) {
|
||||
if (!root) return;
|
||||
const changed = next !== mood;
|
||||
if (changed) {
|
||||
mood = next;
|
||||
root.dataset.mood = next;
|
||||
poseFor(next);
|
||||
} else if (next !== "idle") {
|
||||
poseFor(next);
|
||||
}
|
||||
if (reducedMotion()) {
|
||||
motion.pupilX = motion.targetX;
|
||||
motion.pupilY = motion.targetY;
|
||||
motion.leanRed = motion.targetLeanRed;
|
||||
motion.leanGreen = motion.targetLeanGreen;
|
||||
setVars();
|
||||
}
|
||||
}
|
||||
|
||||
function focusedControl() {
|
||||
const active = document.activeElement;
|
||||
if (!active || !card.contains(active)) return "";
|
||||
if (active.classList.contains("login-password-toggle")) return "loginPassword";
|
||||
return active.id || "";
|
||||
}
|
||||
|
||||
function desiredMood() {
|
||||
if (locked === "success") return "success";
|
||||
if (state.loading) return "busy";
|
||||
const focused = focusedControl();
|
||||
if (focused === "loginPassword" || focused === "loginPasswordConfirm") return "password";
|
||||
if (focused === "loginUsername") return "account";
|
||||
if (locked === "fail") return "fail";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function sync() {
|
||||
applyMood(desiredMood());
|
||||
}
|
||||
|
||||
function succeed() {
|
||||
locked = "success";
|
||||
applyMood("success");
|
||||
}
|
||||
|
||||
function fail() {
|
||||
locked = "fail";
|
||||
applyMood("fail");
|
||||
global.clearTimeout(failTimer);
|
||||
failTimer = global.setTimeout(() => {
|
||||
if (locked === "fail") locked = "";
|
||||
sync();
|
||||
}, 900);
|
||||
}
|
||||
|
||||
function blink() {
|
||||
if (!root || reducedMotion()) return;
|
||||
if (mood !== "idle" && mood !== "account" && mood !== "busy") return;
|
||||
root.classList.remove("is-blinking");
|
||||
void root.offsetWidth;
|
||||
root.classList.add("is-blinking");
|
||||
global.setTimeout(() => root.classList.remove("is-blinking"), 160);
|
||||
}
|
||||
|
||||
function scheduleBlink() {
|
||||
global.clearTimeout(blinkTimer);
|
||||
if (reducedMotion()) return;
|
||||
const waitMs = 4000 + Math.random() * 2000;
|
||||
blinkTimer = global.setTimeout(() => {
|
||||
blink();
|
||||
scheduleBlink();
|
||||
}, waitMs);
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
if (reducedMotion() || !finePointer()) return;
|
||||
if (desiredMood() !== "idle") return;
|
||||
const rect = root.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width * 0.42;
|
||||
const cy = rect.top + rect.height * 0.42;
|
||||
const dx = event.clientX - cx;
|
||||
const dy = event.clientY - cy;
|
||||
const dist = Math.hypot(dx, dy) || 1;
|
||||
const cap = 3.5;
|
||||
motion.targetX = (dx / dist) * Math.min(cap, Math.abs(dx) / 90);
|
||||
motion.targetY = (dy / dist) * Math.min(cap, Math.abs(dy) / 90);
|
||||
const tilt = Math.max(-5, Math.min(5, (dx / Math.max(global.innerWidth, 1)) * 10));
|
||||
motion.targetLeanRed = tilt;
|
||||
motion.targetLeanGreen = tilt * 0.8;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!root) return;
|
||||
if (!reducedMotion()) {
|
||||
motion.pupilX += (motion.targetX - motion.pupilX) * 0.18;
|
||||
motion.pupilY += (motion.targetY - motion.pupilY) * 0.18;
|
||||
motion.leanRed += (motion.targetLeanRed - motion.leanRed) * 0.18;
|
||||
motion.leanGreen += (motion.targetLeanGreen - motion.leanGreen) * 0.18;
|
||||
setVars();
|
||||
} else {
|
||||
motion.pupilX = motion.targetX;
|
||||
motion.pupilY = motion.targetY;
|
||||
motion.leanRed = motion.targetLeanRed;
|
||||
motion.leanGreen = motion.targetLeanGreen;
|
||||
setVars();
|
||||
}
|
||||
raf = global.requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function onFocusChange() {
|
||||
global.requestAnimationFrame(sync);
|
||||
}
|
||||
|
||||
if (root) {
|
||||
document.addEventListener("focusin", onFocusChange);
|
||||
document.addEventListener("focusout", onFocusChange);
|
||||
global.addEventListener("mousemove", onMouseMove, { passive: true });
|
||||
if (!reducedMotion()) {
|
||||
raf = global.requestAnimationFrame(tick);
|
||||
scheduleBlink();
|
||||
} else {
|
||||
poseFor("idle");
|
||||
setVars();
|
||||
}
|
||||
sync();
|
||||
}
|
||||
|
||||
return { sync, succeed, fail };
|
||||
})();
|
||||
|
||||
themeButton.addEventListener("click", () => {
|
||||
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
|
||||
});
|
||||
|
||||
+383
-3
@@ -1827,16 +1827,351 @@ body.login-portal {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.login-brand-copy {
|
||||
.login-mascots {
|
||||
display: flex;
|
||||
|
||||
flex: 1 1 auto;
|
||||
|
||||
align-items: center;
|
||||
|
||||
min-height: 0;
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-mascots-stage {
|
||||
display: flex;
|
||||
|
||||
align-items: flex-end;
|
||||
|
||||
padding-left: calc(var(--login-mascot-height) * 82 / 210);
|
||||
|
||||
gap: calc(var(--login-mascot-height) * 10 / 210);
|
||||
}
|
||||
|
||||
.login-mascot {
|
||||
position: relative;
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
overflow: visible;
|
||||
|
||||
--pupil-x: 0px;
|
||||
|
||||
--pupil-y: 0px;
|
||||
|
||||
--lean: 0deg;
|
||||
}
|
||||
|
||||
.login-mascot.is-red {
|
||||
width: calc(var(--login-mascot-height) * 96 / 210);
|
||||
|
||||
height: var(--login-mascot-height);
|
||||
}
|
||||
|
||||
.login-mascot.is-green {
|
||||
width: calc(var(--login-mascot-height) * 84 / 210);
|
||||
|
||||
height: calc(var(--login-mascot-height) * 175 / 210);
|
||||
|
||||
perspective: 420px;
|
||||
}
|
||||
|
||||
.login-mascot-breathe {
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="idle"] .login-mascot-breathe {
|
||||
animation: login-mascot-breathe 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.login-mascot-lean {
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
transform: rotate(var(--lean));
|
||||
|
||||
transform-origin: 50% 88%;
|
||||
}
|
||||
|
||||
.login-mascot.is-red .login-mascot-figure {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-mascot.is-green .login-mascot-figure {
|
||||
position: relative;
|
||||
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
transform-style: preserve-3d;
|
||||
|
||||
transition: transform 280ms ease-in-out;
|
||||
}
|
||||
|
||||
.login-mascot-front {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.login-mascot-back {
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
backface-visibility: hidden;
|
||||
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-green .login-mascot-figure {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.login-mascot-body {
|
||||
fill: var(--login-mascot-red);
|
||||
}
|
||||
|
||||
.login-mascot.is-green .login-mascot-body {
|
||||
fill: var(--login-mascot-green);
|
||||
}
|
||||
|
||||
.login-mascot-wick {
|
||||
stroke: var(--login-mascot-red);
|
||||
|
||||
stroke-width: 4;
|
||||
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.login-mascot.is-green .login-mascot-wick {
|
||||
stroke: var(--login-mascot-green);
|
||||
|
||||
stroke-width: 3.5;
|
||||
}
|
||||
|
||||
.login-mascot-eye {
|
||||
fill: var(--login-mascot-eye);
|
||||
}
|
||||
|
||||
.login-mascot-pupil {
|
||||
fill: var(--login-mascot-pupil);
|
||||
|
||||
transform: translate(var(--pupil-x), var(--pupil-y));
|
||||
|
||||
transform-box: fill-box;
|
||||
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.login-mascot-lid {
|
||||
fill: var(--login-mascot-red);
|
||||
|
||||
transform: scaleY(0);
|
||||
|
||||
transform-box: fill-box;
|
||||
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.login-mascot.is-green .login-mascot-lid {
|
||||
fill: var(--login-mascot-green);
|
||||
}
|
||||
|
||||
.login-mascots.is-blinking .login-mascot-lid {
|
||||
animation: login-mascot-blink 140ms ease-out;
|
||||
}
|
||||
|
||||
.login-mascot-happy {
|
||||
fill: none;
|
||||
|
||||
stroke: var(--login-mascot-pupil);
|
||||
|
||||
stroke-width: 2.4;
|
||||
|
||||
stroke-linecap: round;
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-mascot-sad {
|
||||
fill: var(--login-mascot-pupil);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-mascot-arm {
|
||||
fill: var(--login-mascot-red);
|
||||
|
||||
transform-box: fill-box;
|
||||
|
||||
transform-origin: center;
|
||||
|
||||
transition: transform 250ms cubic-bezier(0.2, 0.9, 0.25, 1.2);
|
||||
}
|
||||
|
||||
.login-mascot.is-green .login-mascot-arm {
|
||||
fill: var(--login-mascot-green);
|
||||
}
|
||||
|
||||
.login-mascot-hand {
|
||||
fill: var(--login-mascot-red-shade);
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transform-box: fill-box;
|
||||
|
||||
transform-origin: center;
|
||||
|
||||
transition: transform 250ms cubic-bezier(0.2, 0.9, 0.25, 1.2), opacity 180ms ease-out;
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-hand {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-hand.is-left {
|
||||
transform: translate(20px, -34px) scale(1.2);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-hand.is-right {
|
||||
transform: translate(-20px, -34px) scale(1.2);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-arm.is-left {
|
||||
transform: translate(10px, -18px) rotate(-18deg);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-arm.is-right {
|
||||
transform: translate(-10px, -18px) rotate(18deg);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-pupil,
|
||||
.login-mascots[data-mood="password"] .login-mascot.is-red .login-mascot-eye {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-mascot-slit {
|
||||
fill: var(--login-mascot-green-shade);
|
||||
}
|
||||
|
||||
.login-mascot-shadow {
|
||||
fill: rgba(6, 10, 24, 0.28);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="success"] .login-mascot-pupil,
|
||||
.login-mascots[data-mood="success"] .login-mascot-lid,
|
||||
.login-mascots[data-mood="fail"] .login-mascot-pupil {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="success"] .login-mascot-happy,
|
||||
.login-mascots[data-mood="fail"] .login-mascot-sad {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="success"] .login-mascot.is-red .login-mascot-arm.is-left {
|
||||
transform: translate(-2px, -36px) rotate(-48deg);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="success"] .login-mascot.is-red .login-mascot-arm.is-right {
|
||||
transform: translate(2px, -36px) rotate(48deg);
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="success"] .login-mascot.is-red {
|
||||
animation: login-mascot-hop-red 450ms ease-in-out;
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="success"] .login-mascot.is-green {
|
||||
animation: login-mascot-hop-green 450ms ease-in-out 80ms;
|
||||
}
|
||||
|
||||
.login-mascots[data-mood="fail"] .login-mascot-arm {
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-mascots[data-mood="idle"] .login-mascot-breathe,
|
||||
.login-mascots.is-blinking .login-mascot-lid,
|
||||
.login-mascots[data-mood="success"] .login-mascot.is-red,
|
||||
.login-mascots[data-mood="success"] .login-mascot.is-green {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.login-mascot.is-green .login-mascot-figure,
|
||||
.login-mascot-hand,
|
||||
.login-mascot-arm {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-password-wrap {
|
||||
position: relative;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-portal .login-password-wrap input {
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.login-password-toggle {
|
||||
position: absolute;
|
||||
|
||||
top: 50%;
|
||||
|
||||
right: 8px;
|
||||
|
||||
z-index: 1;
|
||||
|
||||
min-height: 24px;
|
||||
|
||||
padding: 0;
|
||||
|
||||
border: 0;
|
||||
|
||||
background: transparent;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
|
||||
transform: translateY(-50%);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-brand-copy {
|
||||
display: flex;
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
justify-content: flex-end;
|
||||
|
||||
padding: 24px 0 20px;
|
||||
padding: 0 0 20px;
|
||||
}
|
||||
|
||||
.login-brand-kicker {
|
||||
@@ -2119,6 +2454,50 @@ body.login-portal {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-mascot-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1.015);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-mascot-blink {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-mascot-hop-red {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
42% {
|
||||
transform: translateY(-13px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-mascot-hop-green {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
42% {
|
||||
transform: translateY(-11px);
|
||||
}
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin: 0;
|
||||
|
||||
@@ -2466,7 +2845,8 @@ body.login-portal {
|
||||
|
||||
.login-brand-copy,
|
||||
.login-brand-market,
|
||||
.login-brand-disclaimer {
|
||||
.login-brand-disclaimer,
|
||||
.login-mascots {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,13 @@
|
||||
--login-candle-up: #e07078;
|
||||
--login-candle-down: #3db88a;
|
||||
--login-trend-line: rgba(244, 247, 255, 0.88);
|
||||
--login-mascot-red: #e8605a;
|
||||
--login-mascot-red-shade: #c74a45;
|
||||
--login-mascot-green: #46be93;
|
||||
--login-mascot-green-shade: #37997a;
|
||||
--login-mascot-eye: #f7f9fc;
|
||||
--login-mascot-pupil: #1a2440;
|
||||
--login-mascot-height: clamp(150px, 15vw, 260px);
|
||||
|
||||
--font-size-aux: 11.5px;
|
||||
--font-size-caption: 12.5px;
|
||||
|
||||
@@ -229,12 +229,26 @@ async function assertConfirmedSkeleton(page, { width, height }) {
|
||||
expect(title.y).toBeLessThan(height * 0.72);
|
||||
expect(stats.y).toBeGreaterThan(height * 0.55);
|
||||
expect(chart.height).toBeGreaterThan(80);
|
||||
await expect(page.locator("#loginMascots")).toBeVisible();
|
||||
expect(card.width).toBeGreaterThan(380);
|
||||
expect(card.width).toBeLessThan(450);
|
||||
if (width === 1440) {
|
||||
expect(brand.width).toBeGreaterThan(470);
|
||||
expect(brand.width).toBeLessThan(520);
|
||||
expect(brand.height).toBe(height);
|
||||
const mascots = await page.locator("#loginMascots").boundingBox();
|
||||
const kicker = await page.locator(".login-brand-kicker").boundingBox();
|
||||
expect(mascots).toBeTruthy();
|
||||
expect(kicker).toBeTruthy();
|
||||
expect(mascots.y).toBeGreaterThan(header.y + header.height - 4);
|
||||
expect(mascots.y + mascots.height).toBeLessThan(kicker.y + 8);
|
||||
const gapTop = header.y + header.height;
|
||||
const gapBottom = kicker.y;
|
||||
const mid = (gapTop + gapBottom) / 2;
|
||||
const mascotMid = mascots.y + mascots.height / 2;
|
||||
expect(Math.abs(mascotMid - mid)).toBeLessThan(48);
|
||||
expect(title.y).toBeGreaterThan(470);
|
||||
expect(title.y).toBeLessThan(580);
|
||||
} else if (width === 1920) {
|
||||
expect(brand.width).toBeGreaterThan(540);
|
||||
expect(brand.width).toBeLessThan(580);
|
||||
@@ -444,3 +458,99 @@ test("workspace switch-account from a second page also returns to that page", as
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=ladderView/);
|
||||
});
|
||||
|
||||
test("desktop mascots react to account focus, password, toggle, loading, success and failure", async ({ page }) => {
|
||||
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
||||
const mascots = page.locator("#loginMascots");
|
||||
await expect(mascots).toHaveAttribute("data-mood", "idle");
|
||||
await page.locator("#loginUsername").focus();
|
||||
await expect(mascots).toHaveAttribute("data-mood", "account");
|
||||
await page.locator("#loginPassword").focus();
|
||||
await expect(mascots).toHaveAttribute("data-mood", "password");
|
||||
await expect.poll(async () => (
|
||||
page.locator(".login-mascot.is-red .login-mascot-hand.is-left").evaluate((node) => getComputedStyle(node).opacity)
|
||||
)).toBe("1");
|
||||
await page.locator(".login-password-toggle").click();
|
||||
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "text");
|
||||
await expect(mascots).toHaveAttribute("data-mood", "password");
|
||||
await page.locator(".login-password-toggle").click();
|
||||
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "password");
|
||||
await expect(mascots).toHaveAttribute("data-mood", "password");
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-password-light-1440.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("login loading and failure drive short mascot feedback", async ({ page }) => {
|
||||
await openPortal(page, {
|
||||
theme: "dark",
|
||||
width: 1440,
|
||||
height: 900,
|
||||
accounts: [],
|
||||
loginFails: true,
|
||||
loginDelay: 800,
|
||||
});
|
||||
await page.locator("#loginUsername").fill("baiqizhi");
|
||||
await page.locator("#loginPassword").fill("wrong-password");
|
||||
const submit = page.locator(".login-submit").click();
|
||||
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "busy");
|
||||
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
|
||||
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "fail");
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-fail-dark-1440.png"), fullPage: true });
|
||||
await submit;
|
||||
});
|
||||
|
||||
test("login success plays a hop before leaving the portal", async ({ page }) => {
|
||||
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
||||
await page.evaluate(() => {
|
||||
window.location.replace = () => {};
|
||||
});
|
||||
await page.locator("#loginUsername").fill("baiqizhi");
|
||||
await page.locator("#loginPassword").fill("password12");
|
||||
const submit = page.locator(".login-submit").click();
|
||||
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", /busy|success/);
|
||||
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "success", { timeout: 4000 });
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-success-light-1440.png"), fullPage: true });
|
||||
await submit;
|
||||
});
|
||||
|
||||
test("reduced motion keeps static mascots without mouse tracking", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openPortal(page, { theme: "dark", width: 1440, height: 900, accounts: [] });
|
||||
const mascots = page.locator("#loginMascots");
|
||||
await expect(mascots).toBeVisible();
|
||||
await expect(mascots).toHaveAttribute("data-mood", "idle");
|
||||
const before = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
||||
await page.mouse.move(1200, 120);
|
||||
await page.waitForTimeout(120);
|
||||
const after = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
||||
expect(after).toBe(before);
|
||||
await page.locator("#loginPassword").focus();
|
||||
await expect(mascots).toHaveAttribute("data-mood", "password");
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-reduced-dark-1440.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("mouse follow updates mascot pupils on a fine pointer", async ({ page }) => {
|
||||
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
||||
const mascots = page.locator("#loginMascots");
|
||||
await page.mouse.move(80, 160);
|
||||
await page.waitForTimeout(180);
|
||||
const left = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
||||
await page.mouse.move(1280, 200);
|
||||
await page.waitForTimeout(180);
|
||||
const right = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
||||
expect(Number.parseFloat(right)).toBeGreaterThan(Number.parseFloat(left));
|
||||
});
|
||||
|
||||
test("narrow screens hide mascots without moving the login card", async ({ page }) => {
|
||||
await openPortal(page, { theme: "light", width: 800, height: 900, accounts: [] });
|
||||
await expect(page.locator("#loginMascots")).toBeHidden();
|
||||
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
|
||||
await expect(page.locator(".login-brand-title")).toBeHidden();
|
||||
});
|
||||
|
||||
test("theme toggle keeps mascots in the brand gap", async ({ page }) => {
|
||||
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
||||
await page.locator("#loginThemeToggle").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-idle-dark-1440.png"), fullPage: true });
|
||||
});
|
||||
|
||||
@@ -44,6 +44,29 @@ class LoginPortalContractTests(unittest.TestCase):
|
||||
self.assertIn("padding-bottom: 0;", AUTH)
|
||||
self.assertNotIn("padding: 0 0 var(--statusbar-height);", AUTH)
|
||||
|
||||
def test_confirmed_mascots_fill_the_brand_gap(self) -> None:
|
||||
for needle in (
|
||||
'id="loginMascots"',
|
||||
'aria-hidden="true"',
|
||||
'class="login-mascot is-red"',
|
||||
'class="login-mascot is-green"',
|
||||
'class="login-mascot-back"',
|
||||
'class="login-mascot-slit"',
|
||||
):
|
||||
self.assertIn(needle, LOGIN)
|
||||
for needle in (
|
||||
"--login-mascot-red: #e8605a;",
|
||||
"--login-mascot-green: #46be93;",
|
||||
"--login-mascot-height: clamp(150px, 15vw, 260px);",
|
||||
):
|
||||
self.assertIn(needle, TOKENS)
|
||||
self.assertIn("flex: 1 1 auto;", AUTH)
|
||||
self.assertIn(".login-mascots {", AUTH)
|
||||
self.assertIn("prefers-reduced-motion: reduce", AUTH)
|
||||
self.assertIn('next === "password"', LOGIN_JS)
|
||||
self.assertIn("toggle-password", LOGIN_JS)
|
||||
self.assertIn("celebrateLogin", LOGIN_JS)
|
||||
|
||||
def test_portal_keeps_account_switch_and_theme_hooks(self) -> None:
|
||||
self.assertIn("data-switch-id", LOGIN_JS)
|
||||
self.assertIn("data-resume-id", LOGIN_JS)
|
||||
|
||||
+11
-5
@@ -20,12 +20,18 @@ registry, and verification tools.
|
||||
- `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`:
|
||||
auditable recent trading-day dashboard snapshot backfill. See
|
||||
`docs/maintenance/行情历史补档.md`.
|
||||
- `bash tools/build_image.sh <commit> <tag>`: the only sanctioned way to build the
|
||||
production Docker image. Streams `git archive <commit>` to the deploy host over SSH
|
||||
(default `moxiaobai@192.168.200.11`), refuses tags that do not end with the commit
|
||||
- `tools/update_from_main.sh` (deployed to the server as
|
||||
`~/xiaobai-build/update-from-main.sh`): the server-side update-and-build entry for
|
||||
the managed local worktree at `/opt/1panel/docker/compose/xiaobaifupan`. Fetches
|
||||
Gitea `main`, enforces branch/clean/fast-forward checks, builds a
|
||||
`main-<shortsha>` tagged image with the revision label, and verifies the label
|
||||
after the build. `tools/xiaobai-git` is the matching git wrapper for that
|
||||
worktree (`status`/`log`/`diff`).
|
||||
- `bash tools/build_image.sh <commit> <tag>`: agent-grade entry that streams
|
||||
`git archive <commit>` to the deploy host over SSH (default
|
||||
`moxiaobai@192.168.200.11`), refuses tags that do not end with the commit
|
||||
short SHA, verifies the revision label after the build, and appends a record to
|
||||
`~/xiaobai-build/BUILD_LOG.tsv` on the host. Building from any server-side working
|
||||
tree is forbidden; see `DOCKER_DEPLOY.md`. Before building, it runs
|
||||
`~/xiaobai-build/BUILD_LOG.tsv` on the host. Before building, it runs
|
||||
`tools/check_deploy_baseline.sh` so the candidate commit must contain the currently
|
||||
running container's Git revision as an ancestor.
|
||||
- `bash tools/check_deploy_baseline.sh <commit> [--live-revision <sha>]`: deployment
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# 小白复盘服务器本地目录安全更新/构建入口(HEL-235B 固化)
|
||||
# 作用:把 /opt/1panel/docker/compose/xiaobaifupan 的 Git 工作目录安全快进到 Gitea main,
|
||||
# 校验“本地 HEAD = origin/main = 镜像 revision”后,从本地目录构建带提交号的镜像。
|
||||
# 禁止:不从 main 构建;不使用不带提交短号的 tag;本地有改动/落后/分叉时一律停止。
|
||||
# 说明:目录顶层归 root,本脚本用“截断写入”绕开 git 对顶层文件 unlink+重建的权限要求;
|
||||
# 但 main 新增/删除顶层文件时无法自动处理,会列出需管理员执行的精确清单。
|
||||
set -euo pipefail
|
||||
|
||||
GIT_DIR_PATH="$HOME/xiaobai-build/repos/xiaobai-review.git"
|
||||
WORK_TREE="/opt/1panel/docker/compose/xiaobaifupan"
|
||||
IMAGE_REPO="xiaobai-review"
|
||||
LOG_FILE="$HOME/xiaobai-build/BUILD_LOG.tsv"
|
||||
MODE="${1:-build}"
|
||||
|
||||
g() { git --git-dir="$GIT_DIR_PATH" --work-tree="$WORK_TREE" "$@"; }
|
||||
|
||||
refuse() { printf '拒绝:%s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$MODE" = "build" ] || [ "$MODE" = "verify-tag" ] || refuse "未知子命令「${MODE}」(可用:build / verify-tag <tag>)"
|
||||
[ -d "$GIT_DIR_PATH" ] || refuse "Git 目录不存在:$GIT_DIR_PATH"
|
||||
|
||||
echo "==> 拉取 Gitea origin/main"
|
||||
g fetch --quiet origin main || refuse "无法连接 Gitea 拉取 origin/main"
|
||||
|
||||
echo "==> 检查分支与工作区"
|
||||
BRANCH="$(g symbolic-ref --short HEAD 2>/dev/null || true)"
|
||||
[ "$BRANCH" = "main" ] || refuse "当前不在 main 分支(${BRANCH:-detached}),停止"
|
||||
DIRTY="$(g status --porcelain)"
|
||||
[ -z "$DIRTY" ] || refuse "本地目录有未提交改动或多余文件,先处理再构建:
|
||||
$DIRTY"
|
||||
|
||||
LOCAL_HEAD="$(g rev-parse HEAD)"
|
||||
REMOTE_HEAD="$(g rev-parse origin/main)"
|
||||
if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
|
||||
TOP_AD="$(g diff --name-status HEAD origin/main | grep -E "^[AD][[:space:]]+[^/]+$" || true)"
|
||||
[ -z "$TOP_AD" ] || refuse "main 相比本地新增/删除了顶层文件,目录顶层归 root,需管理员执行:
|
||||
$TOP_AD"
|
||||
TOP_MOD="$(g diff --name-status HEAD origin/main | grep -E "^M[[:space:]]+[^/]+$" | awk '{print $2}' || true)"
|
||||
if [ -n "$TOP_MOD" ]; then
|
||||
echo "==> 预写入顶层改动文件(顶层目录无删除权限,改为截断写入)"
|
||||
while IFS= read -r f; do g show "origin/main:$f" > "$WORK_TREE/$f"; done <<< "$TOP_MOD"
|
||||
fi
|
||||
echo "==> 快进合并到 origin/main"
|
||||
g merge --ff-only origin/main >/dev/null 2>&1 || refuse "无法快进合并 origin/main(历史分叉),停止"
|
||||
DIRTY="$(g status --porcelain)"
|
||||
[ -z "$DIRTY" ] || refuse "快进后工作区仍不一致,停止:
|
||||
$DIRTY"
|
||||
LOCAL_HEAD="$(g rev-parse HEAD)"
|
||||
fi
|
||||
[ "$LOCAL_HEAD" = "$REMOTE_HEAD" ] || refuse "本地 HEAD 与 origin/main 不一致,停止"
|
||||
SHORT="${LOCAL_HEAD:0:7}"
|
||||
echo "==> 校验通过:本地 HEAD = origin/main = ${LOCAL_HEAD}(${SHORT})"
|
||||
|
||||
if [ "$MODE" = "verify-tag" ]; then
|
||||
TAG="${2:?用法: update-from-main.sh verify-tag <tag>}"
|
||||
LABEL="$(docker image inspect "${IMAGE_REPO}:${TAG}" \
|
||||
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null)" \
|
||||
|| refuse "镜像 ${IMAGE_REPO}:${TAG} 不存在"
|
||||
[ "$LABEL" = "$LOCAL_HEAD" ] || refuse "镜像 revision(${LABEL})与当前 main(${LOCAL_HEAD})不一致,禁止部署"
|
||||
echo "==> 通过:${IMAGE_REPO}:${TAG} 的 revision 与 main 一致,可以部署"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TAG="main-${SHORT}"
|
||||
echo "==> 从本地目录构建 ${IMAGE_REPO}:${TAG}"
|
||||
docker build --rm -t "${IMAGE_REPO}:${TAG}" \
|
||||
--label "org.opencontainers.image.revision=${LOCAL_HEAD}" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
"$WORK_TREE" 2>&1 | tail -5
|
||||
|
||||
echo "==> 回读校验镜像 revision"
|
||||
GOT="$(docker image inspect "${IMAGE_REPO}:${TAG}" \
|
||||
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
|
||||
if [ "$GOT" != "$LOCAL_HEAD" ]; then
|
||||
docker rmi "${IMAGE_REPO}:${TAG}" >/dev/null 2>&1 || true
|
||||
refuse "镜像 revision(${GOT})与 main(${LOCAL_HEAD})不一致,已删除镜像"
|
||||
fi
|
||||
IMAGE_ID="$(docker image inspect "${IMAGE_REPO}:${TAG}" --format '{{.Id}}' | cut -c8-19)"
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
printf '%s\t%s\t%s\t%s\tlocal-worktree\n' \
|
||||
"$(date '+%F %T')" "${IMAGE_REPO}:${TAG}" "${LOCAL_HEAD}" "${IMAGE_ID}" >> "$LOG_FILE"
|
||||
|
||||
cat <<EOF
|
||||
==> 完成:${IMAGE_REPO}:${TAG}(revision=${LOCAL_HEAD})
|
||||
部署需人工确认,参考 ~/xiaobai-build/README.md 的换版与回滚步骤。
|
||||
EOF
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# 查看服务器本地目录 Git 状态的便捷入口:xiaobai-git status / log / diff 等
|
||||
exec git \
|
||||
--git-dir="$HOME/xiaobai-build/repos/xiaobai-review.git" \
|
||||
--work-tree="/opt/1panel/docker/compose/xiaobaifupan" \
|
||||
"$@"
|
||||
Reference in New Issue
Block a user