agent-loft 0.2.22 → 0.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/bin/cli.js +4 -2
- package/package.json +1 -1
- package/src/agents/claude/session.ts +1 -1
- package/src/agents/codex/session.ts +1 -0
- package/src/channels/feishu/bot.ts +22 -8
- package/src/channels/web/access.ts +26 -0
- package/src/channels/web/api.ts +176 -42
- package/src/channels/web/auth.ts +92 -38
- package/src/channels/web/push.ts +30 -8
- package/src/channels/web/server.ts +50 -17
- package/src/cli/doctor.ts +2 -1
- package/src/cli/passwd.ts +24 -7
- package/src/cli/user.ts +99 -0
- package/src/core/hub.ts +49 -8
- package/src/core/store.ts +33 -0
- package/src/core/types.ts +5 -0
- package/src/core/uploads.ts +12 -0
- package/src/core/users.ts +261 -0
- package/web/app.js +311 -14
- package/web/style.css +28 -2
package/web/app.js
CHANGED
|
@@ -255,8 +255,12 @@ const S = {
|
|
|
255
255
|
allowedAgents: [],
|
|
256
256
|
search: "",
|
|
257
257
|
loadToken: 0,
|
|
258
|
+
/** 登录的用户 { id, name, role } */
|
|
259
|
+
user: null,
|
|
258
260
|
};
|
|
259
261
|
|
|
262
|
+
const isAdmin = () => S.user?.role === "admin";
|
|
263
|
+
|
|
260
264
|
const ui = {};
|
|
261
265
|
|
|
262
266
|
/**
|
|
@@ -269,7 +273,7 @@ const desktopQuota = typeof desktop?.onQuota === "function";
|
|
|
269
273
|
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
|
|
270
274
|
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
271
275
|
/**
|
|
272
|
-
*
|
|
276
|
+
* 新版桌面端顶栏的「更多」里能打开每台服务器的设置:侧栏底部的设置按钮就不显示了(额度照常显示,数据由桌面端推)。
|
|
273
277
|
* 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
|
|
274
278
|
*/
|
|
275
279
|
const desktopSettings = typeof desktop?.onOpenSettings === "function";
|
|
@@ -509,6 +513,14 @@ async function showAuth() {
|
|
|
509
513
|
const err = h("div", { class: "error-text" });
|
|
510
514
|
const pw = h("input", { type: "password", placeholder: "密码", autocomplete: me.needsSetup ? "new-password" : "current-password" });
|
|
511
515
|
const token = h("input", { placeholder: "设置码(在启动日志里)", autocomplete: "off" });
|
|
516
|
+
// 只有一个用户时和以前一样只要密码;多个用户才要填用户名
|
|
517
|
+
const askName = me.needsSetup || me.multiUser;
|
|
518
|
+
const name = h("input", {
|
|
519
|
+
placeholder: me.needsSetup ? "admin" : "用户名",
|
|
520
|
+
autocomplete: "username",
|
|
521
|
+
autocapitalize: "off",
|
|
522
|
+
spellcheck: "false",
|
|
523
|
+
});
|
|
512
524
|
const btn = h("button", { class: "btn primary", type: "submit" }, me.needsSetup ? "设置密码并进入" : "登录");
|
|
513
525
|
const form = h(
|
|
514
526
|
"form",
|
|
@@ -519,8 +531,11 @@ async function showAuth() {
|
|
|
519
531
|
err.textContent = "";
|
|
520
532
|
btn.disabled = true;
|
|
521
533
|
try {
|
|
522
|
-
if (me.needsSetup)
|
|
523
|
-
|
|
534
|
+
if (me.needsSetup) {
|
|
535
|
+
await api("/api/setup", { method: "POST", body: { token: token.value, name: name.value.trim() || "admin", password: pw.value } });
|
|
536
|
+
} else {
|
|
537
|
+
await api("/api/login", { method: "POST", body: { ...(askName ? { name: name.value.trim() } : {}), password: pw.value } });
|
|
538
|
+
}
|
|
524
539
|
await boot();
|
|
525
540
|
} catch (e2) {
|
|
526
541
|
err.textContent = e2.message;
|
|
@@ -530,14 +545,15 @@ async function showAuth() {
|
|
|
530
545
|
},
|
|
531
546
|
},
|
|
532
547
|
h("h1", {}, "Agent Loft"),
|
|
533
|
-
h("p", {}, me.needsSetup ? "
|
|
548
|
+
h("p", {}, me.needsSetup ? "首次使用:输入启动日志里的设置码,建一个管理员账号(密码至少 8 位)" : "登录以继续"),
|
|
534
549
|
me.needsSetup ? h("label", { class: "field" }, h("span", {}, "设置码"), token) : null,
|
|
550
|
+
askName ? h("label", { class: "field" }, h("span", {}, me.needsSetup ? "管理员用户名" : "用户名"), name) : null,
|
|
535
551
|
h("label", { class: "field" }, h("span", {}, me.needsSetup ? "新密码" : "密码"), pw),
|
|
536
552
|
btn,
|
|
537
553
|
err,
|
|
538
554
|
);
|
|
539
555
|
fill($app, h("div", { class: "auth" }, form));
|
|
540
|
-
(me.needsSetup ? token : pw).focus();
|
|
556
|
+
(me.needsSetup ? token : askName ? name : pw).focus();
|
|
541
557
|
}
|
|
542
558
|
|
|
543
559
|
// ---------- 主界面 ----------
|
|
@@ -549,6 +565,7 @@ async function boot() {
|
|
|
549
565
|
WS.epoch = null;
|
|
550
566
|
WS.hellos = 0;
|
|
551
567
|
const data = await api("/api/bootstrap");
|
|
568
|
+
S.user = data.user ?? me.user ?? null;
|
|
552
569
|
S.agents = data.agents;
|
|
553
570
|
S.bots = data.bots;
|
|
554
571
|
S.workspaceRoot = data.workspaceRoot;
|
|
@@ -581,6 +598,7 @@ async function boot() {
|
|
|
581
598
|
if ("serviceWorker" in navigator) {
|
|
582
599
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
583
600
|
clearAppBadge();
|
|
601
|
+
void releaseForeignPushSub().catch(() => {});
|
|
584
602
|
// 点了系统通知、页面本来就开着:跳到通知对应的会话
|
|
585
603
|
navigator.serviceWorker.addEventListener("message", (e) => {
|
|
586
604
|
if (e.data?.type !== "open") return;
|
|
@@ -615,6 +633,21 @@ function renderLayout() {
|
|
|
615
633
|
showMenu({ x: e.clientX, y: e.clientY }, [{ label: "历史会话…", onclick: () => openBotConversations(bot) }]);
|
|
616
634
|
return;
|
|
617
635
|
}
|
|
636
|
+
// 项目目录那一行:调整顺序(触屏上拖不动,靠这个菜单)
|
|
637
|
+
const dir = e.target.closest?.("[data-dir]")?.dataset.dir;
|
|
638
|
+
if (dir) {
|
|
639
|
+
const i = projDirs.indexOf(dir);
|
|
640
|
+
const moves = [
|
|
641
|
+
...(i > 0 ? [{ label: "置顶", onclick: () => moveProjectDir(dir, null) }, { label: "上移", onclick: () => moveProjectDir(dir, projDirs[i - 1]) }] : []),
|
|
642
|
+
...(i >= 0 && i < projDirs.length - 1 ? [{ label: "下移", onclick: () => moveProjectDir(dir, projDirs[i + 1], true) }] : []),
|
|
643
|
+
];
|
|
644
|
+
const reset = sideState.order.length ? [{ label: "恢复按最近活跃排序", onclick: resetProjectOrder }] : [];
|
|
645
|
+
const items = moves.length && reset.length ? [...moves, "sep", ...reset] : [...moves, ...reset];
|
|
646
|
+
if (!items.length) return;
|
|
647
|
+
e.preventDefault();
|
|
648
|
+
showMenu({ x: e.clientX, y: e.clientY }, items);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
618
651
|
const key = e.target.closest?.("[data-key]")?.dataset.key;
|
|
619
652
|
if (!key || !S.convs.has(key)) return;
|
|
620
653
|
e.preventDefault();
|
|
@@ -979,7 +1012,43 @@ function agentShort(id) {
|
|
|
979
1012
|
}
|
|
980
1013
|
|
|
981
1014
|
/** 侧栏里展开 / 收起的状态:哪些项目目录展开了、「最近」和各目录是否显示全部 */
|
|
982
|
-
const sideState = { open: [], more: [], folded: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
|
|
1015
|
+
const sideState = { open: [], more: [], folded: [], order: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* 「项目」目录的顺序:没手动排过时按最近活跃;拖过之后按 sideState.order,
|
|
1019
|
+
* 之后新出现的目录排在已排好的后面(同样按活跃),不会挤掉用户置顶的目录
|
|
1020
|
+
*/
|
|
1021
|
+
function orderProjectDirs(dirs) {
|
|
1022
|
+
const rank = new Map(sideState.order.map((d, i) => [d, i]));
|
|
1023
|
+
const ranked = dirs.filter((d) => rank.has(d)).sort((a, b) => rank.get(a) - rank.get(b));
|
|
1024
|
+
return [...ranked, ...dirs.filter((d) => !rank.has(d))];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** 当前侧栏里显示的目录顺序(renderSidebar 时记下,拖拽 / 右键菜单用) */
|
|
1028
|
+
let projDirs = [];
|
|
1029
|
+
|
|
1030
|
+
/** 把目录 dir 挪到 target 前面(after 为 true 时挪到后面);target 为 null 表示挪到最前 */
|
|
1031
|
+
function moveProjectDir(dir, target, after = false) {
|
|
1032
|
+
if (dir === target) return;
|
|
1033
|
+
const list = projDirs.filter((d) => d !== dir);
|
|
1034
|
+
const idx = target === null ? 0 : list.indexOf(target);
|
|
1035
|
+
if (idx < 0) return;
|
|
1036
|
+
list.splice(idx + (after ? 1 : 0), 0, dir);
|
|
1037
|
+
// 暂时没显示出来的目录(会话都被隐藏了之类)保留原来的名次,排在后面
|
|
1038
|
+
sideState.order = [...list, ...sideState.order.filter((d) => !list.includes(d))];
|
|
1039
|
+
localStorage.setItem("aim.side", JSON.stringify(sideState));
|
|
1040
|
+
renderSidebar();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function resetProjectOrder() {
|
|
1044
|
+
sideState.order = [];
|
|
1045
|
+
localStorage.setItem("aim.side", JSON.stringify(sideState));
|
|
1046
|
+
renderSidebar();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** 正在拖的目录。拖的过程中侧栏不重绘(重绘会换掉被拖的元素,拖拽就断了),松手后补一次 */
|
|
1050
|
+
let dirDrag = null;
|
|
1051
|
+
let sidebarStale = false;
|
|
983
1052
|
|
|
984
1053
|
/** 「最近」「项目」分组标题:点击折叠 / 展开,hover 时显示箭头 */
|
|
985
1054
|
function groupHead(label, id, folded) {
|
|
@@ -1014,6 +1083,11 @@ function reportDesktop() {
|
|
|
1014
1083
|
|
|
1015
1084
|
function renderSidebar() {
|
|
1016
1085
|
if (!ui.convList) return;
|
|
1086
|
+
if (dirDrag) {
|
|
1087
|
+
sidebarStale = true;
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
sidebarStale = false;
|
|
1017
1091
|
const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
|
|
1018
1092
|
const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
|
|
1019
1093
|
const cur = S.current ? S.convs.get(S.current) : null;
|
|
@@ -1097,17 +1171,68 @@ function renderSidebar() {
|
|
|
1097
1171
|
}
|
|
1098
1172
|
const projFolded = sideState.folded.includes("@projects");
|
|
1099
1173
|
if (byDir.size) nodes.push(groupHead("项目", "@projects", projFolded));
|
|
1174
|
+
projDirs = orderProjectDirs([...byDir.keys()]);
|
|
1100
1175
|
if (byDir.size && !projFolded) {
|
|
1101
|
-
|
|
1176
|
+
const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
|
|
1177
|
+
const endDrag = () => {
|
|
1178
|
+
dirDrag = null;
|
|
1179
|
+
clearDrop();
|
|
1180
|
+
if (sidebarStale) renderSidebar();
|
|
1181
|
+
};
|
|
1182
|
+
for (const dir of projDirs) {
|
|
1183
|
+
const convs = byDir.get(dir);
|
|
1102
1184
|
const expanded = sideState.open.includes(dir);
|
|
1103
1185
|
const showAll = sideState.more.includes(dir);
|
|
1186
|
+
// 鼠标在目标行的上半截就插到它前面,下半截插到后面
|
|
1187
|
+
const isAfter = (e, row) => {
|
|
1188
|
+
const r = row.getBoundingClientRect();
|
|
1189
|
+
return e.clientY > r.top + r.height / 2;
|
|
1190
|
+
};
|
|
1104
1191
|
nodes.push(
|
|
1105
1192
|
h(
|
|
1106
1193
|
"div",
|
|
1107
|
-
{
|
|
1194
|
+
{
|
|
1195
|
+
class: "proj-row",
|
|
1196
|
+
"data-dir": dir,
|
|
1197
|
+
draggable: "true",
|
|
1198
|
+
ondragstart: (e) => {
|
|
1199
|
+
dirDrag = dir;
|
|
1200
|
+
e.dataTransfer.effectAllowed = "move";
|
|
1201
|
+
e.dataTransfer.setData("text/plain", dir);
|
|
1202
|
+
e.currentTarget.classList.add("dragging");
|
|
1203
|
+
stopMarquee();
|
|
1204
|
+
},
|
|
1205
|
+
ondragend: (e) => {
|
|
1206
|
+
e.currentTarget.classList.remove("dragging");
|
|
1207
|
+
endDrag();
|
|
1208
|
+
},
|
|
1209
|
+
ondragover: (e) => {
|
|
1210
|
+
if (!dirDrag) return;
|
|
1211
|
+
e.preventDefault();
|
|
1212
|
+
e.dataTransfer.dropEffect = "move";
|
|
1213
|
+
const after = isAfter(e, e.currentTarget);
|
|
1214
|
+
if (!e.currentTarget.classList.contains(after ? "drop-after" : "drop-before")) {
|
|
1215
|
+
clearDrop();
|
|
1216
|
+
if (dirDrag !== dir) e.currentTarget.classList.add(after ? "drop-after" : "drop-before");
|
|
1217
|
+
}
|
|
1218
|
+
},
|
|
1219
|
+
ondragleave: (e) => {
|
|
1220
|
+
if (!e.currentTarget.contains(e.relatedTarget)) e.currentTarget.classList.remove("drop-before", "drop-after");
|
|
1221
|
+
},
|
|
1222
|
+
ondrop: (e) => {
|
|
1223
|
+
if (!dirDrag) return;
|
|
1224
|
+
e.preventDefault();
|
|
1225
|
+
const from = dirDrag;
|
|
1226
|
+
const after = isAfter(e, e.currentTarget);
|
|
1227
|
+
dirDrag = null;
|
|
1228
|
+
clearDrop();
|
|
1229
|
+
if (from !== dir) moveProjectDir(from, dir, after);
|
|
1230
|
+
else if (sidebarStale) renderSidebar();
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1108
1233
|
h(
|
|
1109
1234
|
"button",
|
|
1110
|
-
{ class: "conv-item proj", title: dir
|
|
1235
|
+
{ class: "conv-item proj", title: `${dir}\n拖动可调整顺序,右键可置顶`, onclick: () => toggleSide("open", dir) },
|
|
1111
1236
|
h("span", { class: "proj-icon", html: expanded ? ICON.folderOpen : ICON.folder }),
|
|
1112
1237
|
h("span", { class: "title" }, baseName(dir)),
|
|
1113
1238
|
expanded ? null : status(convs.map((c) => c.key)),
|
|
@@ -2020,7 +2145,8 @@ function renderInteraction(item) {
|
|
|
2020
2145
|
spec.allowSession
|
|
2021
2146
|
? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
|
|
2022
2147
|
: null,
|
|
2023
|
-
|
|
2148
|
+
// 会落盘、对所有人生效的「总是允许」只给管理员
|
|
2149
|
+
spec.alwaysLabel && (!spec.alwaysGlobal || isAdmin())
|
|
2024
2150
|
? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
|
|
2025
2151
|
: null,
|
|
2026
2152
|
h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
|
|
@@ -3225,6 +3351,21 @@ async function openSettings() {
|
|
|
3225
3351
|
const panel = h("div", { class: "settings-body" });
|
|
3226
3352
|
// 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
|
|
3227
3353
|
modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
|
|
3354
|
+
// 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
|
|
3355
|
+
if (!isAdmin()) {
|
|
3356
|
+
const tabs = [
|
|
3357
|
+
["通知", () => notifyTab(panel)],
|
|
3358
|
+
["账号", () => securityTab(panel)],
|
|
3359
|
+
];
|
|
3360
|
+
const select = (i) => {
|
|
3361
|
+
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
3362
|
+
fill(panel);
|
|
3363
|
+
tabs[i][1]();
|
|
3364
|
+
};
|
|
3365
|
+
nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
|
|
3366
|
+
select(0);
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3228
3369
|
let cfg;
|
|
3229
3370
|
try {
|
|
3230
3371
|
cfg = await api("/api/config");
|
|
@@ -3239,7 +3380,8 @@ async function openSettings() {
|
|
|
3239
3380
|
["飞书机器人", () => feishuTab(cfg, panel)],
|
|
3240
3381
|
["授权", () => authTab(panel)],
|
|
3241
3382
|
["通知", () => notifyTab(panel)],
|
|
3242
|
-
["
|
|
3383
|
+
["账号", () => securityTab(panel)],
|
|
3384
|
+
["用户", () => usersTab(panel)],
|
|
3243
3385
|
["系统", () => systemTab(cfg, panel)],
|
|
3244
3386
|
];
|
|
3245
3387
|
const select = (i) => {
|
|
@@ -3300,6 +3442,25 @@ async function enablePush() {
|
|
|
3300
3442
|
await api("/api/push/subscribe", { method: "POST", body: { subscription: sub.toJSON() } });
|
|
3301
3443
|
}
|
|
3302
3444
|
|
|
3445
|
+
/** 这台设备不再收通知:服务端删掉订阅,浏览器也退订 */
|
|
3446
|
+
async function dropPushSub() {
|
|
3447
|
+
const sub = await currentPushSub();
|
|
3448
|
+
if (!sub) return;
|
|
3449
|
+
await api("/api/push/unsubscribe", { method: "POST", body: { endpoint: sub.endpoint } }).catch(() => {});
|
|
3450
|
+
await sub.unsubscribe().catch(() => {});
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
/**
|
|
3454
|
+
* 登录后:这个浏览器上的通知订阅如果不是当前账号的(上一个人没退出就换了账号),
|
|
3455
|
+
* 退订掉 —— 推送服务随后会让服务端那条订阅失效,上一个人的通知就不会再推到这里。
|
|
3456
|
+
*/
|
|
3457
|
+
async function releaseForeignPushSub() {
|
|
3458
|
+
const sub = await currentPushSub();
|
|
3459
|
+
if (!sub) return;
|
|
3460
|
+
const { subscriptions } = await api("/api/push");
|
|
3461
|
+
if (!subscriptions.some((d) => d.endpoint === sub.endpoint)) await sub.unsubscribe().catch(() => {});
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3303
3464
|
async function notifyTab(panel) {
|
|
3304
3465
|
const support = pushSupport();
|
|
3305
3466
|
const box = h("div", { class: "section" }, h("h4", {}, "系统通知"), h("span", { class: "spinner" }));
|
|
@@ -4459,7 +4620,18 @@ async function securityTab(panel) {
|
|
|
4459
4620
|
const old = h("input", { type: "password", autocomplete: "current-password" });
|
|
4460
4621
|
const pw = h("input", { type: "password", autocomplete: "new-password" });
|
|
4461
4622
|
const sessions = await api("/api/web-sessions");
|
|
4623
|
+
// 管理员看到的是所有人的设备:不止一个人时标上是谁的
|
|
4624
|
+
const owners = new Set(sessions.map((s) => s.userId));
|
|
4625
|
+
const showOwner = isAdmin() && owners.size > 1;
|
|
4462
4626
|
panel.append(
|
|
4627
|
+
S.user
|
|
4628
|
+
? h(
|
|
4629
|
+
"div",
|
|
4630
|
+
{ class: "section" },
|
|
4631
|
+
h("h4", {}, "当前账号"),
|
|
4632
|
+
h("div", {}, S.user.name, " ", h("span", { class: "status-tag" }, S.user.role === "admin" ? "管理员" : "成员")),
|
|
4633
|
+
)
|
|
4634
|
+
: null,
|
|
4463
4635
|
h(
|
|
4464
4636
|
"div",
|
|
4465
4637
|
{ class: "section" },
|
|
@@ -4476,7 +4648,7 @@ async function securityTab(panel) {
|
|
|
4476
4648
|
onclick: async () => {
|
|
4477
4649
|
try {
|
|
4478
4650
|
await api("/api/password", { method: "POST", body: { old: old.value, password: pw.value } });
|
|
4479
|
-
toast("
|
|
4651
|
+
toast("密码已修改,你的其它设备已下线");
|
|
4480
4652
|
old.value = pw.value = "";
|
|
4481
4653
|
} catch (err) {
|
|
4482
4654
|
toast(err.message);
|
|
@@ -4490,12 +4662,17 @@ async function securityTab(panel) {
|
|
|
4490
4662
|
h(
|
|
4491
4663
|
"div",
|
|
4492
4664
|
{ class: "section" },
|
|
4493
|
-
h("h4", {}, "已登录的设备"),
|
|
4665
|
+
h("h4", {}, showOwner ? "已登录的设备(所有用户)" : "已登录的设备"),
|
|
4494
4666
|
sessions.map((s) =>
|
|
4495
4667
|
h(
|
|
4496
4668
|
"div",
|
|
4497
4669
|
{ class: "list-item" },
|
|
4498
|
-
h(
|
|
4670
|
+
h(
|
|
4671
|
+
"div",
|
|
4672
|
+
{ class: "grow" },
|
|
4673
|
+
h("div", { class: "t" }, s.userAgent.slice(0, 80) || "未知设备"),
|
|
4674
|
+
h("div", { class: "s" }, `${showOwner ? `${s.userName ?? "已删除的用户"} · ` : ""}${s.ip} · 最近活动 ${relTime(s.lastSeen)}`),
|
|
4675
|
+
),
|
|
4499
4676
|
h(
|
|
4500
4677
|
"button",
|
|
4501
4678
|
{
|
|
@@ -4519,6 +4696,8 @@ async function securityTab(panel) {
|
|
|
4519
4696
|
{
|
|
4520
4697
|
class: "btn danger",
|
|
4521
4698
|
onclick: async () => {
|
|
4699
|
+
// 退出了就别再收这个账号的通知:换个人在这台设备上登录,不能收到上一个人的
|
|
4700
|
+
await dropPushSub().catch(() => {});
|
|
4522
4701
|
await api("/api/logout", { method: "POST" });
|
|
4523
4702
|
location.reload();
|
|
4524
4703
|
},
|
|
@@ -4529,6 +4708,124 @@ async function securityTab(panel) {
|
|
|
4529
4708
|
);
|
|
4530
4709
|
}
|
|
4531
4710
|
|
|
4711
|
+
// ---------- 用户(管理员) ----------
|
|
4712
|
+
|
|
4713
|
+
async function usersTab(panel) {
|
|
4714
|
+
const box = h("div", { class: "section" }, h("h4", {}, "用户"), h("span", { class: "spinner" }));
|
|
4715
|
+
const addBox = h("div", { class: "section" });
|
|
4716
|
+
fill(panel, box, addBox);
|
|
4717
|
+
const reload = () => {
|
|
4718
|
+
fill(panel);
|
|
4719
|
+
void usersTab(panel);
|
|
4720
|
+
};
|
|
4721
|
+
let data;
|
|
4722
|
+
try {
|
|
4723
|
+
data = await api("/api/users");
|
|
4724
|
+
} catch (err) {
|
|
4725
|
+
fill(box, h("h4", {}, "用户"), h("div", { class: "error-text" }, err.message));
|
|
4726
|
+
return;
|
|
4727
|
+
}
|
|
4728
|
+
const act = (btn, fn) => async () => {
|
|
4729
|
+
btn.disabled = true;
|
|
4730
|
+
try {
|
|
4731
|
+
if ((await fn()) !== false) reload();
|
|
4732
|
+
} catch (err) {
|
|
4733
|
+
toast(err.message);
|
|
4734
|
+
}
|
|
4735
|
+
btn.disabled = false;
|
|
4736
|
+
};
|
|
4737
|
+
const patch = (u, body) => api(`/api/users/${encodeURIComponent(u.id)}`, { method: "PATCH", body });
|
|
4738
|
+
|
|
4739
|
+
/** 重置密码:就地展开一个输入框 */
|
|
4740
|
+
const resetRow = (u, holder) => {
|
|
4741
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "新密码(至少 8 位)" });
|
|
4742
|
+
const ok = h("button", { class: "btn small primary" }, "确定");
|
|
4743
|
+
ok.onclick = act(ok, async () => {
|
|
4744
|
+
await patch(u, { password: pw.value });
|
|
4745
|
+
toast(`已重置「${u.name}」的密码,他的设备已下线`);
|
|
4746
|
+
});
|
|
4747
|
+
fill(holder, h("div", { class: "row" }, pw, ok));
|
|
4748
|
+
pw.focus();
|
|
4749
|
+
};
|
|
4750
|
+
|
|
4751
|
+
fill(
|
|
4752
|
+
box,
|
|
4753
|
+
h("h4", {}, "用户"),
|
|
4754
|
+
h(
|
|
4755
|
+
"div",
|
|
4756
|
+
{ class: "muted" },
|
|
4757
|
+
"每个人只看得到自己的对话、授权和通知。管理员能改所有设置,还能看到飞书机器人的对话。",
|
|
4758
|
+
),
|
|
4759
|
+
h(
|
|
4760
|
+
"div",
|
|
4761
|
+
{ class: "error-text" },
|
|
4762
|
+
"注意:账号不是安全隔离。所有 agent 都以服务器上的同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(包括别人的对话记录和配置里的密钥),用的也是同一个 Claude / Codex 订阅。只把账号给你信任的人。",
|
|
4763
|
+
),
|
|
4764
|
+
data.users.map((u) => {
|
|
4765
|
+
const self = u.id === data.me;
|
|
4766
|
+
const extra = h("div");
|
|
4767
|
+
const pwBtn = h("button", { class: "btn small" }, "重置密码");
|
|
4768
|
+
pwBtn.onclick = () => resetRow(u, extra);
|
|
4769
|
+
const roleBtn = h("button", { class: "btn small", disabled: self }, u.role === "admin" ? "设为成员" : "设为管理员");
|
|
4770
|
+
roleBtn.onclick = act(roleBtn, () => patch(u, { role: u.role === "admin" ? "member" : "admin" }));
|
|
4771
|
+
const disBtn = h("button", { class: "btn small", disabled: self }, u.disabled ? "启用" : "停用");
|
|
4772
|
+
disBtn.onclick = act(disBtn, () => patch(u, { disabled: !u.disabled }));
|
|
4773
|
+
const rmBtn = h("button", { class: "btn small danger", disabled: self }, "删除");
|
|
4774
|
+
rmBtn.onclick = act(rmBtn, async () => {
|
|
4775
|
+
const sure = await confirmDialog({
|
|
4776
|
+
title: `删除用户「${u.name}」?`,
|
|
4777
|
+
message: "他的设备会立刻下线。他的对话不会删除,之后归管理员。",
|
|
4778
|
+
confirmText: "删除",
|
|
4779
|
+
danger: true,
|
|
4780
|
+
});
|
|
4781
|
+
if (!sure) return false;
|
|
4782
|
+
await api(`/api/users/${encodeURIComponent(u.id)}`, { method: "DELETE" });
|
|
4783
|
+
toast("已删除");
|
|
4784
|
+
});
|
|
4785
|
+
return h(
|
|
4786
|
+
"div",
|
|
4787
|
+
{ class: "list-item", style: { flexWrap: "wrap" } },
|
|
4788
|
+
h(
|
|
4789
|
+
"div",
|
|
4790
|
+
{ class: "grow" },
|
|
4791
|
+
h(
|
|
4792
|
+
"div",
|
|
4793
|
+
{ class: "t" },
|
|
4794
|
+
u.name,
|
|
4795
|
+
self ? "(你)" : "",
|
|
4796
|
+
" ",
|
|
4797
|
+
h("span", { class: "status-tag" }, u.role === "admin" ? "管理员" : "成员"),
|
|
4798
|
+
u.disabled ? h("span", { class: "status-tag bad" }, "已停用") : null,
|
|
4799
|
+
),
|
|
4800
|
+
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录`),
|
|
4801
|
+
extra,
|
|
4802
|
+
),
|
|
4803
|
+
pwBtn,
|
|
4804
|
+
roleBtn,
|
|
4805
|
+
disBtn,
|
|
4806
|
+
rmBtn,
|
|
4807
|
+
);
|
|
4808
|
+
}),
|
|
4809
|
+
);
|
|
4810
|
+
|
|
4811
|
+
const name = h("input", { autocomplete: "off", autocapitalize: "off", spellcheck: "false", placeholder: "登录用的名字" });
|
|
4812
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "至少 8 位" });
|
|
4813
|
+
const role = h("select", {}, h("option", { value: "member" }, "成员"), h("option", { value: "admin" }, "管理员"));
|
|
4814
|
+
const add = h("button", { class: "btn primary" }, "添加");
|
|
4815
|
+
add.onclick = act(add, async () => {
|
|
4816
|
+
await api("/api/users", { method: "POST", body: { name: name.value.trim(), password: pw.value, role: role.value } });
|
|
4817
|
+
toast(`已添加「${name.value.trim()}」`);
|
|
4818
|
+
});
|
|
4819
|
+
fill(
|
|
4820
|
+
addBox,
|
|
4821
|
+
h("h4", {}, "添加用户"),
|
|
4822
|
+
field("用户名", name),
|
|
4823
|
+
field("密码", pw, "告诉对方,让他登录后在 设置 → 账号 里自己改掉"),
|
|
4824
|
+
field("角色", role, "成员只能用自己的对话;管理员能改所有设置、管理用户"),
|
|
4825
|
+
h("div", { class: "row" }, add),
|
|
4826
|
+
);
|
|
4827
|
+
}
|
|
4828
|
+
|
|
4532
4829
|
async function systemTab(cfg, panel) {
|
|
4533
4830
|
// 系统信息要等接口返回,先占好位置,版本和网络访问两节不用等它
|
|
4534
4831
|
const sysBox = h("div", { class: "section" }, h("h4", {}, "系统", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
package/web/style.css
CHANGED
|
@@ -431,6 +431,32 @@ button.conv-group:hover {
|
|
|
431
431
|
gap: 10px;
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
+
/* 拖动目录排序:被拖的行变淡,落点处画一条横线 */
|
|
435
|
+
.proj-row.dragging {
|
|
436
|
+
opacity: 0.4;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.proj-row.drop-before::before,
|
|
440
|
+
.proj-row.drop-after::after {
|
|
441
|
+
content: "";
|
|
442
|
+
position: absolute;
|
|
443
|
+
left: 8px;
|
|
444
|
+
right: 8px;
|
|
445
|
+
height: 2px;
|
|
446
|
+
border-radius: 1px;
|
|
447
|
+
background: var(--accent, #3b82f6);
|
|
448
|
+
pointer-events: none;
|
|
449
|
+
z-index: 1;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.proj-row.drop-before::before {
|
|
453
|
+
top: -2px;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.proj-row.drop-after::after {
|
|
457
|
+
bottom: -2px;
|
|
458
|
+
}
|
|
459
|
+
|
|
434
460
|
.proj-icon {
|
|
435
461
|
display: inline-grid;
|
|
436
462
|
color: var(--fg-soft);
|
|
@@ -559,8 +585,8 @@ button.conv-group:hover {
|
|
|
559
585
|
margin-left: auto;
|
|
560
586
|
}
|
|
561
587
|
|
|
562
|
-
/*
|
|
563
|
-
.desktop-settings .side-
|
|
588
|
+
/* 桌面端里:设置在顶栏「更多」里,侧栏底部只留额度(额度由桌面端统一查好推过来) */
|
|
589
|
+
.desktop-settings .side-gear {
|
|
564
590
|
display: none;
|
|
565
591
|
}
|
|
566
592
|
|