";
const page = document.querySelector(".m-page");
if (!page) return;
- const bar = tab === "models"
- ? '
";
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 '
";
+ 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('
";
}).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(
+ '
"
+ );
+ }
+
+ 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('
');
const expiry = member
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
: user.membership_status === "suspended"
? "会员已停用"
: "尚未开通";
- return '
' +
- '
' +
+ '
' +
+ '
全局额度' +
formFieldHtml("会员每日智能分析上限", '
', false) +
- '
' +
- '
' +
+ '
对所有会员生效,每日 0 点自动重置。
' +
+ '
' +
+ '
会员账号 · ' + users.length + " 个" +
userHtml +
+ '
点「管理」为对应账号开通、续期或停用会员。
' +
"
";
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(
+ '
管理会员 · ' + escapeHtml(user.username) + "
" +
+ '" +
+ '
' +
+ '
当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次
" +
+ formFieldHtml("会员状态", '
", false) +
+ formFieldHtml("开通 / 续期时长", '
', false) +
+ '
从当前时间开始顺延;已有会员则叠加续期。
' +
+ '
' +
+ '' +
+ '' +
+ "
"
+ );
+ }
+
+ 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);
diff --git a/frontend/m/js/router.js b/frontend/m/js/router.js
index 7f359e8..17405ec 100644
--- a/frontend/m/js/router.js
+++ b/frontend/m/js/router.js
@@ -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() : "";
diff --git a/tests/e2e/mobile-app.spec.js b/tests/e2e/mobile-app.spec.js
index e532b7e..373c268 100644
--- a/tests/e2e/mobile-app.spec.js
+++ b/tests/e2e/mobile-app.spec.js
@@ -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();
diff --git a/tests/test_deploy_baseline.py b/tests/test_deploy_baseline.py
new file mode 100644
index 0000000..e7cf6eb
--- /dev/null
+++ b/tests/test_deploy_baseline.py
@@ -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()
diff --git a/tests/test_mobile_system_pages.py b/tests/test_mobile_system_pages.py
index 4634819..c634254 100644
--- a/tests/test_mobile_system_pages.py
+++ b/tests/test_mobile_system_pages.py
@@ -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)
diff --git a/tools/README.md b/tools/README.md
index 1c94d9b..cc5c632 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -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
[--live-revision ]`: 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;
diff --git a/tools/build_image.sh b/tools/build_image.sh
index adac9fb..8206a49 100755
--- a/tools/build_image.sh
+++ b/tools/build_image.sh
@@ -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}"
diff --git a/tools/check_deploy_baseline.sh b/tools/check_deploy_baseline.sh
new file mode 100755
index 0000000..ccb2963
--- /dev/null
+++ b/tools/check_deploy_baseline.sh
@@ -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 [--live-revision ]
+ 准备构建/部署的提交(完整或前缀)
+ --live-revision 仅测试用:直接指定线上提交,跳过 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" = "" ]; 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" = "" ]; 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} 的祖先"