feat(HEL-238): 按确认样图重做手机端系统管理,并加入部署基线门禁
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
2a2d205a38
commit
541fb48c1c
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Executable
+126
@@ -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} 的祖先"
|
||||
Reference in New Issue
Block a user