Compare commits

..
Author SHA1 Message Date
总工andmultica-agent 51f410d942 部署(HEL-235B): 服务器本地目录纳入 Git 管理,固化 main 校验构建流程
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 23:22:59 +08:00
总工 a8732f51be 部署集成(HEL-252): 合入登录页动态小人 4a63ccd 2026-08-29 21:59:09 +08:00
4a63ccd10f feat(HEL-251): 按确认稿实现登录页动态小K线人物
在桌面登录门户品牌空白带加入红绿小K线角色,支持焦点、密码遮挡、登录反馈和减少动态效果,且不移动原有文案与行情图。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 21:46:05 +08:00
总工 865d8b0516 部署集成(HEL-244): 合入切换账号返回修复 d2a165a 2026-08-29 21:01:12 +08:00
11 changed files with 982 additions and 79 deletions
+36 -66
View File
@@ -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-29HEL-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
View File
@@ -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"
+70 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+7
View File
@@ -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;
+110
View File
@@ -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 });
});
+23
View File
@@ -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
View File
@@ -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
+87
View File
@@ -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
+6
View File
@@ -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" \
"$@"