Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d2c2fbf6 | ||
|
|
585e42dac7 | ||
|
|
a50d48e5d4 | ||
|
|
cefc86917d | ||
|
|
13cd9940f7 | ||
|
|
f7cf9a6454 | ||
|
|
f0a1adf52f | ||
|
|
213f735f6a |
+32
-3
@@ -149,7 +149,31 @@ docker compose restart xiaobai-review
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 使用 Gitea 更新程序(推荐)
|
||||
### 镜像构建的唯一安全入口(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,解析失败立即中止,绝不使用本地脏状态或服务器旧目录;
|
||||
- 镜像 tag 必须以 `-<提交短号7位>` 结尾(如 `hel234-cefc869`),禁止 `latest`、`rollback-*`;
|
||||
- 通过 `git archive <提交> | ssh 部署机 docker build -` 流式构建,服务器上不存在构建用工作树;
|
||||
- 构建后回读镜像 label 里的 `org.opencontainers.image.revision`,与预期提交不一致则删除镜像并中止;
|
||||
- 每次构建在部署机 `~/xiaobai-build/BUILD_LOG.tsv` 留痕,可追溯每个镜像的来源提交。
|
||||
|
||||
构建只产出镜像,不启动、不替换任何容器;换版用新 tag 起新容器,回滚用既有镜像 tag 重跑。
|
||||
|
||||
### 使用 Gitea 更新程序(旧方式,生产机禁用)
|
||||
|
||||
代码仓库为:
|
||||
|
||||
@@ -174,7 +198,9 @@ cd /opt/xiaobai-review
|
||||
`data/private-mentor-skills/` 复制到服务器项目的同名 `data` 目录,并保持目录仅由
|
||||
部署账号和容器运行用户读取。该内容不会通过 Gitea 同步。
|
||||
|
||||
每次更新前先创建 SQLite 一致性备份,再拉取并重建容器:
|
||||
每次更新前先创建 SQLite 一致性备份,再拉取并重建容器(注意:`docker compose up -d --build`
|
||||
从服务器本地工作树构建,仅适用于来源可信的全新环境;生产机 `192.168.200.11` 禁用,
|
||||
请用 `tools/build_image.sh` 构建后换容器):
|
||||
|
||||
```bash
|
||||
cd /opt/xiaobai-review
|
||||
@@ -189,7 +215,10 @@ curl --fail http://127.0.0.1:8765/api/health
|
||||
数据库迁移会在新容器启动时自动执行。若 `git pull --ff-only` 提示本地代码有修改,
|
||||
先用 `git status` 查明原因,不要用强制重置覆盖 `.env` 或 `data`。
|
||||
|
||||
### 不使用 Git 时更新
|
||||
### 不使用 Git 时更新(生产机禁用)
|
||||
|
||||
`docker compose build` 会从服务器本地目录构建,来源提交不可追溯。生产机
|
||||
`192.168.200.11` 上禁止使用本节方式,一律改用上一节的 `tools/build_image.sh`。
|
||||
|
||||
重新上传代码后执行:
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ 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:
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 = (
|
||||
@@ -9,6 +10,7 @@ MIGRATIONS = (
|
||||
M0002_JOB_RUNS,
|
||||
M0003_LLM_AUDIT,
|
||||
M0004_MENTOR_NOTES,
|
||||
M0005_ACCOUNT_SWITCH_GRANTS,
|
||||
)
|
||||
|
||||
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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",
|
||||
)
|
||||
@@ -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) -> dict[str, Any]:
|
||||
return self.accounts.register(username, password)
|
||||
def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
return self.accounts.register(username, password, device_hash)
|
||||
|
||||
def login_account(self, username: str, password: str) -> dict[str, Any]:
|
||||
return self.accounts.login(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 change_password(self, current_password: str, new_password: str) -> None:
|
||||
self.accounts.change_password(current_password, new_password)
|
||||
|
||||
@@ -1,49 +1,108 @@
|
||||
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_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
HTTPStatus.CREATED,
|
||||
{"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
|
||||
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_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
headers={"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
self._send_authenticated_session(result, HTTPStatus.OK, device_raw)
|
||||
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):
|
||||
@@ -55,6 +114,15 @@ 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,
|
||||
@@ -66,15 +134,19 @@ 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)},
|
||||
|
||||
@@ -234,3 +234,96 @@ 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)
|
||||
|
||||
@@ -6,6 +6,9 @@ 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:
|
||||
|
||||
@@ -77,15 +77,22 @@ class AccountService:
|
||||
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
|
||||
return self.membership_for_access(access)
|
||||
|
||||
def register(self, username: str, password: str) -> dict[str, Any]:
|
||||
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]:
|
||||
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)
|
||||
return self.create_session(user)
|
||||
result = self.create_session(user)
|
||||
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
||||
return result
|
||||
|
||||
def login(self, username: str, password: str) -> dict[str, Any]:
|
||||
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
if not username or not password:
|
||||
raise ValueError("账号名和密码不能为空。")
|
||||
@@ -96,7 +103,9 @@ class AccountService:
|
||||
str(user.get("password_hash") or ""),
|
||||
):
|
||||
raise ValueError("账号名或密码不正确。")
|
||||
return self.create_session(user)
|
||||
result = self.create_session(user)
|
||||
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
||||
return result
|
||||
|
||||
def change_password(self, current_password: str, new_password: str) -> None:
|
||||
current_password = str(current_password or "")
|
||||
@@ -112,6 +121,86 @@ 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)
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 = {
|
||||
|
||||
+26
-10
@@ -9,7 +9,13 @@ from http.cookies import SimpleCookie
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR
|
||||
from backend.bootstrap.config import (
|
||||
DEVICE_COOKIE,
|
||||
DEVICE_MAX_AGE,
|
||||
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
|
||||
@@ -21,15 +27,21 @@ class HttpTransportMixin:
|
||||
application_service: Any
|
||||
route_registry: Any
|
||||
|
||||
def session_token(self) -> str:
|
||||
def cookie_value(self, name: str) -> str:
|
||||
cookie = SimpleCookie()
|
||||
try:
|
||||
cookie.load(self.headers.get("Cookie", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
morsel = cookie.get(SESSION_COOKIE)
|
||||
morsel = cookie.get(name)
|
||||
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
|
||||
@@ -79,15 +91,18 @@ class HttpTransportMixin:
|
||||
return self.require_member()
|
||||
return True
|
||||
|
||||
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}"
|
||||
)
|
||||
def _cookie_header(self, name: str, value: str, max_age: int) -> str:
|
||||
cookie = f"{name}={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:
|
||||
@@ -132,7 +147,7 @@ class HttpTransportMixin:
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
status: HTTPStatus = HTTPStatus.OK,
|
||||
headers: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...] | None = None,
|
||||
) -> None:
|
||||
request_id = getattr(self, "_correlation_id", "")
|
||||
if not request_id:
|
||||
@@ -145,7 +160,8 @@ 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)
|
||||
for name, value in (headers or {}).items():
|
||||
header_items = headers.items() if isinstance(headers, dict) else (headers or ())
|
||||
for name, value in header_items:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
@@ -128,6 +128,20 @@
|
||||
"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",
|
||||
@@ -156,6 +170,13 @@
|
||||
"feature": "auth",
|
||||
"access": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/auth/switch",
|
||||
"match": "exact",
|
||||
"feature": "auth",
|
||||
"access": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/backfill",
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
},
|
||||
"counts": {
|
||||
"primary_pages": 16,
|
||||
"api_exact_paths": 53,
|
||||
"api_exact_paths": 56,
|
||||
"api_prefixes": 0,
|
||||
"api_patterns": 11,
|
||||
"database_tables": 36,
|
||||
"database_tables": 37,
|
||||
"frontend_page_fragments": 12
|
||||
},
|
||||
"pages": [
|
||||
@@ -96,10 +96,13 @@
|
||||
"/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",
|
||||
@@ -189,6 +192,7 @@
|
||||
"assistant_messages",
|
||||
"heaven_readings",
|
||||
"job_runs",
|
||||
"account_switch_grants",
|
||||
"schema_migrations"
|
||||
],
|
||||
"background_job_methods": [
|
||||
@@ -370,10 +374,10 @@
|
||||
}
|
||||
],
|
||||
"css_layers": [
|
||||
"/shared/tokens.css?v=20260820-3",
|
||||
"/shared/tokens.css?v=20260829-1",
|
||||
"/shared/base.css?v=20260806-1",
|
||||
"/shared/shell.css?v=20260820-8",
|
||||
"/shared/auth.css?v=20260820-5",
|
||||
"/shared/auth.css?v=20260829-1",
|
||||
"/shared/components/controls.css?v=20260820-2",
|
||||
"/shared/components/navigation.css?v=20260820-1",
|
||||
"/shared/components/cards.css?v=20260820-1",
|
||||
@@ -461,8 +465,8 @@
|
||||
},
|
||||
{
|
||||
"path": "frontend/index.html",
|
||||
"bytes": 47871,
|
||||
"lines": 661
|
||||
"bytes": 48037,
|
||||
"lines": 663
|
||||
},
|
||||
{
|
||||
"path": "backend/features/screener/catalog.py",
|
||||
@@ -551,8 +555,8 @@
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/session.js",
|
||||
"bytes": 13176,
|
||||
"lines": 293
|
||||
"bytes": 12848,
|
||||
"lines": 283
|
||||
},
|
||||
{
|
||||
"path": "backend/features/market/insights_auction_data.py",
|
||||
@@ -716,8 +720,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/http/dispatch.py",
|
||||
"bytes": 4118,
|
||||
"lines": 115
|
||||
"bytes": 4196,
|
||||
"lines": 117
|
||||
},
|
||||
{
|
||||
"path": "frontend/shared/table.js",
|
||||
@@ -734,16 +738,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,
|
||||
@@ -761,7 +765,7 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/features/accounts/application.py",
|
||||
"bytes": 2442,
|
||||
"bytes": 2514,
|
||||
"lines": 63
|
||||
},
|
||||
{
|
||||
@@ -849,6 +853,11 @@
|
||||
"bytes": 1143,
|
||||
"lines": 19
|
||||
},
|
||||
{
|
||||
"path": "backend/features/accounts/routes.py",
|
||||
"bytes": 908,
|
||||
"lines": 25
|
||||
},
|
||||
{
|
||||
"path": "backend/features/popularity/routes.py",
|
||||
"bytes": 822,
|
||||
@@ -859,11 +868,6 @@
|
||||
"bytes": 817,
|
||||
"lines": 23
|
||||
},
|
||||
{
|
||||
"path": "backend/features/accounts/routes.py",
|
||||
"bytes": 803,
|
||||
"lines": 22
|
||||
},
|
||||
{
|
||||
"path": "backend/features/sentiment/routes.py",
|
||||
"bytes": 724,
|
||||
|
||||
+4
-2
@@ -25,8 +25,10 @@ async function initialize() {
|
||||
try {
|
||||
const session = await apiRequest("/api/auth/me");
|
||||
if (!session.authenticated) {
|
||||
if (session.registration_required) selectAuthMode("register");
|
||||
showAuthGate();
|
||||
const params = new URLSearchParams();
|
||||
if (session.registration_required) params.set("mode", "register");
|
||||
const query = params.toString();
|
||||
window.location.replace("/login/" + (query ? `?${query}` : ""));
|
||||
return;
|
||||
}
|
||||
await applyAuthenticatedSession(session);
|
||||
|
||||
+6
-4
@@ -24,7 +24,9 @@
|
||||
(() => {
|
||||
let theme = "light";
|
||||
try {
|
||||
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
|
||||
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";
|
||||
}
|
||||
@@ -32,10 +34,10 @@
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260820-3">
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-1">
|
||||
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
|
||||
<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/auth.css?v=20260829-1">
|
||||
<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">
|
||||
@@ -57,7 +59,7 @@
|
||||
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260820-4">
|
||||
</head>
|
||||
<body>
|
||||
<section id="authGate" class="auth-gate" aria-label="账号登录">
|
||||
<section id="authGate" class="auth-gate" aria-label="账号登录" hidden>
|
||||
<div class="auth-shell">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark" aria-hidden="true"><span class="brand-glyph">复</span></div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<!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-1">
|
||||
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
|
||||
<link rel="stylesheet" href="/shared/auth.css?v=20260829-1">
|
||||
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
|
||||
</head>
|
||||
<body class="login-portal">
|
||||
<button id="loginThemeToggle" class="login-theme-toggle" type="button">🌙 夜间</button>
|
||||
<aside class="login-brand" aria-hidden="true">
|
||||
<div class="login-brand-mark"><span class="login-brand-glyph">复</span></div>
|
||||
<p class="login-brand-kicker">收盘之后 · 复盘开始</p>
|
||||
<h1 class="login-brand-title">小白复盘</h1>
|
||||
<p class="login-brand-lead">看懂情绪周期,把复盘变成下一次的先手。</p>
|
||||
<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">
|
||||
<dt>两市成交</dt>
|
||||
<dd>1.02万亿</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
<main class="login-stage">
|
||||
<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-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,273 @@
|
||||
(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: "",
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (ch) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 membershipLabel(account) {
|
||||
if (account.role === "admin") return account.membership?.subscribed ? "管理员 · 会员" : "管理员";
|
||||
return account.membership?.subscribed ? "会员" : "普通用户";
|
||||
}
|
||||
|
||||
function enterApp() {
|
||||
const next = new URLSearchParams(global.location.search).get("next");
|
||||
global.location.replace(next && next.startsWith("/") ? next : "/");
|
||||
}
|
||||
|
||||
function formMarkup(options) {
|
||||
const registering = state.mode === "register";
|
||||
const submitLabel = options.submitLabel
|
||||
|| (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录");
|
||||
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(options.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" required></label>',
|
||||
`<label class="form-field"><span>密码</span><input id="loginPassword" type="password" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" required></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">密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。</p>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
function accountRow(account) {
|
||||
const current = Number(account.user_id) === Number(state.currentUserId);
|
||||
const confirming = Number(state.confirmingId) === Number(account.user_id);
|
||||
const classes = `login-account-row${current ? " is-current" : ""}${confirming ? " is-confirming" : ""}`;
|
||||
if (state.view === "manage" && 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 action = state.view === "manage"
|
||||
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}">移除</button>`
|
||||
: current
|
||||
? '<span class="login-account-check" aria-hidden="true">✓</span>'
|
||||
: `<button class="login-account-enter" type="button" data-switch-id="${account.user_id}">进入</button>`;
|
||||
return [
|
||||
`<div class="${classes}" data-user-id="${account.user_id}">`,
|
||||
'<div class="login-account-meta">',
|
||||
`<strong>${escapeHtml(account.username)}</strong>`,
|
||||
`<span>${escapeHtml(membershipLabel(account))}${current ? " · 当前" : ""}</span>`,
|
||||
"</div>",
|
||||
action,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function pickerMarkup() {
|
||||
const count = state.accounts.length;
|
||||
const managing = state.view === "manage";
|
||||
return [
|
||||
`<h2 class="login-card-title">${managing ? "管理账号记录" : "选择账号"}</h2>`,
|
||||
`<p class="login-card-lead">这台电脑已记录 ${count} 个账号,可直接进入,无需再次输入密码。</p>`,
|
||||
managing
|
||||
? '<button class="login-manage" type="button" data-login-action="picker">完成</button>'
|
||||
: "",
|
||||
`<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>` : "",
|
||||
'<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: "登录后进入你的复盘空间",
|
||||
add: state.view === "add",
|
||||
back: state.view === "add",
|
||||
});
|
||||
} else {
|
||||
card.innerHTML = pickerMarkup();
|
||||
}
|
||||
bindCard();
|
||||
}
|
||||
|
||||
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 === "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-confirm-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
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;
|
||||
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 });
|
||||
enterApp();
|
||||
} catch (error) {
|
||||
state.loading = false;
|
||||
setError(error.message || "账号操作失败");
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -3174,3 +3174,51 @@
|
||||
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);
|
||||
box-shadow: var(--elevation-card);
|
||||
}
|
||||
|
||||
.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-account-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.m-page[data-page^="system/"] .m-btn-primary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.m-page[data-page^="system/"] .m-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -246,6 +246,29 @@ 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;
|
||||
@@ -313,7 +336,8 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.m-form-field input {
|
||||
.m-form-field input,
|
||||
.m-form-field select {
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -323,11 +347,20 @@ body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.m-form-field input:focus {
|
||||
.m-form-field input:focus,
|
||||
.m-form-field select: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;
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
(() => {
|
||||
let theme = "light";
|
||||
try {
|
||||
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
|
||||
function readTheme() {
|
||||
try {
|
||||
return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
|
||||
const stored = global.localStorage.getItem(THEME_KEY);
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
} catch (_error) {
|
||||
return "light";
|
||||
}
|
||||
if (global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
|
||||
+559
-1
@@ -90,6 +90,7 @@
|
||||
"calendar-check": '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/>',
|
||||
"sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
};
|
||||
|
||||
function icon(name, size) {
|
||||
@@ -186,6 +187,11 @@
|
||||
form: null,
|
||||
dirty: false,
|
||||
},
|
||||
system: {
|
||||
account: null,
|
||||
admin: null,
|
||||
adminTab: "market",
|
||||
},
|
||||
};
|
||||
|
||||
let sheetToken = 0;
|
||||
@@ -3067,6 +3073,11 @@
|
||||
"review/daily": setupReviewPage,
|
||||
"review/notes": setupReviewPage,
|
||||
"review/alerts": setupReviewPage,
|
||||
"system/profile": setupSystemPage,
|
||||
"system/password": setupSystemPage,
|
||||
"system/membership": setupSystemPage,
|
||||
"system/admin": setupSystemPage,
|
||||
"system/members": setupSystemPage,
|
||||
};
|
||||
|
||||
const COMPLEX_LOADERS = {
|
||||
@@ -3085,6 +3096,11 @@
|
||||
"review/daily": loadReview,
|
||||
"review/notes": loadReview,
|
||||
"review/alerts": loadReview,
|
||||
"system/profile": loadSystem,
|
||||
"system/password": loadSystem,
|
||||
"system/membership": loadSystem,
|
||||
"system/admin": loadSystem,
|
||||
"system/members": loadSystem,
|
||||
};
|
||||
|
||||
function isComplexPage(key) {
|
||||
@@ -3450,7 +3466,7 @@
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const root = document.getElementById("m-sheet-root");
|
||||
const root = ensureSheetRoot();
|
||||
if (!root) return;
|
||||
let toast = document.getElementById("m-toast");
|
||||
if (!toast) {
|
||||
@@ -4847,6 +4863,524 @@
|
||||
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 系统管理(恢复桌面端已有能力,禁止再走占位页) */
|
||||
|
||||
function isSystemPage(key) {
|
||||
return String(key || state.key || "").indexOf("system/") === 0;
|
||||
}
|
||||
|
||||
function systemForbiddenHtml() {
|
||||
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
|
||||
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
|
||||
"<p>仅管理员可访问</p>" +
|
||||
"<small>系统设置和会员管理需要管理员权限。</small>" +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
function systemFill(html) {
|
||||
const scroll = document.getElementById("m-scroll");
|
||||
if (scroll) scroll.innerHTML = html;
|
||||
}
|
||||
|
||||
function membershipDateLabel(value) {
|
||||
if (!value) return "";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
|
||||
}
|
||||
|
||||
function setupSystemPage(key) {
|
||||
state.key = key;
|
||||
state.requestedDate = todayString();
|
||||
state.sort = { key: "", dir: null };
|
||||
state.sortTable = { cols: null, reapply: null };
|
||||
state.detail = null;
|
||||
if (key === "system/admin") state.system.adminTab = "market";
|
||||
document.getElementById("m-view").classList.add("m-view-feature");
|
||||
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
|
||||
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
|
||||
}
|
||||
|
||||
function loadSystem() {
|
||||
const key = state.key;
|
||||
if ((key === "system/admin" || key === "system/members") && !global.MobileSession.isAdmin()) {
|
||||
systemFill(systemForbiddenHtml());
|
||||
return;
|
||||
}
|
||||
if (key === "system/password") {
|
||||
renderSystemPassword();
|
||||
return;
|
||||
}
|
||||
const seq = nextSeq();
|
||||
const url = (key === "system/admin" || key === "system/members") ? "/api/admin/settings" : "/api/account/status";
|
||||
global.MobileAPI.request(url).then(function (payload) {
|
||||
if (seq !== state.seq || state.key !== key) return;
|
||||
if (key === "system/admin" || key === "system/members") {
|
||||
state.system.admin = payload || {};
|
||||
renderSystemAdmin(key);
|
||||
} else {
|
||||
state.system.account = payload || {};
|
||||
if (key === "system/profile") renderSystemProfile();
|
||||
else renderSystemMembership();
|
||||
}
|
||||
}).catch(function (error) {
|
||||
if (seq !== state.seq || state.key !== key) return;
|
||||
systemFill(errorHtml(error && error.message ? error.message : "加载失败"));
|
||||
});
|
||||
}
|
||||
|
||||
function currentUsername() {
|
||||
const user = global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null;
|
||||
return user && user.username ? user.username : "当前账号";
|
||||
}
|
||||
|
||||
function renderSystemProfile() {
|
||||
const payload = state.system.account || {};
|
||||
const birth = payload.birth_profile || {};
|
||||
let birthDate = "";
|
||||
let birthTime = "";
|
||||
if (birth.birth_datetime) {
|
||||
const parts = String(birth.birth_datetime).split("T");
|
||||
birthDate = parts[0] || "";
|
||||
birthTime = (parts[1] || "").slice(0, 5);
|
||||
}
|
||||
const gender = birth.gender || "unspecified";
|
||||
const configured = Boolean(payload.birth_profile_configured);
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="profile">' +
|
||||
'<div class="m-card"><strong>' + escapeHtml(currentUsername()) + "</strong><p class=\"m-sys-lead\">出生信息仅对当前账号可见并加密保存。</p></div>" +
|
||||
'<p class="m-sys-lead">原始信息加密保存且不在观气页回显;智能解读只使用排盘后的派生结果。</p>' +
|
||||
formFieldHtml("出生日期", dateInputHtml("m-sys-birth-date", birthDate), true) +
|
||||
formFieldHtml("出生时间", '<input id="m-sys-birth-time" type="time" value="' + escapeHtml(birthTime) + '">', true) +
|
||||
formFieldHtml("性别", '<select id="m-sys-birth-gender">' +
|
||||
'<option value="unspecified"' + (gender === "unspecified" ? " selected" : "") + ">不指定</option>" +
|
||||
'<option value="male"' + (gender === "male" ? " selected" : "") + ">男</option>" +
|
||||
'<option value="female"' + (gender === "female" ? " selected" : "") + ">女</option>" +
|
||||
"</select>", false) +
|
||||
'<p class="m-sys-lead">资料状态:' + (configured ? "已加密保存" : "尚未设置") + "</p>" +
|
||||
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-birth' + (configured ? "" : " disabled") + ">删除资料</button>" +
|
||||
'<div class="m-card m-sys-account-actions">' +
|
||||
'<button class="m-btn-primary" type="button" data-system-switch>切换账号</button>' +
|
||||
'<button class="m-btn-primary m-btn-danger" type="button" data-system-logout>退出当前账号</button>' +
|
||||
"</div></div>";
|
||||
const page = document.querySelector(".m-page");
|
||||
if (page) {
|
||||
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
|
||||
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-birth>保存资料</button></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemPassword() {
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="password">' +
|
||||
'<p class="m-sys-lead">仅修改当前账号密码,不会保存在这台设备上。</p>' +
|
||||
formFieldHtml("当前密码", '<input id="m-sys-password-current" type="password" autocomplete="current-password">', true) +
|
||||
formFieldHtml("新密码", '<input id="m-sys-password-new" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) +
|
||||
formFieldHtml("确认新密码", '<input id="m-sys-password-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) +
|
||||
"</div>";
|
||||
const page = document.querySelector(".m-page");
|
||||
if (page) {
|
||||
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
|
||||
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-password>更新密码</button></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemMembership() {
|
||||
const payload = state.system.account || {};
|
||||
const access = payload.llm_access || {};
|
||||
const membership = access.membership || {};
|
||||
const badge = membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户";
|
||||
const stateLabel = membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通";
|
||||
const remaining = membership.subscribed && membership.expires_at
|
||||
? number(membership.remaining_days) + " 天"
|
||||
: (membership.is_admin || membership.subscribed ? "长期有效" : "--");
|
||||
const detail = membership.subscribed
|
||||
? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效"))
|
||||
: membership.is_admin
|
||||
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
|
||||
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。";
|
||||
const quota = "会员默认每日智能分析额度 " + number(access.daily_limit) + " 次,由管理员统一设置。";
|
||||
const usage = membership.active ? ("今日已用 " + number(access.used_today) + " 次") : "今日智能分析:--";
|
||||
const usageSummary = membership.active ? (number(access.used_today) + " / " + number(access.daily_limit)) : "--";
|
||||
const remainingCalls = membership.is_admin ? "不限" : membership.active ? (number(access.remaining_calls) + " 次") : "--";
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="membership">' +
|
||||
'<div class="m-card"><strong>' + escapeHtml(badge) + "</strong><p class=\"m-sys-lead\">" + escapeHtml(detail) + "</p><p class=\"m-sys-lead\">" + escapeHtml(quota) + "</p></div>" +
|
||||
'<div class="m-sys-grid">' +
|
||||
"<div><span>开通状态</span><strong>" + escapeHtml(stateLabel) + "</strong></div>" +
|
||||
"<div><span>剩余时长</span><strong>" + escapeHtml(remaining) + "</strong></div>" +
|
||||
"<div><span>今日智能分析</span><strong>" + escapeHtml(usageSummary) + "</strong></div>" +
|
||||
"<div><span>剩余智能分析</span><strong>" + escapeHtml(remainingCalls) + "</strong></div>" +
|
||||
"</div>" +
|
||||
'<div class="m-card"><p class="m-sys-lead">' + escapeHtml(usage) + "</p>" +
|
||||
'<p class="m-sys-lead">行情、搜索、自选与复盘:普通用户可用。智能选股、问师、问天、复盘助手:仅会员可用。</p></div>' +
|
||||
"</div>";
|
||||
systemFill(html);
|
||||
}
|
||||
|
||||
function adminTabHtml() {
|
||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
||||
return '<div class="m-source-tabs" role="tablist" aria-label="系统设置分类">' +
|
||||
'<button class="m-source-tab' + (tab === "market" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="market">行情管理</button>' +
|
||||
'<button class="m-source-tab' + (tab === "models" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="models">模型池</button>' +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
function renderSystemAdmin(key) {
|
||||
if (key === "system/members") {
|
||||
renderSystemMembers();
|
||||
return;
|
||||
}
|
||||
const payload = state.system.admin || {};
|
||||
const data = payload.data || {};
|
||||
const ifind = data.ifind || {};
|
||||
const llm = payload.llm || {};
|
||||
const status = "Tushare " + (data.configured ? "已配置" : "未配置") +
|
||||
" · iFinD " + (ifind.configured ? "已配置" : "未配置") +
|
||||
" · " + number(data.snapshot_dates) + " 个交易日";
|
||||
const refreshLabel = data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停";
|
||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
||||
const marketHtml =
|
||||
'<div class="m-form-body" data-system-admin-panel="market">' +
|
||||
'<div class="m-card"><strong>公共行情</strong><p class="m-sys-lead">' + escapeHtml(status) + "</p><p class=\"m-sys-lead\">" + escapeHtml(refreshLabel) + "</p></div>" +
|
||||
formFieldHtml("Tushare Token", '<input id="m-sys-token" type="password" autocomplete="off" minlength="20" placeholder="留空保留现有 Token">', false) +
|
||||
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token">', false) +
|
||||
formFieldHtml("交易时段后台刷新", '<input id="m-sys-bg-refresh" type="checkbox"' + (data.background_refresh_enabled ? " checked" : "") + ">", false) +
|
||||
'<p class="m-sys-lead">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>' +
|
||||
'<button class="m-btn-primary" type="button" data-system-refresh>立即后台刷新</button>' +
|
||||
'<div class="m-card"><strong>历史数据回补</strong></div>' +
|
||||
formFieldHtml("开始日期", dateInputHtml("m-sys-backfill-start", ""), false) +
|
||||
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
|
||||
'<button class="m-btn-primary" type="button" data-system-backfill>开始回补</button>' +
|
||||
"</div>";
|
||||
const modelsHtml =
|
||||
'<div class="m-form-body" data-system-admin-panel="models">' +
|
||||
formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) +
|
||||
formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) +
|
||||
'<div id="m-sys-model-list">' + renderModelPoolHtml(llm.models || []) + "</div>" +
|
||||
'<button class="m-btn-primary" type="button" data-system-add-model>添加模型</button>' +
|
||||
"</div>";
|
||||
const page = document.querySelector(".m-page");
|
||||
if (!page) return;
|
||||
const bar = tab === "models"
|
||||
? '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-models>保存模型池</button></div>'
|
||||
: '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-market>保存行情配置</button></div>';
|
||||
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>" + bar;
|
||||
if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
|
||||
}
|
||||
|
||||
function renderModelPoolHtml(models) {
|
||||
if (!models.length) {
|
||||
return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>';
|
||||
}
|
||||
return models.map(function (item, index) {
|
||||
return '<article class="m-card" data-model-id="' + escapeHtml(item.id) + '">' +
|
||||
"<strong>" + escapeHtml(item.name || ("模型 " + (index + 1))) + "</strong>" +
|
||||
'<p class="m-sys-lead">' + (item.configured ? "已保存密钥" : "待配置") + "</p>" +
|
||||
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) +
|
||||
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) +
|
||||
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) +
|
||||
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空保留已保存的 Key" : "输入 API Key") + '">', !item.configured) +
|
||||
'<button class="m-btn-primary" type="button" data-system-test-model>测试连接</button>' +
|
||||
'<p class="m-sys-lead" data-model-test-status>未测试</p>' +
|
||||
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-model>删除模型</button>' +
|
||||
"</article>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function collectSystemModelPool() {
|
||||
const models = (state.system.admin && state.system.admin.llm && state.system.admin.llm.models) || [];
|
||||
const saved = new Map(models.map(function (item) { return [item.id, item]; }));
|
||||
return Array.prototype.map.call(document.querySelectorAll("#m-sys-model-list [data-model-id]"), function (row) {
|
||||
function fieldValue(name) {
|
||||
const input = row.querySelector("[data-model-field='" + name + "']");
|
||||
return String(input && input.value != null ? input.value : "").trim();
|
||||
}
|
||||
return {
|
||||
id: row.dataset.modelId,
|
||||
name: fieldValue("name"),
|
||||
base_url: fieldValue("base_url"),
|
||||
model: fieldValue("model"),
|
||||
api_key: fieldValue("api_key"),
|
||||
configured: Boolean(saved.get(row.dataset.modelId) && saved.get(row.dataset.modelId).configured),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateSystemModelRoleOptions(primaryId, fallbackId) {
|
||||
const models = collectSystemModelPool();
|
||||
const options = models.map(function (item) {
|
||||
return '<option value="' + escapeHtml(item.id) + '">' + escapeHtml(item.name || item.model || "未命名模型") + "</option>";
|
||||
}).join("");
|
||||
const primary = document.getElementById("m-sys-primary-model");
|
||||
const fallback = document.getElementById("m-sys-fallback-model");
|
||||
if (!primary || !fallback) return;
|
||||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
||||
fallback.innerHTML = '<option value="">不启用辅助模型</option>' + options;
|
||||
const keepPrimary = models.some(function (item) { return item.id === primaryId; }) ? primaryId : (models[0] && models[0].id) || "";
|
||||
primary.value = keepPrimary;
|
||||
fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
|
||||
}
|
||||
|
||||
function renderSystemMembers() {
|
||||
const payload = state.system.admin || {};
|
||||
const membership = payload.membership || {};
|
||||
const users = payload.users || [];
|
||||
const userHtml = users.map(function (user) {
|
||||
const admin = user.role === "admin";
|
||||
const member = Boolean(user.membership_subscribed);
|
||||
const identity = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
|
||||
const expiry = member
|
||||
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
|
||||
: user.membership_status === "suspended"
|
||||
? "会员已停用"
|
||||
: "尚未开通";
|
||||
return '<article class="m-card" data-admin-user="' + number(user.id) + '">' +
|
||||
"<strong>" + escapeHtml(user.username) + "</strong>" +
|
||||
'<p class="m-sys-lead">' + escapeHtml(identity) + " · 今日调用 " + number(user.used_today) + "</p>" +
|
||||
'<p class="m-sys-lead">' + escapeHtml(expiry) + "</p>" +
|
||||
formFieldHtml("状态", '<select data-member-status>' +
|
||||
'<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" +
|
||||
'<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" +
|
||||
'<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" +
|
||||
"</select>", false) +
|
||||
formFieldHtml("开通 / 续期时长", '<select data-member-duration><option value="">选择时长</option>' +
|
||||
'<option value="1_month">1个月</option><option value="3_months">3个月</option>' +
|
||||
'<option value="12_months">12个月</option><option value="3_years">3年</option>' +
|
||||
'<option value="permanent">永久</option></select>', false) +
|
||||
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
|
||||
"</article>";
|
||||
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>';
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="members">' +
|
||||
'<div class="m-card"><strong>会员调用额度</strong><p class="m-sys-lead">每日自动重置</p></div>' +
|
||||
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) +
|
||||
'<button class="m-btn-primary" type="button" data-system-save-limit>保存调用额度</button>' +
|
||||
'<div class="m-card"><strong>会员账号</strong><p class="m-sys-lead">手动开通与续期</p></div>' +
|
||||
userHtml +
|
||||
"</div>";
|
||||
systemFill(html);
|
||||
}
|
||||
|
||||
function saveSystemBirth() {
|
||||
const birthDate = (document.getElementById("m-sys-birth-date") || {}).value;
|
||||
const birthTime = (document.getElementById("m-sys-birth-time") || {}).value;
|
||||
if (!birthDate || !birthTime) {
|
||||
showToast("请填写完整出生日期和时间");
|
||||
return;
|
||||
}
|
||||
const button = document.querySelector("[data-system-save-birth]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/account/birth-profile", "POST", {
|
||||
birth_datetime: birthDate + "T" + birthTime,
|
||||
gender: (document.getElementById("m-sys-birth-gender") || {}).value || "unspecified",
|
||||
trade_date: todayString(),
|
||||
}).then(function () {
|
||||
showToast("个人命理资料已保存到当前账号");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "个人命理资料保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSystemBirth() {
|
||||
openConfirmSheet("删除资料", "确定删除当前账号保存的个人命理资料吗?", {
|
||||
danger: true,
|
||||
confirmLabel: "删除",
|
||||
onConfirm: function () {
|
||||
global.MobileAPI.request("/api/account/birth-profile", "DELETE").then(function () {
|
||||
closeSheet();
|
||||
showToast("个人命理资料已删除");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "删除失败");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemPassword() {
|
||||
const current = (document.getElementById("m-sys-password-current") || {}).value || "";
|
||||
const next = (document.getElementById("m-sys-password-new") || {}).value || "";
|
||||
const confirm = (document.getElementById("m-sys-password-confirm") || {}).value || "";
|
||||
const button = document.querySelector("[data-system-save-password]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/account/password", "POST", {
|
||||
current_password: current,
|
||||
new_password: next,
|
||||
confirm_password: confirm,
|
||||
}).then(function () {
|
||||
const formIds = ["m-sys-password-current", "m-sys-password-new", "m-sys-password-confirm"];
|
||||
formIds.forEach(function (id) {
|
||||
const input = document.getElementById(id);
|
||||
if (input) input.value = "";
|
||||
});
|
||||
showToast("密码已更新");
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "密码更新失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function switchSystemAccount() {
|
||||
global.location.assign("/login/");
|
||||
}
|
||||
|
||||
function logoutSystemAccount() {
|
||||
openConfirmSheet("退出当前账号", "退出后需要重新登录。本机已记录的其他账号仍可直接切换。", {
|
||||
danger: true,
|
||||
confirmLabel: "退出",
|
||||
onConfirm: function () {
|
||||
global.MobileSession.logout().then(function () {
|
||||
closeSheet();
|
||||
global.MobileRouter.replace("#/auth");
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "退出失败");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemMarket() {
|
||||
const button = document.querySelector("[data-system-save-market]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
||||
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
|
||||
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
|
||||
background_refresh_enabled: Boolean((document.getElementById("m-sys-bg-refresh") || {}).checked),
|
||||
}).then(function () {
|
||||
showToast("行情配置已保存");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "系统配置保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemModels() {
|
||||
const button = document.querySelector("[data-system-save-models]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
||||
models: collectSystemModelPool(),
|
||||
primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "",
|
||||
fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "",
|
||||
}).then(function () {
|
||||
showToast("模型池已保存");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "模型池保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function addSystemModel() {
|
||||
const models = collectSystemModelPool();
|
||||
const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
|
||||
models.push({ id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
|
||||
const list = document.getElementById("m-sys-model-list");
|
||||
if (list) list.innerHTML = renderModelPoolHtml(models);
|
||||
updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
|
||||
}
|
||||
|
||||
function deleteSystemModel(row) {
|
||||
if (!row) return;
|
||||
const id = row.dataset.modelId;
|
||||
const primary = (document.getElementById("m-sys-primary-model") || {}).value;
|
||||
const fallback = (document.getElementById("m-sys-fallback-model") || {}).value;
|
||||
if (id === primary || id === fallback) {
|
||||
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
|
||||
return;
|
||||
}
|
||||
const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
|
||||
const list = document.getElementById("m-sys-model-list");
|
||||
if (list) list.innerHTML = renderModelPoolHtml(models);
|
||||
updateSystemModelRoleOptions(primary, fallback);
|
||||
}
|
||||
|
||||
function testSystemModel(row) {
|
||||
if (!row) return;
|
||||
const status = row.querySelector("[data-model-test-status]");
|
||||
const button = row.querySelector("[data-system-test-model]");
|
||||
const profile = collectSystemModelPool().find(function (item) { return item.id === row.dataset.modelId; }) || {};
|
||||
if (button) button.disabled = true;
|
||||
if (status) status.textContent = "连接中";
|
||||
global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile: profile }).then(function (payload) {
|
||||
if (status) status.textContent = "已连通 · " + number(payload.result && payload.result.latency_ms) + " ms";
|
||||
}).catch(function (error) {
|
||||
if (status) status.textContent = error && error.message ? error.message : "测试失败";
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startSystemRefresh() {
|
||||
const button = document.querySelector("[data-system-refresh]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/refresh", "POST", { trade_date: todayString() }).then(function (payload) {
|
||||
showToast((payload && payload.message) || "后台刷新已提交");
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "后台刷新启动失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startSystemBackfill() {
|
||||
const button = document.querySelector("[data-system-backfill]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/backfill", "POST", {
|
||||
start_date: (document.getElementById("m-sys-backfill-start") || {}).value,
|
||||
end_date: (document.getElementById("m-sys-backfill-end") || {}).value,
|
||||
}).then(function (payload) {
|
||||
const count = payload && payload.results ? payload.results.length : 0;
|
||||
showToast("历史回补完成,共处理 " + count + " 个工作日");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "回补失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemMemberLimit() {
|
||||
const button = document.querySelector("[data-system-save-limit]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
||||
member_daily_limit: number((document.getElementById("m-sys-member-limit") || {}).value),
|
||||
}).then(function () {
|
||||
showToast("会员调用额度已保存");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "会员调用额度保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemMember(card) {
|
||||
if (!card) return;
|
||||
const button = card.querySelector("[data-system-save-member]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/membership", "POST", {
|
||||
user_id: card.dataset.adminUser,
|
||||
status: (card.querySelector("[data-member-status]") || {}).value,
|
||||
duration: (card.querySelector("[data-member-duration]") || {}).value,
|
||||
}).then(function (payload) {
|
||||
if (state.system.admin) state.system.admin.users = payload.users || [];
|
||||
showToast("会员状态已更新");
|
||||
renderSystemMembers();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "会员状态保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- events */
|
||||
|
||||
function bindEvents() {
|
||||
@@ -4976,6 +5510,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const systemAdminTab = event.target.closest("[data-system-admin-tab]");
|
||||
if (systemAdminTab) {
|
||||
state.system.adminTab = systemAdminTab.dataset.systemAdminTab === "models" ? "models" : "market";
|
||||
renderSystemAdmin("system/admin");
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
|
||||
if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
|
||||
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
||||
if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; }
|
||||
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
||||
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
|
||||
if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
|
||||
if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
|
||||
const deleteModel = event.target.closest("[data-system-delete-model]");
|
||||
if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; }
|
||||
const testModel = event.target.closest("[data-system-test-model]");
|
||||
if (testModel) { testSystemModel(testModel.closest("[data-model-id]")); return; }
|
||||
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
|
||||
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
|
||||
if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; }
|
||||
const saveMember = event.target.closest("[data-system-save-member]");
|
||||
if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
|
||||
|
||||
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
||||
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
|
||||
if (reviewAddTrade) { openForm("trade"); return; }
|
||||
|
||||
@@ -250,6 +250,7 @@
|
||||
'<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>',
|
||||
@@ -265,6 +266,7 @@
|
||||
].join("");
|
||||
authMode = "login";
|
||||
bindAuth();
|
||||
loadMobileAccounts();
|
||||
}
|
||||
|
||||
function setAuthMode(mode) {
|
||||
@@ -287,6 +289,34 @@
|
||||
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");
|
||||
|
||||
@@ -47,5 +47,30 @@
|
||||
return Boolean(state.user && state.user.role === "admin");
|
||||
}
|
||||
|
||||
global.MobileSession = { state: state, me: me, login: login, register: register, logout: logout, isAdmin: isAdmin };
|
||||
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
|
||||
};
|
||||
})(window);
|
||||
|
||||
+73
-6
@@ -1011,9 +1011,9 @@
|
||||
|
||||
border-radius: 6px;
|
||||
|
||||
background: var(--r2-ink);
|
||||
background: var(--sentiment-tooltip-bg);
|
||||
|
||||
color: var(--text-inverse);
|
||||
color: var(--sentiment-tooltip-fg);
|
||||
|
||||
font-size: 11px;
|
||||
|
||||
@@ -1021,6 +1021,8 @@
|
||||
}
|
||||
|
||||
.sentiment-chart-tooltip b {
|
||||
color: inherit;
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -1568,10 +1570,6 @@
|
||||
#sentimentCycleView .sentiment-component-item {
|
||||
padding: 4px 0px;
|
||||
}
|
||||
|
||||
#sentimentCycleView .sentiment-component-item small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -1764,3 +1762,72 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
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}`;
|
||||
@@ -126,6 +155,7 @@ function animateSentimentTrendChart(rows) {
|
||||
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
|
||||
if (!motionEnabled()) {
|
||||
drawSentimentTrendChart(rows, 1);
|
||||
observeSentimentTrendChart();
|
||||
return;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
@@ -135,7 +165,10 @@ function animateSentimentTrendChart(rows) {
|
||||
const progress = 1 - (1 - rawProgress) ** 3;
|
||||
drawSentimentTrendChart(rows, progress);
|
||||
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
else sentimentChartAnimationFrame = null;
|
||||
else {
|
||||
sentimentChartAnimationFrame = null;
|
||||
observeSentimentTrendChart();
|
||||
}
|
||||
};
|
||||
sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
@@ -144,9 +177,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) return;
|
||||
const width = Math.max(320, rect.width);
|
||||
const height = Math.max(220, rect.height);
|
||||
if (rect.width < 8 || rect.height < 8) return;
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
@@ -258,6 +291,7 @@ 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) {
|
||||
|
||||
@@ -1667,3 +1667,445 @@ button.account-role-badge:focus-visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.login-portal {
|
||||
min-height: 100vh;
|
||||
|
||||
display: flex;
|
||||
|
||||
background: var(--canvas);
|
||||
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-theme-toggle {
|
||||
position: fixed;
|
||||
|
||||
z-index: 2;
|
||||
|
||||
top: 16px;
|
||||
|
||||
right: 20px;
|
||||
|
||||
min-height: 32px;
|
||||
|
||||
padding: 0 12px;
|
||||
|
||||
border: 1px solid var(--border-strong);
|
||||
|
||||
border-radius: var(--size-radius-md);
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: var(--font-size-label);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
width: clamp(420px, 34vw, 560px);
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
padding: 44px 48px 36px;
|
||||
|
||||
background: var(--login-brand-gradient);
|
||||
|
||||
color: #f4f7ff;
|
||||
}
|
||||
|
||||
.login-brand-mark {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
width: 56px;
|
||||
|
||||
height: 56px;
|
||||
|
||||
border-radius: 16px;
|
||||
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.login-brand-glyph {
|
||||
font-size: 24px;
|
||||
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.login-brand-kicker {
|
||||
margin: 28px 0 8px;
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
margin: 0;
|
||||
|
||||
font-size: 36px;
|
||||
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.login-brand-lead {
|
||||
margin: 12px 0 0;
|
||||
|
||||
max-width: 18em;
|
||||
|
||||
font-size: var(--font-size-body);
|
||||
|
||||
line-height: 1.7;
|
||||
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.login-brand-stats {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
gap: 16px 20px;
|
||||
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
|
||||
.login-stat {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-stat dt {
|
||||
color: rgba(244, 247, 255, 0.64);
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.login-stat dd {
|
||||
margin: 4px 0 0;
|
||||
|
||||
font-size: 20px;
|
||||
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.login-stat-tag {
|
||||
margin-left: 6px;
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.login-stage {
|
||||
flex: 1 1 auto;
|
||||
|
||||
display: grid;
|
||||
|
||||
place-items: center;
|
||||
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 400px;
|
||||
|
||||
max-width: calc(100vw - 48px);
|
||||
|
||||
padding: 32px;
|
||||
|
||||
border: 1px solid var(--border-strong);
|
||||
|
||||
border-radius: var(--size-radius-dialog);
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.login-card.is-loading .login-form {
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.login-card-title {
|
||||
margin: 0;
|
||||
|
||||
font-size: 22px;
|
||||
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.login-card-lead {
|
||||
margin: 8px 0 0;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: var(--font-size-label);
|
||||
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
margin-top: 24px;
|
||||
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
min-height: 40px;
|
||||
|
||||
border: 0;
|
||||
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
background: transparent;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-tab.is-active {
|
||||
border-bottom-color: var(--action);
|
||||
|
||||
color: var(--action);
|
||||
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
|
||||
gap: 14px;
|
||||
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-portal .form-field input {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
|
||||
height: 40px;
|
||||
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.login-spinner {
|
||||
width: 14px;
|
||||
|
||||
height: 14px;
|
||||
|
||||
border: 2px solid currentColor;
|
||||
|
||||
border-right-color: transparent;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
animation: login-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes login-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin: 0;
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.login-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-hint,
|
||||
.login-privacy {
|
||||
margin: 16px 0 0;
|
||||
|
||||
color: var(--text-tertiary);
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-portal .login-privacy {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.login-lock {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-back,
|
||||
.login-add,
|
||||
.login-manage,
|
||||
.login-account-enter,
|
||||
.login-account-remove {
|
||||
border: 0;
|
||||
|
||||
background: transparent;
|
||||
|
||||
color: var(--action);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-portal .login-back {
|
||||
margin-bottom: 12px;
|
||||
|
||||
padding: 0;
|
||||
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.login-portal .login-add {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
|
||||
min-height: 40px;
|
||||
|
||||
margin-top: 8px;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-portal .login-manage {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
|
||||
min-height: 40px;
|
||||
|
||||
margin-top: 8px;
|
||||
|
||||
text-align: left;
|
||||
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-account-list {
|
||||
display: grid;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-account-row {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
min-height: 58px;
|
||||
|
||||
padding: 0 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: var(--size-radius-md);
|
||||
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.login-account-row.is-current {
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.login-account-row.is-confirming {
|
||||
display: grid;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.login-account-meta {
|
||||
display: grid;
|
||||
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.login-account-meta strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.login-account-meta span {
|
||||
color: var(--text-tertiary);
|
||||
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.login-account-check {
|
||||
color: var(--action);
|
||||
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-confirm-copy {
|
||||
margin: 0;
|
||||
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.login-confirm-actions {
|
||||
display: flex;
|
||||
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login-portal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
width: 100%;
|
||||
|
||||
padding: 20px 20px 16px;
|
||||
}
|
||||
|
||||
.login-brand-lead,
|
||||
.login-brand-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-brand-kicker {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.login-stage {
|
||||
padding: 28px 16px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,10 @@ async function applyAuthenticatedSession(session) {
|
||||
function showAuthGate(message = "") {
|
||||
state.user = null;
|
||||
state.csrfToken = "";
|
||||
const gate = document.querySelector("#authGate");
|
||||
gate.hidden = false;
|
||||
const errorElement = document.querySelector("#authError");
|
||||
errorElement.textContent = message;
|
||||
errorElement.hidden = !message;
|
||||
document.querySelector("#authUsername").focus();
|
||||
const params = new URLSearchParams();
|
||||
if (message) params.set("notice", message);
|
||||
const query = params.toString();
|
||||
window.location.replace("/login/" + (query ? `?${query}` : ""));
|
||||
}
|
||||
|
||||
async function logoutAccount() {
|
||||
@@ -197,16 +195,8 @@ async function changeAccountPassword(event) {
|
||||
}
|
||||
|
||||
async function switchAccount() {
|
||||
const button = document.querySelector("#switchAccountMenuButton");
|
||||
button.disabled = true;
|
||||
toggleAccountDropdown(false);
|
||||
try {
|
||||
await apiRequest("/api/auth/logout", "POST", {});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
showToast(error.message || "切换账号失败");
|
||||
button.disabled = false;
|
||||
}
|
||||
window.location.assign("/login/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
--shadow-float: var(--elevation-float);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 220ms;
|
||||
--login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc);
|
||||
|
||||
--font-size-aux: 11.5px;
|
||||
--font-size-caption: 12.5px;
|
||||
@@ -210,6 +211,9 @@
|
||||
--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;
|
||||
@@ -505,10 +509,13 @@
|
||||
--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);
|
||||
--dialog-backdrop: var(--backdrop);
|
||||
--ladder-level-1: #2d2426;
|
||||
--ladder-level-2: #2b2822;
|
||||
|
||||
@@ -47,6 +47,72 @@ 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");
|
||||
}
|
||||
@@ -721,6 +787,7 @@ 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();
|
||||
@@ -732,6 +799,8 @@ 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,
|
||||
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
|
||||
chartHeight: chart.height,
|
||||
@@ -743,6 +812,8 @@ 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.detailAfterAnalysis).toBe(true);
|
||||
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
|
||||
@@ -780,6 +851,68 @@ 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");
|
||||
await mockApplication(page, session("user", true));
|
||||
const viewports = [
|
||||
{ name: "zoom-100", width: 2560, height: 1440 },
|
||||
{ name: "zoom-110", width: 2327, height: 1309 },
|
||||
{ name: "zoom-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.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 }) => {
|
||||
await mockApplication(page, session("user", true));
|
||||
await page.goto("/index.html");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
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") {
|
||||
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") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
authenticated: Boolean(currentUserId),
|
||||
csrf_token: "portal-csrf",
|
||||
user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null,
|
||||
}),
|
||||
});
|
||||
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");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 5 页,
|
||||
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
|
||||
|
||||
const EMPTY_DASHBOARD = {
|
||||
@@ -138,6 +138,31 @@ 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/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" },
|
||||
@@ -230,6 +255,16 @@ 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) => {
|
||||
@@ -296,6 +331,54 @@ 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-switch]")).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("empty profile save click shows a toast instead of a dead button", 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(".m-hub-grid")).toBeVisible();
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/members"]')).toHaveCount(0);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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()
|
||||
@@ -25,6 +25,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
("0002", "create_job_runs"),
|
||||
("0003", "extend_llm_audit"),
|
||||
("0004", "add_mentor_note"),
|
||||
("0005", "create_account_switch_grants"),
|
||||
],
|
||||
)
|
||||
columns = {
|
||||
@@ -39,7 +40,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||
).fetchone()["count"]
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(count, 5)
|
||||
|
||||
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
|
||||
self,
|
||||
@@ -60,7 +61,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||
).fetchone()["count"]
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(count, 5)
|
||||
|
||||
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
@@ -87,7 +88,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
"PRAGMA table_info(mentor_preferences)"
|
||||
)
|
||||
]
|
||||
self.assertEqual(versions, {"0001", "0002", "0003", "0004"})
|
||||
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
|
||||
self.assertIn("note", note_rows)
|
||||
|
||||
def test_database_with_unknown_migration_is_rejected(self) -> None:
|
||||
|
||||
@@ -344,6 +344,22 @@ class FrontendContractTests(unittest.TestCase):
|
||||
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)
|
||||
|
||||
def test_mentor_final_visual_fix_contract(self):
|
||||
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8")
|
||||
mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")
|
||||
|
||||
@@ -65,8 +65,11 @@ 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"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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="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",
|
||||
'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",
|
||||
):
|
||||
self.assertIn(name, pages)
|
||||
self.assertNotIn(name + '">', pages)
|
||||
@@ -17,6 +17,12 @@ 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.
|
||||
- `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`.
|
||||
|
||||
`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;
|
||||
|
||||
@@ -50,7 +50,13 @@ 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"}:
|
||||
if path == "/api/health" or path in {
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/auth/accounts",
|
||||
"/api/auth/switch",
|
||||
"/api/auth/forget",
|
||||
}:
|
||||
return "public"
|
||||
from api_access import required_role
|
||||
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/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 "==> 构建计划"
|
||||
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"
|
||||
Reference in New Issue
Block a user