Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b688fd473 | ||
|
|
1e8da5fee2 | ||
|
|
51f410d942 | ||
|
|
a8732f51be | ||
|
|
4a63ccd10f | ||
|
|
865d8b0516 | ||
|
|
d2a165ada8 | ||
|
|
e29d5115fa |
+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-build/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"
|
||||
|
||||
@@ -553,16 +553,16 @@
|
||||
"bytes": 13681,
|
||||
"lines": 338
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/session.js",
|
||||
"bytes": 13219,
|
||||
"lines": 289
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/dashboard.js",
|
||||
"bytes": 12894,
|
||||
"lines": 274
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/session.js",
|
||||
"bytes": 12848,
|
||||
"lines": 283
|
||||
},
|
||||
{
|
||||
"path": "backend/features/market/insights_auction_data.py",
|
||||
"bytes": 12829,
|
||||
@@ -673,16 +673,16 @@
|
||||
"bytes": 5451,
|
||||
"lines": 118
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages.config.js",
|
||||
"bytes": 5385,
|
||||
"lines": 130
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/market/search.js",
|
||||
"bytes": 5384,
|
||||
"lines": 131
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages.config.js",
|
||||
"bytes": 5380,
|
||||
"lines": 130
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/auction/page.html",
|
||||
"bytes": 5350,
|
||||
|
||||
@@ -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-hel240b">
|
||||
<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-hel240"></script>
|
||||
<script src="/login/page.js?v=20260829-hel251"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+296
-6
@@ -15,6 +15,7 @@
|
||||
error: "",
|
||||
username: "",
|
||||
password: "",
|
||||
passwordVisible: false,
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -77,9 +78,22 @@
|
||||
return chips.join("");
|
||||
}
|
||||
|
||||
function returnPath() {
|
||||
const raw = new URLSearchParams(global.location.search).get("next") || "";
|
||||
if (!raw) return "/";
|
||||
try {
|
||||
const url = new URL(raw, global.location.origin);
|
||||
if (url.origin !== global.location.origin) return "/";
|
||||
const path = url.pathname || "/";
|
||||
if (path === "/login" || path.startsWith("/login/")) return "/";
|
||||
return `${path}${url.search}${url.hash}` || "/";
|
||||
} catch (_error) {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
function enterApp() {
|
||||
const next = new URLSearchParams(global.location.search).get("next");
|
||||
global.location.replace(next && next.startsWith("/") ? next : "/");
|
||||
global.location.replace(returnPath());
|
||||
}
|
||||
|
||||
function formMarkup(options) {
|
||||
@@ -89,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>'
|
||||
@@ -101,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" : ""}>`,
|
||||
@@ -120,7 +136,7 @@
|
||||
"login-account-row",
|
||||
current ? "is-current" : "",
|
||||
confirming ? "is-confirming" : "",
|
||||
!managing && !current ? "is-switchable" : "",
|
||||
!managing ? "is-switchable" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
if (managing && confirming) {
|
||||
return [
|
||||
@@ -138,11 +154,12 @@
|
||||
const action = managing
|
||||
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}" aria-label="移除 ${escapeHtml(account.username)}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M6 2h4l.5 1H14v1H2V3h3.5L6 2zm1 4v6H6V6h1zm3 0v6H9V6h1zM3.5 5H13l-.7 8.2A1.5 1.5 0 0 1 10.81 14H5.19a1.5 1.5 0 0 1-1.49-1.8L3.5 5z"></path></svg></button>`
|
||||
: current
|
||||
? '<span class="login-account-check" aria-hidden="true">✓</span>'
|
||||
? '<span class="login-account-action"><span class="login-account-check" aria-hidden="true">✓</span>继续使用</span>'
|
||||
: "";
|
||||
const switchAttr = !managing && !current ? ` data-switch-id="${account.user_id}"` : "";
|
||||
const resumeAttr = !managing && current ? ` data-resume-id="${account.user_id}"` : "";
|
||||
return [
|
||||
`<div class="${classes}" data-user-id="${account.user_id}"${switchAttr}>`,
|
||||
`<div class="${classes}" data-user-id="${account.user_id}"${switchAttr}${resumeAttr}>`,
|
||||
`<span class="login-avatar tone-${tone}" aria-hidden="true">${glyph}</span>`,
|
||||
'<div class="login-account-meta">',
|
||||
'<div class="login-account-name">',
|
||||
@@ -160,6 +177,9 @@
|
||||
const count = state.accounts.length;
|
||||
const managing = state.view === "manage";
|
||||
return [
|
||||
managing
|
||||
? ""
|
||||
: '<button class="login-back" type="button" data-login-action="resume">返回复盘</button>',
|
||||
`<h2 class="login-card-title">${managing ? "管理账号记录" : "选择账号"}</h2>`,
|
||||
`<p class="login-card-lead">${managing
|
||||
? "移除只删除这台电脑上的登录记录,不会注销账号"
|
||||
@@ -197,6 +217,7 @@
|
||||
card.innerHTML = pickerMarkup();
|
||||
}
|
||||
bindCard();
|
||||
if (mascots) mascots.sync();
|
||||
}
|
||||
|
||||
function bindCard() {
|
||||
@@ -210,6 +231,14 @@
|
||||
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;
|
||||
}
|
||||
if (action === "picker") {
|
||||
state.view = state.accounts.length ? "picker" : "first";
|
||||
state.confirmingId = null;
|
||||
@@ -228,6 +257,9 @@
|
||||
card.querySelectorAll("[data-switch-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId)));
|
||||
});
|
||||
card.querySelectorAll("[data-resume-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => resumeCurrentAccount());
|
||||
});
|
||||
card.querySelectorAll("[data-confirm-id]").forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -269,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +325,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeCurrentAccount() {
|
||||
state.loading = true;
|
||||
setError("");
|
||||
render();
|
||||
try {
|
||||
const session = await api.request("/api/auth/me");
|
||||
const sessionUserId = session.user?.id;
|
||||
const matches = Boolean(session.authenticated) && (
|
||||
!state.currentUserId || Number(sessionUserId) === Number(state.currentUserId)
|
||||
);
|
||||
if (!matches) {
|
||||
throw new Error("当前会话已失效,请重新登录");
|
||||
}
|
||||
enterApp();
|
||||
} catch (error) {
|
||||
state.loading = false;
|
||||
setError(error.message || "当前会话已失效,请重新登录");
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetAccount(userId) {
|
||||
try {
|
||||
await api.request("/api/auth/forget", "POST", { user_id: userId });
|
||||
@@ -305,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);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"/shared/table.js?v=20260803-1",
|
||||
"/shared/theme.js?v=20260803-1",
|
||||
"/shared/dashboard.js?v=20260820-1",
|
||||
"/shared/session.js?v=20260803-1",
|
||||
"/shared/session.js?v=20260829-hel243",
|
||||
"/shared/admin.js?v=20260803-1",
|
||||
"/app.js?v=20260803-2",
|
||||
];
|
||||
|
||||
+395
-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;
|
||||
|
||||
@@ -2389,6 +2768,18 @@ body.login-portal {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-account-action {
|
||||
display: grid;
|
||||
|
||||
justify-items: end;
|
||||
|
||||
gap: 2px;
|
||||
|
||||
color: var(--action);
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.login-account-remove {
|
||||
display: grid;
|
||||
|
||||
@@ -2454,7 +2845,8 @@ body.login-portal {
|
||||
|
||||
.login-brand-copy,
|
||||
.login-brand-market,
|
||||
.login-brand-disclaimer {
|
||||
.login-brand-disclaimer,
|
||||
.login-mascots {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,13 @@ async function changeAccountPassword(event) {
|
||||
|
||||
async function switchAccount() {
|
||||
toggleAccountDropdown(false);
|
||||
window.location.assign("/login/");
|
||||
const params = new URLSearchParams();
|
||||
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (next.startsWith("/") && !next.startsWith("//") && next !== "/login" && !next.startsWith("/login/") && !next.startsWith("/login?")) {
|
||||
params.set("next", next);
|
||||
}
|
||||
const query = params.toString();
|
||||
window.location.assign("/login/" + (query ? `?${query}` : ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -81,14 +81,58 @@ async function mockLoginPortal(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/auth/me") {
|
||||
const current = accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null;
|
||||
const authenticated = Boolean(current) && !options.sessionExpired;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
authenticated: Boolean(currentUserId),
|
||||
authenticated,
|
||||
csrf_token: "portal-csrf",
|
||||
user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null,
|
||||
user: authenticated ? {
|
||||
id: current.user_id,
|
||||
username: current.username,
|
||||
role: current.role,
|
||||
membership: current.membership,
|
||||
} : null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dashboard") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
meta: {
|
||||
trade_date: "2026-07-22",
|
||||
requested_date: "2026-07-22",
|
||||
source: "tushare",
|
||||
realtime: false,
|
||||
cached: true,
|
||||
market_status: "closed",
|
||||
updated_at: "2026-07-22T15:00:00+08:00",
|
||||
},
|
||||
overview: {
|
||||
up_count: 2100,
|
||||
down_count: 2800,
|
||||
limit_up_count: 42,
|
||||
limit_down_count: 8,
|
||||
broken_count: 17,
|
||||
seal_rate: 71.2,
|
||||
amount_billion: 12600,
|
||||
sentiment_score: 48,
|
||||
},
|
||||
limits: [],
|
||||
broken: [],
|
||||
down_limits: [],
|
||||
yesterday_limits: [],
|
||||
limit_performance: [],
|
||||
ladders: [],
|
||||
sectors: [],
|
||||
sector_rotation: [],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@@ -185,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);
|
||||
@@ -289,3 +347,210 @@ test("loading button appears on the confirmed first-login skeleton", async ({ pa
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, "loading-dark-1440.png"), fullPage: true });
|
||||
await submit;
|
||||
});
|
||||
|
||||
async function openPicker(page, options = {}) {
|
||||
const accounts = options.accounts || SAVED_ACCOUNTS.map((item) => ({ ...item }));
|
||||
const currentUserId = options.currentUserId ?? 1;
|
||||
const next = options.next || "/index.html?view=sentimentCycleView";
|
||||
await page.unroute("**/api/**").catch(() => {});
|
||||
await mockLoginPortal(page, {
|
||||
accounts,
|
||||
currentUserId,
|
||||
sessionExpired: options.sessionExpired,
|
||||
switchFails: options.switchFails,
|
||||
});
|
||||
await page.goto(`/login/?next=${encodeURIComponent(next)}`);
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
}
|
||||
|
||||
test("clicking the current account from two workspace pages returns without switching", async ({ page }) => {
|
||||
const views = ["sentimentCycleView", "ladderView"];
|
||||
for (const viewId of views) {
|
||||
const next = `/index.html?view=${viewId}`;
|
||||
const switchCalls = [];
|
||||
const onRequest = (request) => {
|
||||
if (request.url().includes("/api/auth/switch") && request.method() === "POST") {
|
||||
switchCalls.push(request);
|
||||
}
|
||||
};
|
||||
page.on("request", onRequest);
|
||||
await openPicker(page, { next });
|
||||
await expect(page.locator('[data-resume-id="1"]')).toContainText("继续使用");
|
||||
await expect(page.locator('[data-resume-id="1"]')).toContainText("当前");
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]view=${viewId}\\b`));
|
||||
expect(switchCalls).toEqual([]);
|
||||
page.off("request", onRequest);
|
||||
}
|
||||
});
|
||||
|
||||
test("a lone current account can return from the picker instead of dead-ending", async ({ page }) => {
|
||||
await openPicker(page, {
|
||||
accounts: [SAVED_ACCOUNTS[0]],
|
||||
currentUserId: 1,
|
||||
next: "/index.html?view=reviewWorkspaceView",
|
||||
});
|
||||
await expect(page.locator(".login-account-row")).toHaveCount(1);
|
||||
await expect(page.locator('[data-switch-id]')).toHaveCount(0);
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=reviewWorkspaceView/);
|
||||
});
|
||||
|
||||
test("the return control also restores the originating workspace page", async ({ page }) => {
|
||||
await openPicker(page, { next: "/index.html?view=ladderView" });
|
||||
await page.locator('[data-login-action="resume"]').click();
|
||||
await expect(page).toHaveURL(/view=ladderView/);
|
||||
});
|
||||
|
||||
test("refreshing the picker still returns to the originating page", async ({ page }) => {
|
||||
await openPicker(page, { next: "/index.html?view=sentimentCycleView" });
|
||||
await page.reload();
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=sentimentCycleView/);
|
||||
});
|
||||
|
||||
test("an expired current session asks for login instead of pretending to return", async ({ page }) => {
|
||||
await openPicker(page, {
|
||||
next: "/index.html?view=sentimentCycleView",
|
||||
sessionExpired: true,
|
||||
});
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page.locator(".login-error")).toHaveText("当前会话已失效,请重新登录");
|
||||
await expect(page).toHaveURL(/\/login\/?/);
|
||||
});
|
||||
|
||||
test("other saved accounts still switch while the current row only resumes", async ({ page }) => {
|
||||
await openPicker(page, { next: "/index.html?view=auctionView" });
|
||||
const switched = page.waitForRequest((request) => (
|
||||
request.url().includes("/api/auth/switch") && request.method() === "POST"
|
||||
));
|
||||
await page.locator('[data-switch-id="2"]').click();
|
||||
const request = await switched;
|
||||
expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 });
|
||||
});
|
||||
|
||||
test("workspace switch-account menu carries the current page back to the picker", async ({ page }) => {
|
||||
await mockLoginPortal(page, {
|
||||
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
|
||||
currentUserId: 1,
|
||||
});
|
||||
await page.goto("/index.html?view=sentimentCycleView");
|
||||
await expect(page.locator("#accountButton")).toBeVisible();
|
||||
await page.locator("#accountButton").click();
|
||||
await page.locator("#switchAccountMenuButton").click();
|
||||
await expect(page).toHaveURL(/\/login\/\?next=/);
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
await page.locator('[data-resume-id="1"]').click();
|
||||
await expect(page).toHaveURL(/view=sentimentCycleView/);
|
||||
});
|
||||
|
||||
test("workspace switch-account from a second page also returns to that page", async ({ page }) => {
|
||||
await mockLoginPortal(page, {
|
||||
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
|
||||
currentUserId: 1,
|
||||
});
|
||||
await page.goto("/index.html?view=ladderView");
|
||||
await expect(page.locator("#accountButton")).toBeVisible();
|
||||
await page.locator("#accountButton").click();
|
||||
await page.locator("#switchAccountMenuButton").click();
|
||||
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
|
||||
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,11 +44,47 @@ 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)
|
||||
self.assertIn('data-login-action="manage"', LOGIN_JS)
|
||||
self.assertIn('data-login-action="add"', LOGIN_JS)
|
||||
self.assertIn('data-login-action="resume"', LOGIN_JS)
|
||||
self.assertIn("继续使用", LOGIN_JS)
|
||||
self.assertIn("返回复盘", LOGIN_JS)
|
||||
self.assertIn("/api/auth/me", LOGIN_JS)
|
||||
self.assertIn("xiaobaiTheme", LOGIN_JS)
|
||||
self.assertNotIn("内网个人版", LOGIN)
|
||||
self.assertNotIn("192.168.200.11", LOGIN)
|
||||
self.assertNotIn("/api/heaven", LOGIN_JS)
|
||||
|
||||
def test_current_account_row_stays_clickable_without_reswitching(self) -> None:
|
||||
self.assertIn("resumeCurrentAccount", LOGIN_JS)
|
||||
self.assertIn("当前会话已失效,请重新登录", LOGIN_JS)
|
||||
self.assertNotIn("!managing && !current ? \"is-switchable\"", LOGIN_JS)
|
||||
session = (ROOT / "frontend" / "shared" / "session.js").read_text(encoding="utf-8")
|
||||
self.assertIn('params.set("next", next)', session)
|
||||
self.assertIn(".login-account-action", AUTH)
|
||||
|
||||
+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
+110
@@ -0,0 +1,110 @@
|
||||
#!/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
|
||||
g merge-base --is-ancestor "$LOCAL_HEAD" "$REMOTE_HEAD" \
|
||||
|| refuse "本地 main 与 origin/main 历史分叉,停止(未改写工作目录)"
|
||||
CHANGES="$(g diff --no-renames --name-status HEAD origin/main)"
|
||||
TOP_AD="$(printf '%s\n' "$CHANGES" | grep -E "^[AD][[:space:]]+[^/]+$" || true)"
|
||||
[ -z "$TOP_AD" ] || refuse "main 相比本地新增/删除了顶层文件,目录顶层归 root,需管理员执行:
|
||||
$TOP_AD"
|
||||
echo "==> 同步 origin/main 文件(顶层文件保留原 inode,避免目录权限限制)"
|
||||
while IFS=$'\t' read -r status path; do
|
||||
[ -n "$path" ] || continue
|
||||
case "$status" in
|
||||
D)
|
||||
case "$path" in
|
||||
*/*) rm -f -- "$WORK_TREE/$path" ;;
|
||||
*) refuse "main 删除了顶层文件 $path,需管理员处理" ;;
|
||||
esac
|
||||
;;
|
||||
A|M)
|
||||
MODE_BITS="$(g ls-tree origin/main -- "$path" | awk '{print $1}')"
|
||||
case "$MODE_BITS" in
|
||||
100644|100755) ;;
|
||||
*) refuse "文件 ${path} 的 Git 类型 ${MODE_BITS} 不支持自动同步,需管理员处理" ;;
|
||||
esac
|
||||
mkdir -p -- "$(dirname "$WORK_TREE/$path")"
|
||||
g show "origin/main:$path" > "$WORK_TREE/$path"
|
||||
[ "$MODE_BITS" = "100755" ] && chmod 755 "$WORK_TREE/$path" || chmod 644 "$WORK_TREE/$path"
|
||||
;;
|
||||
*) refuse "遇到未支持的 Git 变更类型 ${status}:${path}" ;;
|
||||
esac
|
||||
done <<< "$CHANGES"
|
||||
g read-tree origin/main
|
||||
g update-ref refs/heads/main "$REMOTE_HEAD" "$LOCAL_HEAD"
|
||||
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>}"
|
||||
[ "$TAG" = "main-${SHORT}" ] \
|
||||
|| refuse "镜像标签必须是当前 main 对应的 main-${SHORT},收到:${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