Compare commits

..
Author SHA1 Message Date
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
10 changed files with 1268 additions and 176 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`,与预期提交不一致则删除镜像并中止;
+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() : "";
+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()
+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} 的祖先"