Files
dsh_shenxian/poc/portal-entry/lib/client.js
T
admin 3efd68517f chore: 并入已删除会话的在途成果(防丢失;原会话已删,未做功能验收)
**背景**:这些改动原属本工作区另外几个会话(T01/T02 等),**那些会话已被用户删除** ⇒
工作树里的成果处于"无主"状态,一次错误 checkout / 覆盖即**永久丢失** ⇒ 代入库保全。
口径遵循本项目**先例**(`39a1f2e` / `b617cdb`:**别人的活,代入库并在提交信息里注明**)。

**内容**:档案 101「能力管理」改名 + 页内 tab 分页|档案 102 语言切换搬入「用户设置」|
`07-实例UI分区登记表.md`|`scripts/find-ui*.mjs`(UI 元素定位工具)|`poc/portal-entry/`(0.5.3)|
`src/web/locale-pref.ts` + `home-files.ts`(语言偏好持久化)|`test/locale-pref.test.mjs`|
`package.json`|`BRIEF.md` / `INDEX.md` / `docs-manifest.json` / `03-路线图与待办.md` / 档案 100 增量。

**已做最小健全性检查**(⚠️ **未跑完整构建 / 单测** —— 那是原会话的验收职责,本次只求"不丢"):
- JSON 合法:`package.json` / `poc/business-plugins/package.json` / `docs-manifest.json` ✓
- 4 个 TS 文件 `{}`/`()` 配平 ✓;新增文件均非空 ✓
- 规模:13 文件改动 +340/−210,新增 12 条

**未 push**(按 §4 提交边界:用户说"提交",未说"推送")。
2026-09-15 21:17:12 +08:00

330 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @dsh-local/portal-entry — client half (dsh 0.1.2-rc.1, v0.5.0)
//
// dsh client bundles are executed as plain scripts that register a factory
// with window.__ModuleLoader__; materialization (factory(require) -> exports)
// happens lazily on first import by the browser module system. The require
// handed to the factory resolves platform seed words (react/jsx-runtime) and
// graph rows (other dsh.client packages). Mirror the shipped shape of official
// client bundles exactly (see @deepseek-ai/dsh-client-ui-brand-official
// lib/client.js): CJS-style factory, named `apply` export.
//
// v0.3.x saga: footer.action (kind:list) registrations were served with the
// correct bundle yet never rendered by the sidebar shell (footerActions stayed
// empty even for the official cordis-panel entry) — the slot appears unused by
// the 0.1.2-rc.1 sidebar consumer. Abandoned.
//
// v0.4.0: the SETTINGS panel, in contrast, is proven: official sections
// (general / models / plugins) register into `settings.section` (kind:list,
// scope root, declared by dsh-client-ui-settings-general's SettingsRoot) and
// DO render in the left nav.
//
// v0.4.1: BROWSER-VERIFIED root cause for the section never rendering despite
// `[portal-entry] apply RUN`: the extra `exports.default = apply` made the
// loader's ESM/CJS interop pick the bare `apply` FUNCTION as the plugin body
// (function form has no inject declaration site), so ctx.slots threw
// `cannot get property "slots" without inject`. Official bundles export ONLY
// `apply` + `inject` — no `default`. R3 red line: never reintroduce it.
//
// v0.4.2 / v0.4.3: the settings panel runs on the DARK theme, where
// `--dsw-alias-label-primary` AND `--dsw-alias-button-elevated-fill` both
// resolve to #f9fafb, so `var(--name, <fallback>)` always took the variable
// value (light text on light background -> invisible). v0.4.3 HARDCODES the
// colors instead of relying on theme variables. Keep that: do not reintroduce
// var() for contrast-critical props.
//
// v0.5.0 (2026-09-11):
// · section renamed 「平台管理」→「用户管理」(id `platform` →
// `user-management`) and moved BELOW 「功能插件」: order 100 → 102
// (business-plugins registers the feature-plugins section at order 101;
// official sections use models=10 / plugins=15, so 102 is last).
// · the section is now shown to EVERY user, not only admins — therefore the
// platform must install this bundle into every profile (see
// scripts/ensure-portal-entry.cjs + /usr/local/bin/provision-new-users.sh).
// An admin-only install would leave ordinary users with no logout entry.
// · content is role-aware: admins see the portal origin + 「打开管理台」;
// non-admins see 「退出登录」 only. Role comes from the portal's
// `GET /api/auth/me` (requireAuth) over the shared `.alotbuy.com` session
// cookie with credentials:"include" — the same cross-subdomain mechanism
// business-plugins already uses for /api/plugins/mine (portal CORS allows
// baseDomain + subdomains). On ANY failure we fall back to the non-admin
// view: fail-closed, never advertise a management page the viewer cannot
// open.
// v0.5.3 (2026-09-15 · 用户要求): 「界面语言」切换**搬进本区**(用户设置),同时撤掉
// business-plugins 的「偏好设置」分区 —— 语言是"用户级偏好",与账号 / 退出登录同属本区,
// 单独再开一个分区就是重复入口。实现走官方扩展点(零官方改动):
// `ctx.locale.getLocale()` 读、`ctx.locale.setLocale(id)` 切、`ctx.locale.register(NS,{zh,en})`
// 提供本行自己的双语文案 ⇒ `inject` 相应加上 `"locale"`。
// ⚠️ 颜色沿用本文件硬编码:本区渲染在 DARK 主题(见 v0.4.2/v0.4.3 的结论)。
window.__ModuleLoader__.load({
id: "@dsh-local/portal-entry",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var jsxRuntime = require("react/jsx-runtime");
var React = require("react");
/**
* 宿主 ctx(在 `apply(ctx)` 里赋值)—— 组件内要读官方 locale 服务,而组件是
* 顶层函数、拿不到 apply 的入参,所以存在这个闭包变量里。
*/
var hostCtx = null;
/** 官方 `dsh-client-locale` 的 LOCALE_IDS(`en` / `zh`);`en` = 官方 FALLBACK。 */
var LOCALES = [{ id: "en", name: "English" }, { id: "zh", name: "中文" }];
/** 本 bundle 自己的词典(走官方 locale 扩展点,零官方改动)。 */
var PE_NS = "@dsh-local/portal-entry";
var PE_DICT = {
zh: { lang: "界面语言", langHint: "切换后立即生效" },
en: { lang: "Interface language", langHint: "Applies immediately" }
};
/** 当前界面语言:优先问官方 locale;拿不到就退回官方 FALLBACK `en`。 */
function currentLocale() {
try {
if (hostCtx && hostCtx.locale && typeof hostCtx.locale.getLocale === "function") {
var id = hostCtx.locale.getLocale();
if (id) return String(id).split("-")[0];
}
} catch (e) { /* 官方 API 变动 ⇒ 只影响本行展示,不让整个分区崩掉 */ }
return "en";
}
/** 切语言:官方 API 立即生效 **+** 让平台把选择写进 `settings.yaml`(跨页面 / 跨重启保留)。 */
function pickLocale(id) {
if (!id || id === currentLocale()) return;
try {
if (hostCtx && hostCtx.locale && typeof hostCtx.locale.setLocale === "function") {
hostCtx.locale.setLocale(id);
} else {
console.warn("[portal-entry] ctx.locale.setLocale 不可用,语言未切换");
}
} catch (e) {
console.warn("[portal-entry] setLocale 失败:", e);
}
// ⚠️ 官方 `dsh-client-locale` **只对 loopback 页面**持久化;平台是"浏览器经域名访问远程
// 实例" ⇒ 非 loopback ⇒ 官方只为当前进程保留选择,刷新即回默认英语。
// 这里让平台**替官方把它自己的设置写进它自己的文件**(`locale.preference`)——
// 官方语义不破(启动时读自己的设置即生效),用户的选择也不会丢(档案 102 §六)。
// 持久化失败**不影响本次切换**(就地已生效)。
try {
fetch(portalHost() + "/api/me/locale", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: id })
}).catch(function (e) { console.warn("[portal-entry] 语言偏好持久化失败:", e); });
} catch (e) {
console.warn("[portal-entry] 语言偏好持久化调用失败:", e);
}
}
/** 词典取值:官方 locale 优先(会随语言切换),否则退回自带字典。 */
function tPe(key) {
try {
if (hostCtx && hostCtx.locale && typeof hostCtx.locale.bind === "function") {
var v = hostCtx.locale.bind(PE_NS)(key);
if (v && v !== key) return v;
}
} catch (e) {}
var d = PE_DICT[currentLocale()] || PE_DICT.en;
return d[key] || key;
}
// Portal origin + management page on the registered site host minus the
// per-user subdomain label (admin.alotbuy.com -> alotbuy.com).
function portalHost() {
try {
var labels = window.location.hostname.split(".");
if (labels.length > 2) {
return window.location.protocol + "//" + labels.slice(1).join(".");
}
} catch (e) {}
return window.location.origin;
}
function openManagement(event) {
if (event) event.preventDefault();
window.open(portalHost() + "/desktop.html", "_blank", "noopener");
}
// 退出登录:整页跳转到门户同源 /logout(清 sid cookie + 删 session → 302 登录页)。
// 必须整页跳转而非 fetch:浏览器侧跨子域调用门户 API 会被 CORS 拦截。
function doLogout(event) {
if (event) event.preventDefault();
try {
window.location.href = portalHost() + "/logout";
} catch (e) {
window.location.href = "/logout";
}
}
function rowButton(key, style, children, onClick) {
var base = {
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 14px",
borderRadius: "8px",
border: "1px solid #43464d",
background: "transparent",
color: "#f9fafb",
cursor: "pointer",
fontSize: "13px"
};
var merged = Object.assign(base, style || {});
// Always force contrast-critical props after caller override so
// any inherited theme var(--...) that the caller passed cannot
// collapse text/background to the same value again (v0.4.3).
if (merged.color == null || /var\(/i.test(String(merged.color))) merged.color = "#f9fafb";
if (merged.background == null || /var\(/i.test(String(merged.background))) merged.background = "transparent";
return jsxRuntime.jsx(
"button",
{
key: key,
type: "button",
onClick: onClick,
style: merged,
children: [children]
}
);
}
// Settings section content: user-management card.
// role: null = still probing, "admin" = platform admin, "user" = anyone else.
function UserManagementSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var state = useState(null);
var role = state[0];
var setRole = state[1];
var meState = useState(null);
var me = meState[0];
var setMe = meState[1];
/** 只用来在「切语言」后强制重渲染本行 —— 官方 locale 的通知不会打到这里。 */
var tickState = useState(0);
var setTick = tickState[1];
useEffect(function () {
var alive = true;
// 跨子域读门户会话(共享 .alotbuy.com cookie)。失败一律按非管理员渲染。
fetch(portalHost() + "/api/auth/me", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!alive) return;
var r = d && d.user && d.user.role;
setMe((d && d.user) || null);
setRole(r === "admin" ? "admin" : "user");
})
.catch(function () { if (alive) setRole("user"); });
return function () { alive = false; };
}, []);
var isAdmin = role === "admin";
var rows = [];
if (isAdmin) {
rows.push(jsxRuntime.jsx("div", {
key: "origin",
style: { fontSize: "13px", lineHeight: "20px", color: "#c9cdd4" },
children: "账号:" + (me && me.username ? me.username : "—") + " 角色:" + (isAdmin ? "管理员" : "普通用户")
}));
}
// ── 界面语言(v0.5.3 · 2026-09-15 用户要求:语言切换从 business-plugins 的
// 「偏好设置」分区**搬到本区**,并撤掉那个分区)────────────────────────────
// 走官方扩展点(**零官方改动**):`ctx.locale.getLocale()` 读、`setLocale(id)` 切。
// ⚠️ 本分区渲染在 **DARK 主题** 下 ⇒ 颜色沿用本文件既有硬编码(v0.4.3 结论:
// 这里不能依赖 `--dsw-*`,其 fallback 会让文字与底色同色而看不见)。
rows.push(jsxRuntime.jsxs("div", {
key: "lang",
style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" },
children: [
jsxRuntime.jsx("span", {
key: "lb",
style: { fontSize: "13px", lineHeight: "20px", color: "#c9cdd4" },
children: tPe("lang")
}),
jsxRuntime.jsx("select", {
key: "sel",
"aria-label": tPe("lang"),
value: currentLocale(),
onChange: function (e) {
pickLocale(e.target.value);
// 官方 locale 的通知不会重渲染本组件 ⇒ 自己 bump 一次,让 select 立刻显示新值。
if (typeof setTick === "function") setTick(function (n) { return n + 1; });
},
style: {
minWidth: "140px", padding: "6px 8px", borderRadius: "6px", cursor: "pointer",
border: "1px solid #43464d", background: "#1f2127", color: "#f9fafb", fontSize: "13px"
},
children: LOCALES.map(function (l) {
return jsxRuntime.jsx("option", { value: l.id, children: l.name }, l.id);
})
}),
jsxRuntime.jsx("span", {
key: "hint",
style: { fontSize: "12px", lineHeight: "18px", color: "#8b8f99" },
children: tPe("langHint")
})
]
}));
// 2026-09-13(用户要求):**去掉「打开管理台」入口** —— 平台管理已就地化到
// 「平台管理」分区(见 @dsh-local/business-plugins),本区不再跳转门户。
var actions = [];
actions.push(rowButton("logout", { color: "#ff4d4f", borderColor: "#ff4d4f" }, "退出登录", doLogout));
rows.push(jsxRuntime.jsx("div", {
key: "actions",
style: { display: "flex", gap: "8px", flexWrap: "wrap" },
children: actions
}));
return jsxRuntime.jsx("div", {
style: {
display: "flex",
flexDirection: "column",
gap: "12px",
padding: "4px 2px",
color: "#f9fafb"
},
children: rows
});
}
/** Services required by this client surface: the UI slot registry. */
const inject = ["slots", "locale"];
/** Client-side apply: add the 用户管理 section to the settings panel. */
function apply(ctx) {
try {
console.log("[portal-entry] apply RUN");
hostCtx = ctx;
// 词典注册(官方扩展点)。失败不影响分区本身。
try {
ctx.locale.register(PE_NS, PE_DICT);
} catch (e) {
console.warn("[portal-entry] locale.register 失败(本行文案退回自带字典):", e);
}
ctx.slots.inject("settings.section", () => ctx.slots.register(
{
name: "settings.section",
id: "user-settings",
order: 103,
label: () => "\u7528\u6237\u8bbe\u7f6e"
},
UserManagementSection
));
} catch (err) {
try {
console.warn("portal-entry: settings.section registration failed", err);
} catch (e) {}
}
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});
;