Compare commits

..
Author SHA1 Message Date
b11ac6965f fix(HEL-217): 限制温度走势图高度,按左栏对齐右侧并保留夜间提示
撤掉图表随右栏无限拉伸的布局,恢复一屏内的成熟比例;右侧两卡跟随左栏高度,夜间 tooltip 改用主题变量。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 14:05:41 +00:00
71 changed files with 687 additions and 7214 deletions
-1
View File
@@ -9,7 +9,6 @@ __pycache__/
*.log
runtime/
data/cache/
data/backups/
data/private-mentor-skills/
data/*.db
data/*.db-shm
+3 -49
View File
@@ -14,29 +14,13 @@
v
xiaobai-review 容器 :8765
|-- /app 只读应用代码
| `-- backend/features/heaven/assets/heaven_knowledge.json
| 镜像内 seed(不受 data 挂载遮盖)
`-- /app/data 宿主机 ./data 持久化挂载
|-- review.db
|-- iching_zh.json
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
```
账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在
`data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与
密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。
问天静态知识文件:
- `data/iching_zh.json``data/heaven_knowledge.json` 纳入 Git 与镜像构建;
`.dockerignore` 不排除这两个文件(只排除 `data/*.db``data/cache/` 等运行时产物)。
- Compose 把宿主机 `./data` 整目录挂到 `/app/data`,会遮盖镜像里同路径文件。
因此宿主机 `data/` 应保留上述两个 JSON;若只缺 `heaven_knowledge.json`
服务会回退读取镜像内
`backend/features/heaven/assets/heaven_knowledge.json`,解势仍可用。
- 持久化位置:正式环境以宿主机项目目录下的 `./data/heaven_knowledge.json` 为准;
补文件后无需改代码,重启容器即可加载。
管理员私有的问师 Skill 保存在宿主机 `data/private-mentor-skills/`。该目录随 `data`
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
@@ -165,32 +149,7 @@ 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 更新程序(旧方式,生产机禁用)
### 使用 Gitea 更新程序(推荐
代码仓库为:
@@ -215,9 +174,7 @@ cd /opt/xiaobai-review
`data/private-mentor-skills/` 复制到服务器项目的同名 `data` 目录,并保持目录仅由
部署账号和容器运行用户读取。该内容不会通过 Gitea 同步。
每次更新前先创建 SQLite 一致性备份,再拉取并重建容器(注意:`docker compose up -d --build`
从服务器本地工作树构建,仅适用于来源可信的全新环境;生产机 `192.168.200.11` 禁用,
请用 `tools/build_image.sh` 构建后换容器):
每次更新前先创建 SQLite 一致性备份,再拉取并重建容器
```bash
cd /opt/xiaobai-review
@@ -232,10 +189,7 @@ curl --fail http://127.0.0.1:8765/api/health
数据库迁移会在新容器启动时自动执行。若 `git pull --ff-only` 提示本地代码有修改,
先用 `git status` 查明原因,不要用强制重置覆盖 `.env``data`
### 不使用 Git 时更新(生产机禁用)
`docker compose build` 会从服务器本地目录构建,来源提交不可追溯。生产机
`192.168.200.11` 上禁止使用本节方式,一律改用上一节的 `tools/build_image.sh`
### 不使用 Git 时更新
重新上传代码后执行:
+1 -4
View File
@@ -23,10 +23,7 @@ COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY --chown=xiaobai:xiaobai . .
RUN mkdir -p /app/data && chown -R xiaobai:xiaobai /app/data \
&& test -f /app/data/heaven_knowledge.json \
&& test -f /app/data/iching_zh.json \
&& test -f /app/backend/features/heaven/assets/heaven_knowledge.json
RUN mkdir -p /app/data && chown -R xiaobai:xiaobai /app/data
USER xiaobai
-2
View File
@@ -18,8 +18,6 @@ TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$")
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$")
SESSION_COOKIE = "xiaobai_session"
SESSION_MAX_AGE = 30 * 24 * 60 * 60
DEVICE_COOKIE = "xiaobai_device"
DEVICE_MAX_AGE = 180 * 24 * 60 * 60
def load_local_env() -> None:
@@ -41,16 +41,13 @@ class DashboardMixin:
raise TushareError(f"No daily data returned for {trade_date}")
notices: list[str] = []
limit_data_source = "official"
try:
limit_rows = self._load_limit_lists(trade_date)
previous_limit_rows = self._load_limit_type(previous_trade_date, "U")
if not limit_rows:
limit_data_source = "derived"
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
limit_rows = self._derive_limits(trade_date, daily)
except TushareError as exc:
limit_data_source = "derived"
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
limit_rows = self._derive_limits(trade_date, daily)
previous_daily = self._load_daily(previous_trade_date)
@@ -82,7 +79,6 @@ class DashboardMixin:
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_trade_date),
"source": "tushare",
"limit_data_source": limit_data_source,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(notices),
},
-2
View File
@@ -2,7 +2,6 @@ from .m0001_adopt_legacy import MIGRATION as M0001_ADOPT_LEGACY
from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS
from .runner import Migration, MigrationError, MigrationRunner
MIGRATIONS = (
@@ -10,7 +9,6 @@ MIGRATIONS = (
M0002_JOB_RUNS,
M0003_LLM_AUDIT,
M0004_MENTOR_NOTES,
M0005_ACCOUNT_SWITCH_GRANTS,
)
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
@@ -1,42 +0,0 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def create_account_switch_grants(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS account_switch_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_hash TEXT NOT NULL,
user_id INTEGER NOT NULL,
granted_at TEXT NOT NULL,
last_used_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (device_hash, user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_switch_grants_device
ON account_switch_grants(device_hash, last_used_at DESC)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_switch_grants_expiry
ON account_switch_grants(expires_at)
"""
)
MIGRATION = Migration(
version="0005",
name="create_account_switch_grants",
action=create_account_switch_grants,
signature="account-switch-grants:v1:device,user,granted,used,expires,unique",
)
+4 -4
View File
@@ -28,11 +28,11 @@ class AccountApplicationMixin:
def update_membership(self, payload: dict[str, Any]) -> None:
self.accounts.update_membership(payload)
def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
return self.accounts.register(username, password, device_hash)
def register_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.register(username, password)
def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
return self.accounts.login(username, password, device_hash)
def login_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.login(username, password)
def change_password(self, current_password: str, new_password: str) -> None:
self.accounts.change_password(current_password, new_password)
+22 -94
View File
@@ -1,108 +1,49 @@
from __future__ import annotations
import json
import secrets
from http import HTTPStatus
from backend.features.accounts.security import token_hash
class AccountHttpMixin:
def _device_hash(self) -> str:
raw = self.device_token()
return token_hash(raw) if raw else ""
def _ensure_device_token(self) -> str:
return self.device_token() or secrets.token_urlsafe(32)
def _auth_success_headers(self, session_token: str, device_raw: str) -> list[tuple[str, str]]:
return [
("Set-Cookie", self.session_cookie(session_token)),
("Set-Cookie", self.device_cookie(device_raw)),
]
def _send_authenticated_session(self, result: dict, status: HTTPStatus, device_raw: str) -> None:
self.send_json(
{
"ok": True,
"authenticated": True,
"user": result["user"],
"csrf_token": result["csrf_token"],
},
status,
self._auth_success_headers(result["session_token"], device_raw),
)
def auth_register(self) -> None:
try:
body = self.read_json_body()
device_raw = self._ensure_device_token()
result = self.application_service.register_account(
str(body.get("username") or ""),
str(body.get("password") or ""),
token_hash(device_raw),
)
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
self.send_json(
{
"ok": True,
"authenticated": True,
"user": result["user"],
"csrf_token": result["csrf_token"],
},
HTTPStatus.CREATED,
{"Set-Cookie": self.session_cookie(result["session_token"])},
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def auth_login(self) -> None:
try:
body = self.read_json_body()
device_raw = self._ensure_device_token()
result = self.application_service.login_account(
str(body.get("username") or ""),
str(body.get("password") or ""),
token_hash(device_raw),
)
self._send_authenticated_session(result, HTTPStatus.OK, device_raw)
self.send_json(
{
"ok": True,
"authenticated": True,
"user": result["user"],
"csrf_token": result["csrf_token"],
},
headers={"Set-Cookie": self.session_cookie(result["session_token"])},
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
def auth_accounts(self) -> None:
current_user_id = None
if self.require_auth(send_error=False):
current_user_id = int(self.auth_user["id"])
payload = self.application_service.accounts.list_device_accounts(
self._device_hash(),
current_user_id,
)
self.send_json({"ok": True, **payload})
def auth_switch(self) -> None:
try:
body = self.read_json_body()
try:
user_id = int(body.get("user_id"))
except (TypeError, ValueError):
user_id = 0
device_raw = self.device_token()
result = self.application_service.accounts.switch_account(
token_hash(device_raw) if device_raw else "",
user_id,
)
self._send_authenticated_session(
result,
HTTPStatus.OK,
device_raw or self._ensure_device_token(),
)
except PermissionError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def auth_forget(self) -> None:
try:
body = self.read_json_body()
try:
user_id = int(body.get("user_id"))
except (TypeError, ValueError):
user_id = 0
self.application_service.accounts.forget_account(self._device_hash(), user_id)
except (ValueError, json.JSONDecodeError):
pass
self.send_json({"ok": True})
def auth_me(self) -> None:
service = self.application_service
if not self.require_auth(send_error=False):
@@ -114,15 +55,6 @@ class AccountHttpMixin:
}
)
return
headers = None
if not self.device_token():
device_raw = secrets.token_urlsafe(32)
service.accounts.remember_account(
token_hash(device_raw),
int(self.auth_user["id"]),
fresh=True,
)
headers = [("Set-Cookie", self.device_cookie(device_raw))]
self.send_json(
{
"ok": True,
@@ -134,19 +66,15 @@ class AccountHttpMixin:
"membership": service.membership(),
},
"csrf_token": str(self.auth_user["csrf_token"]),
},
headers=headers,
}
)
def auth_logout(self) -> None:
raw_token = self.session_token()
user_id = int(getattr(self, "auth_user", {}).get("id") or 0)
if raw_token:
from backend.features.accounts.security import token_hash
self.application_service.database.delete_session(token_hash(raw_token))
self.application_service.accounts.revoke_current_device_grant(
self._device_hash(),
user_id,
)
self.send_json(
{"ok": True},
headers={"Set-Cookie": self.session_cookie("", clear=True)},
-93
View File
@@ -234,96 +234,3 @@ class AccountRepositoryMixin:
(user_id,),
)
return cursor.rowcount > 0
def cleanup_expired_switch_grants(self, now: str) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM account_switch_grants WHERE expires_at <= ?",
(now,),
)
return int(cursor.rowcount)
def list_switch_grants(self, device_hash: str, now: str) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status,
u.membership_plan, u.membership_starts_at, u.membership_expires_at,
u.created_at, g.last_used_at, g.granted_at, g.expires_at
FROM account_switch_grants AS g
JOIN users AS u ON u.id = g.user_id
WHERE g.device_hash = ? AND g.expires_at > ?
ORDER BY g.last_used_at DESC, g.id DESC
""",
(device_hash, now),
).fetchall()
return [dict(row) for row in rows]
def get_switch_grant(self, device_hash: str, user_id: int) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"""
SELECT device_hash, user_id, granted_at, last_used_at, expires_at
FROM account_switch_grants
WHERE device_hash = ? AND user_id = ?
""",
(device_hash, user_id),
).fetchone()
return dict(row) if row else None
def upsert_switch_grant(
self,
device_hash: str,
user_id: int,
granted_at: str,
last_used_at: str,
expires_at: str,
) -> None:
with self.connect() as connection:
connection.execute(
"""
INSERT INTO account_switch_grants
(device_hash, user_id, granted_at, last_used_at, expires_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(device_hash, user_id) DO UPDATE SET
granted_at = excluded.granted_at,
last_used_at = excluded.last_used_at,
expires_at = excluded.expires_at
""",
(device_hash, user_id, granted_at, last_used_at, expires_at),
)
def prune_switch_grants(self, device_hash: str, keep: int) -> int:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT id FROM account_switch_grants
WHERE device_hash = ?
ORDER BY last_used_at DESC, id DESC
""",
(device_hash,),
).fetchall()
extra = [int(row["id"]) for row in rows[keep:]]
if not extra:
return 0
connection.execute(
f"DELETE FROM account_switch_grants WHERE id IN ({','.join('?' * len(extra))})",
extra,
)
return len(extra)
def delete_switch_grant(self, device_hash: str, user_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM account_switch_grants WHERE device_hash = ? AND user_id = ?",
(device_hash, user_id),
)
return cursor.rowcount > 0
def delete_switch_grants_for_user(self, user_id: int) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM account_switch_grants WHERE user_id = ?",
(user_id,),
)
return int(cursor.rowcount)
-3
View File
@@ -6,9 +6,6 @@ class AccountRoutesMixin:
if parsed.path == "/api/auth/me":
self.auth_me()
return True
if parsed.path == "/api/auth/accounts":
self.auth_accounts()
return True
return False
def _handle_accounts_get(self, parsed) -> bool:
+4 -93
View File
@@ -77,22 +77,15 @@ class AccountService:
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
return self.membership_for_access(access)
GRANT_SLIDE_DAYS = 30
GRANT_HARD_DAYS = 180
MAX_GRANTS_PER_DEVICE = 5
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
def register(self, username: str, password: str) -> dict[str, Any]:
username = username.strip()
self.validate_input(username, password)
with self.auth_lock:
salt, password_digest = hash_password(password)
user = self.database.create_user(username, salt, password_digest)
result = self.create_session(user)
self.remember_account(device_hash, int(user["id"]), fresh=True)
return result
return self.create_session(user)
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
def login(self, username: str, password: str) -> dict[str, Any]:
username = username.strip()
if not username or not password:
raise ValueError("账号名和密码不能为空。")
@@ -103,9 +96,7 @@ class AccountService:
str(user.get("password_hash") or ""),
):
raise ValueError("账号名或密码不正确。")
result = self.create_session(user)
self.remember_account(device_hash, int(user["id"]), fresh=True)
return result
return self.create_session(user)
def change_password(self, current_password: str, new_password: str) -> None:
current_password = str(current_password or "")
@@ -121,86 +112,6 @@ class AccountService:
salt, digest = hash_password(new_password)
if not self.database.update_user_password(self.current_user_id, salt, digest):
raise ValueError("账号不存在。")
self.database.delete_switch_grants_for_user(self.current_user_id)
@staticmethod
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
@classmethod
def _iso(cls, value: datetime) -> str:
return value.isoformat(timespec="seconds")
def remember_account(self, device_hash: str, user_id: int, *, fresh: bool = False) -> None:
if not device_hash or user_id <= 0:
return
now = self._utc_now()
now_text = self._iso(now)
self.database.cleanup_expired_switch_grants(now_text)
existing = None if fresh else self.database.get_switch_grant(device_hash, user_id)
granted_at = parse_iso_datetime(existing["granted_at"]) if existing else now
if granted_at is None:
granted_at = now
expires = min(
now + timedelta(days=self.GRANT_SLIDE_DAYS),
granted_at + timedelta(days=self.GRANT_HARD_DAYS),
)
if not existing:
self.database.prune_switch_grants(device_hash, self.MAX_GRANTS_PER_DEVICE - 1)
self.database.upsert_switch_grant(
device_hash,
user_id,
self._iso(granted_at),
now_text,
self._iso(expires),
)
def list_device_accounts(
self, device_hash: str, current_user_id: int | None = None
) -> dict[str, Any]:
if not device_hash:
return {"accounts": [], "current_user_id": current_user_id}
now_text = self._iso(self._utc_now())
self.database.cleanup_expired_switch_grants(now_text)
accounts = []
for row in self.database.list_switch_grants(device_hash, now_text):
accounts.append(
{
"user_id": int(row["id"]),
"username": str(row["username"]),
"role": str(row.get("role") or "user"),
"membership": self.membership_for_access(row),
"last_used_at": str(row.get("last_used_at") or ""),
}
)
return {"accounts": accounts, "current_user_id": current_user_id}
def switch_account(self, device_hash: str, user_id: int) -> dict[str, Any]:
if not device_hash or user_id <= 0:
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
now = self._utc_now()
now_text = self._iso(now)
self.database.cleanup_expired_switch_grants(now_text)
grant = self.database.get_switch_grant(device_hash, user_id)
expires = parse_iso_datetime(grant.get("expires_at")) if grant else None
if not grant or not expires or expires <= now:
if grant:
self.database.delete_switch_grant(device_hash, user_id)
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
user = self.database.user_access(user_id)
if not user:
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
result = self.create_session(user)
self.remember_account(device_hash, user_id)
return result
def forget_account(self, device_hash: str, user_id: int) -> None:
if device_hash and user_id > 0:
self.database.delete_switch_grant(device_hash, user_id)
def revoke_current_device_grant(self, device_hash: str, user_id: int) -> None:
if device_hash and user_id > 0:
self.database.delete_switch_grant(device_hash, user_id)
def create_session(self, user: dict[str, Any]) -> dict[str, Any]:
session_token = secrets.token_urlsafe(32)
@@ -1,117 +0,0 @@
{
"version": "2026.08.05-5",
"sources": {
"zhouyi": {
"title": "周易经文与十翼",
"scope": "卦辞、爻辞、彖传、象传",
"kind": "public_domain_primary",
"note": "观势与观心只引用本项目已校录的卦爻原文,不把现代网络释文当作原典。"
},
"jingfang": {
"title": "京氏易传",
"scope": "八宫与纳甲体系来源",
"kind": "public_domain_traditional",
"note": "确定性程序采用京房纳甲、八宫世应的通行排法。"
},
"huozhulin": {
"title": "火珠林",
"scope": "纳甲筮法、六亲与日月关系",
"kind": "public_domain_traditional",
"note": "用于观心规则脉络,不直接复制后世简化断语。"
},
"zengshan": {
"title": "增删卜易",
"scope": "用神、世应、动变、日月旺衰",
"kind": "public_domain_traditional",
"note": "只采用可明确编码且有一致输入条件的规则;争议规则单独标记。"
},
"neijing": {
"title": "黄帝内经·素问运气七篇",
"scope": "五运、司天在泉、主客气与运气关系",
"kind": "public_domain_primary",
"note": "观气将原典关系转成当日自我观察语言,不宣称对股价存在因果作用。"
}
},
"trend": {
"method": "本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构;多动爻全部保留,不以固定口诀删去用户实际得到的爻。",
"rules": {
"stable": "无动爻时以本卦整体、上下卦关系和大象为主,说明结构的延续条件,不把静止等同于永远不变。",
"single": "一爻动时以该爻的时位、爻辞和象辞为变化核心,并用之卦检查变化后的结构。",
"multiple": "多爻动时逐一保留相关爻义,先找共同方向与冲突,再结合之卦给出有条件的倾向;不得用固定套话把不同动爻压成同一结论。"
}
},
"fortune": {
"principle": "先立中运与司天在泉的年纲,再察当前客气加临主气,最后以日辰说明当日触发;不使用产品权重推导传统结论。",
"movement": {
"太过": "太过表示该运之气偏于有余,解释时同时观察其本气表现与对所胜、所生关系的牵动,不直接等同于吉或凶。",
"不及": "不及表示该运之气偏于不足,解释时同时观察其所不胜来乘与所生受累的可能,不直接等同于弱势结论。"
},
"qi": {
"厥阴风木": "厥阴取风木之动,侧重疏泄、升发、变化与不定;偏盛时可表现为动摇、急变或升散不收。",
"少阴君火": "少阴取君火之明与热,侧重显化、温煦和内在驱动;偏盛时容易躁热,受制时则显而不畅。",
"太阴湿土": "太阴取湿土之濡与承载,侧重黏滞、蓄积和转化;偏盛时容易困重迟缓,得化时则能承接。",
"少阳相火": "少阳取相火之行与枢转,侧重外达、加速和往来;偏盛时容易浮越躁动,受阻时表现为枢机不利。",
"阳明燥金": "阳明取燥金之收与清肃,侧重收敛、裁决和边界;偏盛时容易干急严峻,得润时则清明有序。",
"太阳寒水": "太阳取寒水之藏与凝,侧重潜藏、收引和下行;偏盛时容易凝滞退缩,得温时则蓄势有根。"
},
"relations": {
"same": "客主同气表示同类气相并,重点看是否相得而彰,还是同气偏盛而亢;不能机械判为有利。",
"guest_generates_host": "客生主表示来气生助时令本气,气机较易衔接;仍需观察生助是否过度及年纲是否承接。",
"host_generates_guest": "主生客表示时令本气向来气流转,有相生也有外泄;不能只取相生而忽略主气受耗。",
"guest_controls_host": "客克主表示来气制约主气,传统称客胜为从;重点解释外来变化居上及原有节律受制。",
"host_controls_guest": "主克客表示主气制约来气,传统称主胜为逆;重点解释时令与来气相持而不把相克直接断凶。"
},
"day_trigger": "日辰只说明当日关系如何被触发,不与中运、司天在泉或主客气并列重复计权。",
"industry_boundary": "五行对应行业只作传统取象:可以说明本次已经出现的五行之气对相应行业形成的象征性关注、节奏或约束,但不得读取或猜测行业实时行情,不得预测涨跌,也不得把取象写成投资推荐。",
"personal_boundary": "personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master中的pillars是当日历法,stem_relations只是当日年、月、日三柱天干相对本命日主的确定性关系标签。只能使用本次检索到的关系释义,不得自行重算十神、扩展五行生克、使用藏干、库气或支的燥湿属性,也不得把当日日柱称为用户命局,或由这些字段推断命局中某一十神偏重、身强身弱或喜用神。",
"personal_relations": {
"比肩": "比肩作为当日天干关系标签,只提示用户可能更在意自主判断、同类比较或坚持原有立场;不能据此判断命局强弱或现实事件。",
"劫财": "劫财作为当日天干关系标签,只提示用户留意精力、注意力或可支配资源在同类事项间的分流与竞争感;不等同于破财或他人争夺。",
"食神": "食神作为当日天干关系标签,只提示用户留意表达、输出、舒缓与完成感;不等同于收益或确定的轻松结果。",
"伤官": "伤官作为当日天干关系标签,只提示用户留意质疑规则、急于表达或追求自主空间的倾向;不等同于冲突或违规。",
"偏财": "偏财作为当日天干关系标签,只提示用户留意机会分配、灵活取舍与非固定资源的吸引力;不等同于意外获利。",
"正财": "正财作为当日天干关系标签,只提示用户更关注可核对的结果、资源边界和务实落地;不等同于必得收益或现金变化。",
"七杀": "七杀作为当日天干关系标签,只提示用户留意紧迫感、外部压力和快速决断冲动;不等同于危险必然发生。",
"正官": "正官作为当日天干关系标签,只提示用户更在意规则、责任、秩序和可交付标准;不等同于结果必然受控。",
"偏印": "偏印作为当日天干关系标签,只提示用户留意内省、非惯常信息和反复推敲的倾向;不等同于退缩、失眠或方向错误。",
"正印": "正印作为当日天干关系标签,只提示用户更在意依据、支持、学习和安全边界;不等同于必然获得帮助。"
}
},
"heart": {
"presets": {
"trade": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?",
"mind": "此刻影响我交易判断的情绪、执念或盲点是什么?",
"unthemed": "不设具体问题,只观此刻一念。"
},
"focus": {
"trade": "以世爻、应爻、妻财爻及实际动变为主要检索对象,同时检查兄弟、官鬼和子孙的生克,不把任何单一六亲固定判吉凶。",
"mind": "以世爻和实际动爻为主,观察官鬼所示压力、子孙所示舒解及内外生克;不把心境问题强行翻译成价格方向。",
"unthemed": "不强选事项用神,以本卦、世爻、实际动爻和之卦作一般观照,不猜测用户没有提出的问题。",
"custom": "先依据用户明确写出的股票交易问题选择相关六亲;无法明确归类时退回世爻、动爻和卦变的一般解释,不擅自补全问题。"
},
"evidence_order": [
"用户问题与预设来源",
"本卦及卦宫",
"世应与所问相关六亲",
"月建日辰、旬空及冲合生克",
"实际动爻与变爻",
"之卦与整体卦义",
"六神辅助象义"
],
"limits": "六神只作辅助象义;空亡、月破、日冲、合冲刑害均需结合用神、世应和动变,不得单项宣布结果。",
"semantics": {
"self_response": "世爻表示求测者当前立场与承受状态,应爻表示所问事项的外部一端或对照面。应爻不是固定的合作方、庄家或资金方;只有用户问题明确给出该角色时,才可作对应解释。",
"calendar": "月建与日辰用于判断爻在起卦时刻的承受、生扶和制约。旬空表示该爻所象征的条件当下可能未落实、难发挥或有名无实,但不能单凭旬空判失败,也不能用填实日期预测何时涨跌或行动。月破、日冲、六合、六冲、六害和相刑同样必须与世应、相关六亲及动变合看。",
"movement": "动爻说明关系正在变化;变爻说明变化后的承接方向。回头生、回头克和原变爻生克只描述力量关系,不自动对应现实中的借贷、融资、合作或某个具体人物。进神退神只说明同类地支变化的进退趋势,不直接宣布价格方向。",
"six_spirits": "六神只补充表达色彩,不单独定成败。青龙不必然有利,白虎不必然紧急或凶险,朱雀不必然等同口舌,玄武不必然等同欺骗,勾陈与螣蛇也不得脱离爻位、六亲和动变独断。",
"timing_boundary": "观心不作应期预测。可以说明某项条件在起卦时刻尚未落实或受制,但不得给出未来若干日、某干支日、出空或填实后必然发生什么。",
"relatives": {
"兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。",
"子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。",
"妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。",
"官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。",
"父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。"
}
}
}
}
+4 -13
View File
@@ -3,24 +3,15 @@ from __future__ import annotations
import json
from http import HTTPStatus
from backend.features.heaven.knowledge import HeavenKnowledgeError
class HeavenHttpMixin:
def _send_heaven_client_error(self, exc: Exception) -> None:
payload: dict = {"error": str(exc)}
code = getattr(exc, "error_code", None)
if code:
payload["code"] = str(code)
self.send_json(payload, HTTPStatus.BAD_REQUEST)
def heaven_hexagram(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.heaven_hexagram(body.get("lines"))
self.send_json({"ok": True, "hexagram": result})
except (ValueError, json.JSONDecodeError) as exc:
self._send_heaven_client_error(exc)
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def heaven_personal(self) -> None:
try:
@@ -28,12 +19,12 @@ class HeavenHttpMixin:
result = self.application_service.heaven_personal(body)
self.send_json({"ok": True, "personal": result})
except (ValueError, json.JSONDecodeError) as exc:
self._send_heaven_client_error(exc)
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def heaven_interpret(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.heaven_interpret(body)
self.send_json({"ok": True, **result})
except (HeavenKnowledgeError, ValueError, json.JSONDecodeError) as exc:
self._send_heaven_client_error(exc)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+2 -53
View File
@@ -2,23 +2,12 @@ from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from backend.bootstrap.config import APP_DIR
KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json"
# Baked into the image outside the ./data bind mount so volume overlay cannot hide it.
KNOWLEDGE_SEED_FILE = Path(__file__).resolve().parent / "assets" / "heaven_knowledge.json"
class HeavenKnowledgeError(ValueError):
"""Structured knowledge-file failure surfaced to HTTP as Chinese API errors."""
def __init__(self, message: str, *, code: str) -> None:
super().__init__(message)
self.error_code = code
def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]:
@@ -390,49 +379,9 @@ def _line_record(line: dict[str, Any]) -> dict[str, Any]:
}
def resolve_heaven_knowledge_path() -> Path:
"""Prefer the persisted data-dir file; fall back to the image-baked seed."""
if KNOWLEDGE_FILE.is_file():
return KNOWLEDGE_FILE
if KNOWLEDGE_SEED_FILE.is_file():
return KNOWLEDGE_SEED_FILE
raise HeavenKnowledgeError(
"问天知识文件缺失:未找到 heaven_knowledge.json。"
"请确认宿主机 data 目录或镜像内 seed 文件完整。",
code="heaven_knowledge_missing",
)
@lru_cache(maxsize=1)
def _knowledge_catalog() -> dict[str, Any]:
path = resolve_heaven_knowledge_path()
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
raise HeavenKnowledgeError(
f"问天知识文件无法读取({path.name}):{exc.strerror or exc}",
code="heaven_knowledge_missing",
) from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise HeavenKnowledgeError(
f"问天知识文件 JSON 损坏({path.name}),无法解析:"
f"{exc.lineno} 行附近。",
code="heaven_knowledge_invalid",
) from exc
if not isinstance(payload, dict):
raise HeavenKnowledgeError(
f"问天知识文件格式不正确({path.name}):根节点必须是对象。",
code="heaven_knowledge_invalid",
)
payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8"))
if not payload.get("version") or not isinstance(payload.get("sources"), dict):
raise HeavenKnowledgeError(
f"问天知识库格式不完整({path.name}):缺少 version 或 sources。",
code="heaven_knowledge_invalid",
)
raise ValueError("问天知识库格式不完整。")
return payload
def clear_heaven_knowledge_cache() -> None:
_knowledge_catalog.cache_clear()
-202
View File
@@ -1,202 +0,0 @@
"""Auditable recent-trading-day snapshot backfill helpers.
Planning and backup stay free of provider imports so feature boundary tests remain green.
The service layer supplies open trading dates from the live calendar and executes sync.
"""
from __future__ import annotations
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Iterable
MAX_RANGE_TRADING_DAYS = 15
MAX_RECENT_TRADING_DAYS = 60
DEFAULT_RECENT_TRADING_DAYS = 60
# Tables touched by a successful historical dashboard sync. User / token / model
# tables must never appear here.
SNAPSHOT_BACKFILL_WRITE_TABLES = frozenset(
{
"dashboard_snapshots",
"data_snapshots",
"sync_runs",
}
)
def clamp_recent_lookback(lookback: int) -> int:
value = int(lookback)
if value < 1:
raise ValueError("回补交易日数量至少为 1。")
if value > MAX_RECENT_TRADING_DAYS:
raise ValueError(f"单次最多回补最近 {MAX_RECENT_TRADING_DAYS} 个交易日。")
return value
def calendar_window_start(end_date: str, lookback: int) -> str:
"""Natural-day lower bound large enough to cover lookback open sessions."""
end = datetime.strptime(end_date, "%Y%m%d").date()
span = max(40, int(lookback * 2) + 20)
return (end - timedelta(days=span)).strftime("%Y%m%d")
def select_open_trade_dates(
calendar_rows: Iterable[dict[str, Any]],
end_date: str,
lookback: int,
) -> list[str]:
"""Pick the last ``lookback`` open SSE sessions on or before ``end_date``."""
lookback = clamp_recent_lookback(lookback)
end = normalize_compact_date(end_date)
open_dates = sorted(
{
normalize_compact_date(str(row.get("cal_date") or ""))
for row in calendar_rows
if int(row.get("is_open") or 0) == 1 and row.get("cal_date")
}
)
open_dates = [item for item in open_dates if item <= end]
if not open_dates:
raise ValueError("交易日历未返回可用交易日,请检查行情 Token。")
return open_dates[-lookback:]
def select_open_trade_dates_in_range(
calendar_rows: Iterable[dict[str, Any]],
start_date: str,
end_date: str,
*,
maximum: int = MAX_RANGE_TRADING_DAYS,
) -> tuple[list[str], list[str]]:
"""Return (open_dates, skipped_non_trading_days) inside an inclusive range."""
start = normalize_compact_date(start_date)
end = normalize_compact_date(end_date)
if start > end:
raise ValueError("开始日期不能晚于结束日期。")
open_set = {
normalize_compact_date(str(row.get("cal_date") or ""))
for row in calendar_rows
if int(row.get("is_open") or 0) == 1 and row.get("cal_date")
}
open_dates: list[str] = []
skipped: list[str] = []
cursor = datetime.strptime(start, "%Y%m%d").date()
last = datetime.strptime(end, "%Y%m%d").date()
while cursor <= last:
compact = cursor.strftime("%Y%m%d")
if compact in open_set:
open_dates.append(compact)
else:
skipped.append(compact)
cursor += timedelta(days=1)
if len(open_dates) > maximum:
raise ValueError(f"单次最多回补 {maximum} 个交易日。")
return open_dates, skipped
def classify_snapshot_coverage(
trade_dates: list[str],
existing_dates: Iterable[str],
) -> dict[str, Any]:
present_set = {
normalize_compact_date(item)
for item in existing_dates
if item
}
present = [item for item in trade_dates if item in present_set]
missing = [item for item in trade_dates if item not in present_set]
return {
"trade_dates": list(trade_dates),
"present": present,
"missing": missing,
"present_count": len(present),
"missing_count": len(missing),
}
def create_sqlite_backup(
source_path: Path,
backup_dir: Path,
*,
label: str = "pre-backfill",
stamped_at: datetime | None = None,
) -> Path:
"""Create a timestamped SQLite backup via the native backup API."""
source = Path(source_path)
if not source.exists():
raise FileNotFoundError(f"数据库不存在:{source}")
stamp = (stamped_at or datetime.now().astimezone()).strftime("%Y%m%d-%H%M%S")
safe_label = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in label).strip("-") or "backup"
backup_dir = Path(backup_dir)
backup_dir.mkdir(parents=True, exist_ok=True)
target = backup_dir / f"review-{safe_label}-{stamp}.db"
source_conn = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
try:
target_conn = sqlite3.connect(target)
try:
source_conn.backup(target_conn)
target_conn.commit()
finally:
target_conn.close()
finally:
source_conn.close()
return target
def display_date(compact: str) -> str:
value = normalize_compact_date(compact)
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
def normalize_compact_date(value: str) -> str:
compact = str(value or "").replace("-", "").strip()
if len(compact) != 8 or not compact.isdigit():
raise ValueError("日期格式应为 YYYY-MM-DD。")
datetime.strptime(compact, "%Y%m%d")
return compact
def build_backfill_audit(
*,
mode: str,
end_date: str,
lookback: int | None,
coverage: dict[str, Any],
skipped_non_trading_days: list[str] | None = None,
backup_path: str | None = None,
dry_run: bool = False,
results: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
results = list(results or [])
succeeded = [row for row in results if row.get("status") == "success"]
skipped = [row for row in results if row.get("status") == "skipped"]
failed = [row for row in results if row.get("status") == "failed"]
return {
"ok": not failed,
"mode": mode,
"dry_run": dry_run,
"end_date": display_date(end_date),
"lookback": lookback,
"backup_path": backup_path,
"write_tables": sorted(SNAPSHOT_BACKFILL_WRITE_TABLES),
"trade_dates": [display_date(item) for item in coverage.get("trade_dates") or []],
"present": [display_date(item) for item in coverage.get("present") or []],
"missing": [display_date(item) for item in coverage.get("missing") or []],
"skipped_non_trading_days": [
display_date(item) for item in (skipped_non_trading_days or [])
],
"present_count": int(coverage.get("present_count") or 0),
"missing_count": int(coverage.get("missing_count") or 0),
"results": results,
"succeeded_count": len(succeeded),
"skipped_count": len(skipped),
"failed_count": len(failed),
"created_dates": [
str(row.get("trade_date") or "")
for row in succeeded
if row.get("action") == "created"
],
}
-25
View File
@@ -227,31 +227,6 @@ class MarketRepositoryMixin:
result.append(payload)
return result
def list_snapshot_trade_dates(
self,
start_date: str = "",
end_date: str = "",
) -> list[str]:
clauses: list[str] = []
parameters: list[Any] = []
if start_date:
clauses.append("trade_date >= ?")
parameters.append(start_date)
if end_date:
clauses.append("trade_date <= ?")
parameters.append(end_date)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT trade_date FROM dashboard_snapshots
{where}
ORDER BY trade_date
""",
parameters,
).fetchall()
return [str(row["trade_date"]) for row in rows]
def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
+22 -234
View File
@@ -3,11 +3,9 @@ from __future__ import annotations
import copy
import re
from datetime import date, datetime, time as dt_time, timedelta
from pathlib import Path
from typing import Any
from backend.bootstrap.config import (
DATA_DIR,
normalize_date,
tushare_code,
validate_stock_code,
@@ -15,17 +13,6 @@ from backend.bootstrap.config import (
)
from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.features.market.backfill_history import (
DEFAULT_RECENT_TRADING_DAYS,
MAX_RANGE_TRADING_DAYS,
build_backfill_audit,
calendar_window_start,
classify_snapshot_coverage,
create_sqlite_backup,
display_date,
select_open_trade_dates,
select_open_trade_dates_in_range,
)
from backend.features.market.charts import ChartDataError
from backend.features.market.insights import MarketInsightsService
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
@@ -198,11 +185,6 @@ class MarketServiceMixin:
raise TushareError("公共行情尚未配置")
dashboard = self._tushare_client().dashboard(normalized_date)
if (dashboard.get("meta") or {}).get("limit_data_source") == "derived":
raise TushareError(
str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回")
)
dashboard["meta"]["source"] = source
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
@@ -908,226 +890,31 @@ class MarketServiceMixin:
"intraday": intraday_points,
}
def backfill(
self,
start_date: str = "",
end_date: str = "",
*,
lookback: int | None = None,
dry_run: bool = False,
force: bool = False,
create_backup: bool = True,
) -> dict[str, Any]:
"""Backfill dashboard snapshots for real trading days only.
- Date-range mode keeps the admin UI contract (max 15 open sessions).
- Recent mode fills the last N open sessions (default/max 60).
Weekends and holidays are reported as skipped non-trading days, not errors.
"""
if not self.configured:
raise ValueError("公共行情尚未配置,无法回补历史快照。")
normalized_end = normalize_date(end_date or date.today().isoformat())
if lookback is not None or not (start_date and end_date):
target_lookback = (
DEFAULT_RECENT_TRADING_DAYS if lookback is None else int(lookback)
)
return self.backfill_recent_trading_days(
end_date=normalized_end,
lookback=target_lookback,
dry_run=dry_run,
force=force,
create_backup=create_backup,
)
return self._backfill_date_range(
start_date=normalize_date(start_date),
end_date=normalized_end,
dry_run=dry_run,
force=force,
create_backup=create_backup,
)
def backfill_recent_trading_days(
self,
end_date: str = "",
lookback: int = DEFAULT_RECENT_TRADING_DAYS,
*,
dry_run: bool = False,
force: bool = False,
create_backup: bool = True,
) -> dict[str, Any]:
normalized_end = normalize_date(end_date or date.today().isoformat())
trade_dates = self._load_recent_open_trade_dates(normalized_end, lookback)
existing = self.database.list_snapshot_trade_dates(
trade_dates[0], trade_dates[-1]
)
coverage = classify_snapshot_coverage(trade_dates, existing)
return self._execute_snapshot_backfill(
mode="recent",
end_date=normalized_end,
lookback=lookback,
coverage=coverage,
skipped_non_trading_days=[],
dry_run=dry_run,
force=force,
create_backup=create_backup,
)
def _backfill_date_range(
self,
start_date: str,
end_date: str,
*,
dry_run: bool = False,
force: bool = False,
create_backup: bool = True,
) -> dict[str, Any]:
window_start = calendar_window_start(end_date, MAX_RANGE_TRADING_DAYS)
calendar_rows = self._tushare_client().query(
"trade_cal",
{
"exchange": "SSE",
"start_date": min(window_start, start_date),
"end_date": end_date,
},
"cal_date,is_open,pretrade_date",
)
trade_dates, skipped = select_open_trade_dates_in_range(
calendar_rows,
start_date,
end_date,
maximum=MAX_RANGE_TRADING_DAYS,
)
if not trade_dates:
raise ValueError("选定区间内没有交易日,周末或节假日无需回补。")
existing = self.database.list_snapshot_trade_dates(trade_dates[0], trade_dates[-1])
coverage = classify_snapshot_coverage(trade_dates, existing)
return self._execute_snapshot_backfill(
mode="range",
end_date=end_date,
lookback=None,
coverage=coverage,
skipped_non_trading_days=skipped,
dry_run=dry_run,
force=force,
create_backup=create_backup,
)
def _load_recent_open_trade_dates(self, end_date: str, lookback: int) -> list[str]:
start_date = calendar_window_start(end_date, lookback)
calendar_rows = self._tushare_client().query(
"trade_cal",
{
"exchange": "SSE",
"start_date": start_date,
"end_date": end_date,
},
"cal_date,is_open,pretrade_date",
)
return select_open_trade_dates(calendar_rows, end_date, lookback)
def _execute_snapshot_backfill(
self,
*,
mode: str,
end_date: str,
lookback: int | None,
coverage: dict[str, Any],
skipped_non_trading_days: list[str],
dry_run: bool,
force: bool,
create_backup: bool,
) -> dict[str, Any]:
targets = list(coverage["trade_dates"] if force else coverage["missing"])
backup_path: str | None = None
if create_backup and not dry_run and targets:
backup = create_sqlite_backup(
Path(self.database.path),
DATA_DIR / "backups",
label=f"pre-{mode}-backfill",
)
backup_path = str(backup)
results: list[dict[str, Any]] = []
if dry_run:
for trade_date in coverage["trade_dates"]:
exists = trade_date in coverage["present"]
if exists and not force:
status = "skipped"
action = "exists"
else:
status = "planned"
action = "refresh" if exists else "create"
results.append(
{
"requested_date": display_date(trade_date),
"trade_date": display_date(trade_date),
"status": status,
"action": action,
}
)
return build_backfill_audit(
mode=mode,
end_date=end_date,
lookback=lookback,
coverage=coverage,
skipped_non_trading_days=skipped_non_trading_days,
backup_path=backup_path,
dry_run=True,
results=results,
)
present_before = set(coverage["present"])
for trade_date in targets:
existed = trade_date in present_before
try:
dashboard = self.sync_dashboard(trade_date)
actual = normalize_date(
str(dashboard.get("meta", {}).get("trade_date") or trade_date)
)
results.append(
{
"requested_date": display_date(trade_date),
"trade_date": display_date(actual),
"status": "success",
"action": "refreshed" if existed else "created",
"source": dashboard.get("meta", {}).get("source"),
"records": self._record_count(dashboard),
}
)
except Exception as exc:
results.append(
{
"requested_date": display_date(trade_date),
"trade_date": display_date(trade_date),
"status": "failed",
"action": "refresh" if existed else "create",
"error": str(exc),
}
)
for trade_date in coverage["present"]:
if force:
continue
def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date()
end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date()
if start > end:
raise ValueError("开始日期不能晚于结束日期。")
weekdays = []
current = start
while current <= end:
if current.weekday() < 5:
weekdays.append(current)
current += timedelta(days=1)
if len(weekdays) > 15:
raise ValueError("单次最多回补 15 个工作日。")
results = []
for day in weekdays:
dashboard = self.sync_dashboard(day.strftime("%Y%m%d"))
results.append(
{
"requested_date": display_date(trade_date),
"trade_date": display_date(trade_date),
"status": "skipped",
"action": "exists",
"requested_date": day.isoformat(),
"trade_date": dashboard["meta"]["trade_date"],
"source": dashboard["meta"]["source"],
"records": self._record_count(dashboard),
}
)
results.sort(key=lambda row: str(row.get("requested_date") or ""))
return build_backfill_audit(
mode=mode,
end_date=end_date,
lookback=lookback,
coverage=coverage,
skipped_non_trading_days=skipped_non_trading_days,
backup_path=backup_path,
dry_run=False,
results=results,
)
return results
def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]:
snapshot = self.database.get_snapshot(trade_date) or {}
@@ -1168,3 +955,4 @@ class MarketServiceMixin:
len(dashboard.get(key) or [])
for key in ("limits", "broken", "down_limits", "yesterday_limits")
)
+1 -3
View File
@@ -26,15 +26,13 @@ class SystemHttpMixin:
def start_background_refresh(self) -> None:
try:
body = self.read_json_body(allow_empty=True)
refresh = self.application_service.request_background_sync(
started = self.application_service.request_background_sync(
str(body.get("trade_date") or date.today().isoformat())
)
started = bool(refresh.get("started"))
self.send_json(
{
"ok": True,
"started": started,
"job_key": str(refresh.get("job_key") or ""),
"message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
},
HTTPStatus.ACCEPTED,
+2 -8
View File
@@ -29,17 +29,11 @@ class SystemRoutesMixin:
def backfill_data(self) -> None:
try:
body = self.read_json_body()
lookback_raw = body.get("lookback")
lookback = int(lookback_raw) if lookback_raw not in (None, "") else None
audit = self.application_service.backfill(
results = self.application_service.backfill(
str(body.get("start_date") or ""),
str(body.get("end_date") or ""),
lookback=lookback,
dry_run=bool(body.get("dry_run")),
force=bool(body.get("force")),
create_backup=body.get("create_backup", True) is not False,
)
self.send_json({"ok": True, **audit, "results": audit.get("results") or []})
self.send_json({"ok": True, "results": results})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
-2
View File
@@ -7,8 +7,6 @@ from urllib.parse import urlparse
PUBLIC_POST_HANDLERS = {
"/api/auth/register": "auth_register",
"/api/auth/login": "auth_login",
"/api/auth/switch": "auth_switch",
"/api/auth/forget": "auth_forget",
}
AUTHENTICATED_POST_HANDLERS = {
+10 -26
View File
@@ -9,13 +9,7 @@ from http.cookies import SimpleCookie
from typing import Any
from urllib.parse import unquote
from backend.bootstrap.config import (
DEVICE_COOKIE,
DEVICE_MAX_AGE,
SESSION_COOKIE,
SESSION_MAX_AGE,
STATIC_DIR,
)
from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR
from backend.features.accounts.security import token_hash
from backend.http.context import correlation_id
from backend.http.errors import normalize_error_payload
@@ -27,21 +21,15 @@ class HttpTransportMixin:
application_service: Any
route_registry: Any
def cookie_value(self, name: str) -> str:
def session_token(self) -> str:
cookie = SimpleCookie()
try:
cookie.load(self.headers.get("Cookie", ""))
except Exception:
return ""
morsel = cookie.get(name)
morsel = cookie.get(SESSION_COOKIE)
return morsel.value if morsel else ""
def session_token(self) -> str:
return self.cookie_value(SESSION_COOKIE)
def device_token(self) -> str:
return self.cookie_value(DEVICE_COOKIE)
def require_auth(self, send_error: bool = True) -> bool:
raw_token = self.session_token()
service = self.application_service
@@ -91,18 +79,15 @@ class HttpTransportMixin:
return self.require_member()
return True
def _cookie_header(self, name: str, value: str, max_age: int) -> str:
cookie = f"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
def session_cookie(self, value: str, clear: bool = False) -> str:
max_age = 0 if clear else SESSION_MAX_AGE
cookie = (
f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
)
if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
cookie += "; Secure"
return cookie
def session_cookie(self, value: str, clear: bool = False) -> str:
return self._cookie_header(SESSION_COOKIE, value, 0 if clear else SESSION_MAX_AGE)
def device_cookie(self, value: str, clear: bool = False) -> str:
return self._cookie_header(DEVICE_COOKIE, value, 0 if clear else DEVICE_MAX_AGE)
def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length == 0 and allow_empty:
@@ -147,7 +132,7 @@ class HttpTransportMixin:
self,
payload: dict[str, Any],
status: HTTPStatus = HTTPStatus.OK,
headers: dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...] | None = None,
headers: dict[str, str] | None = None,
) -> None:
request_id = getattr(self, "_correlation_id", "")
if not request_id:
@@ -160,8 +145,7 @@ class HttpTransportMixin:
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Request-ID", request_id)
header_items = headers.items() if isinstance(headers, dict) else (headers or ())
for name, value in header_items:
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(content)
+3 -14
View File
@@ -7,16 +7,6 @@ from datetime import date
from backend.bootstrap.config import normalize_date
def _verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]:
meta = dashboard.get("meta") or {}
if isinstance(meta, dict) and meta.get("carried_forward"):
return {
"status": "failed",
"error": str(meta.get("notice") or "未获取到所选日期的最新行情"),
}
return dashboard
class JobServiceMixin:
def start_background_jobs(self) -> threading.Thread:
return self.jobs.start_scheduler(
@@ -30,16 +20,15 @@ class JobServiceMixin:
workers_stopped = self.jobs.wait_for_idle(timeout_seconds)
return scheduler_stopped and workers_stopped
def request_background_sync(self, trade_date: str) -> dict[str, object]:
def request_background_sync(self, trade_date: str) -> bool:
normalized = normalize_date(trade_date)
key = f"manual:{normalized}:{time.time_ns()}"
started = self.jobs.submit(
return self.jobs.submit(
"market.refresh",
key,
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)),
lambda: self.sync_dashboard(normalized),
{"trade_date": normalized, "trigger": "administrator"},
)
return {"started": started, "job_key": key if started else ""}
def _background_refresh_tick(self) -> None:
if not (
-21
View File
@@ -128,20 +128,6 @@
"feature": "auction",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/auth/accounts",
"match": "exact",
"feature": "auth",
"access": "public"
},
{
"method": "POST",
"path": "/api/auth/forget",
"match": "exact",
"feature": "auth",
"access": "public"
},
{
"method": "POST",
"path": "/api/auth/login",
@@ -170,13 +156,6 @@
"feature": "auth",
"access": "public"
},
{
"method": "POST",
"path": "/api/auth/switch",
"match": "exact",
"feature": "auth",
"access": "public"
},
{
"method": "POST",
"path": "/api/backfill",
+55 -59
View File
@@ -10,10 +10,10 @@
},
"counts": {
"primary_pages": 16,
"api_exact_paths": 56,
"api_exact_paths": 53,
"api_prefixes": 0,
"api_patterns": 11,
"database_tables": 37,
"database_tables": 36,
"frontend_page_fragments": 12
},
"pages": [
@@ -96,13 +96,10 @@
"/api/assistant/chat",
"/api/assistant/messages",
"/api/auction",
"/api/auth/accounts",
"/api/auth/forget",
"/api/auth/login",
"/api/auth/logout",
"/api/auth/me",
"/api/auth/register",
"/api/auth/switch",
"/api/backfill",
"/api/chart/intraday",
"/api/dashboard",
@@ -192,7 +189,6 @@
"assistant_messages",
"heaven_readings",
"job_runs",
"account_switch_grants",
"schema_migrations"
],
"background_job_methods": [
@@ -374,11 +370,11 @@
}
],
"css_layers": [
"/shared/tokens.css?v=20260829-hel240",
"/shared/tokens.css?v=20260820-3",
"/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260829-hel237",
"/shared/auth.css?v=20260829-hel240b",
"/shared/components/controls.css?v=20260829-hel237",
"/shared/shell.css?v=20260820-8",
"/shared/auth.css?v=20260820-5",
"/shared/components/controls.css?v=20260820-2",
"/shared/components/navigation.css?v=20260820-1",
"/shared/components/cards.css?v=20260820-1",
"/shared/components/tables.css?v=20260820-1",
@@ -394,8 +390,8 @@
"/pages/popularity/foundation.css?v=20260820-1",
"/pages/dragon-tiger/foundation.css?v=20260820-1",
"/pages/screener/foundation.css?v=20260820-4",
"/pages/mentor/foundation.css?v=20260827-hel183",
"/pages/heaven/foundation.css?v=20260827-hel183",
"/pages/mentor/foundation.css?v=20260820-2",
"/pages/heaven/foundation.css?v=20260806-2",
"/pages/review/foundation.css?v=20260820-4"
],
"frontend_composition": {
@@ -440,8 +436,8 @@
"code_hotspots": [
{
"path": "frontend/pages/heaven/foundation.css",
"bytes": 182616,
"lines": 11494
"bytes": 185936,
"lines": 11734
},
{
"path": "frontend/pages/screener/foundation.css",
@@ -450,13 +446,13 @@
},
{
"path": "frontend/pages/heaven/page.js",
"bytes": 97268,
"lines": 2070
"bytes": 97189,
"lines": 2069
},
{
"path": "frontend/shared/shell.css",
"bytes": 63659,
"lines": 3763
"bytes": 63550,
"lines": 3757
},
{
"path": "backend/features/heaven/engine.py",
@@ -465,8 +461,8 @@
},
{
"path": "frontend/index.html",
"bytes": 48254,
"lines": 664
"bytes": 47871,
"lines": 661
},
{
"path": "backend/features/screener/catalog.py",
@@ -490,8 +486,8 @@
},
{
"path": "backend/data/providers/tushare_dashboard.py",
"bytes": 28234,
"lines": 648
"bytes": 28051,
"lines": 644
},
{
"path": "backend/data/providers/tushare_industries.py",
@@ -505,8 +501,8 @@
},
{
"path": "frontend/pages/heaven/page.html",
"bytes": 19885,
"lines": 269
"bytes": 19747,
"lines": 262
},
{
"path": "frontend/pages/screener/page.html",
@@ -545,8 +541,8 @@
},
{
"path": "frontend/shared/admin.js",
"bytes": 14410,
"lines": 268
"bytes": 14145,
"lines": 261
},
{
"path": "backend/features/heaven/market_context.py",
@@ -555,13 +551,8 @@
},
{
"path": "frontend/shared/session.js",
"bytes": 13219,
"lines": 289
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 12894,
"lines": 274
"bytes": 13176,
"lines": 293
},
{
"path": "backend/features/market/insights_auction_data.py",
@@ -583,6 +574,11 @@
"bytes": 10539,
"lines": 244
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 9993,
"lines": 220
},
{
"path": "backend/data/providers/tushare_sectors.py",
"bytes": 9876,
@@ -673,16 +669,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,
@@ -720,8 +716,8 @@
},
{
"path": "backend/http/dispatch.py",
"bytes": 4196,
"lines": 117
"bytes": 4118,
"lines": 115
},
{
"path": "frontend/shared/table.js",
@@ -738,16 +734,16 @@
"bytes": 3369,
"lines": 81
},
{
"path": "frontend/app.js",
"bytes": 3337,
"lines": 95
},
{
"path": "frontend/pages/themes/page.html",
"bytes": 3316,
"lines": 55
},
{
"path": "frontend/app.js",
"bytes": 3201,
"lines": 93
},
{
"path": "backend/features/market/insights_context.py",
"bytes": 3175,
@@ -765,7 +761,7 @@
},
{
"path": "backend/features/accounts/application.py",
"bytes": 2514,
"bytes": 2442,
"lines": 63
},
{
@@ -773,11 +769,6 @@
"bytes": 2299,
"lines": 57
},
{
"path": "backend/jobs/service.py",
"bytes": 2219,
"lines": 60
},
{
"path": "backend/features/screener/regime.py",
"bytes": 2202,
@@ -809,9 +800,9 @@
"lines": 45
},
{
"path": "backend/features/system/routes.py",
"bytes": 1791,
"lines": 46
"path": "backend/jobs/service.py",
"bytes": 1746,
"lines": 49
},
{
"path": "backend/features/alerts/routes.py",
@@ -838,6 +829,11 @@
"bytes": 1455,
"lines": 48
},
{
"path": "backend/features/system/routes.py",
"bytes": 1423,
"lines": 40
},
{
"path": "backend/features/themes/routes.py",
"bytes": 1337,
@@ -853,11 +849,6 @@
"bytes": 1143,
"lines": 19
},
{
"path": "backend/features/accounts/routes.py",
"bytes": 908,
"lines": 25
},
{
"path": "backend/features/popularity/routes.py",
"bytes": 822,
@@ -868,6 +859,11 @@
"bytes": 817,
"lines": 23
},
{
"path": "backend/features/accounts/routes.py",
"bytes": 803,
"lines": 22
},
{
"path": "backend/features/sentiment/routes.py",
"bytes": 724,
-69
View File
@@ -1,69 +0,0 @@
# 行情历史补档(最近 60 个交易日)
用于修复 `dashboard_snapshots` 断档导致情绪周期 / 主题轮动 / 智能选股只剩当天的问题。
保留 `latest_contiguous_history` 连续性规则;通过真实交易日历回补缺失交易日快照。
## 适用场景
- 库中已有稀疏历史快照,但最近一个真实交易日缺失,接口 `available_days=1`
- 需要可重复执行、可审计、可回退的补档,而不是迁库或放宽算法。
## 前置
1. 使用与线上一致的代码分支。
2. 管理员账号已配置可用的公共 Tushare Token。
3. 只操作目标环境自己的 `data/review.db`;禁止 `.36``.11` 互拷。
## 上线步骤(总工执行)
在目标环境容器内执行(应用根目录;宿主机也可直接跑,脚本已自带仓库根 `sys.path` 引导):
```bash
# 1) 只读规划:区分已有、真正缺档;不会写入
docker compose exec xiaobai-review python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --dry-run --json
# 2) 正式补档:先走 SQLite backup API 写 data/backups/review-pre-recent-backfill-*.db
# 再对缺失交易日调用现有 sync_dashboard
docker compose exec xiaobai-review python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --json
# 3) 验证
# GET /api/sentiment/history?trade_date=YYYY-MM-DD&limit=60
# 期望 available_days >= 20,且不再只有 1 天
```
管理端日期区间回补(`/api/backfill`)已改为只处理交易日历中的开市日,周末/节假日会进入
`skipped_non_trading_days`,不再当成错误;单次仍限制 15 个交易日。最近 60 日请用本工具。
## 写入边界
只会通过现有同步路径写入:
- `dashboard_snapshots`
- 同步审计表 `sync_runs`
- 必要时的 `data_snapshots`(仅当请求日被解析到其他交易日)
不得改动用户、Token、模型绑定或系统配置表。
## 回滚
1. 优先按审计结果的 `created_dates` 精确删除新增行:
```sql
DELETE FROM dashboard_snapshots WHERE trade_date IN ('YYYYMMDD', ...);
```
2. 若需整库回退,停止写入后用补档前备份覆盖:
```bash
# 示例:把 data/backups/review-pre-recent-backfill-YYYYMMDD-HHMMSS.db
# 复制回 data/review.db 后重启容器
```
3. 代码回退:对该提交执行 Git revert 后重新部署镜像。
## 验收要点
- dry-run 与正式执行可重复跑;已有交易日默认跳过。
- 周末、节假日出现在 `skipped_non_trading_days`,不计入失败。
- 部分交易日同步失败时,其他日期仍会继续,并在审计结果中标 `failed`
- 情绪周期、主题轮动 9 列、智能选股置信度随连续交易日恢复。
+2 -4
View File
@@ -25,10 +25,8 @@ async function initialize() {
try {
const session = await apiRequest("/api/auth/me");
if (!session.authenticated) {
const params = new URLSearchParams();
if (session.registration_required) params.set("mode", "register");
const query = params.toString();
window.location.replace("/login/" + (query ? `?${query}` : ""));
if (session.registration_required) selectAuthMode("register");
showAuthGate();
return;
}
await applyAuthenticatedSession(session);
+8 -11
View File
@@ -24,9 +24,7 @@
(() => {
let theme = "light";
try {
const stored = localStorage.getItem("xiaobaiTheme");
if (stored === "dark" || stored === "light") theme = stored;
else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) theme = "dark";
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
} catch (_error) {
theme = "light";
}
@@ -34,11 +32,11 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel240">
<link rel="stylesheet" href="/shared/tokens.css?v=20260820-3">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/shell.css?v=20260829-hel237">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel240b">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260829-hel237">
<link rel="stylesheet" href="/shared/shell.css?v=20260820-8">
<link rel="stylesheet" href="/shared/auth.css?v=20260820-5">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/tables.css?v=20260820-1">
@@ -54,12 +52,12 @@
<link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/screener/foundation.css?v=20260820-4">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260827-hel183">
<link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260827-hel183">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260820-2">
<link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260820-4">
</head>
<body>
<section id="authGate" class="auth-gate" aria-label="账号登录" hidden>
<section id="authGate" class="auth-gate" aria-label="账号登录">
<div class="auth-shell">
<div class="auth-brand">
<div class="brand-mark" aria-hidden="true"><span class="brand-glyph"></span></div>
@@ -611,7 +609,6 @@
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配置</button></div>
</form>
<section class="settings-section">
-162
View File
@@ -1,162 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>登录 · 小白复盘</title>
<script>
(() => {
let theme = "light";
try {
const stored = localStorage.getItem("xiaobaiTheme");
if (stored === "dark" || stored === "light") theme = stored;
else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) theme = "dark";
} catch (_error) {
theme = "light";
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();
</script>
<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-hel251">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
</head>
<body class="login-portal">
<aside class="login-brand" aria-hidden="true">
<div class="login-brand-header">
<div class="login-brand-mark"><span class="login-brand-glyph"></span></div>
<div class="login-brand-identity">
<p class="login-brand-name">小白复盘</p>
<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>
<p class="login-brand-lead">情绪周期、涨停梯队、主题轮动、竞价、龙虎榜、人气榜、交易复盘,集中在一个安静的复盘空间。</p>
</div>
<div class="login-brand-market">
<svg class="login-brand-chart" viewBox="0 0 480 168" focusable="false">
<defs>
<linearGradient id="loginChartFade" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#d7e4ff" stop-opacity="0.18"></stop>
<stop offset="100%" stop-color="#d7e4ff" stop-opacity="0"></stop>
</linearGradient>
</defs>
<path class="login-chart-area" d="M8 118 C 52 108, 78 96, 112 102 S 168 128, 204 112 S 268 78, 312 86 S 372 118, 428 92 L 472 84 L 472 168 L 8 168 Z"></path>
<g class="login-candles">
<g class="is-up" transform="translate(36 0)"><line x1="8" y1="58" x2="8" y2="128"></line><rect x="2" y="72" width="12" height="40"></rect></g>
<g class="is-down" transform="translate(68 0)"><line x1="8" y1="64" x2="8" y2="132"></line><rect x="2" y="86" width="12" height="28"></rect></g>
<g class="is-up" transform="translate(100 0)"><line x1="8" y1="48" x2="8" y2="118"></line><rect x="2" y="60" width="12" height="44"></rect></g>
<g class="is-down" transform="translate(132 0)"><line x1="8" y1="70" x2="8" y2="136"></line><rect x="2" y="92" width="12" height="26"></rect></g>
<g class="is-up" transform="translate(164 0)"><line x1="8" y1="42" x2="8" y2="110"></line><rect x="2" y="54" width="12" height="38"></rect></g>
<g class="is-up" transform="translate(196 0)"><line x1="8" y1="36" x2="8" y2="98"></line><rect x="2" y="48" width="12" height="32"></rect></g>
<g class="is-down" transform="translate(228 0)"><line x1="8" y1="58" x2="8" y2="128"></line><rect x="2" y="78" width="12" height="36"></rect></g>
<g class="is-up" transform="translate(260 0)"><line x1="8" y1="40" x2="8" y2="104"></line><rect x="2" y="52" width="12" height="36"></rect></g>
<g class="is-down" transform="translate(292 0)"><line x1="8" y1="66" x2="8" y2="134"></line><rect x="2" y="88" width="12" height="30"></rect></g>
<g class="is-up" transform="translate(324 0)"><line x1="8" y1="44" x2="8" y2="112"></line><rect x="2" y="58" width="12" height="40"></rect></g>
<g class="is-down" transform="translate(356 0)"><line x1="8" y1="72" x2="8" y2="138"></line><rect x="2" y="96" width="12" height="24"></rect></g>
<g class="is-up" transform="translate(388 0)"><line x1="8" y1="38" x2="8" y2="108"></line><rect x="2" y="50" width="12" height="42"></rect></g>
<g class="is-up" transform="translate(420 0)"><line x1="8" y1="32" x2="8" y2="96"></line><rect x="2" y="44" width="12" height="34"></rect></g>
</g>
<path class="login-chart-line" d="M8 118 C 52 108, 78 96, 112 102 S 168 128, 204 112 S 268 78, 312 86 S 372 118, 428 92 L 472 84"></path>
</svg>
<dl class="login-brand-stats">
<div class="login-stat">
<dt>市场情绪</dt>
<dd>72 <span class="login-stat-tag">高热</span></dd>
</div>
<div class="login-stat">
<dt>涨停</dt>
<dd>63</dd>
</div>
<div class="login-stat">
<dt>跌停</dt>
<dd>4</dd>
</div>
<div class="login-stat login-stat-wide">
<dt>两市成交</dt>
<dd>1.02万亿</dd>
</div>
</dl>
</div>
<p class="login-brand-disclaimer">股市有风险,投资需谨慎 · 本工具仅供个人复盘学习使用</p>
</aside>
<main class="login-stage">
<button id="loginThemeToggle" class="login-theme-toggle" type="button">🌙 夜间</button>
<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-hel251"></script>
</body>
</html>
-610
View File
@@ -1,610 +0,0 @@
(function bootLoginPortal(global) {
"use strict";
const THEME_KEY = "xiaobaiTheme";
const api = global.XiaobaiAPI;
const card = document.querySelector("#loginCard");
const themeButton = document.querySelector("#loginThemeToggle");
const state = {
view: "first",
mode: "login",
accounts: [],
currentUserId: null,
loading: false,
confirmingId: null,
error: "",
username: "",
password: "",
passwordVisible: false,
};
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]
));
}
function preferredTheme() {
try {
const stored = global.localStorage.getItem(THEME_KEY);
if (stored === "dark" || stored === "light") return stored;
} catch (_error) {
// Fall through to the system preference.
}
return global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyTheme(theme, persist) {
const normalized = theme === "dark" ? "dark" : "light";
document.documentElement.dataset.theme = normalized;
document.documentElement.style.colorScheme = normalized;
themeButton.textContent = normalized === "dark" ? "☀ 日间" : "🌙 夜间";
themeButton.setAttribute("aria-label", normalized === "dark" ? "切换到日间模式" : "切换到夜间模式");
if (persist) {
try {
global.localStorage.setItem(THEME_KEY, normalized);
} catch (_error) {
// Theme still applies for the current page when storage is unavailable.
}
}
}
function setError(message) {
state.error = message || "";
}
function formatLastUsed(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
const now = new Date();
const hh = String(parsed.getHours()).padStart(2, "0");
const mm = String(parsed.getMinutes()).padStart(2, "0");
if (parsed.toDateString() === now.toDateString()) return `今天 ${hh}:${mm}`;
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (parsed.toDateString() === yesterday.toDateString()) return `昨天 ${hh}:${mm}`;
return `${parsed.getMonth() + 1}${parsed.getDate()}`;
}
function chipsFor(account, current) {
const chips = [];
if (current) chips.push('<span class="login-chip login-chip-current">当前</span>');
if (account.role === "admin") chips.push('<span class="login-chip">管理员</span>');
if (account.membership?.subscribed) chips.push('<span class="login-chip login-chip-member">会员</span>');
else if (account.role !== "admin") chips.push('<span class="login-chip">普通用户</span>');
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() {
global.location.replace(returnPath());
}
function formMarkup(options) {
const registering = state.mode === "register";
const submitLabel = options.submitLabel
|| (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录");
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>'
: "",
`<h2 class="login-card-title">${escapeHtml(options.title)}</h2>`,
`<p class="login-card-lead">${escapeHtml(lead)}</p>`,
'<div class="login-tabs" role="tablist">',
`<button class="login-tab${state.mode === "login" ? " is-active" : ""}" type="button" data-auth-mode="login">登录</button>`,
`<button class="login-tab${state.mode === "register" ? " is-active" : ""}" type="button" data-auth-mode="register">注册</button>`,
"</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><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" : ""}>`,
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
`<span>${escapeHtml(submitLabel)}</span></button>`,
"</form>",
`<p class="login-hint">${escapeHtml(hint)}</p>`,
].join("");
}
function accountRow(account) {
const current = Number(account.user_id) === Number(state.currentUserId);
const confirming = Number(state.confirmingId) === Number(account.user_id);
const managing = state.view === "manage";
const classes = [
"login-account-row",
current ? "is-current" : "",
confirming ? "is-confirming" : "",
!managing ? "is-switchable" : "",
].filter(Boolean).join(" ");
if (managing && confirming) {
return [
`<div class="${classes}" data-user-id="${account.user_id}">`,
`<p class="login-confirm-copy">移除「${escapeHtml(account.username)}」的本机记录?</p>`,
'<div class="login-confirm-actions">',
`<button class="button danger-button" type="button" data-forget-id="${account.user_id}">移除</button>`,
'<button class="button" type="button" data-login-action="cancel-forget">取消</button>',
"</div></div>",
].join("");
}
const glyph = escapeHtml(String(account.username || "账").slice(0, 1));
const tone = Number(account.user_id || 0) % 4;
const used = formatLastUsed(account.last_used_at);
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-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}${resumeAttr}>`,
`<span class="login-avatar tone-${tone}" aria-hidden="true">${glyph}</span>`,
'<div class="login-account-meta">',
'<div class="login-account-name">',
`<strong>${escapeHtml(account.username)}</strong>`,
chipsFor(account, current),
"</div>",
used ? `<span class="login-account-used">上次登录 ${escapeHtml(used)}</span>` : "",
"</div>",
action,
"</div>",
].join("");
}
function pickerMarkup() {
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
? "移除只删除这台电脑上的登录记录,不会注销账号"
: `这台电脑已记录 ${count} 个账号,点选即可进入,无需再次输入密码。`}</p>`,
managing
? '<div class="login-manage-toolbar"><p class="login-manage-hint">点击右侧图标移除对应记录</p><button class="login-manage-done" type="button" data-login-action="picker">完成</button></div>'
: "",
`<div class="login-account-list">${state.accounts.map(accountRow).join("")}</div>`,
managing
? ""
: '<button class="login-add" type="button" data-login-action="add"> 添加账号</button>',
managing
? ""
: '<button class="login-manage" type="button" data-login-action="manage">管理已记录的账号</button>',
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : "",
managing
? '<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>移除后再次登录该账号需重新输入密码</p>'
: '<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>账号记录仅保存在这台电脑的浏览器中</p>',
].join("");
}
function render() {
card.classList.toggle("is-loading", state.loading);
if (state.view === "first" || state.view === "add") {
card.innerHTML = formMarkup({
title: state.view === "add" ? "添加账号" : "欢迎回来",
lead: state.view === "add" ? "登录另一个账号,添加后可随时一键切换" : "登录后进入你的复盘空间",
hint: state.view === "add"
? "添加后账号会保存在这台电脑,方便随时切换。"
: "密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。",
add: state.view === "add",
back: state.view === "add",
});
} else {
card.innerHTML = pickerMarkup();
}
bindCard();
if (mascots) mascots.sync();
}
function bindCard() {
card.querySelectorAll("[data-auth-mode]").forEach((button) => {
button.addEventListener("click", () => {
state.mode = button.dataset.authMode === "register" ? "register" : "login";
setError("");
render();
});
});
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;
} else if (action === "add") {
state.view = "add";
state.mode = "login";
} else if (action === "manage") {
state.view = "manage";
} else if (action === "cancel-forget") {
state.confirmingId = null;
}
setError("");
render();
});
});
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();
state.confirmingId = Number(button.dataset.confirmId);
render();
});
});
card.querySelectorAll("[data-forget-id]").forEach((button) => {
button.addEventListener("click", () => forgetAccount(Number(button.dataset.forgetId)));
});
const form = card.querySelector("#loginForm");
if (form) form.addEventListener("submit", submitCredentials);
}
async function loadAccounts() {
const payload = await api.request("/api/auth/accounts");
state.accounts = payload.accounts || [];
state.currentUserId = payload.current_user_id ?? null;
const params = new URLSearchParams(global.location.search);
if (params.get("mode") === "register") state.mode = "register";
if (params.get("notice")) setError(params.get("notice"));
if (state.accounts.length) state.view = "picker";
else state.view = "first";
}
async function submitCredentials(event) {
event.preventDefault();
const username = document.querySelector("#loginUsername").value.trim();
const password = document.querySelector("#loginPassword").value;
state.username = username;
state.password = password;
if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) {
setError("两次输入的密码不一致。");
render();
return;
}
state.loading = true;
setError("");
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();
}
}
async function switchAccount(userId) {
state.loading = true;
setError("");
render();
try {
await api.request("/api/auth/switch", "POST", { user_id: userId });
enterApp();
} catch (error) {
state.loading = false;
setError(error.message || "该账号需重新验证");
render();
}
}
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 });
state.accounts = state.accounts.filter((item) => Number(item.user_id) !== Number(userId));
state.confirmingId = null;
if (!state.accounts.length) state.view = "first";
setError("");
render();
} catch (error) {
setError(error.message || "移除失败");
render();
}
}
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);
});
applyTheme(preferredTheme(), false);
loadAccounts()
.then(render)
.catch((error) => {
setError(error.message || "无法连接本地服务");
state.view = "first";
render();
});
})(window);
-455
View File
@@ -3174,458 +3174,3 @@
border-top: 1px solid var(--border);
transform: translateY(calc(-1 * var(--m-keyboard-inset, 0px)));
}
.m-sys-lead {
margin: 8px 0 12px;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.5;
}
.m-sys-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0 0 12px;
}
.m-sys-grid div {
padding: 12px;
border-radius: 8px;
background: var(--surface-muted);
}
.m-sys-grid span {
display: block;
font-size: var(--font-size-label);
color: var(--text-tertiary);
}
.m-sys-grid strong {
display: block;
margin-top: 4px;
font-size: var(--font-size-body);
color: var(--text-primary);
}
.m-sys-home {
padding: 0 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.m-sys-body {
padding: 12px 12px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.m-sys-home .m-card,
.m-sys-body .m-card {
margin-left: 0;
margin-right: 0;
}
.m-sys-profile-card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px;
}
.m-sys-avatar {
flex: 0 0 auto;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--action-soft);
color: var(--action);
font-size: var(--font-size-page-title);
font-weight: 600;
}
.m-sys-profile-card strong {
display: block;
font-size: var(--font-size-card-title);
color: var(--text-primary);
}
.m-sys-profile-meta {
margin: 4px 0 8px;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.m-sys-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: var(--font-size-aux);
font-weight: 600;
background: var(--surface-muted);
color: var(--text-secondary);
}
.m-sys-badge--admin {
background: var(--action-soft);
color: var(--action);
}
.m-sys-badge--ok {
background: var(--market-down-soft);
color: var(--market-down);
}
.m-sys-group-title {
margin: 4px 0 0;
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--text-tertiary);
}
.m-sys-list {
padding: 0;
overflow: hidden;
}
.m-sys-row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-sys-row + .m-sys-row {
border-top: 1px solid var(--border);
}
.m-sys-row:active {
background: var(--surface-hover);
}
.m-sys-row-icon {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--action-soft);
color: var(--action);
}
.m-sys-row--danger .m-sys-row-icon {
background: var(--market-up-soft);
color: var(--market-up);
}
.m-sys-row-body {
flex: 1;
min-width: 0;
}
.m-sys-row-body strong {
display: block;
font-size: var(--font-size-body);
font-weight: 600;
color: var(--text-primary);
}
.m-sys-row-body small {
display: block;
margin-top: 2px;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-row-chevron {
flex: 0 0 auto;
color: var(--text-tertiary);
}
.m-sys-foot {
margin: 8px 0 0;
text-align: center;
font-size: var(--font-size-caption);
color: var(--text-tertiary);
}
.m-sys-notice {
padding: 12px;
border-radius: 12px;
background: var(--surface-muted);
}
.m-sys-notice p {
margin: 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.55;
}
.m-sys-notice .m-sys-badges {
margin-top: 8px;
}
.m-sys-section {
padding: 12px;
}
.m-sys-section > strong,
.m-sys-section-title {
display: block;
margin-bottom: 10px;
font-size: var(--font-size-card-title);
color: var(--text-primary);
}
.m-sys-hint {
margin: 8px 0 0;
font-size: var(--font-size-caption);
color: var(--text-tertiary);
line-height: 1.45;
}
.m-sys-status-list {
display: grid;
gap: 8px;
}
.m-sys-status-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: var(--font-size-body);
color: var(--text-primary);
}
.m-sys-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-tertiary);
}
.m-sys-dot--ok {
background: var(--market-down);
}
.m-sys-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.m-sys-switch-row strong {
display: block;
font-size: var(--font-size-body);
margin-bottom: 0;
}
.m-btn-outline,
.m-btn-outline-danger {
width: 100%;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
background: var(--surface);
font-size: var(--font-size-body);
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-btn-outline {
border: 1px solid var(--action);
color: var(--action);
}
.m-btn-outline:active {
background: var(--action-soft);
}
.m-btn-outline-danger {
border: 1px solid var(--market-up);
color: var(--market-up);
}
.m-btn-outline-danger:active {
background: var(--market-up-soft);
}
.m-btn-outline:disabled,
.m-btn-outline-danger:disabled {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.m-sys-model-card .m-sys-badges,
.m-sys-user-row .m-sys-badges {
margin-top: 6px;
}
.m-sys-model-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 12px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.m-sys-model-card + .m-sys-model-card {
border-top: 1px solid var(--border);
}
.m-sys-model-card:active {
background: var(--surface-hover);
}
.m-sys-user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
}
.m-sys-user-row + .m-sys-user-row {
border-top: 1px solid var(--border);
}
.m-sys-user-row .m-btn-outline {
width: auto;
height: 32px;
padding: 0 12px;
flex: 0 0 auto;
}
.m-sys-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.m-sheet-root.is-dialog .m-sheet {
display: none;
}
.m-dialog {
position: absolute;
left: 50%;
top: 42%;
width: calc(100% - 48px);
max-width: 320px;
transform: translate(-50%, -46%) scale(0.96);
border-radius: 12px;
background: var(--surface);
box-shadow: var(--elevation-float);
padding: 18px 16px 14px;
z-index: 3;
opacity: 0;
transition: opacity var(--motion-enter) var(--ease-enter),
transform var(--motion-enter) var(--ease-enter);
}
.m-sheet-root.is-open .m-dialog {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.m-dialog h2 {
margin: 0 0 8px;
font-size: var(--font-size-card-title);
color: var(--text-primary);
text-align: center;
}
.m-dialog p {
margin: 0 0 16px;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.5;
text-align: center;
}
.m-dialog-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.m-dialog-actions .m-btn-outline,
.m-dialog-actions .m-btn-outline-danger,
.m-dialog-actions .m-btn-primary {
height: 40px;
}
.m-sys-sheet-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.m-sys-test-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0 12px;
}
.m-sys-test-row .m-btn-outline {
width: auto;
flex: 0 0 auto;
height: 36px;
padding: 0 12px;
}
.m-sys-test-row [data-model-test-status] {
flex: 1;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-hero {
padding: 14px;
}
.m-sys-hero strong {
display: block;
font-size: var(--font-size-page-title);
margin-bottom: 6px;
}
.m-page[data-page^="system/"] .m-card {
margin-bottom: 0;
}
+2 -35
View File
@@ -246,29 +246,6 @@ body {
padding-top: 8px;
}
.m-auth-accounts {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.m-auth-account {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text-primary);
}
.m-auth-account span {
color: var(--action);
font-size: var(--font-size-label);
}
.m-auth-brand {
text-align: center;
margin: 24px 0 20px;
@@ -336,8 +313,7 @@ body {
color: var(--text-secondary);
}
.m-form-field input,
.m-form-field select {
.m-form-field input {
height: 44px;
padding: 0 12px;
border: 1px solid var(--border-strong);
@@ -347,20 +323,11 @@ body {
font-size: 14px;
}
.m-form-field input:focus,
.m-form-field select:focus {
.m-form-field input:focus {
outline: none;
border-color: var(--action);
}
.m-form-field input[type="checkbox"] {
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
}
.m-auth-error {
margin: 0 0 12px;
padding: 10px 12px;
+1 -3
View File
@@ -15,9 +15,7 @@
(() => {
let theme = "light";
try {
const stored = localStorage.getItem("xiaobaiTheme");
if (stored === "dark" || stored === "light") theme = stored;
else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) theme = "dark";
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
} catch (_error) {
theme = "light";
}
+1 -6
View File
@@ -5,15 +5,10 @@
function readTheme() {
try {
const stored = global.localStorage.getItem(THEME_KEY);
if (stored === "dark" || stored === "light") return stored;
return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
} catch (_error) {
return "light";
}
if (global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
function applyTheme(theme) {
+9 -912
View File
File diff suppressed because it is too large Load Diff
+2 -36
View File
@@ -187,9 +187,9 @@
const dark = document.getElementById("m-app").dataset.theme === "dark";
const toggle = document.querySelector("[data-theme-toggle]");
if (toggle) toggle.setAttribute("aria-checked", dark ? "true" : "false");
const rowIcon = document.querySelector(".m-theme-row-icon, [data-theme-row-icon]");
const rowIcon = document.querySelector(".m-theme-row-icon");
if (rowIcon) rowIcon.innerHTML = icon(dark ? "moon" : "sun");
const rowBodySmall = document.querySelector(".m-theme-row-body small, [data-theme-row-label]");
const rowBodySmall = document.querySelector(".m-theme-row-body small");
if (rowBodySmall) rowBodySmall.textContent = dark ? "当前:夜间模式" : "当前:日间模式";
}
@@ -206,10 +206,6 @@
replace(DEFAULT_HASH);
return;
}
if (key === "system" && global.MobilePages && typeof global.MobilePages.renderSystemHome === "function") {
global.MobilePages.renderSystemHome();
return;
}
const items = visibleHubItems(hub);
updateHeader({ title: hub.title, back: false });
const section = key === "system" ? themeToggleSection() : "";
@@ -254,7 +250,6 @@
'<h2>小白复盘</h2>',
'<p>登录后进入你的复盘空间</p>',
"</div>",
'<div class="m-auth-accounts" id="m-auth-accounts" hidden></div>',
'<div class="m-auth-tabs">',
'<button class="m-auth-tab active" type="button" data-auth-mode="login">登录</button>',
'<button class="m-auth-tab" type="button" data-auth-mode="register">注册</button>',
@@ -270,7 +265,6 @@
].join("");
authMode = "login";
bindAuth();
loadMobileAccounts();
}
function setAuthMode(mode) {
@@ -293,34 +287,6 @@
document.getElementById("m-auth-form").addEventListener("submit", submitAuth);
}
async function loadMobileAccounts() {
const host = document.getElementById("m-auth-accounts");
if (!host || !global.MobileSession.listAccounts) return;
try {
const payload = await global.MobileSession.listAccounts();
const accounts = payload.accounts || [];
if (!accounts.length) return;
host.hidden = false;
host.innerHTML = accounts.map(function (account) {
return '<button class="m-auth-account" type="button" data-switch-id="' + account.user_id + '">' +
'<strong>' + escapeHtml(account.username) + '</strong>' +
'<span>直接进入</span></button>';
}).join("");
host.querySelectorAll("[data-switch-id]").forEach(function (button) {
button.addEventListener("click", async function () {
try {
await global.MobileSession.switchAccount(Number(button.dataset.switchId));
replace(DEFAULT_HASH);
} catch (error) {
showAuthError(document.getElementById("m-auth-error"), error.message || "该账号需重新验证");
}
});
});
} catch (_error) {
host.hidden = true;
}
}
function showAuthError(element, message) {
element.textContent = message;
element.classList.remove("m-motion-fade-in");
+1 -26
View File
@@ -47,30 +47,5 @@
return Boolean(state.user && state.user.role === "admin");
}
async function listAccounts() {
return global.MobileAPI.request("/api/auth/accounts");
}
async function switchAccount(userId) {
const payload = await global.MobileAPI.request("/api/auth/switch", "POST", {
user_id: userId
});
return applySession(payload);
}
async function forgetAccount(userId) {
await global.MobileAPI.request("/api/auth/forget", "POST", { user_id: userId });
}
global.MobileSession = {
state: state,
me: me,
login: login,
register: register,
logout: logout,
listAccounts: listAccounts,
switchAccount: switchAccount,
forgetAccount: forgetAccount,
isAdmin: isAdmin
};
global.MobileSession = { state: state, me: me, login: login, register: register, logout: logout, isAdmin: isAdmin };
})(window);
+1 -1
View File
@@ -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=20260829-hel243",
"/shared/session.js?v=20260803-1",
"/shared/admin.js?v=20260803-1",
"/app.js?v=20260803-2",
];
+243 -3
View File
@@ -93,6 +93,28 @@ body[data-active-view="heavenView"] .workspace-view {
display: none;
}
:where(#heavenView) .heaven-tabs {
display: flex;
border-bottom: 1px solid var(--line);
}
:where(#heavenView) .heaven-tab {
border-bottom: 3px solid transparent;
background: transparent;
cursor: pointer;
}
:where(#heavenView) .heaven-tab.active {
border-bottom-color: var(--coral);
}
:where(#heavenView) .heaven-tab:hover {
border-bottom-color: var(--coral);
}
:where(#heavenView) .heaven-panel {
display: none;
}
@@ -1853,6 +1875,60 @@ body[data-active-view="heavenView"] .workspace-view {
color: var(--heaven-ink-faint);
}
#heavenView .heaven-tab {
font-family: var(--heaven-serif);
height: 52px;
position: relative;
padding: 0px 2px;
border: 0px;
color: var(--heaven-ink-soft);
font-size: 14px;
font-weight: 600;
}
#heavenView .heaven-tab::after {
content: "";
position: absolute;
right: 0px;
bottom: 0px;
left: 0px;
height: 2px;
background: var(--heaven-cinnabar);
opacity: 0;
transform: scaleX(0.3);
transition: opacity 220ms ease, transform 260ms var(--ease-out);
}
#heavenView .heaven-tab.active {
color: var(--heaven-ink);
}
#heavenView .heaven-tab:hover {
color: var(--heaven-ink);
}
#heavenView .heaven-tab.active::after {
opacity: 1;
transform: scaleX(1);
}
:where(#heavenView) .heaven-proverb {
margin: 0px;
@@ -1884,7 +1960,7 @@ body[data-active-view="heavenView"] .workspace-view {
}
#heavenView .button:focus-visible,
#heavenView .segment:focus-visible,
#heavenView .heaven-tab:focus-visible,
#heavenView summary:focus-visible {
outline: 2px solid var(--heaven-cinnabar);
@@ -2547,6 +2623,12 @@ body[data-active-view="heavenView"] .workspace-view {
padding: 0px 14px;
}
#heavenView .heaven-tabs {
gap: 22px;
padding: 0px 14px;
}
.heaven-proverb {
padding: 8px 14px;
@@ -3011,7 +3093,7 @@ body[data-active-view="heavenView"] .workspace-view {
#heavenView.heaven-data-loading .heaven-panel,
#heavenView.heaven-data-loading .heaven-proverb,
#heavenView.heaven-data-loading .heaven-page-head {
#heavenView.heaven-data-loading .heaven-tabs {
opacity: 0.42;
pointer-events: none;
@@ -3613,7 +3695,7 @@ body[data-active-view="heavenView"] .workspace-view {
@media (max-width: 900px) {
#heavenView .heaven-panel > ,
#heavenView .heaven-proverb,
#heavenView .heaven-page-head,
#heavenView .heaven-tabs,
#heavenView .heaven-toolbar {
width: min(100% - 28px, 1280px);
}
@@ -3664,6 +3746,22 @@ body[data-active-view="heavenView"] .workspace-view {
display: none;
}
#heavenView .heaven-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0px, 1fr));
gap: 0px;
padding: 0px;
}
#heavenView .heaven-tab {
width: 100%;
min-width: 0px;
}
#heavenFortunePanel .fortune-heading {
display: flex;
@@ -3769,6 +3867,18 @@ body[data-active-view="heavenView"] .workspace-view {
}
@media (max-width: 520px) {
.heaven-tabs {
gap: 0px;
padding: 0px 8px;
}
.heaven-tab {
min-width: 0px;
flex: 1 1 0%;
}
.heaven-controls {
grid-template-columns: 1fr;
@@ -6122,6 +6232,26 @@ body[data-active-view="heavenView"] .workspace-view {
padding: 12px 20px;
}
#heavenView .heaven-tabs {
align-items: stretch;
gap: 30px;
border-color: var(--heaven-rule);
background: rgba(253, 252, 248, 0.96);
width: min(100% - 40px, 1280px);
margin-right: auto;
margin-left: auto;
min-height: 54px;
padding: 0px 20px;
}
#heavenView .heaven-proverb {
margin-right: auto;
@@ -6167,6 +6297,12 @@ body[data-active-view="heavenView"] .workspace-view {
padding: 10px 14px;
}
#heavenView .heaven-tabs {
min-height: 52px;
padding: 0px 14px;
}
#heavenView .heaven-proverb {
padding: 9px 14px;
}
@@ -6532,6 +6668,18 @@ body[data-active-view="heavenView"] .workspace-view {
color: var(--wt-faint);
}
:root[data-theme="light"] #heavenView .wt-tabs .wt-tab {
color: var(--wt-muted);
}
:root[data-theme="light"] #heavenView .wt-tabs .wt-tab small {
color: var(--wt-faint);
}
:root[data-theme="light"] #heavenView .wt-tabs .wt-tab.on {
color: var(--wt-gold-bright);
}
:root[data-theme="light"] #heavenView .wt-empty {
color: var(--wt-muted);
}
@@ -6980,6 +7128,88 @@ body[data-active-view="heavenView"] .workspace-view {
letter-spacing: 4px;
}
.wt-tabs {
display: flex;
justify-content: center;
gap: 34px;
margin-top: 20px;
}
.wt-tabs .wt-tab {
position: relative;
padding: 8px 4px;
border: 0px;
background: transparent;
color: rgba(216, 210, 189, 0.5);
font-size: 15px;
letter-spacing: 3px;
transition: color 0.2s;
}
.wt-tabs .wt-tab small {
display: block;
margin-top: 3px;
color: rgba(216, 210, 189, 0.3);
font-family: inherit;
font-size: 10px;
letter-spacing: 1px;
}
.wt-tabs .wt-tab::after {
content: "";
position: absolute;
bottom: -2px;
left: 50%;
width: 0px;
height: 1.5px;
background: var(--wt-gold);
transform: translateX(-50%);
transition: 0.25s;
}
.wt-tabs .wt-tab.on {
color: var(--wt-gold-bright);
}
.wt-tabs .wt-tab.on::after {
width: 100%;
}
.wt-tabs .wt-tab:disabled {
cursor: default;
}
.wt-tabs .wt-tab:focus {
outline: none;
}
.wt-tabs .wt-tab:focus-visible {
box-shadow: rgba(201, 165, 92, 0.45) 0px 2px 0px;
}
.heaven-proverb {
margin: 8px 0px 0px;
@@ -8842,6 +9072,16 @@ body[data-active-view="heavenView"] .workspace-view {
gap: 5px;
}
.wt-tabs {
gap: 16px;
}
.wt-tabs .wt-tab {
font-size: 13px;
letter-spacing: 2px;
}
.heaven-proverb {
text-align: center;
}
+6 -13
View File
@@ -1,23 +1,16 @@
<section id="heavenView" class="workspace-view member-feature-view heaven-shell wt">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问天仅对会员开放</strong><span>开通会员后可使用观势、观气、观心及平台解读。会员状态可从顶部账号标识进入。</span></div></div>
<div class="section-toolbar redesigned-page-head heaven-page-head">
<div class="section-title-group">
<h2>问天</h2>
<span class="section-subtitle"><b id="heavenDataDate">--</b></span>
</div>
<div class="toolbar-controls">
<div class="segmented" role="group" aria-label="问天模块">
<button class="segment active on" type="button" data-heaven-panel="trend" aria-current="page">观势</button>
<button class="segment" type="button" data-heaven-panel="fortune">观气</button>
<button class="segment" type="button" data-heaven-panel="heart">观心</button>
</div>
</div>
</div>
<header class="wt-head">
<div class="wt-title-line">
<h1 class="wt-serif">问 天</h1>
<span id="heavenDataDate">--</span>
</div>
<div class="verse wt-serif">观天之道 · 执天之行</div>
<nav class="wt-tabs" aria-label="问天模块">
<button class="wt-tab wt-serif on" type="button" data-heaven-panel="trend" aria-current="page">观势<small>三才六爻 · 量化成卦</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="fortune">观气<small>五运六气 · 日辰生克</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="heart">观心<small>静心占卜 · 第一念</small></button>
</nav>
</header>
<p class="heaven-proverb wt-serif">遇事不决可问春风,春风不语即随本心</p>
<div id="heavenNotice" class="inline-notice" role="status" hidden></div>
+1 -2
View File
@@ -1363,8 +1363,7 @@ async function interpretHeaven(mode) {
} catch (error) {
stopHeavenReadingAnimation();
state.heavenReadingLoading = false;
const detail = error?.payload?.message || error?.payload?.error || error.message;
state.heavenReadingError = detail || "问天解读失败";
state.heavenReadingError = error.message || "问天解读失败";
renderHeavenReadingDialog();
showHeavenNotice(state.heavenReadingError);
showToast(state.heavenReadingError);
+1 -1
View File
@@ -49,7 +49,7 @@ body[data-active-view="mentorView"] .app-page-context span {
height: 100%;
min-height: 0;
flex-direction: column;
padding: var(--page-pad-y) 0 0;
padding: 0;
color: var(--qp-text-1);
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
+105 -71
View File
@@ -1011,9 +1011,9 @@
border-radius: 6px;
background: var(--sentiment-tooltip-bg);
background: var(--r2-ink);
color: var(--sentiment-tooltip-fg);
color: var(--text-inverse);
font-size: 11px;
@@ -1026,6 +1026,16 @@
font-weight: 700;
}
:root[data-theme="dark"] #sentimentCycleView .sentiment-chart-tooltip {
border: 1px solid var(--border-strong);
background: var(--surface-muted);
color: var(--text-primary);
box-shadow: var(--elevation-float);
}
.sentiment-phase-block {
display: flex;
@@ -1570,6 +1580,10 @@
#sentimentCycleView .sentiment-component-item {
padding: 4px 0px;
}
#sentimentCycleView .sentiment-component-item small {
display: none;
}
}
@media (min-width: 768px) {
@@ -1671,6 +1685,95 @@
}
}
@media (min-width: 1024px) {
.redesigned-emotion-grid,
.emo-grid {
align-items: stretch;
}
.redesigned-sentiment-view .sentiment-analysis-main {
height: max-content;
}
.redesigned-sentiment-view .sentiment-analysis-rail {
height: 0;
min-height: 100%;
overflow: auto;
}
.redesigned-sentiment-view .sentiment-trend-panel {
display: flex;
flex-direction: column;
}
.redesigned-sentiment-view .sentiment-chart-shell {
flex: 0 0 auto;
height: min(350px, 40vh);
}
.redesigned-sentiment-view .sentiment-chart-shell canvas {
height: 100%;
}
.redesigned-sentiment-view .sentiment-cycle-summary {
flex: 0 0 auto;
}
.redesigned-sentiment-view .sentiment-components-panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.redesigned-sentiment-view .sentiment-component-list {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 4px 16px 8px;
}
.redesigned-sentiment-view .sentiment-component-item {
padding: 4px 0px;
}
.redesigned-sentiment-view .sentiment-component-item small {
display: none;
}
.redesigned-sentiment-view .sentiment-phase-block {
gap: 12px;
padding: 10px 12px;
}
.redesigned-sentiment-view .sentiment-current-phase-badge {
padding: 8px 12px;
}
.redesigned-sentiment-view .sentiment-phase-advice {
margin-top: 4px;
padding: 4px 8px;
line-height: 1.4;
}
.redesigned-sentiment-view .sentiment-feedback-strip > span {
padding: 4px 10px;
}
}
@media (min-width: 1024px) {
.sentiment-cycle-summary {
grid-template-columns: minmax(270px, 1.25fr) repeat(3, minmax(160px, 1fr));
@@ -1762,72 +1865,3 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
#sentimentCycleView .redesigned-emotion-grid {
align-items: stretch;
height: var(--sentiment-analysis-height);
max-height: var(--sentiment-analysis-height);
overflow: hidden;
}
.redesigned-sentiment-view .sentiment-analysis-main,
.redesigned-sentiment-view .sentiment-analysis-rail {
align-self: stretch;
height: 100%;
min-height: 0px;
}
.redesigned-sentiment-view .sentiment-trend-panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0px;
}
.redesigned-sentiment-view .sentiment-chart-shell {
flex: 1 1 auto;
height: auto;
min-height: 0px;
}
.redesigned-sentiment-view .sentiment-chart-shell canvas {
height: 100%;
}
.redesigned-sentiment-view .sentiment-cycle-summary {
flex: 0 0 auto;
}
.redesigned-sentiment-view .sentiment-components-panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0px;
}
.redesigned-sentiment-view .sentiment-component-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: space-evenly;
min-height: 0px;
}
}
+4 -38
View File
@@ -1,39 +1,10 @@
let sentimentChartAnimationFrame = null;
let sentimentChartResizeObserver = null;
let sentimentChartLastSize = "";
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
bind: bindSentimentEvents,
enter: ["loadSentiment"],
});
function sentimentChartSizeKey(target) {
if (!target) return "";
const rect = target.getBoundingClientRect();
return `${Math.round(rect.width)}x${Math.round(rect.height)}x${window.devicePixelRatio || 1}`;
}
function observeSentimentTrendChart() {
const shell = document.querySelector("#sentimentCycleView .sentiment-chart-shell");
if (!shell) return;
if (!sentimentChartResizeObserver) {
sentimentChartResizeObserver = new ResizeObserver(() => {
if (sentimentChartAnimationFrame) return;
if (state.activeView !== "sentimentCycleView") return;
const rows = state.sentimentHistory?.rows;
if (!rows?.length) return;
const current = document.querySelector("#sentimentCycleView .sentiment-chart-shell");
const nextKey = sentimentChartSizeKey(current);
if (!nextKey || nextKey === sentimentChartLastSize) return;
drawSentimentTrendChart(rows, 1);
});
} else {
sentimentChartResizeObserver.disconnect();
}
sentimentChartLastSize = sentimentChartSizeKey(shell);
sentimentChartResizeObserver.observe(shell);
}
async function loadSentimentHistory(force = false) {
if (!state.dashboard || state.sentimentLoading) return;
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
@@ -155,7 +126,6 @@ function animateSentimentTrendChart(rows) {
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
if (!motionEnabled()) {
drawSentimentTrendChart(rows, 1);
observeSentimentTrendChart();
return;
}
const startedAt = performance.now();
@@ -165,10 +135,7 @@ function animateSentimentTrendChart(rows) {
const progress = 1 - (1 - rawProgress) ** 3;
drawSentimentTrendChart(rows, progress);
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
else {
sentimentChartAnimationFrame = null;
observeSentimentTrendChart();
}
else sentimentChartAnimationFrame = null;
};
sentimentChartAnimationFrame = requestAnimationFrame(frame);
}
@@ -177,9 +144,9 @@ function drawSentimentTrendChart(rows, progress = 1) {
const canvas = document.querySelector("#sentimentTrendChart");
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
const rect = canvas.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) return;
const width = rect.width;
const height = rect.height;
if (!rect.width) return;
const width = Math.max(320, rect.width);
const height = Math.max(220, rect.height);
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
@@ -291,7 +258,6 @@ function drawSentimentTrendChart(rows, progress = 1) {
const dateText = displayCompactDate(row.trade_date).slice(5);
context.fillText(dateText, x(index), height - padding.bottom + 10);
});
sentimentChartLastSize = sentimentChartSizeKey(canvas.closest(".sentiment-chart-shell"));
}
function bindSentimentChartTooltip(rows) {
+1 -8
View File
@@ -7,14 +7,7 @@ async function backfillData() {
start_date: document.querySelector("#backfillStart").value,
end_date: document.querySelector("#backfillEnd").value,
});
const failed = (payload.failed_count || 0);
const skipped = (payload.skipped_non_trading_days || []).length;
const suffix = failed
? `,失败 ${failed}`
: skipped
? `,跳过 ${skipped} 个非交易日`
: "";
showToast(`历史回补完成,共处理 ${payload.results.length} 个交易日${suffix}`);
showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`);
state.sentimentHistory = null;
state.sentimentHistoryKey = "";
if (state.activeView === "sentimentCycleView") {
+2 -19
View File
@@ -46,29 +46,12 @@
}
}
function readableRequestError(error) {
const message = String(error?.message || "");
if (
error instanceof TypeError
|| /failed to fetch|networkerror|load failed|network request failed/i.test(message)
) {
return "网络请求失败,服务暂时不可用,请稍后重试。";
}
return message || "请求失败";
}
async function request(url, method = "GET", body = null, options = {}) {
let response;
try {
response = await fetch(url, requestOptions(method, body, options.signal));
} catch (error) {
throw new ApiError(readableRequestError(error), 0, null);
}
const response = await fetch(url, requestOptions(method, body, options.signal));
const payload = await parseJson(response);
handleUnauthorized(response, url);
if (!response.ok || payload.error) {
const message = payload.message || payload.error || "请求失败";
throw new ApiError(message, response.status, payload);
throw new ApiError(payload.error || "请求失败", response.status, payload);
}
return payload;
}
File diff suppressed because it is too large Load Diff
+2 -10
View File
@@ -660,9 +660,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
text-align: left;
height: var(--size-statusbar);
min-height: var(--size-statusbar);
min-height: 38px;
display: flex;
@@ -672,7 +670,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
margin: auto -8px -8px;
padding: 0 16px;
padding: 10px 16px;
border-right: 0px;
@@ -693,12 +691,6 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
font-size: 12px;
}
.sidebar-collapse-button .lucide {
width: 14px;
height: 14px;
}
body.sidebar-collapsed .sidebar-collapse-button span {
display: none;
}
+4 -58
View File
@@ -40,71 +40,17 @@ async function loadDashboard(force = false, background = false, showOverlay = tr
async function startAdminRefresh() {
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
buttons.forEach((button) => { button.disabled = true; });
const requestedDate = elements.tradeDate.value;
setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle");
try {
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate });
if (!payload.started || !payload.job_key) {
setAdminRefreshStatus("warning", "已有刷新任务正在运行,请稍后再试。", "clock-3");
showToast(payload.message || "已有后台刷新任务正在运行");
return;
}
setStatus(`正在刷新 ${requestedDate} 的行情`);
const job = await waitForAdminRefresh(payload.job_key);
if (job.status === "failed") {
const reason = job.message || job.error_code || "数据源未返回结果";
setAdminRefreshStatus("failure", `刷新失败:${reason}`, "circle-x");
setStatus("后台刷新失败");
showToast("后台刷新失败");
return;
}
const query = new URLSearchParams({ trade_date: requestedDate });
const dashboard = await apiRequest(`/api/dashboard?${query}`);
applyDashboard(dashboard);
const meta = dashboard.meta || {};
const actualDate = String(meta.trade_date || "").slice(0, 10);
const requestedCompact = requestedDate.replaceAll("-", "");
const actualCompact = actualDate.replaceAll("-", "");
const updated = formatTimestamp(meta.updated_at);
if (actualCompact !== requestedCompact || meta.carried_forward) {
const reason = meta.notice ? `${meta.notice}` : "";
setAdminRefreshStatus("warning", `刷新已完成,但没有获取到 ${requestedDate} 的最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert");
showToast("刷新完成,但未获取到所选日期的最新行情");
} else if (meta.notice) {
setAdminRefreshStatus("warning", `已刷新到 ${actualDate}${updated}),但数据源提示:${meta.notice}`, "triangle-alert");
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
} else {
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`, "circle-check");
showToast(`刷新成功:已获取 ${actualDate} 的最新行情`);
}
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
showToast(payload.message || "后台刷新已提交");
setStatus("后台刷新运行中,当前页面保持不变");
} catch (error) {
const message = error.message || "后台刷新失败";
setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x");
setStatus("后台刷新失败");
showToast(message);
showToast(error.message || "后台刷新启动失败");
} finally {
buttons.forEach((button) => { button.disabled = false; });
}
}
function setAdminRefreshStatus(tone, message, icon = "circle-dot") {
const status = document.querySelector("#adminRefreshStatus");
if (!status) return;
status.dataset.tone = tone;
status.innerHTML = `<i data-lucide="${icon}"></i><span>${escapeHtml(message)}</span>`;
refreshIcons();
}
async function waitForAdminRefresh(jobKey) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const payload = await apiRequest("/api/admin/settings");
const job = (payload.data?.jobs || []).find((item) => item.idempotency_key === jobKey);
if (job && ["success", "failed"].includes(job.status)) return job;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("刷新等待超时,请稍后重试");
}
function applyDashboard(payload, background = false) {
state.dashboard = payload;
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
+14 -10
View File
@@ -53,10 +53,12 @@ async function applyAuthenticatedSession(session) {
function showAuthGate(message = "") {
state.user = null;
state.csrfToken = "";
const params = new URLSearchParams();
if (message) params.set("notice", message);
const query = params.toString();
window.location.replace("/login/" + (query ? `?${query}` : ""));
const gate = document.querySelector("#authGate");
gate.hidden = false;
const errorElement = document.querySelector("#authError");
errorElement.textContent = message;
errorElement.hidden = !message;
document.querySelector("#authUsername").focus();
}
async function logoutAccount() {
@@ -195,14 +197,16 @@ async function changeAccountPassword(event) {
}
async function switchAccount() {
const button = document.querySelector("#switchAccountMenuButton");
button.disabled = true;
toggleAccountDropdown(false);
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);
try {
await apiRequest("/api/auth/logout", "POST", {});
window.location.reload();
} catch (error) {
showToast(error.message || "切换账号失败");
button.disabled = false;
}
const query = params.toString();
window.location.assign("/login/" + (query ? `?${query}` : ""));
}
+4 -10
View File
@@ -1294,9 +1294,7 @@ body.sidebar-collapsed {
}
.sidebar-brand {
height: var(--size-topbar);
min-height: var(--size-topbar);
min-height: 55px;
display: flex;
@@ -1306,7 +1304,7 @@ body.sidebar-collapsed {
margin: 0px -8px 7px;
padding: 0 16px;
padding: 0px 16px;
border-bottom: 1px solid var(--r2-line-soft);
@@ -2148,17 +2146,13 @@ body.sidebar-collapsed .status-bar {
}
.module-nav .sidebar-brand {
height: var(--size-topbar);
min-height: var(--size-topbar);
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
padding: 14px 16px;
border-bottom: 1px solid var(--line-soft);
}
@@ -3500,7 +3494,7 @@ body.sidebar-collapsed .status-bar {
flex: 0 0 auto;
align-items: center;
gap: var(--header-action-gap);
margin-left: auto;
margin-left: 0;
overflow: visible;
}
-36
View File
@@ -146,32 +146,6 @@
--shadow-float: var(--elevation-float);
--duration-fast: 150ms;
--duration-normal: 220ms;
--login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc);
--login-brand-share: 34%;
--login-brand-min: 420px;
--login-brand-cap: 560px;
--login-brand-wide-share: 29.2%;
--login-brand-pad: 36px 40px 24px;
--login-brand-mark-size: 44px;
--login-brand-name-size: 16px;
--login-hero-size: 28px;
--login-card-width: 408px;
--login-card-pad: 32px;
--login-card-title-size: 22px;
--login-account-row-min: 72px;
--login-account-avatar: 40px;
--login-submit-height: 40px;
--login-stat-chip-bg: rgba(8, 12, 24, 0.48);
--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;
@@ -236,9 +210,6 @@
--pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap));
--sentiment-history-max-height: 510px;
--sentiment-history-min-height: 220px;
--sentiment-analysis-height: 600px;
--sentiment-tooltip-bg: var(--text-primary);
--sentiment-tooltip-fg: var(--text-inverse);
--primary-share: 1.45fr;
--secondary-share: .75fr;
--mobile-nav-height: 64px;
@@ -534,17 +505,10 @@
--chart-repair: #e2ad58;
--chart-ma-10: #d39a45;
--chart-ma-20: #9aa5af;
--sentiment-tooltip-bg: #26293e;
--sentiment-tooltip-fg: #e8eaed;
--on-action: #101418;
--warning-line: #6d5a38;
--warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
--login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a);
--login-stat-chip-bg: rgba(6, 8, 16, 0.58);
--login-candle-up: #f06d73;
--login-candle-down: #43bc8a;
--login-trend-line: rgba(232, 236, 244, 0.9);
--dialog-backdrop: var(--backdrop);
--ladder-level-1: #2d2426;
--ladder-level-2: #2b2822;
+127 -220
View File
@@ -1,4 +1,6 @@
const { test, expect } = require("@playwright/test");
const fs = require("fs");
const path = require("path");
const dashboard = {
meta: {
@@ -47,72 +49,6 @@ function session(role = "admin", subscribed = true) {
};
}
function sentimentHistoryPayload(days = 20) {
const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"];
const rows = Array.from({ length: days }, (_, index) => {
const score = 28 + ((index * 7) % 55);
return {
trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`,
score,
label: "情绪观察",
phase: phases[index % phases.length],
direction: index % 2 ? "升温" : "降温",
day_change: index % 2 ? 3.2 : -2.1,
seal_rate: 71.5,
limit_up_count: 40 + index,
first_board_count: 18,
second_board_count: 8,
three_plus_count: 4,
max_height: 5,
broken_count: 12,
limit_down_count: 3,
previous_limit_count: 38,
previous_positive_count: 22,
previous_positive_rate: 57.9,
average_previous_change: 1.2,
normalization: "固定锚点",
components: {
breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" },
limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" },
profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" },
ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" },
amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" },
},
};
});
return { available_days: days, rows };
}
async function renderSentimentFixture(page, days = 20) {
await page.evaluate((payload) => {
state.sentimentHistory = payload;
renderSentimentHistory();
}, sentimentHistoryPayload(days));
await page.locator('[data-view="sentimentCycleView"]').first().click();
await page.waitForTimeout(900);
}
function readSentimentLayout() {
const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect();
const small = document.querySelector(".sentiment-component-item small");
return {
topDelta: Math.abs(trend.top - summary.top),
bottomDelta: Math.abs(trend.bottom - components.bottom),
analysisHeight: analysis.height,
chartHeight: chart.height,
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5,
tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom,
smallVisible: small ? getComputedStyle(small).display !== "none" : false,
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
}
function waitForApplicationRuntime(page) {
return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true");
}
@@ -787,11 +723,11 @@ test("collapsed overview and sentiment layout keep a single current reading", as
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => {
const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
const rail = document.querySelector(".sentiment-analysis-rail").getBoundingClientRect();
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
const label = document.querySelector(".sentiment-block .metric-label");
const status = document.querySelector(".sentiment-block .sentiment-text");
@@ -799,11 +735,11 @@ test("collapsed overview and sentiment layout keep a single current reading", as
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return {
columnsAligned: Math.abs(trend.top - summary.top) < 1,
bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1,
analysisHeight: analysis.height,
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
bottomsAligned: Math.abs(trend.bottom - rail.bottom) < 2,
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
chartHeight: chart.height,
viewportHeight: window.innerHeight,
sameType: labelStyle.fontSize === statusStyle.fontSize
&& labelStyle.fontWeight === statusStyle.fontWeight
&& labelStyle.lineHeight === statusStyle.lineHeight,
@@ -812,11 +748,12 @@ test("collapsed overview and sentiment layout keep a single current reading", as
};
});
expect(alignment.columnsAligned).toBe(true);
expect(alignment.bottomsAligned).toBe(true);
expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1);
expect(alignment.railAligned).toBe(true);
expect(alignment.bottomsAligned).toBe(true);
expect(alignment.detailAfterAnalysis).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
expect(alignment.chartHeight).toBeLessThanOrEqual(360);
expect(alignment.chartHeight / alignment.viewportHeight).toBeLessThan(0.45);
expect(alignment.sameType).toBe(true);
expect(alignment.sameBaseline).toBe(true);
expect(alignment.noStatusOffset).toBe(true);
@@ -851,66 +788,132 @@ test("collapsed overview and sentiment layout keep a single current reading", as
}
});
test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => {
const shotDir = testInfo.outputPath("hel-221-shots");
function buildSentimentHistoryRows(count = 20) {
const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"];
return Array.from({ length: count }, (_, index) => {
const score = 28 + ((index * 7) % 55);
return {
trade_date: `2026-07-${String(index + 1).padStart(2, "0")}`,
score,
label: "情绪观察",
phase: phases[index % phases.length],
direction: index % 2 ? "升温" : "降温",
day_change: index % 2 ? 2.4 : -1.6,
seal_rate: 70 + (index % 10),
limit_up_count: 30 + index,
first_board_count: 20,
second_board_count: 6,
three_plus_count: 3,
max_height: 4,
broken_count: 17,
limit_down_count: 8,
previous_limit_count: 40,
previous_positive_count: 12,
previous_positive_rate: 30,
average_previous_change: -1.2,
normalization: "固定锚点",
history_days: count,
components: {
breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数偏少" },
limit: { label: "涨停生态", score: 79.0, weight: 25, summary: "封板率仍需确认" },
profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈偏弱" },
ladder: { label: "连板结构", score: 81.5, weight: 15, summary: "高度仍在压缩" },
amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能低于均值" },
},
};
});
}
function contrastRatio(rgbA, rgbB) {
const channel = (value) => {
const scaled = value / 255;
return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
};
const luminance = (rgb) => {
const [red, green, blue] = rgb.match(/\d+/g).map(Number);
return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue);
};
const first = luminance(rgbA);
const second = luminance(rgbB);
const [high, low] = first > second ? [first, second] : [second, first];
return (high + 0.05) / (low + 0.05);
}
test("sentiment trend chart keeps one-screen proportion across 4K-equivalent zooms", async ({ page }) => {
const screenshotDir = path.join("runtime", "hel217-screenshots");
fs.mkdirSync(screenshotDir, { recursive: true });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
const rows = buildSentimentHistoryRows(20);
await page.evaluate((historyRows) => {
state.sentimentHistory = { available_days: 20, rows: historyRows };
renderSentimentHistory();
}, rows);
await page.locator('[data-view="sentimentCycleView"]').first().click();
const viewports = [
{ name: "zoom-100", width: 2560, height: 1440 },
{ name: "zoom-110", width: 2327, height: 1309 },
{ name: "zoom-125", width: 2048, height: 1152 },
{ name: "4k150-100", width: 2560, height: 1440 },
{ name: "4k150-110", width: 2328, height: 1309 },
{ name: "4k150-125", width: 2048, height: 1152 },
];
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto("/index.html");
await renderSentimentFixture(page, 20);
const layout = await page.evaluate(readSentimentLayout);
expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1);
expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1);
expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1);
expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450);
expect(layout.detailAfterAnalysis, viewport.name).toBe(true);
expect(layout.tableVisible, viewport.name).toBe(true);
expect(layout.smallVisible, viewport.name).toBe(true);
expect(layout.overflowX, viewport.name).toBe(false);
await page.screenshot({
path: `${shotDir}/day-${viewport.name}.png`,
fullPage: true,
});
await page.waitForTimeout(200);
for (const theme of ["light", "dark"]) {
const current = await page.locator("html").getAttribute("data-theme");
if (current !== theme) {
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
}
const metrics = await page.evaluate(() => {
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const rail = document.querySelector(".sentiment-analysis-rail").getBoundingClientRect();
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
const history = document.querySelector(".sentiment-history-frame").getBoundingClientRect();
return {
chartHeight: chart.height,
viewportHeight: window.innerHeight,
bottomsAligned: Math.abs(trend.bottom - rail.bottom) < 2,
detailAfterAnalysis: detail.top > Math.max(trend.bottom, rail.bottom) - 1,
historyVisibleBelow: history.top > detail.bottom - 1,
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
});
expect(metrics.chartHeight, viewport.name).toBeGreaterThanOrEqual(280);
expect(metrics.chartHeight, viewport.name).toBeLessThanOrEqual(360);
expect(metrics.chartHeight / metrics.viewportHeight, viewport.name).toBeLessThan(0.42);
expect(metrics.bottomsAligned, viewport.name).toBe(true);
expect(metrics.detailAfterAnalysis, viewport.name).toBe(true);
expect(metrics.historyVisibleBelow, viewport.name).toBe(true);
expect(metrics.overflowX, viewport.name).toBe(false);
if (theme === "dark") {
const tooltip = page.locator("#sentimentChartTooltip");
await page.locator("#sentimentTrendChart").hover({ position: { x: 360, y: 110 } });
await expect(tooltip).toBeVisible();
const tooltipColors = await tooltip.evaluate((element) => {
const style = getComputedStyle(element);
const bold = element.querySelector("b");
return {
color: style.color,
background: style.backgroundColor,
boldColor: bold ? getComputedStyle(bold).color : style.color,
text: element.textContent,
};
});
expect(tooltipColors.text).toMatch(/温度/);
expect(contrastRatio(tooltipColors.color, tooltipColors.background)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(tooltipColors.boldColor, tooltipColors.background)).toBeGreaterThanOrEqual(4.5);
}
await page.screenshot({
path: path.join(screenshotDir, `${viewport.name}-${theme}.png`),
fullPage: true,
});
}
}
await page.setViewportSize({ width: 2560, height: 1440 });
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.waitForTimeout(200);
const nightLayout = await page.evaluate(readSentimentLayout);
expect(nightLayout.topDelta).toBeLessThanOrEqual(1);
expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1);
expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1);
await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
const tooltip = page.locator("#sentimentChartTooltip");
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText("温度");
const tooltipStyle = await tooltip.evaluate((node) => {
const style = getComputedStyle(node);
const bold = getComputedStyle(node.querySelector("b") || node);
return { background: style.backgroundColor, color: style.color, bold: bold.color };
});
expect(tooltipStyle.background).toBe("rgb(38, 41, 62)");
expect(tooltipStyle.color).toBe("rgb(232, 234, 237)");
expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)");
await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true });
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } });
await expect(tooltip).toBeVisible();
const lightTooltip = await tooltip.evaluate((node) => {
const style = getComputedStyle(node);
return { background: style.backgroundColor, color: style.color };
});
expect(lightTooltip.background).toBe("rgb(31, 35, 41)");
expect(lightTooltip.color).toBe("rgb(255, 255, 255)");
});
test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => {
@@ -3868,99 +3871,3 @@ test("desktop header keeps commands in view and tape text unclipped across works
await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") });
fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`);
});
test("HEL-183 heaven tools right-align and shell heights unify", async ({ page }) => {
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/hel183-shots");
fs.mkdirSync(shotDir, { recursive: true });
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const measure = () => page.evaluate(() => {
const box = (node) => {
if (!node) return null;
const r = node.getBoundingClientRect();
return { x: r.x, y: r.y, right: r.right, width: r.width, height: r.height };
};
const brand = document.querySelector(".module-nav .sidebar-brand") || document.querySelector(".sidebar-brand");
const header = document.querySelector(".app-header");
const actions = document.querySelector(".header-actions");
const collapse = document.querySelector(".sidebar-collapse-button");
const status = document.querySelector(".status-bar");
const overview = document.querySelector(".overview-strip");
const brandBox = box(brand);
const headerBox = box(header);
const actionsBox = box(actions);
const collapseBox = box(collapse);
const statusBox = box(status);
return {
brandHeight: brandBox ? Math.round(brandBox.height) : null,
headerHeight: headerBox ? Math.round(headerBox.height) : null,
brandBottom: brandBox ? Math.round(brandBox.y + brandBox.height) : null,
headerBottom: headerBox ? Math.round(headerBox.y + headerBox.height) : null,
collapseHeight: collapseBox ? Math.round(collapseBox.height) : null,
statusHeight: statusBox ? Math.round(statusBox.height) : null,
actionsNearRight: actionsBox && headerBox ? (headerBox.right - actionsBox.right) < 24 : false,
actionsMarginLeft: actions ? getComputedStyle(actions).marginLeft : null,
overviewDisplay: overview ? getComputedStyle(overview).display : null,
mentorPadTop: (() => {
const mentor = document.querySelector("#mentorView");
return mentor ? getComputedStyle(mentor).paddingTop : null;
})(),
};
});
await page.locator('[data-view="sentimentCycleView"]').first().click();
await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/);
let geo = await measure();
expect(geo.brandHeight, "logo height").toBe(64);
expect(geo.headerHeight, "header height").toBe(64);
expect(geo.brandBottom, "logo/header bottom align").toBe(geo.headerBottom);
expect(geo.collapseHeight, "collapse height").toBe(28);
expect(geo.statusHeight, "status height").toBe(28);
expect(geo.actionsNearRight, "sentiment tools right").toBe(true);
await page.locator(".app-header").screenshot({ path: path.join(shotDir, "sentiment-header-day.png") });
await page.screenshot({ path: path.join(shotDir, "sentiment-page-day.png") });
await page.locator('[data-view="heavenView"]').first().click();
await expect(page.locator("#heavenView")).toHaveClass(/active-view/);
await expect(page.locator("#heavenView .heaven-page-head")).toBeVisible();
await expect(page.locator("#heavenView .heaven-page-head .segment")).toHaveCount(3);
geo = await measure();
expect(geo.overviewDisplay, "heaven hides overview").toBe("none");
expect(geo.actionsMarginLeft, "tools margin-left resolved").not.toBe("0px");
expect(Number.parseFloat(geo.actionsMarginLeft), "tools left auto gap").toBeGreaterThan(40);
expect(geo.actionsNearRight, "heaven tools right").toBe(true);
expect(geo.brandHeight).toBe(64);
expect(geo.collapseHeight).toBe(28);
await page.locator(".app-header").screenshot({ path: path.join(shotDir, "heaven-header-day.png") });
await page.screenshot({ path: path.join(shotDir, "heaven-page-day.png") });
await page.locator('[data-heaven-panel="fortune"]').click();
await expect(page.locator('[data-heaven-panel="fortune"]')).toHaveClass(/active/);
await expect(page.locator("#heavenFortunePanel")).toHaveClass(/active-heaven-panel/);
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView")).toHaveClass(/active-view/);
geo = await measure();
expect(geo.mentorPadTop, "mentor top padding").toBe("14px");
await page.screenshot({ path: path.join(shotDir, "mentor-page-day.png") });
await page.locator("#themeToggle").click();
await page.locator('[data-view="heavenView"]').first().click();
await expect(page.locator("#heavenView")).toHaveClass(/active-view/);
geo = await measure();
expect(geo.actionsNearRight, "heaven night tools right").toBe(true);
expect(geo.brandHeight).toBe(64);
await page.locator(".app-header").screenshot({ path: path.join(shotDir, "heaven-header-night.png") });
await page.screenshot({ path: path.join(shotDir, "heaven-page-night.png") });
await page.locator('[data-view="sentimentCycleView"]').first().click();
await page.locator(".app-header").screenshot({ path: path.join(shotDir, "sentiment-header-night.png") });
await page.screenshot({ path: path.join(shotDir, "sentiment-page-night.png") });
await page.locator('[data-view="mentorView"]').first().click();
await page.screenshot({ path: path.join(shotDir, "mentor-page-night.png") });
});
-556
View File
@@ -1,556 +0,0 @@
const { test, expect } = require("@playwright/test");
const fs = require("fs");
const path = require("path");
const SHOT_DIR = path.resolve(__dirname, "../../../verify-shots");
fs.mkdirSync(SHOT_DIR, { recursive: true });
function loginPayload(user) {
return {
ok: true,
authenticated: true,
csrf_token: "portal-csrf",
user,
};
}
async function mockLoginPortal(page, options = {}) {
const accounts = options.accounts || [];
let currentUserId = options.currentUserId ?? null;
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
if (url.pathname === "/api/auth/accounts") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true, accounts, current_user_id: currentUserId }),
});
return;
}
if (url.pathname === "/api/auth/switch" && method === "POST") {
const body = route.request().postDataJSON() || {};
const account = accounts.find((item) => Number(item.user_id) === Number(body.user_id));
if (!account || options.switchFails) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "该账号需重新验证" }),
});
return;
}
currentUserId = account.user_id;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(loginPayload(account)),
});
return;
}
if (url.pathname === "/api/auth/forget" && method === "POST") {
const body = route.request().postDataJSON() || {};
const index = accounts.findIndex((item) => Number(item.user_id) === Number(body.user_id));
if (index >= 0) accounts.splice(index, 1);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") {
if (options.loginDelay) await new Promise((resolve) => setTimeout(resolve, options.loginDelay));
if (options.loginFails) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "账号名或密码不正确,请重新输入。" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(loginPayload({
id: 9,
username: "new_user",
role: "user",
membership: { active: false, subscribed: false, is_admin: false },
})),
});
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,
csrf_token: "portal-csrf",
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;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
}
const SAVED_ACCOUNTS = [
{
user_id: 1,
username: "alpha_user",
role: "admin",
membership: { active: true, subscribed: true, is_admin: true },
last_used_at: "2026-08-29T01:00:00+00:00",
},
{
user_id: 2,
username: "beta_user",
role: "user",
membership: { active: false, subscribed: false, is_admin: false },
last_used_at: "2026-08-28T01:00:00+00:00",
},
];
test("first-time login portal asks for a password and hides environment copy", async ({ page }) => {
await mockLoginPortal(page, { accounts: [] });
await page.goto("/login/");
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
await expect(page.locator("#loginUsername")).toBeVisible();
await expect(page.locator(".login-submit")).toHaveText("登录");
await expect(page.locator("body")).not.toContainText("内网个人版");
await expect(page.locator("body")).not.toContainText("192.168.200.11");
});
test("saved accounts can switch directly and show a re-auth message on failure", async ({ page }) => {
await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
await page.goto("/login/");
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await expect(page.locator(".login-account-row")).toHaveCount(2);
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("failed account switch stays on the portal with the original copy", async ({ page }) => {
await mockLoginPortal(page, {
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
switchFails: true,
});
await page.goto("/login/");
await page.locator('[data-switch-id="2"]').click();
await expect(page.locator(".login-error")).toHaveText("该账号需重新验证");
await expect(page).toHaveURL(/\/login\/?/);
});
test("managing accounts removes a local record after inline confirmation", async ({ page }) => {
await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) });
await page.goto("/login/");
await page.locator('[data-login-action="manage"]').click();
await expect(page.locator(".login-card-title")).toHaveText("管理账号记录");
await page.locator('[data-confirm-id="2"]').click();
await expect(page.locator(".login-confirm-copy")).toContainText("beta_user");
await page.locator('[data-forget-id="2"]').click();
await expect(page.locator(".login-account-row")).toHaveCount(1);
await expect(page.locator(".login-account-row")).toContainText("alpha_user");
});
async function assertConfirmedSkeleton(page, { width, height }) {
await expect(page.locator(".login-brand-title")).toHaveText("看懂情绪周期,把复盘变成下一次的先手。");
await expect(page.locator(".login-brand-header")).toBeVisible();
await expect(page.locator(".login-brand-chart")).toBeVisible();
await expect(page.locator(".login-brand-stats")).toBeVisible();
await expect(page.locator(".login-brand-kicker")).toHaveText("收盘之后 · 复盘开始");
const brand = await page.locator(".login-brand").boundingBox();
const header = await page.locator(".login-brand-header").boundingBox();
const mark = await page.locator(".login-brand-mark").boundingBox();
const name = await page.locator(".login-brand-name").boundingBox();
const title = await page.locator(".login-brand-title").boundingBox();
const stats = await page.locator(".login-brand-stats").boundingBox();
const chart = await page.locator(".login-brand-chart").boundingBox();
const card = await page.locator(".login-card").boundingBox();
expect(brand).toBeTruthy();
expect(header.y - brand.y).toBeLessThan(48);
expect(Math.abs(mark.y - name.y)).toBeLessThan(16);
expect(title.y).toBeGreaterThan(height * 0.28);
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);
} else {
expect(brand.width).toBeGreaterThan(560);
}
}
async function openPortal(page, { theme, width, height, accounts, currentUserId, loginFails, loginDelay }) {
await page.addInitScript((nextTheme) => {
localStorage.setItem("xiaobaiTheme", nextTheme);
}, theme);
await page.setViewportSize({ width, height });
await mockLoginPortal(page, { accounts, currentUserId, loginFails, loginDelay });
await page.goto("/login/");
}
for (const theme of ["light", "dark"]) {
for (const [width, height] of [[1440, 900], [1920, 1080]]) {
test(`confirmed skeleton ${theme} ${width}x${height}`, async ({ page }) => {
await openPortal(page, { theme, width, height, accounts: [] });
await assertConfirmedSkeleton(page, { width, height });
await expect(page.locator("#loginThemeToggle")).toHaveText(theme === "dark" ? "☀ 日间" : "🌙 夜间");
await page.screenshot({ path: path.join(SHOT_DIR, `first-${theme}-${width}.png`), fullPage: true });
});
}
}
test("ultrawide keeps the left brand from collapsing into a strip", async ({ page }) => {
await openPortal(page, { theme: "dark", width: 2560, height: 1080, accounts: [] });
await assertConfirmedSkeleton(page, { width: 2560, height: 1080 });
});
test("picker add remove error and loading share the same desktop skeleton", async ({ page }) => {
const accounts = SAVED_ACCOUNTS.map((item) => ({ ...item }));
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts,
currentUserId: 1,
});
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await expect(page.locator(".login-avatar")).toHaveCount(2);
await expect(page.locator(".login-add")).toBeVisible();
await page.screenshot({ path: path.join(SHOT_DIR, "picker-dark-1440.png"), fullPage: true });
await page.locator('[data-login-action="add"]').click();
await expect(page.locator(".login-card-title")).toHaveText("添加账号");
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "add-dark-1440.png"), fullPage: true });
await page.locator('[data-login-action="picker"]').click();
await page.locator('[data-login-action="manage"]').click();
await expect(page.locator(".login-card-title")).toHaveText("管理账号记录");
await page.locator('[data-confirm-id="2"]').click();
await expect(page.locator(".login-confirm-copy")).toContainText("beta_user");
await page.screenshot({ path: path.join(SHOT_DIR, "remove-dark-1440.png"), fullPage: true });
});
test("login failure and loading keep the confirmed first-login skeleton", async ({ page }) => {
await openPortal(page, {
theme: "light",
width: 1440,
height: 900,
accounts: [],
loginFails: true,
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("wrong-password");
await page.locator(".login-submit").click();
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
await expect(page.locator("#loginPassword")).toHaveClass(/is-invalid/);
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "error-light-1440.png"), fullPage: true });
});
test("loading button appears on the confirmed first-login skeleton", async ({ page }) => {
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts: [],
loginDelay: 2500,
});
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(".login-submit")).toContainText("正在登录...");
await expect(page.locator(".login-spinner")).toBeVisible();
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
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 });
});
+3 -153
View File
@@ -1,7 +1,7 @@
const { test, expect } = require("@playwright/test");
// 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 5 页
// 手机端(/m/)全页面回归:P5 收官打磨。
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
const EMPTY_DASHBOARD = {
@@ -138,36 +138,6 @@ async function mockMobileApi(page, options = {}) {
payload = { items: [] };
} else if (path === "/api/search") {
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
} else if (path === "/api/auth/accounts") {
payload = {
accounts: [{ user_id: auth.user.id, username: auth.user.username, role: auth.user.role, last_used_at: "2026-07-22T09:12:00+08:00" }],
current_user_id: auth.user.id,
};
} else if (path === "/api/account/status") {
payload = {
birth_profile_configured: true,
birth_profile: { birth_datetime: "1990-01-15T08:30", gender: "male" },
llm_access: {
daily_limit: 50,
used_today: 3,
remaining_calls: 47,
membership: {
active: true,
subscribed: true,
is_admin: auth.user.role === "admin",
remaining_days: 20,
expires_at: "2026-09-18",
plan: "会员",
},
},
};
} else if (path === "/api/admin/settings") {
payload = {
data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } },
llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" },
membership: { member_daily_limit: 50 },
users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }],
};
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
payload = {
meta: { trade_date: "2026-07-22", intraday_status: "available" },
@@ -260,16 +230,6 @@ const REVIEW_PAGES = [
["review/alerts", "提醒中心"],
];
const SYSTEM_PAGES = [
["system/profile", "账号资料"],
["system/password", "修改密码"],
["system/membership", "会员状态"],
["system/admin", "系统设置"],
["system/members", "会员管理"],
];
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
test("mobile login renders before authentication", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.route("**/api/**", async (route) => {
@@ -289,16 +249,12 @@ test("mobile login renders before authentication", async ({ page }) => {
test("four hub pages render their icon grids", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const hub of ["market", "tools", "review"]) {
for (const hub of ["market", "tools", "review", "system"]) {
await page.evaluate((h) => { window.MobileRouter.navigate("#/hub/" + h); }, hub);
await expect(page.locator(".m-hub-grid")).toBeVisible();
await expect(page.locator(".m-hub-grid .m-grid-item").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator(".m-sys-row").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
for (const theme of ["day", "night"]) {
@@ -340,112 +296,6 @@ test("review five pages render watchlist, trades, daily, notes and alerts", asyn
}
});
test("system management pages render real content instead of placeholders", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const [key, label] of SYSTEM_PAGES) {
await navigateToFeature(page, key);
await expect(page.locator("#m-title")).toHaveText(label);
await expect(page.locator(".m-placeholder")).toHaveCount(0);
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
await navigateToFeature(page, "system/profile");
await expect(page.locator("#m-sys-birth-date")).toBeVisible();
await expect(page.locator("[data-system-save-birth]")).toBeVisible();
await navigateToFeature(page, "system/password");
await expect(page.locator("#m-sys-password-current")).toBeVisible();
await navigateToFeature(page, "system/membership");
await expect(page.locator(".m-sys-grid")).toBeVisible();
await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-sys-token")).toBeVisible();
await navigateToFeature(page, "system/members");
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
});
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator("#m-view")).toContainText("账号");
await expect(page.locator("#m-view")).toContainText("偏好");
await expect(page.locator("#m-view")).toContainText("管理员专区");
await expect(page.locator("[data-theme-toggle]")).toBeVisible();
await expect(page.locator("[data-system-switch]")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
test("system settings tabs, model editor, delete confirm and theme toggle work", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/admin");
await expect(page.locator("[data-system-admin-panel='market']")).toBeVisible();
await page.locator("[data-system-admin-tab='models']").click();
await expect(page.locator("[data-system-admin-panel='models']")).toBeVisible();
await page.locator("[data-system-edit-model]").first().click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toHaveText("编辑模型");
await page.locator("[data-sheet-close]").click();
await page.locator("[data-system-admin-tab='market']").click();
await expect(page.locator("#m-sys-token")).toBeVisible();
await navigateToFeature(page, "system/profile");
await page.locator("[data-system-delete-birth]").click();
await expect(page.locator(".m-dialog")).toBeVisible();
await expect(page.locator(".m-dialog")).toContainText("删除命理资料");
await page.locator("[data-sheet-close]").click();
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-theme-toggle]")).toBeVisible();
const before = await page.locator("#m-app").getAttribute("data-theme");
await page.locator("[data-theme-toggle]").click();
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
await navigateToFeature(page, "system/members");
await page.locator("[data-system-open-member]").click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员");
});
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/password");
await page.locator("#m-sys-password-current").fill("OldPass12");
await page.locator("#m-sys-password-new").fill("NewPass123");
await page.locator("#m-sys-password-confirm").fill("OtherPass123");
await page.locator("[data-system-save-password]").click();
await expect(page.locator("[data-field-error='confirm']")).toBeVisible();
await expect(page.locator("[data-field-error='confirm']")).toContainText("两次输入的密码不一致");
});
test("empty birth profile save shows a validation toast", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/profile");
await expect(page.locator("[data-system-save-birth]")).toBeVisible();
await page.locator("#m-sys-birth-date").fill("");
await page.locator("#m-sys-birth-time").fill("");
await page.locator("[data-system-save-birth]").click();
await expect(page.locator("#m-toast.is-visible")).toBeVisible();
await expect(page.locator("#m-toast")).toContainText("请填写完整出生日期和时间");
});
test("non-admin cannot open system admin pages as placeholders", async ({ page }) => {
await mockMobileApi(page, { auth: authSession("user", true) });
await openMobile(page);
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0);
await expect(page.locator("[data-system-switch]")).toBeVisible();
await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
});
test("assistant chat renders with presets and input", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
-132
View File
@@ -1,132 +0,0 @@
from __future__ import annotations
import threading
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from backend.features.accounts.security import SecretVault, token_hash
from backend.features.accounts.service import AccountService
from database import ReviewDatabase
class AccountSwitchGrantTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = TemporaryDirectory()
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
self.bound_user_id = 0
self.service = AccountService(
database=self.database,
vault=SecretVault(SecretVault.generate_key()),
current_user_supplier=lambda: self.bound_user_id,
access_supplier=lambda: self.database.user_access(self.bound_user_id) or {},
bind_user=self._bind,
personal_field_builder=lambda *args, **kwargs: {},
auth_lock=threading.Lock(),
)
self.device_a = token_hash("device-a-token")
self.device_b = token_hash("device-b-token")
def tearDown(self) -> None:
self.temp.cleanup()
def _bind(self, user_id: int) -> None:
self.bound_user_id = int(user_id)
def _register(self, username: str, device_hash: str = "") -> dict:
return self.service.register(username, "Password123", device_hash or self.device_a)
def test_login_records_accounts_for_the_current_device_only(self) -> None:
first = self._register("alpha_user")
second = self._register("beta_user")
self.service.login("alpha_user", "Password123", self.device_b)
listed = self.service.list_device_accounts(self.device_a)
names = [item["username"] for item in listed["accounts"]]
self.assertEqual(names, ["beta_user", "alpha_user"])
self.assertEqual(
self.service.list_device_accounts(self.device_b)["accounts"][0]["username"],
"alpha_user",
)
self.assertEqual(self.service.list_device_accounts("")["accounts"], [])
self.assertEqual(first["user"]["username"], "alpha_user")
self.assertEqual(second["user"]["username"], "beta_user")
def test_switch_uses_device_grant_and_keeps_the_original_authorization(self) -> None:
first = self._register("alpha_user")
self._register("beta_user")
switched = self.service.switch_account(self.device_a, int(first["user"]["id"]))
self.assertEqual(switched["user"]["username"], "alpha_user")
remaining = {
item["username"]
for item in self.service.list_device_accounts(self.device_a)["accounts"]
}
self.assertEqual(remaining, {"alpha_user", "beta_user"})
def test_switch_without_a_valid_grant_requires_reauthentication(self) -> None:
user = self._register("alpha_user")
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
self.service.switch_account(self.device_b, int(user["user"]["id"]))
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
self.service.switch_account("", int(user["user"]["id"]))
def test_forget_only_removes_the_current_device_grant(self) -> None:
user = self._register("alpha_user")
self.service.login("alpha_user", "Password123", self.device_b)
self.service.forget_account(self.device_a, int(user["user"]["id"]))
self.service.forget_account(self.device_a, int(user["user"]["id"]))
self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
self.assertEqual(
self.service.list_device_accounts(self.device_b)["accounts"][0]["username"],
"alpha_user",
)
def test_logout_revokes_only_the_current_account_on_this_device(self) -> None:
first = self._register("alpha_user")
second = self._register("beta_user")
self.service.revoke_current_device_grant(self.device_a, int(second["user"]["id"]))
names = {
item["username"]
for item in self.service.list_device_accounts(self.device_a)["accounts"]
}
self.assertEqual(names, {"alpha_user"})
switched = self.service.switch_account(self.device_a, int(first["user"]["id"]))
self.assertEqual(switched["user"]["id"], first["user"]["id"])
def test_password_change_revokes_grants_on_every_device(self) -> None:
user = self._register("alpha_user")
self.service.login("alpha_user", "Password123", self.device_b)
self._bind(int(user["user"]["id"]))
self.service.change_password("Password123", "Password456")
self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
self.assertEqual(self.service.list_device_accounts(self.device_b)["accounts"], [])
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
self.service.switch_account(self.device_a, int(user["user"]["id"]))
def test_device_keeps_at_most_five_accounts(self) -> None:
usernames = [f"user_{index}" for index in range(6)]
ids = [self._register(name)["user"]["id"] for name in usernames]
listed = self.service.list_device_accounts(self.device_a)["accounts"]
self.assertEqual(len(listed), 5)
kept = {item["user_id"] for item in listed}
self.assertNotIn(ids[0], kept)
self.assertTrue(set(ids[1:]).issubset(kept))
def test_expired_grants_are_removed_lazily(self) -> None:
user = self._register("alpha_user")
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(timespec="seconds")
self.database.upsert_switch_grant(
self.device_a,
int(user["user"]["id"]),
past,
past,
past,
)
self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], [])
with self.assertRaisesRegex(PermissionError, "该账号需重新验证"):
self.service.switch_account(self.device_a, int(user["user"]["id"]))
if __name__ == "__main__":
unittest.main()
-24
View File
@@ -1,24 +0,0 @@
from __future__ import annotations
import unittest
from backend.jobs.service import _verified_dashboard_result
class AdminRefreshStatusTests(unittest.TestCase):
def test_carried_snapshot_is_reported_as_failed_job(self):
result = _verified_dashboard_result(
{"meta": {"carried_forward": True, "notice": "官方涨跌停数据尚未返回"}}
)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["error"], "官方涨跌停数据尚未返回")
def test_current_snapshot_is_reported_as_successful_job(self):
dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}}
self.assertIs(_verified_dashboard_result(dashboard), dashboard)
if __name__ == "__main__":
unittest.main()
+3 -4
View File
@@ -25,7 +25,6 @@ class DatabaseMigrationTests(unittest.TestCase):
("0002", "create_job_runs"),
("0003", "extend_llm_audit"),
("0004", "add_mentor_note"),
("0005", "create_account_switch_grants"),
],
)
columns = {
@@ -40,7 +39,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 5)
self.assertEqual(count, 4)
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
self,
@@ -61,7 +60,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 5)
self.assertEqual(count, 4)
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
with tempfile.TemporaryDirectory() as root:
@@ -88,7 +87,7 @@ class DatabaseMigrationTests(unittest.TestCase):
"PRAGMA table_info(mentor_preferences)"
)
]
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
self.assertEqual(versions, {"0001", "0002", "0003", "0004"})
self.assertIn("note", note_rows)
def test_database_with_unknown_migration_is_rejected(self) -> None:
-124
View File
@@ -1,124 +0,0 @@
from __future__ import annotations
import os
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CHECK = ROOT / "tools" / "check_deploy_baseline.sh"
BUILD = ROOT / "tools" / "build_image.sh"
def run_check(repo: Path, candidate: str, live: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["GIT_DIR"] = str(repo / ".git")
env["GIT_WORK_TREE"] = str(repo)
return subprocess.run(
["bash", str(CHECK), candidate, "--live-revision", live],
cwd=repo,
capture_output=True,
text=True,
env=env,
check=False,
)
def git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
class DeployBaselineGateTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tmpdir = tempfile.TemporaryDirectory()
cls.repo = Path(cls.tmpdir.name) / "repo"
cls.repo.mkdir()
git(cls.repo, "init")
git(cls.repo, "config", "user.email", "gate@example.com")
git(cls.repo, "config", "user.name", "Gate")
(cls.repo / "README").write_text("base\n", encoding="utf-8")
git(cls.repo, "add", "README")
git(cls.repo, "commit", "-m", "base")
cls.base = git(cls.repo, "rev-parse", "HEAD")
(cls.repo / "online.txt").write_text("live\n", encoding="utf-8")
git(cls.repo, "add", "online.txt")
git(cls.repo, "commit", "-m", "online")
cls.live = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-b", "successor")
(cls.repo / "next.txt").write_text("next\n", encoding="utf-8")
git(cls.repo, "add", "next.txt")
git(cls.repo, "commit", "-m", "successor of live")
cls.successor = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "lagging-main", cls.base)
(cls.repo / "stale.txt").write_text("stale main\n", encoding="utf-8")
git(cls.repo, "add", "stale.txt")
git(cls.repo, "commit", "-m", "lagging main")
cls.lagging = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "side", cls.base)
(cls.repo / "side.txt").write_text("side branch\n", encoding="utf-8")
git(cls.repo, "add", "side.txt")
git(cls.repo, "commit", "-m", "unrelated side branch")
cls.side = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "successor", cls.successor)
@classmethod
def tearDownClass(cls) -> None:
cls.tmpdir.cleanup()
def test_check_script_is_executable(self) -> None:
self.assertTrue(CHECK.exists())
self.assertTrue(stat.S_IXUSR & CHECK.stat().st_mode)
def test_successor_of_live_passes(self) -> None:
result = run_check(self.repo, self.successor, self.live)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(self.live, result.stdout)
self.assertIn(self.successor, result.stdout)
self.assertIn("next.txt", result.stdout)
self.assertIn("祖先关系通过", result.stdout)
def test_lagging_main_is_blocked(self) -> None:
result = run_check(self.repo, self.lagging, self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝", result.stderr)
def test_side_branch_is_blocked(self) -> None:
result = run_check(self.repo, self.side, self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝", result.stderr)
def test_unknown_commit_is_blocked(self) -> None:
result = run_check(self.repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("无法解析", result.stderr)
def test_build_image_calls_the_gate_and_rejects_latest(self) -> None:
source = BUILD.read_text(encoding="utf-8")
self.assertIn("check_deploy_baseline.sh", source)
self.assertIn("禁止构建 latest", source)
self.assertIn("org.opencontainers.image.revision", source)
gate = CHECK.read_text(encoding="utf-8")
self.assertIn("org.opencontainers.image.revision", gate)
self.assertIn("merge-base --is-ancestor", gate)
self.assertIn("候选将丢失的提交", gate)
self.assertIn("禁止人工填写", gate)
if __name__ == "__main__":
unittest.main()
+5 -16
View File
@@ -343,22 +343,11 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("--sentiment-history-max-height: 510px;", self.tokens)
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
self.assertIn("overflow: auto;", self.sentiment_styles)
def test_sentiment_equal_height_and_tooltip_tokens(self):
self.assertIn("--sentiment-analysis-height: 600px;", self.tokens)
self.assertIn("--sentiment-tooltip-bg: var(--text-primary);", self.tokens)
self.assertIn("--sentiment-tooltip-fg: var(--text-inverse);", self.tokens)
self.assertIn("--sentiment-tooltip-bg: #26293e;", self.tokens)
self.assertIn("--sentiment-tooltip-fg: #e8eaed;", self.tokens)
self.assertIn("height: var(--sentiment-analysis-height);", self.sentiment_styles)
self.assertIn("max-height: var(--sentiment-analysis-height);", self.sentiment_styles)
self.assertIn("justify-content: space-evenly;", self.sentiment_styles)
self.assertIn("background: var(--sentiment-tooltip-bg);", self.sentiment_styles)
self.assertIn("color: var(--sentiment-tooltip-fg);", self.sentiment_styles)
self.assertIn("new ResizeObserver", self.script)
self.assertIn("#sentimentCycleView .redesigned-emotion-grid {", self.sentiment_styles)
self.assertNotIn("height: 100vh", self.sentiment_styles)
self.assertNotIn("min-height: 100%", self.sentiment_styles)
self.assertIn("height: min(350px, 40vh);", self.sentiment_styles)
self.assertIn(':root[data-theme="dark"] #sentimentCycleView .sentiment-chart-tooltip {', self.sentiment_styles)
self.assertIn("background: var(--surface-muted);", self.sentiment_styles)
self.assertIn("color: var(--text-primary);", self.sentiment_styles)
self.assertNotIn("flex: 1 1 auto;\n\n height: auto;", self.sentiment_styles)
def test_mentor_final_visual_fix_contract(self):
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8")
-3
View File
@@ -65,11 +65,8 @@ class GovernanceRegistryTests(unittest.TestCase):
public,
{
("GET", "/api/health"),
("GET", "/api/auth/accounts"),
("POST", "/api/auth/login"),
("POST", "/api/auth/register"),
("POST", "/api/auth/switch"),
("POST", "/api/auth/forget"),
},
)
+1 -150
View File
@@ -1,162 +1,13 @@
from __future__ import annotations
import json
import tempfile
import unittest
from http import HTTPStatus
from pathlib import Path
from unittest.mock import patch
from backend.features.heaven.engine import build_five_phase_field, hexagram_from_lines
from backend.features.heaven.http import HeavenHttpMixin
from backend.features.heaven.knowledge import (
HeavenKnowledgeError,
clear_heaven_knowledge_cache,
prepare_heaven_context,
resolve_heaven_knowledge_path,
_knowledge_catalog,
)
from backend.features.heaven.knowledge import prepare_heaven_context
from backend.features.heaven.six_yao import build_six_yao_chart
class HeavenKnowledgeTests(unittest.TestCase):
def tearDown(self) -> None:
clear_heaven_knowledge_cache()
def test_catalog_loads_from_trusted_repo_file(self):
clear_heaven_knowledge_cache()
path = resolve_heaven_knowledge_path()
catalog = _knowledge_catalog()
self.assertTrue(path.is_file())
self.assertEqual(path.name, "heaven_knowledge.json")
self.assertTrue(str(catalog.get("version") or "").startswith("2026."))
self.assertIn("zhouyi", catalog["sources"])
self.assertIn("neijing", catalog["sources"])
self.assertEqual(len(catalog["fortune"]["qi"]), 6)
self.assertEqual(len(catalog["fortune"]["personal_relations"]), 10)
def test_missing_knowledge_file_raises_chinese_structured_error(self):
clear_heaven_knowledge_cache()
missing = Path(tempfile.mkdtemp()) / "missing-heaven_knowledge.json"
with patch(
"backend.features.heaven.knowledge.KNOWLEDGE_FILE", missing
), patch(
"backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE",
missing.with_name("missing-seed.json"),
):
with self.assertRaises(HeavenKnowledgeError) as raised:
_knowledge_catalog()
self.assertEqual(raised.exception.error_code, "heaven_knowledge_missing")
self.assertIn("缺失", str(raised.exception))
def test_corrupt_knowledge_json_raises_chinese_structured_error(self):
clear_heaven_knowledge_cache()
with tempfile.TemporaryDirectory() as temp_dir:
broken = Path(temp_dir) / "heaven_knowledge.json"
broken.write_text("{not-json", encoding="utf-8")
with patch(
"backend.features.heaven.knowledge.KNOWLEDGE_FILE", broken
), patch(
"backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE",
Path(temp_dir) / "unused-seed.json",
):
with self.assertRaises(HeavenKnowledgeError) as raised:
_knowledge_catalog()
self.assertEqual(raised.exception.error_code, "heaven_knowledge_invalid")
self.assertIn("损坏", str(raised.exception))
def test_interpret_http_returns_structured_chinese_error_for_missing_file(self):
class FakeHandler(HeavenHttpMixin):
def __init__(self) -> None:
self.payload = None
self.status = None
self.application_service = type(
"Svc",
(),
{
"heaven_interpret": staticmethod(
lambda _body: (_ for _ in ()).throw(
HeavenKnowledgeError(
"问天知识文件缺失:未找到 heaven_knowledge.json。",
code="heaven_knowledge_missing",
)
)
)
},
)()
def read_json_body(self):
return {"mode": "trend", "trade_date": "2026-08-04"}
def send_json(self, payload, status=HTTPStatus.OK, headers=None):
self.payload = payload
self.status = status
handler = FakeHandler()
handler.heaven_interpret()
self.assertEqual(handler.status, HTTPStatus.BAD_REQUEST)
self.assertIn("缺失", handler.payload["error"])
self.assertEqual(handler.payload["code"], "heaven_knowledge_missing")
def test_interpret_http_returns_structured_chinese_error_for_corrupt_json(self):
class FakeHandler(HeavenHttpMixin):
def __init__(self) -> None:
self.payload = None
self.status = None
self.application_service = type(
"Svc",
(),
{
"heaven_interpret": staticmethod(
lambda _body: (_ for _ in ()).throw(
HeavenKnowledgeError(
"问天知识文件 JSON 损坏(heaven_knowledge.json),无法解析:第 1 行附近。",
code="heaven_knowledge_invalid",
)
)
)
},
)()
def read_json_body(self):
return {"mode": "trend", "trade_date": "2026-08-04"}
def send_json(self, payload, status=HTTPStatus.OK, headers=None):
self.payload = payload
self.status = status
handler = FakeHandler()
handler.heaven_interpret()
self.assertEqual(handler.status, HTTPStatus.BAD_REQUEST)
self.assertIn("损坏", handler.payload["error"])
self.assertEqual(handler.payload["code"], "heaven_knowledge_invalid")
def test_seed_fallback_when_data_file_missing(self):
clear_heaven_knowledge_cache()
with tempfile.TemporaryDirectory() as temp_dir:
seed = Path(temp_dir) / "seed.json"
seed.write_text(
json.dumps(
{
"version": "test-seed",
"sources": {"zhouyi": {"title": "周易"}},
"trend": {"method": "m", "rules": {"stable": "s", "single": "a", "multiple": "b"}},
"fortune": {},
"heart": {},
},
ensure_ascii=False,
),
encoding="utf-8",
)
missing_data = Path(temp_dir) / "data-heaven_knowledge.json"
with patch(
"backend.features.heaven.knowledge.KNOWLEDGE_FILE", missing_data
), patch(
"backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE", seed
):
catalog = _knowledge_catalog()
self.assertEqual(catalog["version"], "test-seed")
def test_fortune_context_excludes_weighted_summary_and_adds_bounded_industry_symbols(self):
field = build_five_phase_field("2026-08-04")
prepared = prepare_heaven_context(
-90
View File
@@ -1,90 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOGIN = (ROOT / "frontend" / "login" / "index.html").read_text(encoding="utf-8")
LOGIN_JS = (ROOT / "frontend" / "login" / "page.js").read_text(encoding="utf-8")
AUTH = (ROOT / "frontend" / "shared" / "auth.css").read_text(encoding="utf-8")
TOKENS = (ROOT / "frontend" / "shared" / "tokens.css").read_text(encoding="utf-8")
class LoginPortalContractTests(unittest.TestCase):
def test_confirmed_brand_skeleton_is_in_markup(self) -> None:
for needle in (
'class="login-brand-header"',
'class="login-brand-name"',
"A股个人复盘工作台",
'class="login-brand-kicker"',
"看懂情绪周期,把复盘变成下一次的先手。",
'class="login-brand-chart"',
'class="login-brand-stats"',
"股市有风险,投资需谨慎",
'id="loginThemeToggle"',
'id="loginCard"',
):
self.assertIn(needle, LOGIN)
def test_login_tokens_own_the_confirmed_layout_metrics(self) -> None:
for needle in (
"--login-brand-share: 34%;",
"--login-brand-cap: 560px;",
"--login-brand-wide-share: 29.2%;",
"--login-card-width: 408px;",
"--login-hero-size: 28px;",
"--login-account-row-min: 72px;",
):
self.assertIn(needle, TOKENS)
self.assertIn("width: var(--login-brand-share);", AUTH)
self.assertIn("width: var(--login-card-width);", AUTH)
self.assertIn("justify-content: flex-end;", AUTH)
self.assertIn("body.login-portal {", AUTH)
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)
-86
View File
@@ -1,86 +0,0 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
NAV = ROOT / "frontend/m/config/nav.config.js"
PAGES = ROOT / "frontend/m/js/pages.js"
ROUTER = ROOT / "frontend/m/js/router.js"
class MobileSystemPagesRegressionTests(unittest.TestCase):
"""Prevent mobile system-management entries from falling back to placeholders."""
def test_nav_system_entries_are_registered_as_real_pages(self) -> None:
nav = NAV.read_text(encoding="utf-8")
pages = PAGES.read_text(encoding="utf-8")
keys = re.findall(r'key:\s*"(system/[^"]+)"', nav)
self.assertEqual(
keys,
[
"system/profile",
"system/password",
"system/membership",
"system/admin",
"system/members",
],
)
for key in keys:
self.assertIn(f'"{key}": setupSystemPage', pages)
self.assertIn(f'"{key}": loadSystem', pages)
def test_placeholder_copy_is_only_a_router_fallback(self) -> None:
router = ROUTER.read_text(encoding="utf-8")
pages = PAGES.read_text(encoding="utf-8")
self.assertIn("该功能页将在后续批次实现", router)
self.assertNotIn("该功能页将在后续批次实现", pages)
self.assertIn("function setupSystemPage", pages)
self.assertIn("function loadSystem", pages)
def test_system_pages_render_real_controls_not_stubs(self) -> None:
pages = PAGES.read_text(encoding="utf-8")
for marker in (
'data-system-page="home"',
'data-system-page="profile"',
'data-system-page="password"',
'data-system-page="membership"',
'data-system-page="members"',
'data-system-page="forbidden"',
'data-system-admin-panel="market"',
"m-sys-birth-date",
"m-sys-password-current",
"m-sys-token",
"m-sys-member-limit",
"data-system-switch",
"data-system-edit-model",
"data-system-open-member",
"管理员专区",
"保存密钥",
"保存分工",
'location.assign("/login/")',
):
self.assertIn(marker, pages)
def test_system_boolean_attrs_do_not_have_stray_quotes(self) -> None:
pages = PAGES.read_text(encoding="utf-8")
stray = re.findall(r'data-system-[a-z-]*"(?=[>\s])', pages)
self.assertEqual(
stray,
[],
"boolean data-system attributes must not have a trailing quote before > or space",
)
for name in (
"data-system-save-birth",
"data-system-save-password",
"data-system-add-model",
"data-system-save-models",
"data-system-save-market",
"data-system-refresh",
"data-system-toggle-refresh",
"data-system-save-model",
):
self.assertIn(name, pages)
self.assertNotIn(name + '">', pages)
-19
View File
@@ -111,25 +111,6 @@ class RealtimeDashboardTests(unittest.TestCase):
self.assertEqual(quote["amount_billion"], 3.0)
self.assertAlmostEqual(quote["turnover_rate"], 0.01)
def test_close_dashboard_marks_official_limit_data(self):
dashboard = self.client.dashboard("20260720")
self.assertEqual(dashboard["meta"]["limit_data_source"], "official")
def test_close_dashboard_marks_derived_limit_data_as_incomplete(self):
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "limit_list_d":
return []
return original_query(api_name, params, fields)
self.client.query = query
dashboard = self.client.dashboard("20260720")
self.assertEqual(dashboard["meta"]["limit_data_source"], "derived")
self.assertIn("日线数据推算", dashboard["meta"]["notice"])
if __name__ == "__main__":
unittest.main()
-317
View File
@@ -1,317 +0,0 @@
from __future__ import annotations
import json
import tempfile
import threading
import unittest
from datetime import datetime
from pathlib import Path
from typing import Any
from unittest.mock import patch
from backend.features.market.backfill_history import (
build_backfill_audit,
classify_snapshot_coverage,
create_sqlite_backup,
select_open_trade_dates,
select_open_trade_dates_in_range,
)
from backend.features.market.service import MarketServiceMixin
from backend.features.sentiment.engine import (
build_sentiment_history,
latest_contiguous_history,
)
from backend.features.sentiment.service import SentimentServiceMixin
from database import ReviewDatabase
def _snapshot(trade_date: str, previous_trade_date: str) -> dict[str, Any]:
display = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}"
previous_display = (
f"{previous_trade_date[:4]}-{previous_trade_date[4:6]}-{previous_trade_date[6:8]}"
if previous_trade_date
else ""
)
return {
"meta": {
"trade_date": display,
"previous_trade_date": previous_display,
"source": "tushare",
},
"overview": {
"up_count": 2500,
"down_count": 2000,
"flat_count": 100,
"amount_billion": 12000,
"limit_up_count": 40,
"limit_down_count": 5,
"broken_count": 10,
"seal_rate": 70,
"max_height": 3,
"second_board_count": 8,
"three_plus_count": 4,
"previous_limit_count": 35,
"previous_positive_rate": 55,
"average_previous_change": 1.2,
"median_previous_change": 0.8,
"advance_rate": 20,
"severe_loss_rate": 5,
"previous_down_count": 3,
"ladder_completeness": 60,
"limit_amount_billion": 300,
},
"limits": [{"code": "000001"}],
"broken": [],
"down_limits": [],
"yesterday_limits": [],
}
class _BackfillHarness(MarketServiceMixin, SentimentServiceMixin):
def __init__(self, database: ReviewDatabase) -> None:
self.database = database
self.sync_lock = threading.Lock()
self.configured = True
self.token = "test-token"
self.current_user_id = 1
self._calendar_rows: list[dict[str, Any]] = []
self._fail_dates: set[str] = set()
self.sync_calls: list[str] = []
def _tushare_client(self): # type: ignore[override]
harness = self
class _Client:
def query(self, api_name, params, fields=""):
assert api_name == "trade_cal"
start = str(params["start_date"])
end = str(params["end_date"])
return [
row
for row in harness._calendar_rows
if start <= str(row["cal_date"]) <= end
]
return _Client()
def sync_dashboard(self, trade_date: str) -> dict[str, Any]: # type: ignore[override]
compact = trade_date.replace("-", "")
self.sync_calls.append(compact)
if compact in self._fail_dates:
raise ValueError(f"simulated failure for {compact}")
previous = ""
for row in self._calendar_rows:
if str(row["cal_date"]) == compact:
previous = str(row.get("pretrade_date") or "")
break
payload = _snapshot(compact, previous)
self.database.save_snapshot(compact, "tushare", payload)
return payload
def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]:
return dashboard
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
return dashboard
class BackfillHistoryHelperTests(unittest.TestCase):
def test_select_open_trade_dates_skips_weekends_and_holidays(self) -> None:
rows = [
{"cal_date": "20260821", "is_open": 1, "pretrade_date": "20260820"},
{"cal_date": "20260822", "is_open": 0, "pretrade_date": "20260821"}, # Sat
{"cal_date": "20260823", "is_open": 0, "pretrade_date": "20260821"}, # Sun
{"cal_date": "20260824", "is_open": 1, "pretrade_date": "20260821"},
{"cal_date": "20260825", "is_open": 1, "pretrade_date": "20260824"},
{"cal_date": "20260826", "is_open": 1, "pretrade_date": "20260825"},
{"cal_date": "20260827", "is_open": 1, "pretrade_date": "20260826"},
]
selected = select_open_trade_dates(rows, "20260827", 4)
self.assertEqual(selected, ["20260824", "20260825", "20260826", "20260827"])
def test_range_mode_reports_non_trading_days_separately(self) -> None:
rows = [
{"cal_date": "20260821", "is_open": 1},
{"cal_date": "20260824", "is_open": 1},
]
open_dates, skipped = select_open_trade_dates_in_range(
rows, "20260821", "20260824"
)
self.assertEqual(open_dates, ["20260821", "20260824"])
self.assertEqual(skipped, ["20260822", "20260823"])
def test_classify_snapshot_coverage_finds_real_gaps(self) -> None:
coverage = classify_snapshot_coverage(
["20260824", "20260825", "20260826", "20260827"],
["20260824", "20260827"],
)
self.assertEqual(coverage["missing"], ["20260825", "20260826"])
self.assertEqual(coverage["present"], ["20260824", "20260827"])
class ContiguousHistoryGapTests(unittest.TestCase):
def test_missing_previous_trade_day_collapses_to_today(self) -> None:
payloads = [
_snapshot("20260824", "20260821"),
_snapshot("20260827", "20260826"), # gap: 20260826 missing
]
series = latest_contiguous_history(build_sentiment_history(payloads))
self.assertEqual([row["trade_date"] for row in series], ["20260827"])
def test_continuous_history_keeps_full_tail(self) -> None:
payloads = [
_snapshot("20260825", "20260824"),
_snapshot("20260826", "20260825"),
_snapshot("20260827", "20260826"),
]
series = latest_contiguous_history(build_sentiment_history(payloads))
self.assertEqual(
[row["trade_date"] for row in series],
["20260825", "20260826", "20260827"],
)
class SnapshotBackfillServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.db_path = Path(self.temporary.name) / "review.db"
self.database = ReviewDatabase(self.db_path)
self.service = _BackfillHarness(self.database)
self.service._calendar_rows = [
{"cal_date": "20260820", "is_open": 1, "pretrade_date": "20260819"},
{"cal_date": "20260821", "is_open": 1, "pretrade_date": "20260820"},
{"cal_date": "20260822", "is_open": 0, "pretrade_date": "20260821"},
{"cal_date": "20260823", "is_open": 0, "pretrade_date": "20260821"},
{"cal_date": "20260824", "is_open": 1, "pretrade_date": "20260821"},
{"cal_date": "20260825", "is_open": 1, "pretrade_date": "20260824"},
{"cal_date": "20260826", "is_open": 1, "pretrade_date": "20260825"},
{"cal_date": "20260827", "is_open": 1, "pretrade_date": "20260826"},
]
# Sparse history mimicking .11: keep 0824 and today, miss 0825/0826.
self.database.save_snapshot("20260824", "tushare", _snapshot("20260824", "20260821"))
self.database.save_snapshot("20260827", "tushare", _snapshot("20260827", "20260826"))
def tearDown(self) -> None:
self.temporary.cleanup()
def test_recent_backfill_fills_gap_and_restores_history(self) -> None:
before = self.service.sentiment_history("20260827", 20)
self.assertEqual(before["available_days"], 1)
with patch(
"backend.features.market.service.create_sqlite_backup",
return_value=Path(self.temporary.name) / "fake-backup.db",
) as backup:
audit = self.service.backfill_recent_trading_days(
end_date="20260827",
lookback=4,
dry_run=False,
create_backup=True,
)
backup.assert_called_once()
self.assertEqual(sorted(self.service.sync_calls), ["20260825", "20260826"])
self.assertEqual(audit["missing"], ["2026-08-25", "2026-08-26"])
self.assertEqual(sorted(audit["created_dates"]), ["2026-08-25", "2026-08-26"])
after = self.service.sentiment_history("20260827", 20)
self.assertGreaterEqual(after["available_days"], 4)
self.assertEqual(
[row["trade_date"] for row in after["rows"]],
["20260824", "20260825", "20260826", "20260827"],
)
def test_dry_run_does_not_write_snapshots(self) -> None:
audit = self.service.backfill_recent_trading_days(
end_date="20260827",
lookback=4,
dry_run=True,
create_backup=True,
)
self.assertTrue(audit["dry_run"])
self.assertEqual(self.service.sync_calls, [])
self.assertIsNone(audit["backup_path"])
self.assertEqual(
self.database.list_snapshot_trade_dates("20260824", "20260827"),
["20260824", "20260827"],
)
def test_repeat_execution_skips_existing_days(self) -> None:
with patch(
"backend.features.market.service.create_sqlite_backup",
return_value=Path(self.temporary.name) / "fake-backup.db",
):
first = self.service.backfill_recent_trading_days(
end_date="20260827", lookback=4
)
self.service.sync_calls.clear()
second = self.service.backfill_recent_trading_days(
end_date="20260827", lookback=4
)
self.assertEqual(first["succeeded_count"], 2)
self.assertEqual(self.service.sync_calls, [])
self.assertEqual(second["missing_count"], 0)
self.assertEqual(second["skipped_count"], 4)
self.assertIsNone(second["backup_path"])
def test_partial_failure_continues_remaining_days(self) -> None:
self.service._fail_dates.add("20260825")
with patch(
"backend.features.market.service.create_sqlite_backup",
return_value=Path(self.temporary.name) / "fake-backup.db",
):
audit = self.service.backfill_recent_trading_days(
end_date="20260827", lookback=4
)
self.assertFalse(audit["ok"])
self.assertEqual(audit["failed_count"], 1)
self.assertEqual(audit["succeeded_count"], 1)
self.assertIn("20260826", self.database.list_snapshot_trade_dates())
self.assertNotIn("20260825", self.database.list_snapshot_trade_dates())
def test_range_backfill_skips_weekend_without_treating_as_error(self) -> None:
with patch(
"backend.features.market.service.create_sqlite_backup",
return_value=Path(self.temporary.name) / "fake-backup.db",
):
audit = self.service.backfill(
start_date="2026-08-21",
end_date="2026-08-24",
)
self.assertEqual(audit["mode"], "range")
self.assertEqual(audit["skipped_non_trading_days"], ["2026-08-22", "2026-08-23"])
self.assertEqual(sorted(self.service.sync_calls), ["20260821"])
self.assertTrue(audit["ok"])
def test_sqlite_backup_api_creates_restorable_copy(self) -> None:
backup_dir = Path(self.temporary.name) / "backups"
backup = create_sqlite_backup(
self.db_path,
backup_dir,
label="pre-recent-backfill",
stamped_at=datetime(2026, 8, 27, 15, 30, 0),
)
self.assertTrue(backup.exists())
self.assertIn("pre-recent-backfill-20260827-153000", backup.name)
restored = ReviewDatabase(backup)
self.assertEqual(
restored.list_snapshot_trade_dates(),
["20260824", "20260827"],
)
def test_audit_lists_only_snapshot_related_write_tables(self) -> None:
audit = build_backfill_audit(
mode="recent",
end_date="20260827",
lookback=60,
coverage={"trade_dates": [], "present": [], "missing": [], "present_count": 0, "missing_count": 0},
)
self.assertEqual(
audit["write_tables"],
["dashboard_snapshots", "data_snapshots", "sync_runs"],
)
self.assertNotIn("users", audit["write_tables"])
self.assertNotIn("system_settings", audit["write_tables"])
if __name__ == "__main__":
unittest.main()
-16
View File
@@ -17,22 +17,6 @@ registry, and verification tools.
`backend/features/*/routes.py` owners.
- `python tools/build_architecture_inventory.py [--check]`: generate or verify
`config/architecture-inventory.json` from the current source tree.
- `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
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
`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
ancestor gate. Reads the live `org.opencontainers.image.revision` from the running
`xiaobai-review` container (or `--live-revision` in tests), prints the live SHA,
candidate SHA, file diff, and commits the candidate would drop, then exits if the
live revision is not an ancestor of the candidate.
`verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside
this application. Historical comparison scripts were retired after final standalone acceptance;
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env python3
"""Auditable recent trading-day dashboard snapshot backfill.
Examples:
python tools/backfill_recent_snapshots.py --account admin --dry-run
python tools/backfill_recent_snapshots.py --account admin --lookback 60
python tools/backfill_recent_snapshots.py --account admin --end-date 2026-08-27 --force
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from backend.application import SERVICE
from backend.bootstrap.config import normalize_date
from backend.features.market.backfill_history import DEFAULT_RECENT_TRADING_DAYS
def main() -> None:
parser = argparse.ArgumentParser(
description="Backfill the latest N real trading-day dashboard snapshots"
)
parser.add_argument(
"--account",
required=True,
help="Account that can resolve the shared Tushare token",
)
parser.add_argument(
"--end-date",
default=date.today().isoformat(),
help="Inclusive end date YYYY-MM-DD (default: today)",
)
parser.add_argument(
"--lookback",
type=int,
default=DEFAULT_RECENT_TRADING_DAYS,
help=f"Number of open trading days to cover (default {DEFAULT_RECENT_TRADING_DAYS}, max 60)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Plan only: classify missing gaps without writing",
)
parser.add_argument(
"--force",
action="store_true",
help="Re-sync days that already have snapshots",
)
parser.add_argument(
"--no-backup",
action="store_true",
help="Skip the SQLite backup API step (not recommended)",
)
parser.add_argument(
"--json",
action="store_true",
help="Print the full audit payload as JSON",
)
args = parser.parse_args()
user = SERVICE.database.user_by_username(args.account.strip())
if not user:
raise SystemExit("account not found")
SERVICE.bind_user(int(user["id"]))
end_date = normalize_date(args.end_date)
audit = SERVICE.backfill_recent_trading_days(
end_date=end_date,
lookback=args.lookback,
dry_run=args.dry_run,
force=args.force,
create_backup=not args.no_backup,
)
if args.json:
print(json.dumps(audit, ensure_ascii=False, indent=2))
raise SystemExit(0 if audit.get("ok") else 1)
print(
f"mode={audit['mode']} end={audit['end_date']} lookback={audit['lookback']} "
f"dry_run={audit['dry_run']}"
)
print(
f"present={audit['present_count']} missing={audit['missing_count']} "
f"succeeded={audit['succeeded_count']} skipped={audit['skipped_count']} "
f"failed={audit['failed_count']}"
)
if audit.get("backup_path"):
print(f"backup={audit['backup_path']}")
if audit.get("missing"):
print("missing_dates=" + ",".join(audit["missing"]))
if audit.get("created_dates"):
print("created_dates=" + ",".join(audit["created_dates"]))
failed = [row for row in audit.get("results") or [] if row.get("status") == "failed"]
for row in failed:
print(f"failed {row.get('requested_date')}: {row.get('error')}")
if not audit.get("ok"):
raise SystemExit(1)
print("backfill complete")
if __name__ == "__main__":
main()
+1 -7
View File
@@ -50,13 +50,7 @@ def _owner(path: str) -> str:
def _role(method: str, path: str) -> str:
if path == "/api/health" or path in {
"/api/auth/register",
"/api/auth/login",
"/api/auth/accounts",
"/api/auth/switch",
"/api/auth/forget",
}:
if path == "/api/health" or path in {"/api/auth/register", "/api/auth/login"}:
return "public"
from api_access import required_role
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# 小白复盘唯一安全构建入口(HEL-235 固化)
# 方式:从明确 Git 提交 git archive 流式传输到部署机 docker build,不使用任何服务器工作树。
# 铁律:禁止在服务器目录(如 /opt/1panel/docker/compose/xiaobaifupan)里 docker build
# 禁止构建 latest 等不带提交短号的 tag;严禁向 192.168.200.36 构建或部署。
set -euo pipefail
HOST_DEFAULT="moxiaobai@192.168.200.11"
REPO_NAME="xiaobai-review"
usage() {
cat <<'EOF'
用法: tools/build_image.sh <commit> <tag>
<commit> 提交号(完整或前缀),必须能被 origin 解析;构建前会自动 fetch
<tag> 镜像 tag,必须以 -<提交短号7位> 结尾,锁定镜像来源;禁止 latest、rollback-*
示例: tools/build_image.sh cefc86917d89 verify-hel235-cefc869
说明: 仅构建镜像,不启动、不替换任何容器;换版与回滚另行人工执行。
EOF
exit 2
}
[ $# -eq 2 ] || usage
COMMIT="$1"
TAG="$2"
HOST="${XB_BUILD_HOST:-$HOST_DEFAULT}"
case "$HOST" in
*192.168.200.36*)
echo "拒绝:192.168.200.36 已永久废弃,严禁在其上构建或部署。" >&2
exit 1
;;
esac
cd "$(git rev-parse --show-toplevel)"
echo "==> 同步远端引用"
git fetch origin --prune --quiet
FULL_SHA="$(git rev-parse --verify --quiet "${COMMIT}^{commit}" || true)"
if [ -z "$FULL_SHA" ]; then
echo "拒绝:提交 ${COMMIT} 无法解析。构建源必须锁定到已推送 origin 的明确提交。" >&2
exit 1
fi
SHORT="${FULL_SHA:0:7}"
SUBJECT="$(git log -1 --format=%s "$FULL_SHA")"
case "$TAG" in
latest)
echo "拒绝:禁止构建 latest,模糊 tag 无法追溯来源提交。" >&2
exit 1
;;
rollback-*)
echo "拒绝:rollback-* 是部署时对既有镜像的人工 docker tag,不允许用来构建。" >&2
exit 1
;;
esac
if [[ "$TAG" != *-"$SHORT" ]]; then
echo "拒绝:tag「${TAG}」必须以 -${SHORT} 结尾,保证镜像 tag 与来源提交一一对应。" >&2
exit 1
fi
echo "==> 部署基线门禁(线上提交必须是候选祖先)"
bash "$(git rev-parse --show-toplevel)/tools/check_deploy_baseline.sh" "$FULL_SHA"
echo "==> 构建计划"
echo " 提交: ${FULL_SHA} ${SUBJECT}"
echo " 镜像: ${REPO_NAME}:${TAG} @ ${HOST}"
echo " 方式: git archive 流式构建(不读取服务器上任何代码目录)"
echo "==> 流式构建开始"
git archive --format=tar "$FULL_SHA" \
| ssh -o BatchMode=yes "$HOST" docker build --rm \
-t "${REPO_NAME}:${TAG}" \
--label "org.opencontainers.image.revision=${FULL_SHA}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "org.opencontainers.image.source=git-archive-stream" \
-
echo "==> 回读校验镜像内记录的提交号"
GOT="$(ssh -o BatchMode=yes "$HOST" "docker image inspect ${REPO_NAME}:${TAG} --format '{{index .Config.Labels \"org.opencontainers.image.revision\"}}'" 2>/dev/null || true)"
if [ "$GOT" != "$FULL_SHA" ]; then
echo "校验失败:镜像 revision='${GOT:-<空>}',期望 ${FULL_SHA}。删除不可信镜像,中止。" >&2
ssh -o BatchMode=yes "$HOST" docker rmi "${REPO_NAME}:${TAG}" >/dev/null 2>&1 || true
exit 1
fi
IMAGE_ID="$(ssh -o BatchMode=yes "$HOST" "docker image inspect ${REPO_NAME}:${TAG} --format '{{.Id}}'")"
SHORT_ID="${IMAGE_ID##*:}"
ssh -o BatchMode=yes "$HOST" \
"mkdir -p ~/xiaobai-build && printf '%s\t%s\t%s\t%s\tgit-archive-stream\n' \"\$(date '+%F %T')\" ${REPO_NAME}:${TAG} ${FULL_SHA} ${SHORT_ID} >> ~/xiaobai-build/BUILD_LOG.tsv"
echo "==> 完成"
echo " ${REPO_NAME}:${TAG} (${SHORT_ID})"
echo " 来源提交 ${FULL_SHA} 已写入镜像 label 与 ~/xiaobai-build/BUILD_LOG.tsv"
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env bash
# 部署基线门禁(HEL-238):候选提交必须包含当前线上提交的全部历史。
# 线上提交号从运行中的容器镜像 label 读取,禁止人工填写“看起来正确”的基线。
set -euo pipefail
HOST_DEFAULT="moxiaobai@192.168.200.11"
CONTAINER_DEFAULT="xiaobai-review"
usage() {
cat <<'EOF'
用法: tools/check_deploy_baseline.sh <candidate_commit> [--live-revision <sha>]
<candidate_commit> 准备构建/部署的提交(完整或前缀)
--live-revision <sha> 仅测试用:直接指定线上提交,跳过 SSH 读取
环境变量:
XB_BUILD_HOST 部署机 SSH(默认 moxiaobai@192.168.200.11
XB_LIVE_CONTAINER 运行中容器名(默认 xiaobai-review
XB_LIVE_REVISION 若已设置则视为线上提交,不再 SSH
EOF
exit 2
}
[ $# -ge 1 ] || usage
CANDIDATE="$1"
shift
LIVE_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
--live-revision)
[ $# -ge 2 ] || usage
LIVE_OVERRIDE="$2"
shift 2
;;
-h|--help)
usage
;;
*)
echo "拒绝:未知参数 $1" >&2
usage
;;
esac
done
HOST="${XB_BUILD_HOST:-$HOST_DEFAULT}"
CONTAINER="${XB_LIVE_CONTAINER:-$CONTAINER_DEFAULT}"
case "$HOST" in
*192.168.200.36*)
echo "拒绝:192.168.200.36 已永久废弃,严禁在其上构建或部署。" >&2
exit 1
;;
esac
cd "$(git rev-parse --show-toplevel)"
read_live_revision() {
if [ -n "${LIVE_OVERRIDE}" ]; then
printf '%s\n' "${LIVE_OVERRIDE}"
return
fi
if [ -n "${XB_LIVE_REVISION:-}" ]; then
printf '%s\n' "${XB_LIVE_REVISION}"
return
fi
ssh -o BatchMode=yes "$HOST" bash -s -- "$CONTAINER" <<'REMOTE'
set -euo pipefail
container="$1"
revision="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)"
if [ -z "$revision" ] || [ "$revision" = "<no value>" ]; then
image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || true)"
if [ -n "$image" ]; then
revision="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image" 2>/dev/null || true)"
fi
fi
if [ -z "$revision" ] || [ "$revision" = "<no value>" ]; then
echo "拒绝:无法从线上容器 ${container} 读取 org.opencontainers.image.revision。" >&2
exit 1
fi
printf '%s\n' "$revision"
REMOTE
}
LIVE_RAW="$(read_live_revision)"
LIVE_RAW="$(printf '%s' "$LIVE_RAW" | tr -d '[:space:]')"
if [ -z "$LIVE_RAW" ]; then
echo "拒绝:线上提交号为空,禁止继续构建或部署。" >&2
exit 1
fi
CANDIDATE_SHA="$(git rev-parse --verify --quiet "${CANDIDATE}^{commit}" || true)"
if [ -z "$CANDIDATE_SHA" ]; then
echo "拒绝:候选提交 ${CANDIDATE} 无法解析。" >&2
exit 1
fi
LIVE_SHA="$(git rev-parse --verify --quiet "${LIVE_RAW}^{commit}" || true)"
if [ -z "$LIVE_SHA" ]; then
echo "拒绝:线上提交 ${LIVE_RAW} 在本地仓库无法解析;请先 git fetch,禁止手工填写替代基线。" >&2
exit 1
fi
echo "==> 部署基线对照"
echo " 线上提交: ${LIVE_SHA}"
echo " 候选提交: ${CANDIDATE_SHA}"
echo "==> 候选相对线上的文件差异"
DIFF_FILES="$(git diff --name-only "$LIVE_SHA" "$CANDIDATE_SHA" || true)"
if [ -z "$DIFF_FILES" ]; then
echo " (无文件差异)"
else
printf '%s\n' "$DIFF_FILES" | sed 's/^/ /'
fi
echo "==> 候选将丢失的提交(线上有、候选没有)"
LOST="$(git log --oneline "$CANDIDATE_SHA".."$LIVE_SHA" || true)"
if [ -z "$LOST" ]; then
echo " (无)"
else
printf '%s\n' "$LOST" | sed 's/^/ /'
fi
if ! git merge-base --is-ancestor "$LIVE_SHA" "$CANDIDATE_SHA"; then
echo "拒绝:候选提交不是当前线上提交的后继,部署会丢失线上已有提交。禁止构建或部署。" >&2
exit 1
fi
echo "==> 祖先关系通过:线上 ${LIVE_SHA:0:7} 是候选 ${CANDIDATE_SHA:0:7} 的祖先"