diff --git a/apps/web/src/components/SettingsSections.test.tsx b/apps/web/src/components/SettingsSections.test.tsx index 30dfdc13..8674d9ea 100644 --- a/apps/web/src/components/SettingsSections.test.tsx +++ b/apps/web/src/components/SettingsSections.test.tsx @@ -72,7 +72,9 @@ it("lists audit events with tombstone labels and pages by the Server cursor", as expect.anything(), ); expect(view.container.querySelectorAll(".audit-list li")).toHaveLength(3); - expect(view.container.textContent).toContain("unknown kind"); + // An unknown type shows its category in Chinese, never the internal code. + expect(view.container.textContent).toContain("其他事件"); + expect(view.container.textContent).not.toContain("unknown kind"); expect(view.container.textContent).not.toContain("显示更早的记录"); } finally { await view.unmount(); @@ -202,3 +204,39 @@ it.each([ await view.unmount(); } }); + +it("names every audit event in Chinese, falling back to its category for an unknown type", async () => { + const event = (id: string, type: string, category: string) => ({ + id, + type, + category, + createdAt: "2026-10-10T00:00:00.000Z", + details: {}, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + respond({ + events: [ + event("a", "AUTH_LOGIN_SUCCEEDED", "authentication"), + event("b", "MODEL_CONNECTION_CREATED", "settings"), + event("c", "AUTOMATION_PAUSED", "other"), + event("d", "AUTH_PASSKEY_ENROLLED", "authentication"), + ], + }), + ), + ); + const view = await renderComponent(); + try { + await interact(() => undefined); + const titles = Array.from(view.container.querySelectorAll("strong")).map( + (item) => item.textContent, + ); + expect(titles).toEqual( + expect.arrayContaining(["登录成功", "添加模型服务", "暂停例行任务", "登录事件"]), + ); + expect(view.container.textContent).not.toMatch(/auth|passkey|automation/iu); + } finally { + await view.unmount(); + } +}); diff --git a/apps/web/src/components/SettingsSections.tsx b/apps/web/src/components/SettingsSections.tsx index e55355f2..8a4b1fbb 100644 --- a/apps/web/src/components/SettingsSections.tsx +++ b/apps/web/src/components/SettingsSections.tsx @@ -93,7 +93,36 @@ export function ApprovalPolicySettings() { ); } +// Every event type the Server writes (apps/server/src) has a label; an unknown type falls back to +// its category, so the Owner never sees an internal code such as "auth login succeeded". const auditLabels: Record = { + AUTH_LOGIN_SUCCEEDED: "登录成功", + AUTH_LOGIN_FAILED: "登录失败", + AUTH_LOGOUT: "退出登录", + OWNER_PASSWORD_CHANGED: "修改密码", + OWNER_SESSIONS_REVOKED: "退出其他设备", + SETTINGS_OWNER_UPDATED: "更改偏好设置", + SETTINGS_APPROVAL_UPDATED: "更改审批规则", + SETTINGS_STORAGE_UPDATED: "更改存储设置", + SETTINGS_TRASH_AUTO_PURGE_RUN: "自动清空回收站", + MODEL_CONNECTION_CREATED: "添加模型服务", + MODEL_CONNECTION_UPDATED: "更新模型服务", + MODEL_CONNECTION_DELETED: "删除模型服务", + EMPLOYEE_MODEL_UPDATED: "更改 Bot 模型", + EMPLOYEE_APPEARANCE_UPDATED: "更新 Bot 头像", + BOT_GREETING_STARTED: "Bot 开始打招呼", + BOT_GREETING_FAILED: "Bot 打招呼失败", + AUTOMATION_CREATED: "创建例行任务", + AUTOMATION_PAUSED: "暂停例行任务", + AUTOMATION_RESUMED: "恢复例行任务", + AUTOMATION_DELETED: "删除例行任务", + AUTOMATION_OCCURRENCE: "执行例行任务", + CHANNEL_ATTACHMENT_PURGED: "清理频道附件", + BROWSER_COMMAND: "浏览器操作", + BROWSER_CONTROL_STATE: "切换浏览器控制", + BROWSER_HOST_BOUND: "绑定浏览器主机", + BROWSER_TRANSPORT_ADMITTED: "允许浏览器连接", + BROWSER_TRANSPORT_CLAIMED: "接管浏览器连接", CHANNEL_CREATED: "创建频道", CHANNEL_RENAMED: "重命名频道", CHANNEL_DELETED: "删除频道", @@ -132,7 +161,7 @@ function auditTitle(event: AuditEvent) { event.details.primaryBotId == null ? "未设置" : (event.details.to ?? "未知 Bot"); return `更改主 Bot:${previous} → ${current}`; } - const label = auditLabels[event.type] ?? event.type.replaceAll("_", " ").toLocaleLowerCase(); + const label = auditLabels[event.type] ?? `${auditCategoryLabels[event.category ?? "other"]}事件`; const from = event.details.from; const to = event.details.to; if (typeof from === "string" && typeof to === "string") return `${label}:${from} → ${to}`; diff --git a/apps/web/src/components/Sidebar.css b/apps/web/src/components/Sidebar.css index 07fa020f..c7801181 100644 --- a/apps/web/src/components/Sidebar.css +++ b/apps/web/src/components/Sidebar.css @@ -190,6 +190,50 @@ } /* 主 Bot leads the list as a raised card (PrimaryBot artboard); the hover and selection rules below still apply. */ +/* Upgraded workspaces without a 主 Bot get a dismissible nudge where the crowned card would be. */ +.sb .sb-primary-hint { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + margin: 0 0 6px; + padding: 10px 10px 10px 12px; + border-radius: var(--ob-radius-row); + box-shadow: inset 0 0 0 1px var(--ob-divider); + color: var(--ob-text-secondary); +} +.sb .sb-primary-hint .ob-crown-glyph { + margin-top: 3px; +} +.sb .sb-primary-hint span { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.sb .sb-primary-hint strong { + color: var(--ob-text); + font-size: 14px; + font-weight: 600; +} +.sb .sb-primary-hint small { + font-size: 12px; + line-height: 1.45; +} +.sb .sb-primary-hint button { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} +.sb .sb-primary-hint button:hover { + background: var(--ob-fill-hover); +} .sb .sb-primary { padding-bottom: 2px; } diff --git a/apps/web/src/components/Sidebar.test.tsx b/apps/web/src/components/Sidebar.test.tsx index 75fb9c42..b88ea935 100644 --- a/apps/web/src/components/Sidebar.test.tsx +++ b/apps/web/src/components/Sidebar.test.tsx @@ -345,3 +345,46 @@ it("leads with the crowned 主 Bot and offers 设为主 Bot to the others", asyn await view.unmount(); } }); + +it("nudges an upgraded workspace without a 主 Bot until one is chosen or the hint is dismissed", async () => { + window.localStorage.removeItem("openbot.sidebar.primaryHintDismissed"); + const props = { + bots, + channels, + runs: [], + ownerName: "Owner", + onSelectChannel: vi.fn(), + onSelectBot: vi.fn(), + onCreateBot: vi.fn(), + onCreateChannel: vi.fn(), + onSetPrimaryBot: vi.fn(async () => undefined), + }; + const hint = (root: HTMLElement) => root.querySelector(".sb-primary-hint"); + const none = await renderComponent(); + try { + expect(hint(none.container)?.textContent).toContain("选一个主 Bot"); + await interact(() => + none.container.querySelector('[aria-label="不再提示"]')?.click(), + ); + expect(hint(none.container)).toBeNull(); + } finally { + await none.unmount(); + } + // Dismissal is remembered on this device. + const again = await renderComponent(); + try { + expect(hint(again.container)).toBeNull(); + } finally { + await again.unmount(); + } + window.localStorage.removeItem("openbot.sidebar.primaryHintDismissed"); + // A chosen 主 Bot, an older Server (undefined) or no Bots never show the hint. + for (const extra of [{ primaryBotId: "reviewer" }, { primaryBotId: undefined }, { bots: [] }]) { + const view = await renderComponent(); + try { + expect(hint(view.container)).toBeNull(); + } finally { + await view.unmount(); + } + } +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2542fed5..8d5b6d72 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -77,6 +77,15 @@ interface SidebarProps { } /** The shared sidebar of the design contract (docs/design/desktop-ui-2026-10/Sidebar.dc.html). */ +const primaryHintKey = "openbot.sidebar.primaryHintDismissed"; +function readPrimaryHintDismissed() { + try { + return window.localStorage.getItem(primaryHintKey) === "1"; + } catch { + return false; + } +} + export function Sidebar({ creatingBot, onWork, @@ -177,6 +186,22 @@ export function Sidebar({ const primaryEntry = primaryBotId ? entries.find((entry) => entry.key === `bot:${primaryBotId}`) : undefined; + // A workspace upgraded from before 主 Bot existed has none; nudge once, per device, until the + // Owner picks one or dismisses it. The Server never elects one silently during an upgrade. + const [primaryHintDismissed, setPrimaryHintDismissed] = useState(readPrimaryHintDismissed); + const suggestPrimary = + primaryBotId === null && + bots.length > 0 && + onSetPrimaryBot !== undefined && + !primaryHintDismissed; + function dismissPrimaryHint() { + setPrimaryHintDismissed(true); + try { + window.localStorage.setItem(primaryHintKey, "1"); + } catch { + // Storage can be unavailable (private window); the hint then hides for this session only. + } + } const sections = arrangeSidebar( primaryEntry ? entries.filter((entry) => entry !== primaryEntry) : entries, organization, @@ -440,6 +465,17 @@ export function Sidebar({ <> {primaryEntry ? (
{renderRow(primaryEntry, "", true)}
+ ) : suggestPrimary ? ( +
+ + + 选一个主 Bot + 没有 @ 人的消息会先交给它。右键任意 Bot,选「设为主 Bot」。 + + +
) : null} {sections.map((section) => (