Compare commits

...
Author SHA1 Message Date
4a63ccd10f feat(HEL-251): 按确认稿实现登录页动态小K线人物
在桌面登录门户品牌空白带加入红绿小K线角色,支持焦点、密码遮挡、登录反馈和减少动态效果,且不移动原有文案与行情图。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 21:46:05 +08:00
总工 865d8b0516 部署集成(HEL-244): 合入切换账号返回修复 d2a165a 2026-08-29 21:01:12 +08:00
d2a165ada8 fix(HEL-243): 当前账号可点击返回,避免切换死路
当前账号行原先只显示对号、不可点击,单账号时只能关网页。现改为整行继续使用且不重新签发会话,并带回切换前的业务页。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 20:44:18 +08:00
总工 e29d5115fa 部署集成(HEL-241): 合入桌面登录门户返工 aef8a05(含 28px 底部缺口修复) 2026-08-29 20:09:41 +08:00
aef8a059f2 fix(HEL-240): 去掉登录页底部状态栏缺口
登录页覆盖全局 body 底边距,让左栏品牌面板铺满视口高度。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 20:04:22 +08:00
f905b44675 feat(HEL-240): 按确认样图还原桌面登录页左右骨架
把登录门户改回确认稿的品牌行、中下部主标题、底部行情标签和 K 线背景,并让超宽屏按比例展开左栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 19:21:13 +08:00
541fb48c1c feat(HEL-238): 按确认样图重做手机端系统管理,并加入部署基线门禁
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 17:34:50 +08:00
20 changed files with 3090 additions and 309 deletions
+1
View File
@@ -182,6 +182,7 @@ tools/build_image.sh <提交号> <镜像tag>
该脚本的行为约束:
-`git fetch`,再把提交号解析为完整 SHA,解析失败立即中止,绝不使用本地脏状态或服务器旧目录;
- 构建前读取当前线上容器镜像的 `org.opencontainers.image.revision`,用 Git 祖先关系确认候选提交包含线上全部历史;落后 `main`、旁支或错误提交会直接退出,并打印线上提交、候选提交、文件差异和将丢失的提交;
- 镜像 tag 必须以 `-<提交短号7位>` 结尾(如 `hel234-cefc869`),禁止 `latest``rollback-*`
- 通过 `git archive <提交> | ssh 部署机 docker build -` 流式构建,服务器上不存在构建用工作树;
- 构建后回读镜像 label 里的 `org.opencontainers.image.revision`,与预期提交不一致则删除镜像并中止;
+13 -13
View File
@@ -374,10 +374,10 @@
}
],
"css_layers": [
"/shared/tokens.css?v=20260829-1",
"/shared/tokens.css?v=20260829-hel240",
"/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260829-hel237",
"/shared/auth.css?v=20260829-hel237",
"/shared/auth.css?v=20260829-hel240b",
"/shared/components/controls.css?v=20260829-hel237",
"/shared/components/navigation.css?v=20260820-1",
"/shared/components/cards.css?v=20260820-1",
@@ -465,7 +465,7 @@
},
{
"path": "frontend/index.html",
"bytes": 48248,
"bytes": 48254,
"lines": 664
},
{
@@ -553,16 +553,16 @@
"bytes": 13681,
"lines": 338
},
{
"path": "frontend/shared/session.js",
"bytes": 13219,
"lines": 289
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 12894,
"lines": 274
},
{
"path": "frontend/shared/session.js",
"bytes": 12848,
"lines": 283
},
{
"path": "backend/features/market/insights_auction_data.py",
"bytes": 12829,
@@ -673,16 +673,16 @@
"bytes": 5451,
"lines": 118
},
{
"path": "frontend/pages.config.js",
"bytes": 5385,
"lines": 130
},
{
"path": "frontend/pages/market/search.js",
"bytes": 5384,
"lines": 131
},
{
"path": "frontend/pages.config.js",
"bytes": 5380,
"lines": 130
},
{
"path": "frontend/pages/auction/page.html",
"bytes": 5350,
+2 -2
View File
@@ -34,10 +34,10 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-1">
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel240">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/shell.css?v=20260829-hel237">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel237">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel240b">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260829-hel237">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1">
+127 -24
View File
@@ -19,41 +19,144 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-1">
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel251">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-1">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel251">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
</head>
<body class="login-portal">
<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 class="login-brand-header">
<div class="login-brand-mark"><span class="login-brand-glyph"></span></div>
<div class="login-brand-identity">
<p class="login-brand-name">小白复盘</p>
<p class="login-brand-subtitle">A股个人复盘工作台</p>
</div>
<div class="login-stat">
<dt>涨停</dt>
<dd>63</dd>
</div>
<div class="login-mascots" id="loginMascots" aria-hidden="true" data-mood="idle">
<div class="login-mascots-stage">
<div class="login-mascot is-red">
<div class="login-mascot-breathe">
<div class="login-mascot-lean">
<svg class="login-mascot-figure" viewBox="0 0 96 210" focusable="false">
<ellipse class="login-mascot-shadow" cx="48" cy="200" rx="26" ry="5.5"></ellipse>
<line class="login-mascot-wick" x1="48" y1="10" x2="48" y2="50"></line>
<rect class="login-mascot-body" x="16" y="50" width="64" height="126" rx="14"></rect>
<line class="login-mascot-wick" x1="48" y1="176" x2="48" y2="190"></line>
<g class="login-mascot-face">
<circle class="login-mascot-eye" cx="36" cy="94" r="10"></circle>
<circle class="login-mascot-eye" cx="60" cy="94" r="10"></circle>
<circle class="login-mascot-pupil" cx="36" cy="94" r="4.2"></circle>
<circle class="login-mascot-pupil" cx="60" cy="94" r="4.2"></circle>
<rect class="login-mascot-lid" x="24" y="82" width="48" height="24" rx="10"></rect>
<path class="login-mascot-happy" d="M28 96 Q36 88 44 96"></path>
<path class="login-mascot-happy" d="M52 96 Q60 88 68 96"></path>
<circle class="login-mascot-sad" cx="36" cy="96" r="2.2"></circle>
<circle class="login-mascot-sad" cx="60" cy="96" r="2.2"></circle>
</g>
<ellipse class="login-mascot-arm is-left" cx="16" cy="128" rx="8" ry="5.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="80" cy="128" rx="8" ry="5.5"></ellipse>
<ellipse class="login-mascot-hand is-left" cx="16" cy="128" rx="9" ry="7"></ellipse>
<ellipse class="login-mascot-hand is-right" cx="80" cy="128" rx="9" ry="7"></ellipse>
</svg>
</div>
</div>
</div>
<div class="login-mascot is-green">
<div class="login-mascot-breathe">
<div class="login-mascot-lean">
<div class="login-mascot-figure">
<svg class="login-mascot-front" viewBox="0 0 84 175" focusable="false">
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
<g class="login-mascot-face">
<circle class="login-mascot-eye" cx="32" cy="76" r="8.5"></circle>
<circle class="login-mascot-eye" cx="52" cy="76" r="8.5"></circle>
<circle class="login-mascot-pupil" cx="32" cy="76" r="3.6"></circle>
<circle class="login-mascot-pupil" cx="52" cy="76" r="3.6"></circle>
<rect class="login-mascot-lid" x="22" y="66" width="40" height="20" rx="8"></rect>
<path class="login-mascot-happy" d="M25 78 Q32 71 39 78"></path>
<path class="login-mascot-happy" d="M45 78 Q52 71 59 78"></path>
<circle class="login-mascot-sad" cx="32" cy="78" r="1.9"></circle>
<circle class="login-mascot-sad" cx="52" cy="78" r="1.9"></circle>
</g>
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
</svg>
<svg class="login-mascot-back" viewBox="0 0 84 175" focusable="false">
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
<rect class="login-mascot-slit" x="40.5" y="58" width="3" height="72" rx="1.5"></rect>
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
</svg>
</div>
</div>
</div>
</div>
</div>
<div class="login-stat">
<dt>跌停</dt>
<dd>4</dd>
</div>
<div class="login-stat">
<dt>两市成交</dt>
<dd>1.02万亿</dd>
</div>
</dl>
</div>
<div class="login-brand-copy">
<p class="login-brand-kicker">收盘之后 · 复盘开始</p>
<h1 class="login-brand-title">看懂情绪周期,把复盘变成下一次的先手。</h1>
<p class="login-brand-lead">情绪周期、涨停梯队、主题轮动、竞价、龙虎榜、人气榜、交易复盘,集中在一个安静的复盘空间。</p>
</div>
<div class="login-brand-market">
<svg class="login-brand-chart" viewBox="0 0 480 168" focusable="false">
<defs>
<linearGradient id="loginChartFade" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#d7e4ff" stop-opacity="0.18"></stop>
<stop offset="100%" stop-color="#d7e4ff" stop-opacity="0"></stop>
</linearGradient>
</defs>
<path class="login-chart-area" d="M8 118 C 52 108, 78 96, 112 102 S 168 128, 204 112 S 268 78, 312 86 S 372 118, 428 92 L 472 84 L 472 168 L 8 168 Z"></path>
<g class="login-candles">
<g class="is-up" transform="translate(36 0)"><line x1="8" y1="58" x2="8" y2="128"></line><rect x="2" y="72" width="12" height="40"></rect></g>
<g class="is-down" transform="translate(68 0)"><line x1="8" y1="64" x2="8" y2="132"></line><rect x="2" y="86" width="12" height="28"></rect></g>
<g class="is-up" transform="translate(100 0)"><line x1="8" y1="48" x2="8" y2="118"></line><rect x="2" y="60" width="12" height="44"></rect></g>
<g class="is-down" transform="translate(132 0)"><line x1="8" y1="70" x2="8" y2="136"></line><rect x="2" y="92" width="12" height="26"></rect></g>
<g class="is-up" transform="translate(164 0)"><line x1="8" y1="42" x2="8" y2="110"></line><rect x="2" y="54" width="12" height="38"></rect></g>
<g class="is-up" transform="translate(196 0)"><line x1="8" y1="36" x2="8" y2="98"></line><rect x="2" y="48" width="12" height="32"></rect></g>
<g class="is-down" transform="translate(228 0)"><line x1="8" y1="58" x2="8" y2="128"></line><rect x="2" y="78" width="12" height="36"></rect></g>
<g class="is-up" transform="translate(260 0)"><line x1="8" y1="40" x2="8" y2="104"></line><rect x="2" y="52" width="12" height="36"></rect></g>
<g class="is-down" transform="translate(292 0)"><line x1="8" y1="66" x2="8" y2="134"></line><rect x="2" y="88" width="12" height="30"></rect></g>
<g class="is-up" transform="translate(324 0)"><line x1="8" y1="44" x2="8" y2="112"></line><rect x="2" y="58" width="12" height="40"></rect></g>
<g class="is-down" transform="translate(356 0)"><line x1="8" y1="72" x2="8" y2="138"></line><rect x="2" y="96" width="12" height="24"></rect></g>
<g class="is-up" transform="translate(388 0)"><line x1="8" y1="38" x2="8" y2="108"></line><rect x="2" y="50" width="12" height="42"></rect></g>
<g class="is-up" transform="translate(420 0)"><line x1="8" y1="32" x2="8" y2="96"></line><rect x="2" y="44" width="12" height="34"></rect></g>
</g>
<path class="login-chart-line" d="M8 118 C 52 108, 78 96, 112 102 S 168 128, 204 112 S 268 78, 312 86 S 372 118, 428 92 L 472 84"></path>
</svg>
<dl class="login-brand-stats">
<div class="login-stat">
<dt>市场情绪</dt>
<dd>72 <span class="login-stat-tag">高热</span></dd>
</div>
<div class="login-stat">
<dt>涨停</dt>
<dd>63</dd>
</div>
<div class="login-stat">
<dt>跌停</dt>
<dd>4</dd>
</div>
<div class="login-stat login-stat-wide">
<dt>两市成交</dt>
<dd>1.02万亿</dd>
</div>
</dl>
</div>
<p class="login-brand-disclaimer">股市有风险,投资需谨慎 · 本工具仅供个人复盘学习使用</p>
</aside>
<main class="login-stage">
<button id="loginThemeToggle" class="login-theme-toggle" type="button">🌙 夜间</button>
<section class="login-card" id="loginCard" aria-live="polite"></section>
</main>
<script src="/shared/api.js?v=20260803-2"></script>
<script src="/login/page.js?v=20260829-1"></script>
<script src="/login/page.js?v=20260829-hel251"></script>
</body>
</html>
+360 -23
View File
@@ -13,6 +13,9 @@
loading: false,
confirmingId: null,
error: "",
username: "",
password: "",
passwordVisible: false,
};
function escapeHtml(value) {
@@ -52,48 +55,90 @@
state.error = message || "";
}
function membershipLabel(account) {
if (account.role === "admin") return account.membership?.subscribed ? "管理员 · 会员" : "管理员";
return account.membership?.subscribed ? "会员" : "普通用户";
function formatLastUsed(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
const now = new Date();
const hh = String(parsed.getHours()).padStart(2, "0");
const mm = String(parsed.getMinutes()).padStart(2, "0");
if (parsed.toDateString() === now.toDateString()) return `今天 ${hh}:${mm}`;
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (parsed.toDateString() === yesterday.toDateString()) return `昨天 ${hh}:${mm}`;
return `${parsed.getMonth() + 1}${parsed.getDate()}`;
}
function chipsFor(account, current) {
const chips = [];
if (current) chips.push('<span class="login-chip login-chip-current">当前</span>');
if (account.role === "admin") chips.push('<span class="login-chip">管理员</span>');
if (account.membership?.subscribed) chips.push('<span class="login-chip login-chip-member">会员</span>');
else if (account.role !== "admin") chips.push('<span class="login-chip">普通用户</span>');
return chips.join("");
}
function returnPath() {
const raw = new URLSearchParams(global.location.search).get("next") || "";
if (!raw) return "/";
try {
const url = new URL(raw, global.location.origin);
if (url.origin !== global.location.origin) return "/";
const path = url.pathname || "/";
if (path === "/login" || path.startsWith("/login/")) return "/";
return `${path}${url.search}${url.hash}` || "/";
} catch (_error) {
return "/";
}
}
function enterApp() {
const next = new URLSearchParams(global.location.search).get("next");
global.location.replace(next && next.startsWith("/") ? next : "/");
global.location.replace(returnPath());
}
function formMarkup(options) {
const registering = state.mode === "register";
const submitLabel = options.submitLabel
|| (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录");
const lead = options.lead;
const hint = options.hint;
const invalid = state.error ? " is-invalid" : "";
const passwordType = state.passwordVisible ? "text" : "password";
const passwordToggle = state.passwordVisible ? "隐藏" : "显示";
return [
options.back
? '<button class="login-back" type="button" data-login-action="picker">返回账号列表</button>'
: "",
`<h2 class="login-card-title">${escapeHtml(options.title)}</h2>`,
`<p class="login-card-lead">${escapeHtml(options.lead)}</p>`,
`<p class="login-card-lead">${escapeHtml(lead)}</p>`,
'<div class="login-tabs" role="tablist">',
`<button class="login-tab${state.mode === "login" ? " is-active" : ""}" type="button" data-auth-mode="login">登录</button>`,
`<button class="login-tab${state.mode === "register" ? " is-active" : ""}" type="button" data-auth-mode="register">注册</button>`,
"</div>",
'<form class="login-form" id="loginForm">',
'<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" 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"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
`<span>${escapeHtml(submitLabel)}</span></button>`,
"</form>",
'<p class="login-hint">密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。</p>',
`<p class="login-hint">${escapeHtml(hint)}</p>`,
].join("");
}
function accountRow(account) {
const current = Number(account.user_id) === Number(state.currentUserId);
const confirming = Number(state.confirmingId) === Number(account.user_id);
const classes = `login-account-row${current ? " is-current" : ""}${confirming ? " is-confirming" : ""}`;
if (state.view === "manage" && confirming) {
const managing = state.view === "manage";
const classes = [
"login-account-row",
current ? "is-current" : "",
confirming ? "is-confirming" : "",
!managing ? "is-switchable" : "",
].filter(Boolean).join(" ");
if (managing && confirming) {
return [
`<div class="${classes}" data-user-id="${account.user_id}">`,
`<p class="login-confirm-copy">移除「${escapeHtml(account.username)}」的本机记录?</p>`,
@@ -103,16 +148,25 @@
"</div></div>",
].join("");
}
const action = state.view === "manage"
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}">移除</button>`
const glyph = escapeHtml(String(account.username || "账").slice(0, 1));
const tone = Number(account.user_id || 0) % 4;
const used = formatLastUsed(account.last_used_at);
const action = managing
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}" aria-label="移除 ${escapeHtml(account.username)}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M6 2h4l.5 1H14v1H2V3h3.5L6 2zm1 4v6H6V6h1zm3 0v6H9V6h1zM3.5 5H13l-.7 8.2A1.5 1.5 0 0 1 10.81 14H5.19a1.5 1.5 0 0 1-1.49-1.8L3.5 5z"></path></svg></button>`
: current
? '<span class="login-account-check" aria-hidden="true">✓</span>'
: `<button class="login-account-enter" type="button" data-switch-id="${account.user_id}">进入</button>`;
? '<span class="login-account-action"><span class="login-account-check" aria-hidden="true">✓</span>继续使用</span>'
: "";
const switchAttr = !managing && !current ? ` data-switch-id="${account.user_id}"` : "";
const resumeAttr = !managing && current ? ` data-resume-id="${account.user_id}"` : "";
return [
`<div class="${classes}" data-user-id="${account.user_id}">`,
`<div class="${classes}" data-user-id="${account.user_id}"${switchAttr}${resumeAttr}>`,
`<span class="login-avatar tone-${tone}" aria-hidden="true">${glyph}</span>`,
'<div class="login-account-meta">',
'<div class="login-account-name">',
`<strong>${escapeHtml(account.username)}</strong>`,
`<span>${escapeHtml(membershipLabel(account))}${current ? " · 当前" : ""}</span>`,
chipsFor(account, current),
"</div>",
used ? `<span class="login-account-used">上次登录 ${escapeHtml(used)}</span>` : "",
"</div>",
action,
"</div>",
@@ -123,10 +177,15 @@
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>'
? ""
: '<button class="login-back" type="button" data-login-action="resume">返回复盘</button>',
`<h2 class="login-card-title">${managing ? "管理账号记录" : "选择账号"}</h2>`,
`<p class="login-card-lead">${managing
? "移除只删除这台电脑上的登录记录,不会注销账号"
: `这台电脑已记录 ${count} 个账号,点选即可进入,无需再次输入密码。`}</p>`,
managing
? '<div class="login-manage-toolbar"><p class="login-manage-hint">点击右侧图标移除对应记录</p><button class="login-manage-done" type="button" data-login-action="picker">完成</button></div>'
: "",
`<div class="login-account-list">${state.accounts.map(accountRow).join("")}</div>`,
managing
@@ -136,7 +195,9 @@
? ""
: '<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>',
managing
? '<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>移除后再次登录该账号需重新输入密码</p>'
: '<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>账号记录仅保存在这台电脑的浏览器中</p>',
].join("");
}
@@ -145,7 +206,10 @@
if (state.view === "first" || state.view === "add") {
card.innerHTML = formMarkup({
title: state.view === "add" ? "添加账号" : "欢迎回来",
lead: "登录后进入你的复盘空间",
lead: state.view === "add" ? "登录另一个账号,添加后可随时一键切换" : "登录后进入你的复盘空间",
hint: state.view === "add"
? "添加后账号会保存在这台电脑,方便随时切换。"
: "密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。",
add: state.view === "add",
back: state.view === "add",
});
@@ -153,6 +217,7 @@
card.innerHTML = pickerMarkup();
}
bindCard();
if (mascots) mascots.sync();
}
function bindCard() {
@@ -166,6 +231,14 @@
card.querySelectorAll("[data-login-action]").forEach((button) => {
button.addEventListener("click", () => {
const action = button.dataset.loginAction;
if (action === "toggle-password") {
togglePasswordVisible();
return;
}
if (action === "resume") {
resumeCurrentAccount();
return;
}
if (action === "picker") {
state.view = state.accounts.length ? "picker" : "first";
state.confirmingId = null;
@@ -184,8 +257,12 @@
card.querySelectorAll("[data-switch-id]").forEach((button) => {
button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId)));
});
card.querySelectorAll("[data-resume-id]").forEach((button) => {
button.addEventListener("click", () => resumeCurrentAccount());
});
card.querySelectorAll("[data-confirm-id]").forEach((button) => {
button.addEventListener("click", () => {
button.addEventListener("click", (event) => {
event.stopPropagation();
state.confirmingId = Number(button.dataset.confirmId);
render();
});
@@ -212,6 +289,8 @@
event.preventDefault();
const username = document.querySelector("#loginUsername").value.trim();
const password = document.querySelector("#loginPassword").value;
state.username = username;
state.password = password;
if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) {
setError("两次输入的密码不一致。");
render();
@@ -222,11 +301,13 @@
render();
try {
await api.request(`/api/auth/${state.mode}`, "POST", { username, password });
await celebrateLogin();
enterApp();
} catch (error) {
state.loading = false;
setError(error.message || "账号操作失败");
render();
mascots.fail();
}
}
@@ -244,6 +325,27 @@
}
}
async function resumeCurrentAccount() {
state.loading = true;
setError("");
render();
try {
const session = await api.request("/api/auth/me");
const sessionUserId = session.user?.id;
const matches = Boolean(session.authenticated) && (
!state.currentUserId || Number(sessionUserId) === Number(state.currentUserId)
);
if (!matches) {
throw new Error("当前会话已失效,请重新登录");
}
enterApp();
} catch (error) {
state.loading = false;
setError(error.message || "当前会话已失效,请重新登录");
render();
}
}
async function forgetAccount(userId) {
try {
await api.request("/api/auth/forget", "POST", { user_id: userId });
@@ -258,6 +360,241 @@
}
}
function togglePasswordVisible() {
state.passwordVisible = !state.passwordVisible;
const input = document.querySelector("#loginPassword");
const button = document.querySelector(".login-password-toggle");
if (input) {
input.type = state.passwordVisible ? "text" : "password";
input.focus();
}
if (button) {
button.textContent = state.passwordVisible ? "隐藏" : "显示";
button.setAttribute("aria-pressed", state.passwordVisible ? "true" : "false");
button.setAttribute("aria-label", state.passwordVisible ? "隐藏密码" : "显示密码");
}
mascots.sync();
}
function reducedMotion() {
return Boolean(global.matchMedia && global.matchMedia("(prefers-reduced-motion: reduce)").matches);
}
function finePointer() {
return Boolean(global.matchMedia && global.matchMedia("(pointer: fine)").matches);
}
function wait(ms) {
return new Promise((resolve) => global.setTimeout(resolve, ms));
}
async function celebrateLogin() {
mascots.succeed();
if (!reducedMotion()) await wait(720);
}
const mascots = (() => {
const root = document.querySelector("#loginMascots");
const red = root && root.querySelector(".login-mascot.is-red");
const green = root && root.querySelector(".login-mascot.is-green");
const motion = {
pupilX: 0,
pupilY: 0,
targetX: 0,
targetY: 0,
leanRed: 0,
leanGreen: 0,
targetLeanRed: 0,
targetLeanGreen: 0,
};
let mood = "idle";
let locked = "";
let blinkTimer = 0;
let failTimer = 0;
let raf = 0;
function setVars() {
if (!red || !green) return;
const pupilX = `${motion.pupilX.toFixed(2)}px`;
const pupilY = `${motion.pupilY.toFixed(2)}px`;
red.style.setProperty("--pupil-x", pupilX);
red.style.setProperty("--pupil-y", pupilY);
green.style.setProperty("--pupil-x", pupilX);
green.style.setProperty("--pupil-y", pupilY);
red.style.setProperty("--lean", `${motion.leanRed.toFixed(2)}deg`);
green.style.setProperty("--lean", `${motion.leanGreen.toFixed(2)}deg`);
}
function poseFor(next) {
if (next === "account" || next === "busy") {
motion.targetX = 3.2;
motion.targetY = 0.8;
motion.targetLeanRed = 7;
motion.targetLeanGreen = 5.6;
return;
}
if (next === "password") {
motion.targetX = 0;
motion.targetY = 0;
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
return;
}
if (next === "fail") {
motion.targetX = 0;
motion.targetY = 2.8;
motion.targetLeanRed = 8;
motion.targetLeanGreen = 8;
return;
}
if (next === "success") {
motion.targetX = 0;
motion.targetY = 0;
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
return;
}
if (!finePointer()) {
motion.targetX = 2.4;
motion.targetY = 0.4;
motion.targetLeanRed = 4;
motion.targetLeanGreen = 3.2;
return;
}
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
}
function applyMood(next) {
if (!root) return;
const changed = next !== mood;
if (changed) {
mood = next;
root.dataset.mood = next;
poseFor(next);
} else if (next !== "idle") {
poseFor(next);
}
if (reducedMotion()) {
motion.pupilX = motion.targetX;
motion.pupilY = motion.targetY;
motion.leanRed = motion.targetLeanRed;
motion.leanGreen = motion.targetLeanGreen;
setVars();
}
}
function focusedControl() {
const active = document.activeElement;
if (!active || !card.contains(active)) return "";
if (active.classList.contains("login-password-toggle")) return "loginPassword";
return active.id || "";
}
function desiredMood() {
if (locked === "success") return "success";
if (state.loading) return "busy";
const focused = focusedControl();
if (focused === "loginPassword" || focused === "loginPasswordConfirm") return "password";
if (focused === "loginUsername") return "account";
if (locked === "fail") return "fail";
return "idle";
}
function sync() {
applyMood(desiredMood());
}
function succeed() {
locked = "success";
applyMood("success");
}
function fail() {
locked = "fail";
applyMood("fail");
global.clearTimeout(failTimer);
failTimer = global.setTimeout(() => {
if (locked === "fail") locked = "";
sync();
}, 900);
}
function blink() {
if (!root || reducedMotion()) return;
if (mood !== "idle" && mood !== "account" && mood !== "busy") return;
root.classList.remove("is-blinking");
void root.offsetWidth;
root.classList.add("is-blinking");
global.setTimeout(() => root.classList.remove("is-blinking"), 160);
}
function scheduleBlink() {
global.clearTimeout(blinkTimer);
if (reducedMotion()) return;
const waitMs = 4000 + Math.random() * 2000;
blinkTimer = global.setTimeout(() => {
blink();
scheduleBlink();
}, waitMs);
}
function onMouseMove(event) {
if (reducedMotion() || !finePointer()) return;
if (desiredMood() !== "idle") return;
const rect = root.getBoundingClientRect();
const cx = rect.left + rect.width * 0.42;
const cy = rect.top + rect.height * 0.42;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
const dist = Math.hypot(dx, dy) || 1;
const cap = 3.5;
motion.targetX = (dx / dist) * Math.min(cap, Math.abs(dx) / 90);
motion.targetY = (dy / dist) * Math.min(cap, Math.abs(dy) / 90);
const tilt = Math.max(-5, Math.min(5, (dx / Math.max(global.innerWidth, 1)) * 10));
motion.targetLeanRed = tilt;
motion.targetLeanGreen = tilt * 0.8;
}
function tick() {
if (!root) return;
if (!reducedMotion()) {
motion.pupilX += (motion.targetX - motion.pupilX) * 0.18;
motion.pupilY += (motion.targetY - motion.pupilY) * 0.18;
motion.leanRed += (motion.targetLeanRed - motion.leanRed) * 0.18;
motion.leanGreen += (motion.targetLeanGreen - motion.leanGreen) * 0.18;
setVars();
} else {
motion.pupilX = motion.targetX;
motion.pupilY = motion.targetY;
motion.leanRed = motion.targetLeanRed;
motion.leanGreen = motion.targetLeanGreen;
setVars();
}
raf = global.requestAnimationFrame(tick);
}
function onFocusChange() {
global.requestAnimationFrame(sync);
}
if (root) {
document.addEventListener("focusin", onFocusChange);
document.addEventListener("focusout", onFocusChange);
global.addEventListener("mousemove", onMouseMove, { passive: true });
if (!reducedMotion()) {
raf = global.requestAnimationFrame(tick);
scheduleBlink();
} else {
poseFor("idle");
setVars();
}
sync();
}
return { sync, succeed, fail };
})();
themeButton.addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
});
+415 -8
View File
@@ -3192,8 +3192,7 @@
.m-sys-grid div {
padding: 12px;
border-radius: 8px;
background: var(--surface);
box-shadow: var(--elevation-card);
background: var(--surface-muted);
}
.m-sys-grid span {
@@ -3209,16 +3208,424 @@
color: var(--text-primary);
}
.m-sys-account-actions {
display: grid;
.m-sys-home {
padding: 0 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.m-page[data-page^="system/"] .m-btn-primary {
margin-bottom: 8px;
.m-sys-body {
padding: 12px 12px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.m-sys-home .m-card,
.m-sys-body .m-card {
margin-left: 0;
margin-right: 0;
}
.m-sys-profile-card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px;
}
.m-sys-avatar {
flex: 0 0 auto;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--action-soft);
color: var(--action);
font-size: var(--font-size-page-title);
font-weight: 600;
}
.m-sys-profile-card strong {
display: block;
font-size: var(--font-size-card-title);
color: var(--text-primary);
}
.m-sys-profile-meta {
margin: 4px 0 8px;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.m-sys-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: var(--font-size-aux);
font-weight: 600;
background: var(--surface-muted);
color: var(--text-secondary);
}
.m-sys-badge--admin {
background: var(--action-soft);
color: var(--action);
}
.m-sys-badge--ok {
background: var(--market-down-soft);
color: var(--market-down);
}
.m-sys-group-title {
margin: 4px 0 0;
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--text-tertiary);
}
.m-sys-list {
padding: 0;
overflow: hidden;
}
.m-sys-row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-sys-row + .m-sys-row {
border-top: 1px solid var(--border);
}
.m-sys-row:active {
background: var(--surface-hover);
}
.m-sys-row-icon {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--action-soft);
color: var(--action);
}
.m-sys-row--danger .m-sys-row-icon {
background: var(--market-up-soft);
color: var(--market-up);
}
.m-sys-row-body {
flex: 1;
min-width: 0;
}
.m-sys-row-body strong {
display: block;
font-size: var(--font-size-body);
font-weight: 600;
color: var(--text-primary);
}
.m-sys-row-body small {
display: block;
margin-top: 2px;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-row-chevron {
flex: 0 0 auto;
color: var(--text-tertiary);
}
.m-sys-foot {
margin: 8px 0 0;
text-align: center;
font-size: var(--font-size-caption);
color: var(--text-tertiary);
}
.m-sys-notice {
padding: 12px;
border-radius: 12px;
background: var(--surface-muted);
}
.m-sys-notice p {
margin: 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.55;
}
.m-sys-notice .m-sys-badges {
margin-top: 8px;
}
.m-sys-section {
padding: 12px;
}
.m-sys-section > strong,
.m-sys-section-title {
display: block;
margin-bottom: 10px;
font-size: var(--font-size-card-title);
color: var(--text-primary);
}
.m-sys-hint {
margin: 8px 0 0;
font-size: var(--font-size-caption);
color: var(--text-tertiary);
line-height: 1.45;
}
.m-sys-status-list {
display: grid;
gap: 8px;
}
.m-sys-status-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: var(--font-size-body);
color: var(--text-primary);
}
.m-sys-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-tertiary);
}
.m-sys-dot--ok {
background: var(--market-down);
}
.m-sys-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.m-sys-switch-row strong {
display: block;
font-size: var(--font-size-body);
margin-bottom: 0;
}
.m-btn-outline,
.m-btn-outline-danger {
width: 100%;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
background: var(--surface);
font-size: var(--font-size-body);
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-btn-outline {
border: 1px solid var(--action);
color: var(--action);
}
.m-btn-outline:active {
background: var(--action-soft);
}
.m-btn-outline-danger {
border: 1px solid var(--market-up);
color: var(--market-up);
}
.m-btn-outline-danger:active {
background: var(--market-up-soft);
}
.m-btn-outline:disabled,
.m-btn-outline-danger:disabled {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.m-sys-model-card .m-sys-badges,
.m-sys-user-row .m-sys-badges {
margin-top: 6px;
}
.m-sys-model-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 12px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.m-sys-model-card + .m-sys-model-card {
border-top: 1px solid var(--border);
}
.m-sys-model-card:active {
background: var(--surface-hover);
}
.m-sys-user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
}
.m-sys-user-row + .m-sys-user-row {
border-top: 1px solid var(--border);
}
.m-sys-user-row .m-btn-outline {
width: auto;
height: 32px;
padding: 0 12px;
flex: 0 0 auto;
}
.m-sys-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.m-sheet-root.is-dialog .m-sheet {
display: none;
}
.m-dialog {
position: absolute;
left: 50%;
top: 42%;
width: calc(100% - 48px);
max-width: 320px;
transform: translate(-50%, -46%) scale(0.96);
border-radius: 12px;
background: var(--surface);
box-shadow: var(--elevation-float);
padding: 18px 16px 14px;
z-index: 3;
opacity: 0;
transition: opacity var(--motion-enter) var(--ease-enter),
transform var(--motion-enter) var(--ease-enter);
}
.m-sheet-root.is-open .m-dialog {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.m-dialog h2 {
margin: 0 0 8px;
font-size: var(--font-size-card-title);
color: var(--text-primary);
text-align: center;
}
.m-dialog p {
margin: 0 0 16px;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.5;
text-align: center;
}
.m-dialog-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.m-dialog-actions .m-btn-outline,
.m-dialog-actions .m-btn-outline-danger,
.m-dialog-actions .m-btn-primary {
height: 40px;
}
.m-sys-sheet-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.m-sys-test-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0 12px;
}
.m-sys-test-row .m-btn-outline {
width: auto;
flex: 0 0 auto;
height: 36px;
padding: 0 12px;
}
.m-sys-test-row [data-model-test-status] {
flex: 1;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-hero {
padding: 14px;
}
.m-sys-hero strong {
display: block;
font-size: var(--font-size-page-title);
margin-bottom: 6px;
}
.m-page[data-page^="system/"] .m-card {
margin-bottom: 12px;
margin-bottom: 0;
}
+504 -159
View File
@@ -91,6 +91,14 @@
"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"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
gem: '<path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
"sliders-horizontal": '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
"arrow-left-right": '<path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>',
"log-out": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
};
function icon(name, size) {
@@ -191,6 +199,10 @@
account: null,
admin: null,
adminTab: "market",
models: [],
accounts: [],
editingModelId: "",
editingUserId: "",
},
};
@@ -1594,11 +1606,43 @@
/* ---------------------------------------------------------------- helpers shared by new pages */
function bindConfirmAction(onConfirm) {
const ok = document.querySelector("[data-confirm-ok]");
if (ok && typeof onConfirm === "function") {
ok.addEventListener("click", function () {
closeSheet();
onConfirm();
});
}
}
function openCenteredDialog(content) {
const root = ensureSheetRoot();
sheetToken += 1;
root.classList.add("is-dialog");
root.innerHTML =
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
'<div class="m-dialog" role="dialog" aria-modal="true">' + content + "</div>";
global.requestAnimationFrame(function () { root.classList.add("is-open"); });
}
function openConfirmSheet(title, body, options) {
const opts = options || {};
const confirmLabel = opts.confirmLabel || "确定";
const cancelLabel = opts.cancelLabel || "取消";
const danger = Boolean(opts.danger);
if (opts.centered) {
openCenteredDialog(
"<h2>" + escapeHtml(title) + "</h2>" +
(body ? "<p>" + escapeHtml(body) + "</p>" : "") +
'<div class="m-dialog-actions">' +
'<button class="m-btn-outline" type="button" data-sheet-close>' + escapeHtml(cancelLabel) + "</button>" +
'<button class="' + (danger ? "m-btn-outline-danger" : "m-btn-primary") + '" type="button" data-confirm-ok>' + escapeHtml(confirmLabel) + "</button>" +
"</div>"
);
bindConfirmAction(opts.onConfirm);
return;
}
openSheet(
'<div class="m-sheet-head"><h2>' + escapeHtml(title) + '</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + '</button></div>' +
@@ -1610,13 +1654,7 @@
'</div></div>',
{ detail: false }
);
const ok = document.querySelector('[data-confirm-ok]');
if (ok && typeof opts.onConfirm === 'function') {
ok.addEventListener('click', function () {
closeSheet();
opts.onConfirm();
});
}
bindConfirmAction(opts.onConfirm);
}
function nextSeq() {
@@ -3123,6 +3161,7 @@
function openSheet(content, opts) {
const root = ensureSheetRoot();
sheetToken += 1;
root.classList.remove("is-dialog");
root.innerHTML =
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
'<div class="m-sheet' + (opts && opts.detail ? " m-sheet--detail" : "") + '" role="dialog" aria-modal="true">' +
@@ -3138,12 +3177,16 @@
if (!root) return;
const token = sheetToken;
root.classList.remove("is-open");
root.classList.remove("is-dialog");
global.setTimeout(function () {
if (sheetToken === token && !root.classList.contains("is-open")) root.innerHTML = "";
if (sheetToken === token && !root.classList.contains("is-open")) {
root.innerHTML = "";
}
}, 340);
}
function bindSheetDrag(root) {
if (root.classList.contains("is-dialog")) return;
const sheet = root.querySelector(".m-sheet");
const backdrop = root.querySelector(".m-sheet-backdrop");
const handle = root.querySelector(".m-sheet-handle");
@@ -4863,7 +4906,7 @@
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
}
/* ---------------------------------------------------------------- 系统管理(恢复桌面端已有能力,禁止再走占位页 */
/* ---------------------------------------------------------------- 系统管理(HEL-238 按确认样图重排 */
function isSystemPage(key) {
return String(key || state.key || "").indexOf("system/") === 0;
@@ -4889,13 +4932,27 @@
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
}
function currentUser() {
return global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null;
}
function currentUsername() {
const user = currentUser();
return user && user.username ? user.username : "当前账号";
}
function currentMembership() {
const user = currentUser();
return (user && user.membership) || {};
}
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";
if (key === "system/admin") state.system.adminTab = 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)));
@@ -4917,6 +4974,9 @@
if (seq !== state.seq || state.key !== key) return;
if (key === "system/admin" || key === "system/members") {
state.system.admin = payload || {};
state.system.models = ((payload.llm && payload.llm.models) || []).map(function (item) {
return Object.assign({}, item);
});
renderSystemAdmin(key);
} else {
state.system.account = payload || {};
@@ -4929,9 +4989,115 @@
});
}
function currentUsername() {
const user = global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null;
return user && user.username ? user.username : "当前账号";
function formatLastUsed(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
const now = new Date();
const hh = String(parsed.getHours()).padStart(2, "0");
const mm = String(parsed.getMinutes()).padStart(2, "0");
const sameDay = parsed.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (sameDay) return "今天 " + hh + ":" + mm;
if (parsed.toDateString() === yesterday.toDateString()) return "昨天 " + hh + ":" + mm;
return membershipDateLabel(value) + " " + hh + ":" + mm;
}
function systemRowHtml(item) {
return '<button class="m-sys-row" type="button" data-route="#/feature/' + item.key + '">' +
'<span class="m-sys-row-icon">' + icon(item.icon, 18) + "</span>" +
'<span class="m-sys-row-body"><strong>' + escapeHtml(item.label) + "</strong><small>" + escapeHtml(item.hint) + "</small></span>" +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span>" +
"</button>";
}
function systemThemeRowHtml() {
const dark = document.getElementById("m-app") && document.getElementById("m-app").dataset.theme === "dark";
return '<div class="m-sys-row" data-theme-row>' +
'<span class="m-sys-row-icon" data-theme-row-icon>' + icon(dark ? "moon" : "sun", 18) + "</span>" +
'<span class="m-sys-row-body"><strong>外观主题</strong><small data-theme-row-label>' + (dark ? "当前:夜间模式" : "当前:日间模式") + "</small></span>" +
'<button class="m-theme-switch" type="button" data-theme-toggle role="switch" aria-checked="' + (dark ? "true" : "false") + '" aria-label="切换日间/夜间模式"><span class="m-theme-switch-thumb"></span></button>' +
"</div>";
}
function renderSystemHome() {
state.key = "system";
document.getElementById("m-view").classList.remove("m-view-feature");
global.MobileRouter.updateHeader({ title: "系统管理", back: false, actions: "" });
const view = document.getElementById("m-view");
view.innerHTML = '<div class="m-sys-home" data-system-page="home">' + skeletonHtml(4) + "</div>";
const seq = nextSeq();
Promise.all([
global.MobileAPI.request("/api/account/status").catch(function () { return {}; }),
global.MobileSession.listAccounts().catch(function () { return { accounts: [] }; })
]).then(function (results) {
if (seq !== state.seq || state.key !== "system") return;
state.system.account = results[0] || {};
state.system.accounts = (results[1] && results[1].accounts) || [];
paintSystemHome();
});
}
function paintSystemHome() {
const user = currentUser() || {};
const membership = currentMembership();
const account = state.system.account || {};
const access = account.llm_access || {};
const status = access.membership || membership;
const username = user.username || currentUsername();
const avatar = String(username).slice(0, 1);
const remembered = (state.system.accounts || []).some(function (item) {
return String(item.user_id) === String(user.id);
});
const currentGrant = (state.system.accounts || []).find(function (item) {
return String(item.user_id) === String(user.id);
});
const lastUsed = formatLastUsed(currentGrant && currentGrant.last_used_at);
const metaParts = [];
if (lastUsed) metaParts.push("上次登录:" + lastUsed);
if (remembered) metaParts.push("本机已记住");
const badges = [];
if (global.MobileSession.isAdmin()) badges.push('<span class="m-sys-badge m-sys-badge--admin">管理员</span>');
if (status.subscribed) badges.push('<span class="m-sys-badge m-sys-badge--ok">会员有效</span>');
else if (!global.MobileSession.isAdmin()) badges.push('<span class="m-sys-badge">普通用户</span>');
const accountRows = [
{ key: "system/profile", icon: "user", label: "账号资料", hint: "出生信息 · 加密保存" },
{ key: "system/password", icon: "lock", label: "修改密码", hint: "建议定期更换" },
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
];
const adminRows = [
{ key: "system/admin", icon: "sliders-horizontal", label: "系统设置", hint: "行情数据 · 模型池" },
{ key: "system/members", icon: "users", label: "会员管理", hint: "开通 · 续期 · 额度" }
];
const html =
'<div class="m-sys-home" data-system-page="home">' +
'<div class="m-card m-sys-profile-card"><span class="m-sys-avatar">' + escapeHtml(avatar) + "</span><div>" +
"<strong>" + escapeHtml(username) + "</strong>" +
(metaParts.length ? '<p class="m-sys-profile-meta">' + escapeHtml(metaParts.join(" · ")) + "</p>" : "") +
(badges.length ? '<div class="m-sys-badges">' + badges.join("") + "</div>" : "") +
"</div></div>" +
'<h3 class="m-sys-group-title">账号</h3>' +
'<div class="m-card m-sys-list">' + accountRows.map(systemRowHtml).join("") + "</div>" +
'<h3 class="m-sys-group-title">偏好</h3>' +
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
(global.MobileSession.isAdmin()
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + "</div>"
: "") +
'<h3 class="m-sys-group-title">其他</h3>' +
'<div class="m-card m-sys-list">' +
'<button class="m-sys-row" type="button" data-system-switch>' +
'<span class="m-sys-row-icon">' + icon("arrow-left-right", 18) + "</span>" +
'<span class="m-sys-row-body"><strong>切换账号</strong><small>本机免密进入其他账号</small></span>' +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
'<button class="m-sys-row m-sys-row--danger" type="button" data-system-logout>' +
'<span class="m-sys-row-icon">' + icon("log-out", 18) + "</span>" +
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
"</div>" +
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "系统设置与会员管理仅管理员可见") + "</p>" +
"</div>";
document.getElementById("m-view").innerHTML = html;
}
function renderSystemProfile() {
@@ -4947,9 +5113,10 @@
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>' +
'<div class="m-sys-body" data-system-page="profile">' +
'<div class="m-sys-notice"><p>出生信息仅对当前账号可见并加密保存。原始信息不会在观气页回显,智能解读只使用排盘后的派生结果。</p>' +
'<div class="m-sys-badges"><span class="m-sys-badge ' + (configured ? "m-sys-badge--ok" : "") + '">' + (configured ? "已加密保存" : "尚未设置") + "</span></div></div>" +
'<div class="m-card m-sys-section"><strong>命理资料</strong>' +
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">' +
@@ -4957,12 +5124,10 @@
'<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>";
"</div>" +
'<button class="m-btn-outline-danger" type="button" data-system-delete-birth' + (configured ? "" : " disabled") + ">删除命理资料</button>" +
'<p class="m-sys-hint">删除后智能解读将无法使用出生信息,执行前会再次确认。</p>' +
"</div>";
const page = document.querySelector(".m-page");
if (page) {
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
@@ -4972,17 +5137,16 @@
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>";
'<div class="m-sys-body" data-system-page="password">' +
'<div class="m-sys-notice"><p>仅修改当前账号的登录密码,密码不会保存在这台设备上。修改成功后下次登录需使用新密码。</p></div>' +
'<div class="m-card m-sys-section"><strong>设置新密码</strong>' +
formFieldHtml("当前密码", '<input id="m-sys-password-current" type="password" autocomplete="current-password" placeholder="输入现在的密码">', true, '<p class="m-field-error" hidden data-field-error="current"></p>') +
formFieldHtml("新密码", '<input id="m-sys-password-new" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="8-128 位">', true, '<p class="m-sys-hint">建议字母与数字混合,不要与其他网站重复。</p><p class="m-field-error" hidden data-field-error="new"></p>') +
formFieldHtml("确认新密码", '<input id="m-sys-password-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="再输入一次新密码">', true, '<p class="m-field-error" hidden data-field-error="confirm"></p>') +
'<button class="m-btn-primary" type="button" data-system-save-password>更新密码</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-password>更新密码</button></div>';
}
if (page) page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>";
}
function renderSystemMembership() {
@@ -4995,25 +5159,28 @@
? number(membership.remaining_days) + " 天"
: (membership.is_admin || membership.subscribed ? "长期有效" : "--");
const detail = membership.subscribed
? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效"))
? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效") + (membership.remaining_days != null ? ",剩余 " + number(membership.remaining_days) + " 天。" : "。"))
: 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 usedToday = membership.active ? (number(access.used_today) + " 次") : "--";
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-body" data-system-page="membership">' +
'<div class="m-card m-sys-hero"><strong>' + escapeHtml(badge) + '</strong>' +
(membership.subscribed ? '<div class="m-sys-badges"><span class="m-sys-badge m-sys-badge--ok">已开通</span></div>' : "") +
'<p class="m-sys-lead">' + escapeHtml(detail) + "</p></div>" +
'<div class="m-card m-sys-section"><strong>今日智能分析</strong>' +
'<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><span>今日已用</span><strong>" + escapeHtml(usedToday) + "</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>' +
'<p class="m-sys-hint">会员每日智能分析额度 ' + number(access.daily_limit) + " 次,每日 0 点自动重置,由管理员统一设置。</p></div>" +
'<div class="m-card m-sys-section"><strong>权益说明</strong>' +
'<p class="m-sys-lead">全部用户可用:行情、搜索、自选股、交易日志与复盘。</p>' +
'<p class="m-sys-lead">会员专属:智能选股、问师、问天、复盘助手等智能功能。</p></div>' +
"</div>";
systemFill(html);
}
@@ -5026,6 +5193,10 @@
"</div>";
}
function statusDot(ok) {
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
}
function renderSystemAdmin(key) {
if (key === "system/members") {
renderSystemMembers();
@@ -5035,74 +5206,86 @@
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>' +
'<div class="m-sys-body" data-system-admin-panel="market">' +
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
'<div class="m-sys-status-list">' +
'<div class="m-sys-status-item"><span>Tushare</span><span>' + statusDot(data.configured) + (data.configured ? " 已配置" : " 未配置") + "</span></div>" +
'<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" +
'<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" +
'<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" +
"</div></div>" +
'<div class="m-card m-sys-section"><strong>数据源密钥</strong>' +
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) +
'<button class="m-btn-primary" type="button" data-system-save-market>保存密钥</button></div>' +
'<div class="m-card m-sys-section"><strong>后台刷新</strong>' +
'<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' +
'<button class="m-theme-switch" type="button" data-system-toggle-refresh role="switch" aria-checked="' + (data.background_refresh_enabled ? "true" : "false") + '" aria-label="交易时段自动刷新"><span class="m-theme-switch-thumb"></span></button></div>' +
'<input id="m-sys-bg-refresh" type="checkbox"' + (data.background_refresh_enabled ? " checked" : "") + ' hidden>' +
'<button class="m-btn-outline" type="button" data-system-refresh>立即刷新一次</button>' +
'<p class="m-sys-hint">所有用户读取同一份快照,刷新不影响当前页面内容。</p></div>' +
'<div class="m-card m-sys-section"><strong>历史数据回补</strong>' +
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>' +
'<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
"</div>";
const models = state.system.models || [];
const modelsHtml =
'<div class="m-form-body" data-system-admin-panel="models">' +
'<div class="m-sys-body" data-system-admin-panel="models">' +
'<div class="m-card m-sys-section"><strong>模型分工</strong>' +
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>' +
'<p class="m-sys-hint">主模型不可用时自动改用辅助模型</p>' +
'<button class="m-btn-primary" type="button" data-system-save-models>保存分工</button></div>' +
'<div class="m-card m-sys-section"><strong>模型池 · ' + models.length + " 个</strong>" +
'<div id="m-sys-model-list">' + renderModelPoolHtml(models) + "</div>" +
'<p class="m-sys-hint">点任意模型卡片进入编辑:改名称、地址、密钥、测试连接或删除</p>' +
'<button class="m-btn-primary" type="button" data-system-add-model>+ 添加模型</button></div>' +
"</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;
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>";
if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
}
function hostOfUrl(url) {
try {
return new URL(url).host;
} catch (error) {
return String(url || "").replace(/^https?:\/\//, "").split("/")[0] || "--";
}
}
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>";
const llm = (state.system.admin && state.system.admin.llm) || {};
return models.map(function (item) {
const badges = [];
if (item.id === llm.primary_model_id) badges.push('<span class="m-sys-badge m-sys-badge--admin">主模型</span>');
if (item.id === llm.fallback_model_id) badges.push('<span class="m-sys-badge">辅助</span>');
badges.push('<span class="m-sys-badge' + (item.configured ? " m-sys-badge--ok" : "") + '">' + (item.configured ? "已配置" : "待配置") + "</span>");
return '<button class="m-sys-model-card" type="button" data-model-id="' + escapeHtml(item.id) + '" data-system-edit-model>' +
"<div><strong>" + escapeHtml(item.name || "未命名模型") + "</strong>" +
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
'<p class="m-sys-hint">' + escapeHtml(hostOfUrl(item.base_url) + " · " + (item.model || "未填写标识")) + "</p></div>" +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
}).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 (state.system.models || []).map(function (item) {
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),
id: item.id,
name: item.name || "",
base_url: item.base_url || "",
model: item.model || "",
api_key: item.api_key || "",
configured: Boolean(item.configured)
};
});
}
@@ -5122,6 +5305,40 @@
fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
}
function openModelEditSheet(modelId) {
const models = state.system.models || [];
const item = models.find(function (row) { return row.id === modelId; }) || {};
state.system.editingModelId = modelId;
openSheet(
'<div class="m-sheet-head"><h2>编辑模型</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" data-model-id="' + escapeHtml(modelId) + '">' +
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) +
'<div class="m-sys-test-row"><button class="m-btn-outline" type="button" data-system-test-model>测试连接</button>' +
'<span data-model-test-status>未测试</span></div>' +
'<div class="m-sys-sheet-actions">' +
'<button class="m-btn-outline-danger" type="button" data-system-delete-model>删除模型</button>' +
'<button class="m-btn-primary" type="button" data-system-save-model>保存</button>' +
"</div></div>"
);
}
function readModelSheetFields(root) {
function fieldValue(name) {
const input = root.querySelector("[data-model-field='" + name + "']");
return String(input && input.value != null ? input.value : "").trim();
}
return {
name: fieldValue("name"),
base_url: fieldValue("base_url"),
model: fieldValue("model"),
api_key: fieldValue("api_key")
};
}
function renderSystemMembers() {
const payload = state.system.admin || {};
const membership = payload.membership || {};
@@ -5129,39 +5346,77 @@
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 badges = [];
if (admin) badges.push('<span class="m-sys-badge m-sys-badge--admin">管理员</span>');
if (member) badges.push('<span class="m-sys-badge m-sys-badge--ok">会员</span>');
else badges.push('<span class="m-sys-badge">普通用户</span>');
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>";
return '<div class="m-sys-user-row" data-admin-user="' + number(user.id) + '">' +
'<div class="m-sys-row-body"><strong>' + escapeHtml(user.username) + "</strong>" +
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
'<p class="m-sys-hint">' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p></div>" +
'<button class="m-btn-outline" type="button" data-system-open-member>管理</button></div>';
}).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>' +
'<div class="m-sys-body" data-system-page="members">' +
'<div class="m-card m-sys-section"><strong>全局额度</strong>' +
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>' +
'<p class="m-sys-hint">对所有会员生效,每日 0 点自动重置。</p>' +
'<button class="m-btn-primary" type="button" data-system-save-limit>保存额度</button></div>' +
'<div class="m-card m-sys-section"><strong>会员账号 · ' + users.length + " 个</strong>" +
userHtml +
'<p class="m-sys-hint">点「管理」为对应账号开通、续期或停用会员。</p></div>' +
"</div>";
systemFill(html);
}
function openMemberManageSheet(userId) {
const users = (state.system.admin && state.system.admin.users) || [];
const user = users.find(function (item) { return String(item.id) === String(userId); });
if (!user) return;
state.system.editingUserId = String(userId);
const member = Boolean(user.membership_subscribed);
const expiry = member
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
: user.membership_status === "suspended" ? "会员已停用" : "尚未开通";
openSheet(
'<div class="m-sheet-head"><h2>管理会员 · ' + escapeHtml(user.username) + "</h2>" +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" data-admin-user="' + number(user.id) + '">' +
'<p class="m-sys-lead">当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</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) +
'<p class="m-sys-hint">从当前时间开始顺延;已有会员则叠加续期。</p>' +
'<div class="m-sys-sheet-actions">' +
'<button class="m-btn-outline" type="button" data-sheet-close>取消</button>' +
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
"</div></div>"
);
}
function setFieldError(inputId, message) {
const input = document.getElementById(inputId);
const field = input && input.closest(".m-form-field");
if (!field) return;
field.classList.toggle("is-invalid", Boolean(message));
const error = field.querySelector("[data-field-error]");
if (error) {
error.hidden = !message;
error.textContent = message || "";
}
}
function saveSystemBirth() {
const birthDate = (document.getElementById("m-sys-birth-date") || {}).value;
const birthTime = (document.getElementById("m-sys-birth-time") || {}).value;
@@ -5176,7 +5431,7 @@
gender: (document.getElementById("m-sys-birth-gender") || {}).value || "unspecified",
trade_date: todayString(),
}).then(function () {
showToast("个人命理资料已保存到当前账号");
showToast("个人命理资料已保存");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "个人命理资料保存失败");
@@ -5186,12 +5441,12 @@
}
function deleteSystemBirth() {
openConfirmSheet("删除资料", "确定删除当前账号保存的个人命理资料吗?", {
openConfirmSheet("删除命理资料", "删除后智能解读将无法使用你的出生信息,此操作不可恢复。", {
danger: true,
centered: true,
confirmLabel: "删除",
onConfirm: function () {
global.MobileAPI.request("/api/account/birth-profile", "DELETE").then(function () {
closeSheet();
showToast("个人命理资料已删除");
loadSystem();
}).catch(function (error) {
@@ -5205,6 +5460,23 @@
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 || "";
setFieldError("m-sys-password-current", "");
setFieldError("m-sys-password-new", "");
setFieldError("m-sys-password-confirm", "");
let invalid = false;
if (!current) {
setFieldError("m-sys-password-current", "请输入当前密码");
invalid = true;
}
if (next.length < 8 || next.length > 128) {
setFieldError("m-sys-password-new", "新密码长度应为 8 至 128 位");
invalid = true;
}
if (next !== confirm) {
setFieldError("m-sys-password-confirm", "两次输入的密码不一致,请重新输入。");
invalid = true;
}
if (invalid) return;
const button = document.querySelector("[data-system-save-password]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/account/password", "POST", {
@@ -5212,14 +5484,13 @@
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) {
["m-sys-password-current", "m-sys-password-new", "m-sys-password-confirm"].forEach(function (id) {
const input = document.getElementById(id);
if (input) input.value = "";
});
showToast("密码已更新");
}).catch(function (error) {
showToast(error && error.message ? error.message : "密码更新失败");
showToast("更新失败:" + (error && error.message ? error.message : "密码更新失败"));
}).then(function () {
if (button) button.disabled = false;
});
@@ -5232,10 +5503,10 @@
function logoutSystemAccount() {
openConfirmSheet("退出当前账号", "退出后需要重新登录。本机已记录的其他账号仍可直接切换。", {
danger: true,
centered: true,
confirmLabel: "退出",
onConfirm: function () {
global.MobileSession.logout().then(function () {
closeSheet();
global.MobileRouter.replace("#/auth");
}).catch(function (error) {
showToast(error && error.message ? error.message : "退出失败");
@@ -5250,9 +5521,8 @@
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("行情配置已保存");
showToast("行情密钥已保存");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "系统配置保存失败");
@@ -5261,30 +5531,65 @@
});
}
function toggleSystemRefresh() {
const enabled = !Boolean((state.system.admin && state.system.admin.data && state.system.admin.data.background_refresh_enabled));
global.MobileAPI.request("/api/admin/settings", "POST", {
background_refresh_enabled: enabled,
}).then(function () {
showToast(enabled ? "后台刷新已启用" : "后台刷新已暂停");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "后台刷新设置失败");
});
}
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("模型已保存");
showToast("模型分工已保存");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "模型保存失败");
showToast(error && error.message ? error.message : "模型分工保存失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function persistSystemModels(models, message) {
return global.MobileAPI.request("/api/admin/settings", "POST", { models: models }).then(function () {
showToast(message || "模型池已保存");
closeSheet();
loadSystem();
});
}
function saveEditedSystemModel() {
const sheet = document.querySelector(".m-sheet-body[data-model-id]");
if (!sheet) return;
const id = sheet.dataset.modelId;
const fields = readModelSheetFields(sheet);
const models = collectSystemModelPool().map(function (item) {
if (item.id !== id) return item;
return Object.assign({}, item, fields);
});
persistSystemModels(models, "模型已保存").catch(function (error) {
showToast(error && error.message ? error.message : "模型保存失败");
});
}
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 created = { id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false };
state.system.models = models.concat([created]);
const list = document.getElementById("m-sys-model-list");
if (list) list.innerHTML = renderModelPoolHtml(models);
if (list) list.innerHTML = renderModelPoolHtml(state.system.models);
updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
openModelEditSheet(id);
}
function deleteSystemModel(row) {
@@ -5292,25 +5597,36 @@
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) {
const llm = (state.system.admin && state.system.admin.llm) || {};
if (id === primary || id === fallback || id === llm.primary_model_id || id === llm.fallback_model_id) {
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);
openConfirmSheet("删除模型?", "删除后该模型将从模型池移除,此操作不可恢复。", {
danger: true,
centered: true,
confirmLabel: "删除",
onConfirm: function () {
const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
persistSystemModels(models, "模型已删除").catch(function (error) {
showToast(error && error.message ? error.message : "删除失败");
});
}
});
}
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; }) || {};
const status = document.querySelector("[data-model-test-status]");
const button = document.querySelector("[data-system-test-model]");
const sheet = document.querySelector(".m-sheet-body[data-model-id]");
const id = (sheet && sheet.dataset.modelId) || row.dataset.modelId;
const fields = sheet ? readModelSheetFields(sheet) : {};
const profile = Object.assign({}, collectSystemModelPool().find(function (item) { return item.id === id; }) || {}, fields, { id: id });
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";
global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: id, 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 () {
@@ -5331,19 +5647,27 @@
}
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;
const startDate = (document.getElementById("m-sys-backfill-start") || {}).value;
const endDate = (document.getElementById("m-sys-backfill-end") || {}).value;
openConfirmSheet("开始历史回补?", "将按选定日期补齐缺失行情,回补期间页面仍可使用。", {
confirmLabel: "开始回补",
centered: true,
onConfirm: function () {
const button = document.querySelector("[data-system-backfill]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/backfill", "POST", {
start_date: startDate,
end_date: endDate,
}).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;
});
}
});
}
@@ -5364,21 +5688,35 @@
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;
});
const status = (card.querySelector("[data-member-status]") || {}).value;
const apply = function () {
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: status,
duration: (card.querySelector("[data-member-duration]") || {}).value,
}).then(function (payload) {
if (state.system.admin) state.system.admin.users = payload.users || [];
closeSheet();
showToast("会员状态已更新");
renderSystemMembers();
}).catch(function (error) {
showToast(error && error.message ? error.message : "会员状态保存失败");
}).then(function () {
if (button) button.disabled = false;
});
};
if (status === "suspended") {
openConfirmSheet("停用该会员?", "停用后该账号将无法使用会员智能功能,可稍后重新开通。", {
danger: true,
centered: true,
confirmLabel: "停用",
onConfirm: apply
});
return;
}
apply();
}
/* ---------------------------------------------------------------- events */
@@ -5522,8 +5860,12 @@
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-toggle-refresh]")) { toggleSystemRefresh(); return; }
if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
if (event.target.closest("[data-system-save-model]")) { saveEditedSystemModel(); return; }
if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
const editModel = event.target.closest("[data-system-edit-model]");
if (editModel) { openModelEditSheet(editModel.dataset.modelId); 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]");
@@ -5531,6 +5873,8 @@
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 openMember = event.target.closest("[data-system-open-member]");
if (openMember) { openMemberManageSheet(openMember.closest("[data-admin-user]").dataset.adminUser); return; }
const saveMember = event.target.closest("[data-system-save-member]");
if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
@@ -5691,5 +6035,6 @@
global.MobilePages = {
render: renderPage,
has: function (key) { return Boolean(pageConfig(key) || isComplexPage(key)); },
renderSystemHome: renderSystemHome,
};
})(window);
+6 -2
View File
@@ -187,9 +187,9 @@
const dark = document.getElementById("m-app").dataset.theme === "dark";
const toggle = document.querySelector("[data-theme-toggle]");
if (toggle) toggle.setAttribute("aria-checked", dark ? "true" : "false");
const rowIcon = document.querySelector(".m-theme-row-icon");
const rowIcon = document.querySelector(".m-theme-row-icon, [data-theme-row-icon]");
if (rowIcon) rowIcon.innerHTML = icon(dark ? "moon" : "sun");
const rowBodySmall = document.querySelector(".m-theme-row-body small");
const rowBodySmall = document.querySelector(".m-theme-row-body small, [data-theme-row-label]");
if (rowBodySmall) rowBodySmall.textContent = dark ? "当前:夜间模式" : "当前:日间模式";
}
@@ -206,6 +206,10 @@
replace(DEFAULT_HASH);
return;
}
if (key === "system" && global.MobilePages && typeof global.MobilePages.renderSystemHome === "function") {
global.MobilePages.renderSystemHome();
return;
}
const items = visibleHubItems(hub);
updateHeader({ title: hub.title, back: false });
const section = key === "system" ? themeToggleSection() : "";
+1 -1
View File
@@ -95,7 +95,7 @@
"/shared/table.js?v=20260803-1",
"/shared/theme.js?v=20260803-1",
"/shared/dashboard.js?v=20260820-1",
"/shared/session.js?v=20260803-1",
"/shared/session.js?v=20260829-hel243",
"/shared/admin.js?v=20260803-1",
"/app.js?v=20260803-2",
];
+786 -67
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -196,7 +196,13 @@ async function changeAccountPassword(event) {
async function switchAccount() {
toggleAccountDropdown(false);
window.location.assign("/login/");
const params = new URLSearchParams();
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next.startsWith("/") && !next.startsWith("//") && next !== "/login" && !next.startsWith("/login/") && !next.startsWith("/login?")) {
params.set("next", next);
}
const query = params.toString();
window.location.assign("/login/" + (query ? `?${query}` : ""));
}
+29
View File
@@ -147,6 +147,31 @@
--duration-fast: 150ms;
--duration-normal: 220ms;
--login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc);
--login-brand-share: 34%;
--login-brand-min: 420px;
--login-brand-cap: 560px;
--login-brand-wide-share: 29.2%;
--login-brand-pad: 36px 40px 24px;
--login-brand-mark-size: 44px;
--login-brand-name-size: 16px;
--login-hero-size: 28px;
--login-card-width: 408px;
--login-card-pad: 32px;
--login-card-title-size: 22px;
--login-account-row-min: 72px;
--login-account-avatar: 40px;
--login-submit-height: 40px;
--login-stat-chip-bg: rgba(8, 12, 24, 0.48);
--login-candle-up: #e07078;
--login-candle-down: #3db88a;
--login-trend-line: rgba(244, 247, 255, 0.88);
--login-mascot-red: #e8605a;
--login-mascot-red-shade: #c74a45;
--login-mascot-green: #46be93;
--login-mascot-green-shade: #37997a;
--login-mascot-eye: #f7f9fc;
--login-mascot-pupil: #1a2440;
--login-mascot-height: clamp(150px, 15vw, 260px);
--font-size-aux: 11.5px;
--font-size-caption: 12.5px;
@@ -516,6 +541,10 @@
--warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
--login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a);
--login-stat-chip-bg: rgba(6, 8, 16, 0.58);
--login-candle-up: #f06d73;
--login-candle-down: #43bc8a;
--login-trend-line: rgba(232, 236, 244, 0.9);
--dialog-backdrop: var(--backdrop);
--ladder-level-1: #2d2426;
--ladder-level-2: #2b2822;
+407 -2
View File
@@ -1,4 +1,9 @@
const { test, expect } = require("@playwright/test");
const fs = require("fs");
const path = require("path");
const SHOT_DIR = path.resolve(__dirname, "../../../verify-shots");
fs.mkdirSync(SHOT_DIR, { recursive: true });
function loginPayload(user) {
return {
@@ -54,6 +59,15 @@ async function mockLoginPortal(page, options = {}) {
return;
}
if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") {
if (options.loginDelay) await new Promise((resolve) => setTimeout(resolve, options.loginDelay));
if (options.loginFails) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "账号名或密码不正确,请重新输入。" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -67,14 +81,58 @@ async function mockLoginPortal(page, options = {}) {
return;
}
if (url.pathname === "/api/auth/me") {
const current = accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null;
const authenticated = Boolean(current) && !options.sessionExpired;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
ok: true,
authenticated: Boolean(currentUserId),
authenticated,
csrf_token: "portal-csrf",
user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null,
user: authenticated ? {
id: current.user_id,
username: current.username,
role: current.role,
membership: current.membership,
} : null,
}),
});
return;
}
if (url.pathname === "/api/dashboard") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
ok: true,
meta: {
trade_date: "2026-07-22",
requested_date: "2026-07-22",
source: "tushare",
realtime: false,
cached: true,
market_status: "closed",
updated_at: "2026-07-22T15:00:00+08:00",
},
overview: {
up_count: 2100,
down_count: 2800,
limit_up_count: 42,
limit_down_count: 8,
broken_count: 17,
seal_rate: 71.2,
amount_billion: 12600,
sentiment_score: 48,
},
limits: [],
broken: [],
down_limits: [],
yesterday_limits: [],
limit_performance: [],
ladders: [],
sectors: [],
sector_rotation: [],
}),
});
return;
@@ -149,3 +207,350 @@ test("managing accounts removes a local record after inline confirmation", async
await expect(page.locator(".login-account-row")).toHaveCount(1);
await expect(page.locator(".login-account-row")).toContainText("alpha_user");
});
async function assertConfirmedSkeleton(page, { width, height }) {
await expect(page.locator(".login-brand-title")).toHaveText("看懂情绪周期,把复盘变成下一次的先手。");
await expect(page.locator(".login-brand-header")).toBeVisible();
await expect(page.locator(".login-brand-chart")).toBeVisible();
await expect(page.locator(".login-brand-stats")).toBeVisible();
await expect(page.locator(".login-brand-kicker")).toHaveText("收盘之后 · 复盘开始");
const brand = await page.locator(".login-brand").boundingBox();
const header = await page.locator(".login-brand-header").boundingBox();
const mark = await page.locator(".login-brand-mark").boundingBox();
const name = await page.locator(".login-brand-name").boundingBox();
const title = await page.locator(".login-brand-title").boundingBox();
const stats = await page.locator(".login-brand-stats").boundingBox();
const chart = await page.locator(".login-brand-chart").boundingBox();
const card = await page.locator(".login-card").boundingBox();
expect(brand).toBeTruthy();
expect(header.y - brand.y).toBeLessThan(48);
expect(Math.abs(mark.y - name.y)).toBeLessThan(16);
expect(title.y).toBeGreaterThan(height * 0.28);
expect(title.y).toBeLessThan(height * 0.72);
expect(stats.y).toBeGreaterThan(height * 0.55);
expect(chart.height).toBeGreaterThan(80);
await expect(page.locator("#loginMascots")).toBeVisible();
expect(card.width).toBeGreaterThan(380);
expect(card.width).toBeLessThan(450);
if (width === 1440) {
expect(brand.width).toBeGreaterThan(470);
expect(brand.width).toBeLessThan(520);
expect(brand.height).toBe(height);
const mascots = await page.locator("#loginMascots").boundingBox();
const kicker = await page.locator(".login-brand-kicker").boundingBox();
expect(mascots).toBeTruthy();
expect(kicker).toBeTruthy();
expect(mascots.y).toBeGreaterThan(header.y + header.height - 4);
expect(mascots.y + mascots.height).toBeLessThan(kicker.y + 8);
const gapTop = header.y + header.height;
const gapBottom = kicker.y;
const mid = (gapTop + gapBottom) / 2;
const mascotMid = mascots.y + mascots.height / 2;
expect(Math.abs(mascotMid - mid)).toBeLessThan(48);
expect(title.y).toBeGreaterThan(470);
expect(title.y).toBeLessThan(580);
} else if (width === 1920) {
expect(brand.width).toBeGreaterThan(540);
expect(brand.width).toBeLessThan(580);
} else {
expect(brand.width).toBeGreaterThan(560);
}
}
async function openPortal(page, { theme, width, height, accounts, currentUserId, loginFails, loginDelay }) {
await page.addInitScript((nextTheme) => {
localStorage.setItem("xiaobaiTheme", nextTheme);
}, theme);
await page.setViewportSize({ width, height });
await mockLoginPortal(page, { accounts, currentUserId, loginFails, loginDelay });
await page.goto("/login/");
}
for (const theme of ["light", "dark"]) {
for (const [width, height] of [[1440, 900], [1920, 1080]]) {
test(`confirmed skeleton ${theme} ${width}x${height}`, async ({ page }) => {
await openPortal(page, { theme, width, height, accounts: [] });
await assertConfirmedSkeleton(page, { width, height });
await expect(page.locator("#loginThemeToggle")).toHaveText(theme === "dark" ? "☀ 日间" : "🌙 夜间");
await page.screenshot({ path: path.join(SHOT_DIR, `first-${theme}-${width}.png`), fullPage: true });
});
}
}
test("ultrawide keeps the left brand from collapsing into a strip", async ({ page }) => {
await openPortal(page, { theme: "dark", width: 2560, height: 1080, accounts: [] });
await assertConfirmedSkeleton(page, { width: 2560, height: 1080 });
});
test("picker add remove error and loading share the same desktop skeleton", async ({ page }) => {
const accounts = SAVED_ACCOUNTS.map((item) => ({ ...item }));
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts,
currentUserId: 1,
});
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await expect(page.locator(".login-avatar")).toHaveCount(2);
await expect(page.locator(".login-add")).toBeVisible();
await page.screenshot({ path: path.join(SHOT_DIR, "picker-dark-1440.png"), fullPage: true });
await page.locator('[data-login-action="add"]').click();
await expect(page.locator(".login-card-title")).toHaveText("添加账号");
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "add-dark-1440.png"), fullPage: true });
await page.locator('[data-login-action="picker"]').click();
await page.locator('[data-login-action="manage"]').click();
await expect(page.locator(".login-card-title")).toHaveText("管理账号记录");
await page.locator('[data-confirm-id="2"]').click();
await expect(page.locator(".login-confirm-copy")).toContainText("beta_user");
await page.screenshot({ path: path.join(SHOT_DIR, "remove-dark-1440.png"), fullPage: true });
});
test("login failure and loading keep the confirmed first-login skeleton", async ({ page }) => {
await openPortal(page, {
theme: "light",
width: 1440,
height: 900,
accounts: [],
loginFails: true,
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("wrong-password");
await page.locator(".login-submit").click();
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
await expect(page.locator("#loginPassword")).toHaveClass(/is-invalid/);
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "error-light-1440.png"), fullPage: true });
});
test("loading button appears on the confirmed first-login skeleton", async ({ page }) => {
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts: [],
loginDelay: 2500,
});
await page.evaluate(() => {
window.location.replace = () => {};
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("password12");
const submit = page.locator(".login-submit").click();
await expect(page.locator(".login-submit")).toContainText("正在登录...");
await expect(page.locator(".login-spinner")).toBeVisible();
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "loading-dark-1440.png"), fullPage: true });
await submit;
});
async function openPicker(page, options = {}) {
const accounts = options.accounts || SAVED_ACCOUNTS.map((item) => ({ ...item }));
const currentUserId = options.currentUserId ?? 1;
const next = options.next || "/index.html?view=sentimentCycleView";
await page.unroute("**/api/**").catch(() => {});
await mockLoginPortal(page, {
accounts,
currentUserId,
sessionExpired: options.sessionExpired,
switchFails: options.switchFails,
});
await page.goto(`/login/?next=${encodeURIComponent(next)}`);
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
}
test("clicking the current account from two workspace pages returns without switching", async ({ page }) => {
const views = ["sentimentCycleView", "ladderView"];
for (const viewId of views) {
const next = `/index.html?view=${viewId}`;
const switchCalls = [];
const onRequest = (request) => {
if (request.url().includes("/api/auth/switch") && request.method() === "POST") {
switchCalls.push(request);
}
};
page.on("request", onRequest);
await openPicker(page, { next });
await expect(page.locator('[data-resume-id="1"]')).toContainText("继续使用");
await expect(page.locator('[data-resume-id="1"]')).toContainText("当前");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(new RegExp(`[?&]view=${viewId}\\b`));
expect(switchCalls).toEqual([]);
page.off("request", onRequest);
}
});
test("a lone current account can return from the picker instead of dead-ending", async ({ page }) => {
await openPicker(page, {
accounts: [SAVED_ACCOUNTS[0]],
currentUserId: 1,
next: "/index.html?view=reviewWorkspaceView",
});
await expect(page.locator(".login-account-row")).toHaveCount(1);
await expect(page.locator('[data-switch-id]')).toHaveCount(0);
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=reviewWorkspaceView/);
});
test("the return control also restores the originating workspace page", async ({ page }) => {
await openPicker(page, { next: "/index.html?view=ladderView" });
await page.locator('[data-login-action="resume"]').click();
await expect(page).toHaveURL(/view=ladderView/);
});
test("refreshing the picker still returns to the originating page", async ({ page }) => {
await openPicker(page, { next: "/index.html?view=sentimentCycleView" });
await page.reload();
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=sentimentCycleView/);
});
test("an expired current session asks for login instead of pretending to return", async ({ page }) => {
await openPicker(page, {
next: "/index.html?view=sentimentCycleView",
sessionExpired: true,
});
await page.locator('[data-resume-id="1"]').click();
await expect(page.locator(".login-error")).toHaveText("当前会话已失效,请重新登录");
await expect(page).toHaveURL(/\/login\/?/);
});
test("other saved accounts still switch while the current row only resumes", async ({ page }) => {
await openPicker(page, { next: "/index.html?view=auctionView" });
const switched = page.waitForRequest((request) => (
request.url().includes("/api/auth/switch") && request.method() === "POST"
));
await page.locator('[data-switch-id="2"]').click();
const request = await switched;
expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 });
});
test("workspace switch-account menu carries the current page back to the picker", async ({ page }) => {
await mockLoginPortal(page, {
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
currentUserId: 1,
});
await page.goto("/index.html?view=sentimentCycleView");
await expect(page.locator("#accountButton")).toBeVisible();
await page.locator("#accountButton").click();
await page.locator("#switchAccountMenuButton").click();
await expect(page).toHaveURL(/\/login\/\?next=/);
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=sentimentCycleView/);
});
test("workspace switch-account from a second page also returns to that page", async ({ page }) => {
await mockLoginPortal(page, {
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
currentUserId: 1,
});
await page.goto("/index.html?view=ladderView");
await expect(page.locator("#accountButton")).toBeVisible();
await page.locator("#accountButton").click();
await page.locator("#switchAccountMenuButton").click();
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=ladderView/);
});
test("desktop mascots react to account focus, password, toggle, loading, success and failure", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await expect(mascots).toHaveAttribute("data-mood", "idle");
await page.locator("#loginUsername").focus();
await expect(mascots).toHaveAttribute("data-mood", "account");
await page.locator("#loginPassword").focus();
await expect(mascots).toHaveAttribute("data-mood", "password");
await expect.poll(async () => (
page.locator(".login-mascot.is-red .login-mascot-hand.is-left").evaluate((node) => getComputedStyle(node).opacity)
)).toBe("1");
await page.locator(".login-password-toggle").click();
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "text");
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.locator(".login-password-toggle").click();
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "password");
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-password-light-1440.png"), fullPage: true });
});
test("login loading and failure drive short mascot feedback", async ({ page }) => {
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts: [],
loginFails: true,
loginDelay: 800,
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("wrong-password");
const submit = page.locator(".login-submit").click();
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "busy");
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "fail");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-fail-dark-1440.png"), fullPage: true });
await submit;
});
test("login success plays a hop before leaving the portal", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
await page.evaluate(() => {
window.location.replace = () => {};
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("password12");
const submit = page.locator(".login-submit").click();
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", /busy|success/);
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "success", { timeout: 4000 });
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-success-light-1440.png"), fullPage: true });
await submit;
});
test("reduced motion keeps static mascots without mouse tracking", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openPortal(page, { theme: "dark", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await expect(mascots).toBeVisible();
await expect(mascots).toHaveAttribute("data-mood", "idle");
const before = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
await page.mouse.move(1200, 120);
await page.waitForTimeout(120);
const after = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
expect(after).toBe(before);
await page.locator("#loginPassword").focus();
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-reduced-dark-1440.png"), fullPage: true });
});
test("mouse follow updates mascot pupils on a fine pointer", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await page.mouse.move(80, 160);
await page.waitForTimeout(180);
const left = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
await page.mouse.move(1280, 200);
await page.waitForTimeout(180);
const right = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
expect(Number.parseFloat(right)).toBeGreaterThan(Number.parseFloat(left));
});
test("narrow screens hide mascots without moving the login card", async ({ page }) => {
await openPortal(page, { theme: "light", width: 800, height: 900, accounts: [] });
await expect(page.locator("#loginMascots")).toBeHidden();
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
await expect(page.locator(".login-brand-title")).toBeHidden();
});
test("theme toggle keeps mascots in the brand gap", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
await page.locator("#loginThemeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-idle-dark-1440.png"), fullPage: true });
});
+73 -6
View File
@@ -138,6 +138,11 @@ async function mockMobileApi(page, options = {}) {
payload = { items: [] };
} else if (path === "/api/search") {
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
} else if (path === "/api/auth/accounts") {
payload = {
accounts: [{ user_id: auth.user.id, username: auth.user.username, role: auth.user.role, last_used_at: "2026-07-22T09:12:00+08:00" }],
current_user_id: auth.user.id,
};
} else if (path === "/api/account/status") {
payload = {
birth_profile_configured: true,
@@ -284,12 +289,16 @@ test("mobile login renders before authentication", async ({ page }) => {
test("four hub pages render their icon grids", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const hub of ["market", "tools", "review", "system"]) {
for (const hub of ["market", "tools", "review"]) {
await page.evaluate((h) => { window.MobileRouter.navigate("#/hub/" + h); }, hub);
await expect(page.locator(".m-hub-grid")).toBeVisible();
await expect(page.locator(".m-hub-grid .m-grid-item").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator(".m-sys-row").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
for (const theme of ["day", "night"]) {
@@ -344,7 +353,7 @@ test("system management pages render real content instead of placeholders", asyn
}
await navigateToFeature(page, "system/profile");
await expect(page.locator("#m-sys-birth-date")).toBeVisible();
await expect(page.locator("[data-system-switch]")).toBeVisible();
await expect(page.locator("[data-system-save-birth]")).toBeVisible();
await navigateToFeature(page, "system/password");
await expect(page.locator("#m-sys-password-current")).toBeVisible();
await navigateToFeature(page, "system/membership");
@@ -355,7 +364,64 @@ test("system management pages render real content instead of placeholders", asyn
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
});
test("empty profile save click shows a toast instead of a dead button", async ({ page }) => {
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator("#m-view")).toContainText("账号");
await expect(page.locator("#m-view")).toContainText("偏好");
await expect(page.locator("#m-view")).toContainText("管理员专区");
await expect(page.locator("[data-theme-toggle]")).toBeVisible();
await expect(page.locator("[data-system-switch]")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
test("system settings tabs, model editor, delete confirm and theme toggle work", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/admin");
await expect(page.locator("[data-system-admin-panel='market']")).toBeVisible();
await page.locator("[data-system-admin-tab='models']").click();
await expect(page.locator("[data-system-admin-panel='models']")).toBeVisible();
await page.locator("[data-system-edit-model]").first().click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toHaveText("编辑模型");
await page.locator("[data-sheet-close]").click();
await page.locator("[data-system-admin-tab='market']").click();
await expect(page.locator("#m-sys-token")).toBeVisible();
await navigateToFeature(page, "system/profile");
await page.locator("[data-system-delete-birth]").click();
await expect(page.locator(".m-dialog")).toBeVisible();
await expect(page.locator(".m-dialog")).toContainText("删除命理资料");
await page.locator("[data-sheet-close]").click();
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-theme-toggle]")).toBeVisible();
const before = await page.locator("#m-app").getAttribute("data-theme");
await page.locator("[data-theme-toggle]").click();
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
await navigateToFeature(page, "system/members");
await page.locator("[data-system-open-member]").click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员");
});
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/password");
await page.locator("#m-sys-password-current").fill("OldPass12");
await page.locator("#m-sys-password-new").fill("NewPass123");
await page.locator("#m-sys-password-confirm").fill("OtherPass123");
await page.locator("[data-system-save-password]").click();
await expect(page.locator("[data-field-error='confirm']")).toBeVisible();
await expect(page.locator("[data-field-error='confirm']")).toContainText("两次输入的密码不一致");
});
test("empty birth profile save shows a validation toast", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/profile");
@@ -371,9 +437,10 @@ 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 expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0);
await expect(page.locator("[data-system-switch]")).toBeVisible();
await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import os
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CHECK = ROOT / "tools" / "check_deploy_baseline.sh"
BUILD = ROOT / "tools" / "build_image.sh"
def run_check(repo: Path, candidate: str, live: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["GIT_DIR"] = str(repo / ".git")
env["GIT_WORK_TREE"] = str(repo)
return subprocess.run(
["bash", str(CHECK), candidate, "--live-revision", live],
cwd=repo,
capture_output=True,
text=True,
env=env,
check=False,
)
def git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
class DeployBaselineGateTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tmpdir = tempfile.TemporaryDirectory()
cls.repo = Path(cls.tmpdir.name) / "repo"
cls.repo.mkdir()
git(cls.repo, "init")
git(cls.repo, "config", "user.email", "gate@example.com")
git(cls.repo, "config", "user.name", "Gate")
(cls.repo / "README").write_text("base\n", encoding="utf-8")
git(cls.repo, "add", "README")
git(cls.repo, "commit", "-m", "base")
cls.base = git(cls.repo, "rev-parse", "HEAD")
(cls.repo / "online.txt").write_text("live\n", encoding="utf-8")
git(cls.repo, "add", "online.txt")
git(cls.repo, "commit", "-m", "online")
cls.live = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-b", "successor")
(cls.repo / "next.txt").write_text("next\n", encoding="utf-8")
git(cls.repo, "add", "next.txt")
git(cls.repo, "commit", "-m", "successor of live")
cls.successor = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "lagging-main", cls.base)
(cls.repo / "stale.txt").write_text("stale main\n", encoding="utf-8")
git(cls.repo, "add", "stale.txt")
git(cls.repo, "commit", "-m", "lagging main")
cls.lagging = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "side", cls.base)
(cls.repo / "side.txt").write_text("side branch\n", encoding="utf-8")
git(cls.repo, "add", "side.txt")
git(cls.repo, "commit", "-m", "unrelated side branch")
cls.side = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "successor", cls.successor)
@classmethod
def tearDownClass(cls) -> None:
cls.tmpdir.cleanup()
def test_check_script_is_executable(self) -> None:
self.assertTrue(CHECK.exists())
self.assertTrue(stat.S_IXUSR & CHECK.stat().st_mode)
def test_successor_of_live_passes(self) -> None:
result = run_check(self.repo, self.successor, self.live)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(self.live, result.stdout)
self.assertIn(self.successor, result.stdout)
self.assertIn("next.txt", result.stdout)
self.assertIn("祖先关系通过", result.stdout)
def test_lagging_main_is_blocked(self) -> None:
result = run_check(self.repo, self.lagging, self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝", result.stderr)
def test_side_branch_is_blocked(self) -> None:
result = run_check(self.repo, self.side, self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝", result.stderr)
def test_unknown_commit_is_blocked(self) -> None:
result = run_check(self.repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("无法解析", result.stderr)
def test_build_image_calls_the_gate_and_rejects_latest(self) -> None:
source = BUILD.read_text(encoding="utf-8")
self.assertIn("check_deploy_baseline.sh", source)
self.assertIn("禁止构建 latest", source)
self.assertIn("org.opencontainers.image.revision", source)
gate = CHECK.read_text(encoding="utf-8")
self.assertIn("org.opencontainers.image.revision", gate)
self.assertIn("merge-base --is-ancestor", gate)
self.assertIn("候选将丢失的提交", gate)
self.assertIn("禁止人工填写", gate)
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOGIN = (ROOT / "frontend" / "login" / "index.html").read_text(encoding="utf-8")
LOGIN_JS = (ROOT / "frontend" / "login" / "page.js").read_text(encoding="utf-8")
AUTH = (ROOT / "frontend" / "shared" / "auth.css").read_text(encoding="utf-8")
TOKENS = (ROOT / "frontend" / "shared" / "tokens.css").read_text(encoding="utf-8")
class LoginPortalContractTests(unittest.TestCase):
def test_confirmed_brand_skeleton_is_in_markup(self) -> None:
for needle in (
'class="login-brand-header"',
'class="login-brand-name"',
"A股个人复盘工作台",
'class="login-brand-kicker"',
"看懂情绪周期,把复盘变成下一次的先手。",
'class="login-brand-chart"',
'class="login-brand-stats"',
"股市有风险,投资需谨慎",
'id="loginThemeToggle"',
'id="loginCard"',
):
self.assertIn(needle, LOGIN)
def test_login_tokens_own_the_confirmed_layout_metrics(self) -> None:
for needle in (
"--login-brand-share: 34%;",
"--login-brand-cap: 560px;",
"--login-brand-wide-share: 29.2%;",
"--login-card-width: 408px;",
"--login-hero-size: 28px;",
"--login-account-row-min: 72px;",
):
self.assertIn(needle, TOKENS)
self.assertIn("width: var(--login-brand-share);", AUTH)
self.assertIn("width: var(--login-card-width);", AUTH)
self.assertIn("justify-content: flex-end;", AUTH)
self.assertIn("body.login-portal {", AUTH)
self.assertIn("padding-bottom: 0;", AUTH)
self.assertNotIn("padding: 0 0 var(--statusbar-height);", AUTH)
def test_confirmed_mascots_fill_the_brand_gap(self) -> None:
for needle in (
'id="loginMascots"',
'aria-hidden="true"',
'class="login-mascot is-red"',
'class="login-mascot is-green"',
'class="login-mascot-back"',
'class="login-mascot-slit"',
):
self.assertIn(needle, LOGIN)
for needle in (
"--login-mascot-red: #e8605a;",
"--login-mascot-green: #46be93;",
"--login-mascot-height: clamp(150px, 15vw, 260px);",
):
self.assertIn(needle, TOKENS)
self.assertIn("flex: 1 1 auto;", AUTH)
self.assertIn(".login-mascots {", AUTH)
self.assertIn("prefers-reduced-motion: reduce", AUTH)
self.assertIn('next === "password"', LOGIN_JS)
self.assertIn("toggle-password", LOGIN_JS)
self.assertIn("celebrateLogin", LOGIN_JS)
def test_portal_keeps_account_switch_and_theme_hooks(self) -> None:
self.assertIn("data-switch-id", LOGIN_JS)
self.assertIn("data-resume-id", LOGIN_JS)
self.assertIn('data-login-action="manage"', LOGIN_JS)
self.assertIn('data-login-action="add"', LOGIN_JS)
self.assertIn('data-login-action="resume"', LOGIN_JS)
self.assertIn("继续使用", LOGIN_JS)
self.assertIn("返回复盘", LOGIN_JS)
self.assertIn("/api/auth/me", LOGIN_JS)
self.assertIn("xiaobaiTheme", LOGIN_JS)
self.assertNotIn("内网个人版", LOGIN)
self.assertNotIn("192.168.200.11", LOGIN)
self.assertNotIn("/api/heaven", LOGIN_JS)
def test_current_account_row_stays_clickable_without_reswitching(self) -> None:
self.assertIn("resumeCurrentAccount", LOGIN_JS)
self.assertIn("当前会话已失效,请重新登录", LOGIN_JS)
self.assertNotIn("!managing && !current ? \"is-switchable\"", LOGIN_JS)
session = (ROOT / "frontend" / "shared" / "session.js").read_text(encoding="utf-8")
self.assertIn('params.set("next", next)', session)
self.assertIn(".login-account-action", AUTH)
+8
View File
@@ -43,6 +43,7 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
def test_system_pages_render_real_controls_not_stubs(self) -> None:
pages = PAGES.read_text(encoding="utf-8")
for marker in (
'data-system-page="home"',
'data-system-page="profile"',
'data-system-page="password"',
'data-system-page="membership"',
@@ -54,6 +55,11 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"m-sys-token",
"m-sys-member-limit",
"data-system-switch",
"data-system-edit-model",
"data-system-open-member",
"管理员专区",
"保存密钥",
"保存分工",
'location.assign("/login/")',
):
self.assertIn(marker, pages)
@@ -73,6 +79,8 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"data-system-save-models",
"data-system-save-market",
"data-system-refresh",
"data-system-toggle-refresh",
"data-system-save-model",
):
self.assertIn(name, pages)
self.assertNotIn(name + '">', pages)
+8 -1
View File
@@ -25,7 +25,14 @@ registry, and verification tools.
(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`.
tree is forbidden; see `DOCKER_DEPLOY.md`. Before building, it runs
`tools/check_deploy_baseline.sh` so the candidate commit must contain the currently
running container's Git revision as an ancestor.
- `bash tools/check_deploy_baseline.sh <commit> [--live-revision <sha>]`: deployment
ancestor gate. Reads the live `org.opencontainers.image.revision` from the running
`xiaobai-review` container (or `--live-revision` in tests), prints the live SHA,
candidate SHA, file diff, and commits the candidate would drop, then exits if the
live revision is not an ancestor of the candidate.
`verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside
this application. Historical comparison scripts were retired after final standalone acceptance;
+3
View File
@@ -59,6 +59,9 @@ if [[ "$TAG" != *-"$SHORT" ]]; then
exit 1
fi
echo "==> 部署基线门禁(线上提交必须是候选祖先)"
bash "$(git rev-parse --show-toplevel)/tools/check_deploy_baseline.sh" "$FULL_SHA"
echo "==> 构建计划"
echo " 提交: ${FULL_SHA} ${SUBJECT}"
echo " 镜像: ${REPO_NAME}:${TAG} @ ${HOST}"
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# 部署基线门禁(HEL-238):候选提交必须包含当前线上提交的全部历史。
# 线上提交号从运行中的容器镜像 label 读取,禁止人工填写“看起来正确”的基线。
set -euo pipefail
HOST_DEFAULT="moxiaobai@192.168.200.11"
CONTAINER_DEFAULT="xiaobai-review"
usage() {
cat <<'EOF'
用法: tools/check_deploy_baseline.sh <candidate_commit> [--live-revision <sha>]
<candidate_commit> 准备构建/部署的提交(完整或前缀)
--live-revision <sha> 仅测试用:直接指定线上提交,跳过 SSH 读取
环境变量:
XB_BUILD_HOST 部署机 SSH(默认 moxiaobai@192.168.200.11
XB_LIVE_CONTAINER 运行中容器名(默认 xiaobai-review
XB_LIVE_REVISION 若已设置则视为线上提交,不再 SSH
EOF
exit 2
}
[ $# -ge 1 ] || usage
CANDIDATE="$1"
shift
LIVE_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
--live-revision)
[ $# -ge 2 ] || usage
LIVE_OVERRIDE="$2"
shift 2
;;
-h|--help)
usage
;;
*)
echo "拒绝:未知参数 $1" >&2
usage
;;
esac
done
HOST="${XB_BUILD_HOST:-$HOST_DEFAULT}"
CONTAINER="${XB_LIVE_CONTAINER:-$CONTAINER_DEFAULT}"
case "$HOST" in
*192.168.200.36*)
echo "拒绝:192.168.200.36 已永久废弃,严禁在其上构建或部署。" >&2
exit 1
;;
esac
cd "$(git rev-parse --show-toplevel)"
read_live_revision() {
if [ -n "${LIVE_OVERRIDE}" ]; then
printf '%s\n' "${LIVE_OVERRIDE}"
return
fi
if [ -n "${XB_LIVE_REVISION:-}" ]; then
printf '%s\n' "${XB_LIVE_REVISION}"
return
fi
ssh -o BatchMode=yes "$HOST" bash -s -- "$CONTAINER" <<'REMOTE'
set -euo pipefail
container="$1"
revision="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)"
if [ -z "$revision" ] || [ "$revision" = "<no value>" ]; then
image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || true)"
if [ -n "$image" ]; then
revision="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image" 2>/dev/null || true)"
fi
fi
if [ -z "$revision" ] || [ "$revision" = "<no value>" ]; then
echo "拒绝:无法从线上容器 ${container} 读取 org.opencontainers.image.revision。" >&2
exit 1
fi
printf '%s\n' "$revision"
REMOTE
}
LIVE_RAW="$(read_live_revision)"
LIVE_RAW="$(printf '%s' "$LIVE_RAW" | tr -d '[:space:]')"
if [ -z "$LIVE_RAW" ]; then
echo "拒绝:线上提交号为空,禁止继续构建或部署。" >&2
exit 1
fi
CANDIDATE_SHA="$(git rev-parse --verify --quiet "${CANDIDATE}^{commit}" || true)"
if [ -z "$CANDIDATE_SHA" ]; then
echo "拒绝:候选提交 ${CANDIDATE} 无法解析。" >&2
exit 1
fi
LIVE_SHA="$(git rev-parse --verify --quiet "${LIVE_RAW}^{commit}" || true)"
if [ -z "$LIVE_SHA" ]; then
echo "拒绝:线上提交 ${LIVE_RAW} 在本地仓库无法解析;请先 git fetch,禁止手工填写替代基线。" >&2
exit 1
fi
echo "==> 部署基线对照"
echo " 线上提交: ${LIVE_SHA}"
echo " 候选提交: ${CANDIDATE_SHA}"
echo "==> 候选相对线上的文件差异"
DIFF_FILES="$(git diff --name-only "$LIVE_SHA" "$CANDIDATE_SHA" || true)"
if [ -z "$DIFF_FILES" ]; then
echo " (无文件差异)"
else
printf '%s\n' "$DIFF_FILES" | sed 's/^/ /'
fi
echo "==> 候选将丢失的提交(线上有、候选没有)"
LOST="$(git log --oneline "$CANDIDATE_SHA".."$LIVE_SHA" || true)"
if [ -z "$LOST" ]; then
echo " (无)"
else
printf '%s\n' "$LOST" | sed 's/^/ /'
fi
if ! git merge-base --is-ancestor "$LIVE_SHA" "$CANDIDATE_SHA"; then
echo "拒绝:候选提交不是当前线上提交的后继,部署会丢失线上已有提交。禁止构建或部署。" >&2
exit 1
fi
echo "==> 祖先关系通过:线上 ${LIVE_SHA:0:7} 是候选 ${CANDIDATE_SHA:0:7} 的祖先"