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 提交边界:用户说"提交",未说"推送")。
This commit is contained in:
1 parent
03c8363960
commit
3efd68517f
27 files changed
+1712
-206
No files matched your search
@@ -0,0 +1,329 @@
|
||||
// @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;
|
||||
}
|
||||
});
|
||||
;
|
||||
@@ -0,0 +1,101 @@
|
||||
// @dsh-local/portal-entry — PoC host plugin for the dsh web profile.
|
||||
//
|
||||
// Goal: prove the official profile-plugin mechanism on dsh 0.1.2-rc.1
|
||||
// (install -> load -> persist across restart -> remove), with zero risk to
|
||||
// the running profile: every side effect is guarded, nothing can throw out
|
||||
// of apply().
|
||||
//
|
||||
// v1 evidence channel: an append-only marker file
|
||||
// $DSH_HOME/.dsh-poc-portal-entry.log
|
||||
// written when the row is instantiated. Tool registration (defineTool from
|
||||
// @deepseek-ai/dsh-tools) is attempted defensively; failure is logged, never
|
||||
// fatal.
|
||||
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MARKER = ".dsh-poc-portal-entry.log";
|
||||
const PORTAL_ROOT = process.env.DSH_SERVER_LOGIN_URL ?? "http://127.0.0.1:3080";
|
||||
|
||||
/** Append one line to the marker file. Never throws. */
|
||||
function log(line) {
|
||||
try {
|
||||
const home = process.env.DSH_HOME || process.env.HOME || ".";
|
||||
appendFileSync(join(home, MARKER), `${new Date().toISOString()} ${line}\n`);
|
||||
} catch {
|
||||
// marker writes must never take the profile down
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the portal_ping tool definition (lazy import of defineTool). */
|
||||
async function portalPingDefinition() {
|
||||
const { defineTool } = await import("@deepseek-ai/dsh-tools");
|
||||
return defineTool({
|
||||
name: "portal_ping",
|
||||
description:
|
||||
"PoC tool: probe the dsh-server-login portal reachable from this instance. Returns HTTP status and body size of the portal root.",
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
ok: { type: "boolean", required: true },
|
||||
status: { type: "number" },
|
||||
bytes: { type: "number" },
|
||||
note: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
isConcurrencySafe: () => true,
|
||||
async execute() {
|
||||
try {
|
||||
const res = await fetch(PORTAL_ROOT, { redirect: "manual" });
|
||||
const body = await res.arrayBuffer();
|
||||
return {
|
||||
ok: true,
|
||||
status: res.status,
|
||||
bytes: body.byteLength,
|
||||
note: `portal root reachable from instance (${PORTAL_ROOT})`
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, note: `portal unreachable: ${String(err?.message ?? err)}` };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Cordis-style plugin apply. Row declares inject:[tools], so ctx.tools is
|
||||
* available; every failure is still contained and logged. */
|
||||
export function apply(ctx) {
|
||||
log(`apply pid=${process.pid} dshHome=${process.env.DSH_HOME ?? ""} home=${process.env.HOME ?? ""}`);
|
||||
|
||||
const register = () => {
|
||||
try {
|
||||
if (!ctx.tools || typeof ctx.tools.register !== "function") {
|
||||
log("tools service unavailable at apply time");
|
||||
return;
|
||||
}
|
||||
portalPingDefinition()
|
||||
.then((def) => {
|
||||
try {
|
||||
ctx.tools.register(def);
|
||||
log("tool portal_ping registered");
|
||||
} catch (err) {
|
||||
log(`tool register threw: ${String(err?.message ?? err)}`);
|
||||
}
|
||||
})
|
||||
.catch((err) => log(`tool build failed: ${String(err?.message ?? err)}`));
|
||||
} catch (err) {
|
||||
log(`tools access failed: ${String(err?.message ?? err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
register();
|
||||
} catch (err) {
|
||||
log(`register wrapper failed: ${String(err?.message ?? err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default apply;
|
||||
Reference in new issue
Block a user