fix(HEL-233): 恢复移动端系统管理五页,避免再落入占位
nav 里的账号资料/改密/会员/系统设置/会员管理此前从未注册到 pages.js, HEL-227 把用户导向 /m/ 后全部落到占位页。按桌面端已有能力补齐真实页面, 并加源码回归防止再次漏注册。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
f7cf9a6454
commit
13cd9940f7
@@ -3174,3 +3174,51 @@
|
||||
border-top: 1px solid var(--border);
|
||||
transform: translateY(calc(-1 * var(--m-keyboard-inset, 0px)));
|
||||
}
|
||||
|
||||
.m-sys-lead {
|
||||
margin: 8px 0 12px;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.m-sys-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.m-sys-grid div {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--elevation-card);
|
||||
}
|
||||
|
||||
.m-sys-grid span {
|
||||
display: block;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.m-sys-grid strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.m-sys-account-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.m-page[data-page^="system/"] .m-btn-primary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.m-page[data-page^="system/"] .m-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -336,7 +336,8 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.m-form-field input {
|
||||
.m-form-field input,
|
||||
.m-form-field select {
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -346,11 +347,20 @@ body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.m-form-field input:focus {
|
||||
.m-form-field input:focus,
|
||||
.m-form-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.m-form-field input[type="checkbox"] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.m-auth-error {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"calendar-check": '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/>',
|
||||
"sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
};
|
||||
|
||||
function icon(name, size) {
|
||||
@@ -186,6 +187,11 @@
|
||||
form: null,
|
||||
dirty: false,
|
||||
},
|
||||
system: {
|
||||
account: null,
|
||||
admin: null,
|
||||
adminTab: "market",
|
||||
},
|
||||
};
|
||||
|
||||
let sheetToken = 0;
|
||||
@@ -3067,6 +3073,11 @@
|
||||
"review/daily": setupReviewPage,
|
||||
"review/notes": setupReviewPage,
|
||||
"review/alerts": setupReviewPage,
|
||||
"system/profile": setupSystemPage,
|
||||
"system/password": setupSystemPage,
|
||||
"system/membership": setupSystemPage,
|
||||
"system/admin": setupSystemPage,
|
||||
"system/members": setupSystemPage,
|
||||
};
|
||||
|
||||
const COMPLEX_LOADERS = {
|
||||
@@ -3085,6 +3096,11 @@
|
||||
"review/daily": loadReview,
|
||||
"review/notes": loadReview,
|
||||
"review/alerts": loadReview,
|
||||
"system/profile": loadSystem,
|
||||
"system/password": loadSystem,
|
||||
"system/membership": loadSystem,
|
||||
"system/admin": loadSystem,
|
||||
"system/members": loadSystem,
|
||||
};
|
||||
|
||||
function isComplexPage(key) {
|
||||
@@ -4847,6 +4863,524 @@
|
||||
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 系统管理(恢复桌面端已有能力,禁止再走占位页) */
|
||||
|
||||
function isSystemPage(key) {
|
||||
return String(key || state.key || "").indexOf("system/") === 0;
|
||||
}
|
||||
|
||||
function systemForbiddenHtml() {
|
||||
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
|
||||
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
|
||||
"<p>仅管理员可访问</p>" +
|
||||
"<small>系统设置和会员管理需要管理员权限。</small>" +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
function systemFill(html) {
|
||||
const scroll = document.getElementById("m-scroll");
|
||||
if (scroll) scroll.innerHTML = html;
|
||||
}
|
||||
|
||||
function membershipDateLabel(value) {
|
||||
if (!value) return "";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
|
||||
}
|
||||
|
||||
function setupSystemPage(key) {
|
||||
state.key = key;
|
||||
state.requestedDate = todayString();
|
||||
state.sort = { key: "", dir: null };
|
||||
state.sortTable = { cols: null, reapply: null };
|
||||
state.detail = null;
|
||||
if (key === "system/admin") state.system.adminTab = "market";
|
||||
document.getElementById("m-view").classList.add("m-view-feature");
|
||||
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
|
||||
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
|
||||
}
|
||||
|
||||
function loadSystem() {
|
||||
const key = state.key;
|
||||
if ((key === "system/admin" || key === "system/members") && !global.MobileSession.isAdmin()) {
|
||||
systemFill(systemForbiddenHtml());
|
||||
return;
|
||||
}
|
||||
if (key === "system/password") {
|
||||
renderSystemPassword();
|
||||
return;
|
||||
}
|
||||
const seq = nextSeq();
|
||||
const url = (key === "system/admin" || key === "system/members") ? "/api/admin/settings" : "/api/account/status";
|
||||
global.MobileAPI.request(url).then(function (payload) {
|
||||
if (seq !== state.seq || state.key !== key) return;
|
||||
if (key === "system/admin" || key === "system/members") {
|
||||
state.system.admin = payload || {};
|
||||
renderSystemAdmin(key);
|
||||
} else {
|
||||
state.system.account = payload || {};
|
||||
if (key === "system/profile") renderSystemProfile();
|
||||
else renderSystemMembership();
|
||||
}
|
||||
}).catch(function (error) {
|
||||
if (seq !== state.seq || state.key !== key) return;
|
||||
systemFill(errorHtml(error && error.message ? error.message : "加载失败"));
|
||||
});
|
||||
}
|
||||
|
||||
function currentUsername() {
|
||||
const user = global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null;
|
||||
return user && user.username ? user.username : "当前账号";
|
||||
}
|
||||
|
||||
function renderSystemProfile() {
|
||||
const payload = state.system.account || {};
|
||||
const birth = payload.birth_profile || {};
|
||||
let birthDate = "";
|
||||
let birthTime = "";
|
||||
if (birth.birth_datetime) {
|
||||
const parts = String(birth.birth_datetime).split("T");
|
||||
birthDate = parts[0] || "";
|
||||
birthTime = (parts[1] || "").slice(0, 5);
|
||||
}
|
||||
const gender = birth.gender || "unspecified";
|
||||
const configured = Boolean(payload.birth_profile_configured);
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="profile">' +
|
||||
'<div class="m-card"><strong>' + escapeHtml(currentUsername()) + "</strong><p class=\"m-sys-lead\">出生信息仅对当前账号可见并加密保存。</p></div>" +
|
||||
'<p class="m-sys-lead">原始信息加密保存且不在观气页回显;智能解读只使用排盘后的派生结果。</p>' +
|
||||
formFieldHtml("出生日期", dateInputHtml("m-sys-birth-date", birthDate), true) +
|
||||
formFieldHtml("出生时间", '<input id="m-sys-birth-time" type="time" value="' + escapeHtml(birthTime) + '">', true) +
|
||||
formFieldHtml("性别", '<select id="m-sys-birth-gender">' +
|
||||
'<option value="unspecified"' + (gender === "unspecified" ? " selected" : "") + ">不指定</option>" +
|
||||
'<option value="male"' + (gender === "male" ? " selected" : "") + ">男</option>" +
|
||||
'<option value="female"' + (gender === "female" ? " selected" : "") + ">女</option>" +
|
||||
"</select>", false) +
|
||||
'<p class="m-sys-lead">资料状态:' + (configured ? "已加密保存" : "尚未设置") + "</p>" +
|
||||
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-birth' + (configured ? "" : " disabled") + ">删除资料</button>" +
|
||||
'<div class="m-card m-sys-account-actions">' +
|
||||
'<button class="m-btn-primary" type="button" data-system-switch>切换账号</button>' +
|
||||
'<button class="m-btn-primary m-btn-danger" type="button" data-system-logout>退出当前账号</button>' +
|
||||
"</div></div>";
|
||||
const page = document.querySelector(".m-page");
|
||||
if (page) {
|
||||
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
|
||||
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-birth">保存资料</button></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemPassword() {
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="password">' +
|
||||
'<p class="m-sys-lead">仅修改当前账号密码,不会保存在这台设备上。</p>' +
|
||||
formFieldHtml("当前密码", '<input id="m-sys-password-current" type="password" autocomplete="current-password">', true) +
|
||||
formFieldHtml("新密码", '<input id="m-sys-password-new" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) +
|
||||
formFieldHtml("确认新密码", '<input id="m-sys-password-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) +
|
||||
"</div>";
|
||||
const page = document.querySelector(".m-page");
|
||||
if (page) {
|
||||
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
|
||||
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-password">更新密码</button></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemMembership() {
|
||||
const payload = state.system.account || {};
|
||||
const access = payload.llm_access || {};
|
||||
const membership = access.membership || {};
|
||||
const badge = membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户";
|
||||
const stateLabel = membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通";
|
||||
const remaining = membership.subscribed && membership.expires_at
|
||||
? number(membership.remaining_days) + " 天"
|
||||
: (membership.is_admin || membership.subscribed ? "长期有效" : "--");
|
||||
const detail = membership.subscribed
|
||||
? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效"))
|
||||
: membership.is_admin
|
||||
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
|
||||
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。";
|
||||
const quota = "会员默认每日智能分析额度 " + number(access.daily_limit) + " 次,由管理员统一设置。";
|
||||
const usage = membership.active ? ("今日已用 " + number(access.used_today) + " 次") : "今日智能分析:--";
|
||||
const usageSummary = membership.active ? (number(access.used_today) + " / " + number(access.daily_limit)) : "--";
|
||||
const remainingCalls = membership.is_admin ? "不限" : membership.active ? (number(access.remaining_calls) + " 次") : "--";
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="membership">' +
|
||||
'<div class="m-card"><strong>' + escapeHtml(badge) + "</strong><p class=\"m-sys-lead\">" + escapeHtml(detail) + "</p><p class=\"m-sys-lead\">" + escapeHtml(quota) + "</p></div>" +
|
||||
'<div class="m-sys-grid">' +
|
||||
"<div><span>开通状态</span><strong>" + escapeHtml(stateLabel) + "</strong></div>" +
|
||||
"<div><span>剩余时长</span><strong>" + escapeHtml(remaining) + "</strong></div>" +
|
||||
"<div><span>今日智能分析</span><strong>" + escapeHtml(usageSummary) + "</strong></div>" +
|
||||
"<div><span>剩余智能分析</span><strong>" + escapeHtml(remainingCalls) + "</strong></div>" +
|
||||
"</div>" +
|
||||
'<div class="m-card"><p class="m-sys-lead">' + escapeHtml(usage) + "</p>" +
|
||||
'<p class="m-sys-lead">行情、搜索、自选与复盘:普通用户可用。智能选股、问师、问天、复盘助手:仅会员可用。</p></div>' +
|
||||
"</div>";
|
||||
systemFill(html);
|
||||
}
|
||||
|
||||
function adminTabHtml() {
|
||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
||||
return '<div class="m-source-tabs" role="tablist" aria-label="系统设置分类">' +
|
||||
'<button class="m-source-tab' + (tab === "market" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="market">行情管理</button>' +
|
||||
'<button class="m-source-tab' + (tab === "models" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="models">模型池</button>' +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
function renderSystemAdmin(key) {
|
||||
if (key === "system/members") {
|
||||
renderSystemMembers();
|
||||
return;
|
||||
}
|
||||
const payload = state.system.admin || {};
|
||||
const data = payload.data || {};
|
||||
const ifind = data.ifind || {};
|
||||
const llm = payload.llm || {};
|
||||
const status = "Tushare " + (data.configured ? "已配置" : "未配置") +
|
||||
" · iFinD " + (ifind.configured ? "已配置" : "未配置") +
|
||||
" · " + number(data.snapshot_dates) + " 个交易日";
|
||||
const refreshLabel = data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停";
|
||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
||||
const marketHtml =
|
||||
'<div class="m-form-body" data-system-admin-panel="market">' +
|
||||
'<div class="m-card"><strong>公共行情</strong><p class="m-sys-lead">' + escapeHtml(status) + "</p><p class=\"m-sys-lead\">" + escapeHtml(refreshLabel) + "</p></div>" +
|
||||
formFieldHtml("Tushare Token", '<input id="m-sys-token" type="password" autocomplete="off" minlength="20" placeholder="留空保留现有 Token">', false) +
|
||||
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token">', false) +
|
||||
formFieldHtml("交易时段后台刷新", '<input id="m-sys-bg-refresh" type="checkbox"' + (data.background_refresh_enabled ? " checked" : "") + ">", false) +
|
||||
'<p class="m-sys-lead">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>' +
|
||||
'<button class="m-btn-primary" type="button" data-system-refresh>立即后台刷新</button>' +
|
||||
'<div class="m-card"><strong>历史数据回补</strong></div>' +
|
||||
formFieldHtml("开始日期", dateInputHtml("m-sys-backfill-start", ""), false) +
|
||||
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
|
||||
'<button class="m-btn-primary" type="button" data-system-backfill>开始回补</button>' +
|
||||
"</div>";
|
||||
const modelsHtml =
|
||||
'<div class="m-form-body" data-system-admin-panel="models">' +
|
||||
formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) +
|
||||
formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) +
|
||||
'<div id="m-sys-model-list">' + renderModelPoolHtml(llm.models || []) + "</div>" +
|
||||
'<button class="m-btn-primary" type="button" data-system-add-model">添加模型</button>' +
|
||||
"</div>";
|
||||
const page = document.querySelector(".m-page");
|
||||
if (!page) return;
|
||||
const bar = tab === "models"
|
||||
? '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-models">保存模型池</button></div>'
|
||||
: '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-market">保存行情配置</button></div>';
|
||||
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>" + bar;
|
||||
if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
|
||||
}
|
||||
|
||||
function renderModelPoolHtml(models) {
|
||||
if (!models.length) {
|
||||
return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>';
|
||||
}
|
||||
return models.map(function (item, index) {
|
||||
return '<article class="m-card" data-model-id="' + escapeHtml(item.id) + '">' +
|
||||
"<strong>" + escapeHtml(item.name || ("模型 " + (index + 1))) + "</strong>" +
|
||||
'<p class="m-sys-lead">' + (item.configured ? "已保存密钥" : "待配置") + "</p>" +
|
||||
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) +
|
||||
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) +
|
||||
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) +
|
||||
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空保留已保存的 Key" : "输入 API Key") + '">', !item.configured) +
|
||||
'<button class="m-btn-primary" type="button" data-system-test-model>测试连接</button>' +
|
||||
'<p class="m-sys-lead" data-model-test-status>未测试</p>' +
|
||||
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-model>删除模型</button>' +
|
||||
"</article>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function collectSystemModelPool() {
|
||||
const models = (state.system.admin && state.system.admin.llm && state.system.admin.llm.models) || [];
|
||||
const saved = new Map(models.map(function (item) { return [item.id, item]; }));
|
||||
return Array.prototype.map.call(document.querySelectorAll("#m-sys-model-list [data-model-id]"), function (row) {
|
||||
function fieldValue(name) {
|
||||
const input = row.querySelector("[data-model-field='" + name + "']");
|
||||
return String(input && input.value != null ? input.value : "").trim();
|
||||
}
|
||||
return {
|
||||
id: row.dataset.modelId,
|
||||
name: fieldValue("name"),
|
||||
base_url: fieldValue("base_url"),
|
||||
model: fieldValue("model"),
|
||||
api_key: fieldValue("api_key"),
|
||||
configured: Boolean(saved.get(row.dataset.modelId) && saved.get(row.dataset.modelId).configured),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateSystemModelRoleOptions(primaryId, fallbackId) {
|
||||
const models = collectSystemModelPool();
|
||||
const options = models.map(function (item) {
|
||||
return '<option value="' + escapeHtml(item.id) + '">' + escapeHtml(item.name || item.model || "未命名模型") + "</option>";
|
||||
}).join("");
|
||||
const primary = document.getElementById("m-sys-primary-model");
|
||||
const fallback = document.getElementById("m-sys-fallback-model");
|
||||
if (!primary || !fallback) return;
|
||||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
||||
fallback.innerHTML = '<option value="">不启用辅助模型</option>' + options;
|
||||
const keepPrimary = models.some(function (item) { return item.id === primaryId; }) ? primaryId : (models[0] && models[0].id) || "";
|
||||
primary.value = keepPrimary;
|
||||
fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
|
||||
}
|
||||
|
||||
function renderSystemMembers() {
|
||||
const payload = state.system.admin || {};
|
||||
const membership = payload.membership || {};
|
||||
const users = payload.users || [];
|
||||
const userHtml = users.map(function (user) {
|
||||
const admin = user.role === "admin";
|
||||
const member = Boolean(user.membership_subscribed);
|
||||
const identity = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
|
||||
const expiry = member
|
||||
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
|
||||
: user.membership_status === "suspended"
|
||||
? "会员已停用"
|
||||
: "尚未开通";
|
||||
return '<article class="m-card" data-admin-user="' + number(user.id) + '">' +
|
||||
"<strong>" + escapeHtml(user.username) + "</strong>" +
|
||||
'<p class="m-sys-lead">' + escapeHtml(identity) + " · 今日调用 " + number(user.used_today) + "</p>" +
|
||||
'<p class="m-sys-lead">' + escapeHtml(expiry) + "</p>" +
|
||||
formFieldHtml("状态", '<select data-member-status>' +
|
||||
'<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" +
|
||||
'<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" +
|
||||
'<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" +
|
||||
"</select>", false) +
|
||||
formFieldHtml("开通 / 续期时长", '<select data-member-duration><option value="">选择时长</option>' +
|
||||
'<option value="1_month">1个月</option><option value="3_months">3个月</option>' +
|
||||
'<option value="12_months">12个月</option><option value="3_years">3年</option>' +
|
||||
'<option value="permanent">永久</option></select>', false) +
|
||||
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
|
||||
"</article>";
|
||||
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>';
|
||||
const html =
|
||||
'<div class="m-form-body" data-system-page="members">' +
|
||||
'<div class="m-card"><strong>会员调用额度</strong><p class="m-sys-lead">每日自动重置</p></div>' +
|
||||
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) +
|
||||
'<button class="m-btn-primary" type="button" data-system-save-limit>保存调用额度</button>' +
|
||||
'<div class="m-card"><strong>会员账号</strong><p class="m-sys-lead">手动开通与续期</p></div>' +
|
||||
userHtml +
|
||||
"</div>";
|
||||
systemFill(html);
|
||||
}
|
||||
|
||||
function saveSystemBirth() {
|
||||
const birthDate = (document.getElementById("m-sys-birth-date") || {}).value;
|
||||
const birthTime = (document.getElementById("m-sys-birth-time") || {}).value;
|
||||
if (!birthDate || !birthTime) {
|
||||
showToast("请填写完整出生日期和时间");
|
||||
return;
|
||||
}
|
||||
const button = document.querySelector("[data-system-save-birth]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/account/birth-profile", "POST", {
|
||||
birth_datetime: birthDate + "T" + birthTime,
|
||||
gender: (document.getElementById("m-sys-birth-gender") || {}).value || "unspecified",
|
||||
trade_date: todayString(),
|
||||
}).then(function () {
|
||||
showToast("个人命理资料已保存到当前账号");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "个人命理资料保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSystemBirth() {
|
||||
openConfirmSheet("删除资料", "确定删除当前账号保存的个人命理资料吗?", {
|
||||
danger: true,
|
||||
confirmLabel: "删除",
|
||||
onConfirm: function () {
|
||||
global.MobileAPI.request("/api/account/birth-profile", "DELETE").then(function () {
|
||||
closeSheet();
|
||||
showToast("个人命理资料已删除");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "删除失败");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemPassword() {
|
||||
const current = (document.getElementById("m-sys-password-current") || {}).value || "";
|
||||
const next = (document.getElementById("m-sys-password-new") || {}).value || "";
|
||||
const confirm = (document.getElementById("m-sys-password-confirm") || {}).value || "";
|
||||
const button = document.querySelector("[data-system-save-password]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/account/password", "POST", {
|
||||
current_password: current,
|
||||
new_password: next,
|
||||
confirm_password: confirm,
|
||||
}).then(function () {
|
||||
const formIds = ["m-sys-password-current", "m-sys-password-new", "m-sys-password-confirm"];
|
||||
formIds.forEach(function (id) {
|
||||
const input = document.getElementById(id);
|
||||
if (input) input.value = "";
|
||||
});
|
||||
showToast("密码已更新");
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "密码更新失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function switchSystemAccount() {
|
||||
global.location.assign("/login/");
|
||||
}
|
||||
|
||||
function logoutSystemAccount() {
|
||||
openConfirmSheet("退出当前账号", "退出后需要重新登录。本机已记录的其他账号仍可直接切换。", {
|
||||
danger: true,
|
||||
confirmLabel: "退出",
|
||||
onConfirm: function () {
|
||||
global.MobileSession.logout().then(function () {
|
||||
closeSheet();
|
||||
global.MobileRouter.replace("#/auth");
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "退出失败");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemMarket() {
|
||||
const button = document.querySelector("[data-system-save-market]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
||||
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
|
||||
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
|
||||
background_refresh_enabled: Boolean((document.getElementById("m-sys-bg-refresh") || {}).checked),
|
||||
}).then(function () {
|
||||
showToast("行情配置已保存");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "系统配置保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemModels() {
|
||||
const button = document.querySelector("[data-system-save-models]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
||||
models: collectSystemModelPool(),
|
||||
primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "",
|
||||
fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "",
|
||||
}).then(function () {
|
||||
showToast("模型池已保存");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "模型池保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function addSystemModel() {
|
||||
const models = collectSystemModelPool();
|
||||
const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
|
||||
models.push({ id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
|
||||
const list = document.getElementById("m-sys-model-list");
|
||||
if (list) list.innerHTML = renderModelPoolHtml(models);
|
||||
updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
|
||||
}
|
||||
|
||||
function deleteSystemModel(row) {
|
||||
if (!row) return;
|
||||
const id = row.dataset.modelId;
|
||||
const primary = (document.getElementById("m-sys-primary-model") || {}).value;
|
||||
const fallback = (document.getElementById("m-sys-fallback-model") || {}).value;
|
||||
if (id === primary || id === fallback) {
|
||||
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
|
||||
return;
|
||||
}
|
||||
const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
|
||||
const list = document.getElementById("m-sys-model-list");
|
||||
if (list) list.innerHTML = renderModelPoolHtml(models);
|
||||
updateSystemModelRoleOptions(primary, fallback);
|
||||
}
|
||||
|
||||
function testSystemModel(row) {
|
||||
if (!row) return;
|
||||
const status = row.querySelector("[data-model-test-status]");
|
||||
const button = row.querySelector("[data-system-test-model]");
|
||||
const profile = collectSystemModelPool().find(function (item) { return item.id === row.dataset.modelId; }) || {};
|
||||
if (button) button.disabled = true;
|
||||
if (status) status.textContent = "连接中";
|
||||
global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile: profile }).then(function (payload) {
|
||||
if (status) status.textContent = "已连通 · " + number(payload.result && payload.result.latency_ms) + " ms";
|
||||
}).catch(function (error) {
|
||||
if (status) status.textContent = error && error.message ? error.message : "测试失败";
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startSystemRefresh() {
|
||||
const button = document.querySelector("[data-system-refresh]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/refresh", "POST", { trade_date: todayString() }).then(function (payload) {
|
||||
showToast((payload && payload.message) || "后台刷新已提交");
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "后台刷新启动失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startSystemBackfill() {
|
||||
const button = document.querySelector("[data-system-backfill]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/backfill", "POST", {
|
||||
start_date: (document.getElementById("m-sys-backfill-start") || {}).value,
|
||||
end_date: (document.getElementById("m-sys-backfill-end") || {}).value,
|
||||
}).then(function (payload) {
|
||||
const count = payload && payload.results ? payload.results.length : 0;
|
||||
showToast("历史回补完成,共处理 " + count + " 个工作日");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "回补失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemMemberLimit() {
|
||||
const button = document.querySelector("[data-system-save-limit]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
||||
member_daily_limit: number((document.getElementById("m-sys-member-limit") || {}).value),
|
||||
}).then(function () {
|
||||
showToast("会员调用额度已保存");
|
||||
loadSystem();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "会员调用额度保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function saveSystemMember(card) {
|
||||
if (!card) return;
|
||||
const button = card.querySelector("[data-system-save-member]");
|
||||
if (button) button.disabled = true;
|
||||
global.MobileAPI.request("/api/admin/membership", "POST", {
|
||||
user_id: card.dataset.adminUser,
|
||||
status: (card.querySelector("[data-member-status]") || {}).value,
|
||||
duration: (card.querySelector("[data-member-duration]") || {}).value,
|
||||
}).then(function (payload) {
|
||||
if (state.system.admin) state.system.admin.users = payload.users || [];
|
||||
showToast("会员状态已更新");
|
||||
renderSystemMembers();
|
||||
}).catch(function (error) {
|
||||
showToast(error && error.message ? error.message : "会员状态保存失败");
|
||||
}).then(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- events */
|
||||
|
||||
function bindEvents() {
|
||||
@@ -4976,6 +5510,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const systemAdminTab = event.target.closest("[data-system-admin-tab]");
|
||||
if (systemAdminTab) {
|
||||
state.system.adminTab = systemAdminTab.dataset.systemAdminTab === "models" ? "models" : "market";
|
||||
renderSystemAdmin("system/admin");
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
|
||||
if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
|
||||
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
||||
if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; }
|
||||
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
||||
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
|
||||
if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
|
||||
if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
|
||||
const deleteModel = event.target.closest("[data-system-delete-model]");
|
||||
if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; }
|
||||
const testModel = event.target.closest("[data-system-test-model]");
|
||||
if (testModel) { testSystemModel(testModel.closest("[data-model-id]")); return; }
|
||||
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
|
||||
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
|
||||
if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; }
|
||||
const saveMember = event.target.closest("[data-system-save-member]");
|
||||
if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
|
||||
|
||||
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
||||
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
|
||||
if (reviewAddTrade) { openForm("trade"); return; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
|
||||
// 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复。
|
||||
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 5 页,
|
||||
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
|
||||
|
||||
const EMPTY_DASHBOARD = {
|
||||
@@ -138,6 +138,31 @@ async function mockMobileApi(page, options = {}) {
|
||||
payload = { items: [] };
|
||||
} else if (path === "/api/search") {
|
||||
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
|
||||
} else if (path === "/api/account/status") {
|
||||
payload = {
|
||||
birth_profile_configured: true,
|
||||
birth_profile: { birth_datetime: "1990-01-15T08:30", gender: "male" },
|
||||
llm_access: {
|
||||
daily_limit: 50,
|
||||
used_today: 3,
|
||||
remaining_calls: 47,
|
||||
membership: {
|
||||
active: true,
|
||||
subscribed: true,
|
||||
is_admin: auth.user.role === "admin",
|
||||
remaining_days: 20,
|
||||
expires_at: "2026-09-18",
|
||||
plan: "会员",
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (path === "/api/admin/settings") {
|
||||
payload = {
|
||||
data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } },
|
||||
llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" },
|
||||
membership: { member_daily_limit: 50 },
|
||||
users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }],
|
||||
};
|
||||
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
|
||||
payload = {
|
||||
meta: { trade_date: "2026-07-22", intraday_status: "available" },
|
||||
@@ -230,6 +255,16 @@ const REVIEW_PAGES = [
|
||||
["review/alerts", "提醒中心"],
|
||||
];
|
||||
|
||||
const SYSTEM_PAGES = [
|
||||
["system/profile", "账号资料"],
|
||||
["system/password", "修改密码"],
|
||||
["system/membership", "会员状态"],
|
||||
["system/admin", "系统设置"],
|
||||
["system/members", "会员管理"],
|
||||
];
|
||||
|
||||
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
||||
|
||||
test("mobile login renders before authentication", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.route("**/api/**", async (route) => {
|
||||
@@ -296,6 +331,42 @@ test("review five pages render watchlist, trades, daily, notes and alerts", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("system management pages render real content instead of placeholders", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
for (const [key, label] of SYSTEM_PAGES) {
|
||||
await navigateToFeature(page, key);
|
||||
await expect(page.locator("#m-title")).toHaveText(label);
|
||||
await expect(page.locator(".m-placeholder")).toHaveCount(0);
|
||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||
await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible();
|
||||
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await navigateToFeature(page, "system/profile");
|
||||
await expect(page.locator("#m-sys-birth-date")).toBeVisible();
|
||||
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
||||
await navigateToFeature(page, "system/password");
|
||||
await expect(page.locator("#m-sys-password-current")).toBeVisible();
|
||||
await navigateToFeature(page, "system/membership");
|
||||
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
||||
await navigateToFeature(page, "system/admin");
|
||||
await expect(page.locator("#m-sys-token")).toBeVisible();
|
||||
await navigateToFeature(page, "system/members");
|
||||
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
|
||||
});
|
||||
|
||||
test("non-admin cannot open system admin pages as placeholders", async ({ page }) => {
|
||||
await mockMobileApi(page, { auth: authSession("user", true) });
|
||||
await openMobile(page);
|
||||
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
||||
await expect(page.locator(".m-hub-grid")).toBeVisible();
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
||||
await expect(page.locator('.m-grid-item[data-route="#/feature/system/members"]')).toHaveCount(0);
|
||||
await navigateToFeature(page, "system/admin");
|
||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||
await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
|
||||
});
|
||||
|
||||
test("assistant chat renders with presets and input", async ({ page }) => {
|
||||
await mockMobileApi(page);
|
||||
await openMobile(page);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NAV = ROOT / "frontend/m/config/nav.config.js"
|
||||
PAGES = ROOT / "frontend/m/js/pages.js"
|
||||
ROUTER = ROOT / "frontend/m/js/router.js"
|
||||
|
||||
|
||||
class MobileSystemPagesRegressionTests(unittest.TestCase):
|
||||
"""Prevent mobile system-management entries from falling back to placeholders."""
|
||||
|
||||
def test_nav_system_entries_are_registered_as_real_pages(self) -> None:
|
||||
nav = NAV.read_text(encoding="utf-8")
|
||||
pages = PAGES.read_text(encoding="utf-8")
|
||||
keys = re.findall(r'key:\s*"(system/[^"]+)"', nav)
|
||||
self.assertEqual(
|
||||
keys,
|
||||
[
|
||||
"system/profile",
|
||||
"system/password",
|
||||
"system/membership",
|
||||
"system/admin",
|
||||
"system/members",
|
||||
],
|
||||
)
|
||||
for key in keys:
|
||||
self.assertIn(f'"{key}": setupSystemPage', pages)
|
||||
self.assertIn(f'"{key}": loadSystem', pages)
|
||||
|
||||
def test_placeholder_copy_is_only_a_router_fallback(self) -> None:
|
||||
router = ROUTER.read_text(encoding="utf-8")
|
||||
pages = PAGES.read_text(encoding="utf-8")
|
||||
self.assertIn("该功能页将在后续批次实现", router)
|
||||
self.assertNotIn("该功能页将在后续批次实现", pages)
|
||||
self.assertIn("function setupSystemPage", pages)
|
||||
self.assertIn("function loadSystem", pages)
|
||||
|
||||
def test_system_pages_render_real_controls_not_stubs(self) -> None:
|
||||
pages = PAGES.read_text(encoding="utf-8")
|
||||
for marker in (
|
||||
'data-system-page="profile"',
|
||||
'data-system-page="password"',
|
||||
'data-system-page="membership"',
|
||||
'data-system-page="members"',
|
||||
'data-system-page="forbidden"',
|
||||
'data-system-admin-panel="market"',
|
||||
"m-sys-birth-date",
|
||||
"m-sys-password-current",
|
||||
"m-sys-token",
|
||||
"m-sys-member-limit",
|
||||
"data-system-switch",
|
||||
'location.assign("/login/")',
|
||||
):
|
||||
self.assertIn(marker, pages)
|
||||
Reference in New Issue
Block a user