@celestia-island/hikari 0.43.18 → 0.45.0
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/package.json
CHANGED
|
@@ -1,39 +1,66 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
// HkMenu renders its levels through HkSelectPanel, which teleports to
|
|
5
|
+
// body — replace just HkMenu (imported by the component from
|
|
6
|
+
// ./HkMenu) with an inline passthrough that renders the header slot
|
|
7
|
+
// plus one flattened row per item (children included) while open,
|
|
8
|
+
// wiring row clicks to the select handler; the rest of hikari stays
|
|
9
|
+
// real. The select handler reaches the stub through attrs (it is not
|
|
10
|
+
// re-declared as an emit, which would strip it from props).
|
|
11
|
+
vi.mock("./HkMenu", async () => {
|
|
9
12
|
const { defineComponent, h } = await import("vue");
|
|
10
|
-
const
|
|
11
|
-
name: "
|
|
13
|
+
const HMenuStub = defineComponent({
|
|
14
|
+
name: "HMenu",
|
|
12
15
|
props: {
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
open: { type: Boolean, default: false },
|
|
17
|
+
items: { type: Array, default: () => [] },
|
|
18
|
+
title: { type: String, default: "" },
|
|
15
19
|
},
|
|
16
|
-
setup(props, { slots }) {
|
|
17
|
-
return () =>
|
|
18
|
-
props.
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
setup(props, { slots, attrs }) {
|
|
21
|
+
return () => {
|
|
22
|
+
if (!props.open) return null;
|
|
23
|
+
const rows: ReturnType<typeof h>[] = [];
|
|
24
|
+
const flatten = (items: any[]) => {
|
|
25
|
+
for (const item of items ?? []) {
|
|
26
|
+
rows.push(
|
|
27
|
+
h(
|
|
28
|
+
"button",
|
|
29
|
+
{
|
|
30
|
+
class: "menu-stub-row",
|
|
31
|
+
"data-key": item.key,
|
|
32
|
+
"data-checked": item.checked || undefined,
|
|
33
|
+
"data-danger": item.danger || undefined,
|
|
34
|
+
onClick: () => (attrs as any).onSelect?.(item.key),
|
|
35
|
+
},
|
|
36
|
+
item.label,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
if (item.children?.length) flatten(item.children);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
flatten(props.items as any[]);
|
|
43
|
+
return h("div", { class: "menu-stub" }, [...(slots.header?.() ?? []), ...rows]);
|
|
44
|
+
};
|
|
21
45
|
},
|
|
22
46
|
});
|
|
23
|
-
return {
|
|
47
|
+
return { default: HMenuStub };
|
|
24
48
|
});
|
|
25
49
|
|
|
26
50
|
import { HkAdminHeader } from "./HkAdminHeader";
|
|
27
51
|
|
|
28
52
|
/**
|
|
29
|
-
* HkAdminHeader contract tests for the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
53
|
+
* HkAdminHeader contract tests for the unified user dropdown:
|
|
54
|
+
* - the bar beside the avatar carries the PAGE TITLE, never the
|
|
55
|
+
* nickname;
|
|
56
|
+
* - the avatar trigger toggles an HkMenu dropdown whose header slot is
|
|
57
|
+
* the profile identity block (avatar chip + nickname + email) and
|
|
58
|
+
* whose rows end in a danger logout;
|
|
59
|
+
* - avatarAction "drawer" emits avatarClick instead (bar text hidden);
|
|
60
|
+
* - an empty identity renders the signing-in placeholder + the
|
|
61
|
+
* force-sign-out escape row;
|
|
62
|
+
* - Language children carry checked state and emit localeSelect;
|
|
63
|
+
* - the goToFrontend row sits directly above logout.
|
|
37
64
|
*
|
|
38
65
|
* (Repo test convention: raw createApp + container queries, no
|
|
39
66
|
* @vue/test-utils dependency.)
|
|
@@ -66,6 +93,9 @@ function headerNode(props: HeaderProps = {}, slots?: Record<string, unknown>) {
|
|
|
66
93
|
const avatarButton = (c: HTMLElement) =>
|
|
67
94
|
c.querySelector('button[aria-haspopup]') as HTMLButtonElement | null;
|
|
68
95
|
|
|
96
|
+
const menuRows = (c: HTMLElement) =>
|
|
97
|
+
[...c.querySelectorAll(".menu-stub-row")] as HTMLButtonElement[];
|
|
98
|
+
|
|
69
99
|
async function click(el: Element | null) {
|
|
70
100
|
el?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
71
101
|
await Promise.resolve();
|
|
@@ -73,27 +103,60 @@ async function click(el: Element | null) {
|
|
|
73
103
|
}
|
|
74
104
|
|
|
75
105
|
describe("HkAdminHeader", () => {
|
|
76
|
-
it("
|
|
77
|
-
const c = mount(headerNode({ logoutLabel: "Log out" }));
|
|
106
|
+
it("shows the page title beside the avatar, never the nickname", () => {
|
|
107
|
+
const c = mount(headerNode({ title: "Providers", logoutLabel: "Log out" }));
|
|
108
|
+
const barText = c.querySelector("header > div")?.textContent ?? "";
|
|
109
|
+
expect(barText).toContain("Providers");
|
|
110
|
+
// The nickname must not render as bar text — identity lives in the
|
|
111
|
+
// dropdown's identity block only.
|
|
112
|
+
expect(barText).not.toContain("alice");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("renders the letter fallback on the shared avatar primitive, no outline ring", () => {
|
|
116
|
+
const c = mount(headerNode({ title: "Providers" }));
|
|
117
|
+
const btn = avatarButton(c) as HTMLButtonElement;
|
|
118
|
+
// The old border-2 ring made the console avatar read as a different
|
|
119
|
+
// control from the chat frontend's (user report 2026-09-12).
|
|
120
|
+
expect(btn.className).not.toContain("border-2");
|
|
121
|
+
expect(btn.querySelector(".s-user-avatar")?.textContent).toBe("A");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("hides the title node entirely when it is empty", () => {
|
|
125
|
+
const withTitle = mount(headerNode({ title: "Dashboard" }));
|
|
126
|
+
expect(withTitle.textContent).toContain("Dashboard");
|
|
127
|
+
|
|
128
|
+
const withoutTitle = mount(headerNode());
|
|
129
|
+
expect(withoutTitle.querySelector("header > div")?.textContent).not.toContain("Dashboard");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("toggles the user dropdown with the profile identity header in menu mode", async () => {
|
|
133
|
+
const c = mount(headerNode({
|
|
134
|
+
userEmail: "alice@example.com",
|
|
135
|
+
logoutLabel: "Log out",
|
|
136
|
+
}));
|
|
78
137
|
const btn = avatarButton(c);
|
|
79
138
|
expect(btn?.getAttribute("aria-haspopup")).toBe("menu");
|
|
80
139
|
|
|
81
140
|
await click(btn);
|
|
82
|
-
const menu = c.querySelector(".
|
|
83
|
-
// The dropdown leads with the identity block
|
|
141
|
+
const menu = c.querySelector(".menu-stub");
|
|
142
|
+
// The dropdown leads with the identity block — avatar chip,
|
|
143
|
+
// nickname, email — and ends with a danger logout row.
|
|
144
|
+
expect(menu?.querySelector(".s-user-header--profile")).toBeTruthy();
|
|
145
|
+
expect(menu?.querySelector(".s-user-avatar-chip")).toBeTruthy();
|
|
84
146
|
expect(menu?.textContent).toContain("alice");
|
|
85
|
-
expect(
|
|
86
|
-
expect(
|
|
147
|
+
expect(menu?.textContent).toContain("alice@example.com");
|
|
148
|
+
expect(c.querySelector(".menu-stub-row[data-key='logout'][data-danger]")).toBeTruthy();
|
|
87
149
|
|
|
88
150
|
await click(btn);
|
|
89
|
-
expect(c.querySelector(".
|
|
151
|
+
expect(c.querySelector(".menu-stub")).toBeNull();
|
|
90
152
|
});
|
|
91
153
|
|
|
92
154
|
it("emits avatarClick instead of the dropdown in drawer mode", async () => {
|
|
93
155
|
const clicks: number[] = [];
|
|
94
156
|
const c = mount(headerNode({
|
|
95
157
|
avatarAction: "drawer",
|
|
96
|
-
|
|
158
|
+
title: "Providers",
|
|
159
|
+
userEmail: "alice@example.com",
|
|
97
160
|
onAvatarClick: () => clicks.push(1),
|
|
98
161
|
}));
|
|
99
162
|
const btn = avatarButton(c);
|
|
@@ -101,9 +164,10 @@ describe("HkAdminHeader", () => {
|
|
|
101
164
|
|
|
102
165
|
await click(btn);
|
|
103
166
|
expect(clicks).toHaveLength(1);
|
|
104
|
-
// The dropdown must NOT open in drawer mode, and the
|
|
105
|
-
//
|
|
106
|
-
expect(c.querySelector(".
|
|
167
|
+
// The dropdown must NOT open in drawer mode, and neither the title
|
|
168
|
+
// nor the nickname renders — both live in the drawer footer.
|
|
169
|
+
expect(c.querySelector(".menu-stub")).toBeNull();
|
|
170
|
+
expect(c.textContent).not.toContain("Providers");
|
|
107
171
|
expect(c.textContent).not.toContain("alice");
|
|
108
172
|
});
|
|
109
173
|
|
|
@@ -123,63 +187,64 @@ describe("HkAdminHeader", () => {
|
|
|
123
187
|
// No action items above an absent identity.
|
|
124
188
|
expect(c.textContent).not.toContain("Avatar");
|
|
125
189
|
|
|
126
|
-
await click(c.querySelector(".
|
|
190
|
+
await click(c.querySelector(".menu-stub-row"));
|
|
127
191
|
expect(onForceSignOut).toHaveBeenCalledTimes(1);
|
|
128
192
|
});
|
|
129
193
|
|
|
130
|
-
it("hides the context title entirely when it is empty", () => {
|
|
131
|
-
const withTitle = mount(headerNode({ title: "Dashboard" }));
|
|
132
|
-
expect(withTitle.querySelector("h2")?.textContent).toBe("Dashboard");
|
|
133
|
-
|
|
134
|
-
const withoutTitle = mount(headerNode());
|
|
135
|
-
expect(withoutTitle.querySelector("h2")).toBeNull();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("renders no emergency-stop control by default", () => {
|
|
139
|
-
const c = mount(headerNode({ username: "alice" }));
|
|
140
|
-
expect(c.textContent.toLowerCase()).not.toContain("emergency");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
194
|
it("renders the goToFrontend row above logout and emits goToFrontend", async () => {
|
|
144
195
|
const onGoToFrontend = vi.fn();
|
|
145
196
|
const c = mount(headerNode({
|
|
197
|
+
userEmail: "alice@example.com",
|
|
146
198
|
logoutLabel: "Log out",
|
|
147
199
|
goToFrontendLabel: "Go to Frontend",
|
|
148
200
|
onGoToFrontend,
|
|
149
201
|
}));
|
|
150
202
|
// Opt-in: absent without the label prop…
|
|
151
|
-
const bare = mount(headerNode({ logoutLabel: "Log out" }));
|
|
203
|
+
const bare = mount(headerNode({ userEmail: "alice@example.com", logoutLabel: "Log out" }));
|
|
152
204
|
await click(avatarButton(bare));
|
|
153
|
-
expect(bare.
|
|
205
|
+
expect(bare.querySelector(".menu-stub-row[data-key='go-to-frontend']")).toBeNull();
|
|
154
206
|
|
|
155
207
|
// …and present (directly above logout, mirroring the drawer user
|
|
156
208
|
// panel's row order) with the label prop.
|
|
157
209
|
await click(avatarButton(c));
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
expect(
|
|
161
|
-
expect(labels.indexOf("Log out")).toBe(labels.indexOf("Go to Frontend") + 1);
|
|
210
|
+
const keys = menuRows(c).map((r) => r.dataset.key);
|
|
211
|
+
expect(keys.indexOf("go-to-frontend")).toBeGreaterThan(-1);
|
|
212
|
+
expect(keys.indexOf("logout")).toBe(keys.indexOf("go-to-frontend") + 1);
|
|
162
213
|
|
|
163
|
-
await click(
|
|
214
|
+
await click(menuRows(c)[keys.indexOf("go-to-frontend")]);
|
|
164
215
|
expect(onGoToFrontend).toHaveBeenCalledTimes(1);
|
|
165
216
|
});
|
|
166
217
|
|
|
167
|
-
it("
|
|
168
|
-
|
|
169
|
-
const c = mount(headerNode(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
));
|
|
218
|
+
it("offers locales as a checked cascade and emits localeSelect with the code", async () => {
|
|
219
|
+
const onLocaleSelect = vi.fn();
|
|
220
|
+
const c = mount(headerNode({
|
|
221
|
+
userEmail: "alice@example.com",
|
|
222
|
+
localeOptions: [
|
|
223
|
+
{ code: "en", label: "English" },
|
|
224
|
+
{ code: "zh-Hans", label: "简体中文" },
|
|
225
|
+
],
|
|
226
|
+
currentLocale: "zh-Hans",
|
|
227
|
+
onLocaleSelect,
|
|
228
|
+
}));
|
|
178
229
|
await click(avatarButton(c));
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
230
|
+
|
|
231
|
+
// Children flatten after the parent row with checked state on the
|
|
232
|
+
// active locale only.
|
|
233
|
+
const rows = menuRows(c);
|
|
234
|
+
const localeIdx = rows.findIndex((r) => r.dataset.key === "locale");
|
|
235
|
+
const en = rows[localeIdx + 1];
|
|
236
|
+
const zh = rows[localeIdx + 2];
|
|
237
|
+
expect(en.dataset.key).toBe("locale:en");
|
|
238
|
+
expect(zh.dataset.key).toBe("locale:zh-Hans");
|
|
239
|
+
expect(zh.dataset.checked).toBe("true");
|
|
240
|
+
expect(en.dataset.checked).toBeUndefined();
|
|
241
|
+
|
|
242
|
+
await click(en);
|
|
243
|
+
expect(onLocaleSelect).toHaveBeenCalledWith("en");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("renders no emergency-stop control by default", () => {
|
|
247
|
+
const c = mount(headerNode({ username: "alice" }));
|
|
248
|
+
expect(c.textContent.toLowerCase()).not.toContain("emergency");
|
|
184
249
|
});
|
|
185
250
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { defineComponent, ref, type PropType, type VNode } from "vue";
|
|
1
|
+
import { computed, defineComponent, ref, type PropType, type VNode } from "vue";
|
|
2
2
|
import { Camera, ExternalLink, Languages, LogOut, Menu } from "lucide-vue-next";
|
|
3
|
-
import { HBadge, HButton,
|
|
3
|
+
import { HBadge, HButton, HSpinner } from "@celestia-island/hikari";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
|
+
import HkMenu, { type HkMenuItem } from "./HkMenu";
|
|
5
6
|
|
|
6
7
|
export interface LocaleOption {
|
|
7
8
|
code: string;
|
|
@@ -11,25 +12,30 @@ export interface LocaleOption {
|
|
|
11
12
|
export const HkAdminHeader = defineComponent({
|
|
12
13
|
name: "HkAdminHeader",
|
|
13
14
|
props: {
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
/** Page title rendered beside the avatar — the view the console is
|
|
16
|
+
* currently ON (dashboard, providers, …). The signed-in nickname
|
|
17
|
+
* deliberately does NOT go here: identity lives in the dropdown's
|
|
18
|
+
* identity block, the bar labels WHERE you are. Empty hides the
|
|
19
|
+
* node; long titles truncate. */
|
|
17
20
|
title: { type: String, default: "" },
|
|
18
21
|
showHamburger: { type: Boolean, default: false },
|
|
19
22
|
compact: { type: Boolean, default: false },
|
|
20
23
|
actions: { type: Array as PropType<VNode[]>, default: () => [] },
|
|
24
|
+
/** Signed-in identity. Drives the avatar fallback letter, the
|
|
25
|
+
* dropdown identity block and the pending-state branch — it is
|
|
26
|
+
* never rendered as bar text (the title slot above owns that). */
|
|
21
27
|
username: { type: String, default: "" },
|
|
22
28
|
avatarUrl: { type: String, default: "" },
|
|
23
29
|
userEmail: { type: String, default: "" },
|
|
24
30
|
userGroups: { type: Array as PropType<{ id: string; name: string }[]>, default: () => [] },
|
|
25
31
|
/** What the avatar trigger does:
|
|
26
|
-
* - "menu" (default, desktop): toggle the user dropdown
|
|
32
|
+
* - "menu" (default, desktop): toggle the user dropdown
|
|
27
33
|
* (identity, avatar edit, language, logout).
|
|
28
34
|
* - "drawer" (mobile): emit `avatarClick` so the shell opens its nav
|
|
29
35
|
* drawer, whose `userPanel` footer carries the same user content.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* Both the page title and the identity stay hidden in this mode —
|
|
37
|
+
* they live in the drawer, so header copies read as duplicated
|
|
38
|
+
* stray controls. */
|
|
33
39
|
avatarAction: { type: String as PropType<"menu" | "drawer">, default: "menu" },
|
|
34
40
|
/** Placeholder row shown while the identity is still loading (a
|
|
35
41
|
* fetchUser race on hard refresh) — the action items stay hidden
|
|
@@ -51,6 +57,13 @@ export const HkAdminHeader = defineComponent({
|
|
|
51
57
|
/** Accessible label for the avatar trigger button. */
|
|
52
58
|
avatarTriggerLabel: { type: String, default: undefined },
|
|
53
59
|
localeMenuLabel: { type: String, default: undefined },
|
|
60
|
+
/** Offered locales for the Language cascade (the same shape the chat
|
|
61
|
+
* frontend feeds its user menu). An empty list hides the row —
|
|
62
|
+
* hosts without a locale concept simply omit it. Selecting a child
|
|
63
|
+
* emits `localeSelect` with the code. */
|
|
64
|
+
localeOptions: { type: Array as PropType<LocaleOption[]>, default: () => [] },
|
|
65
|
+
/** Currently active locale code (the Language cascade's check). */
|
|
66
|
+
currentLocale: { type: String, default: undefined },
|
|
54
67
|
logoutLabel: { type: String, default: undefined },
|
|
55
68
|
adminGroupLabel: { type: String, default: undefined },
|
|
56
69
|
/** "Go to frontend" menu row (external-face link, rendered directly
|
|
@@ -65,6 +78,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
65
78
|
hamburger: () => true,
|
|
66
79
|
avatarClick: () => true,
|
|
67
80
|
emergencyStop: () => true,
|
|
81
|
+
localeSelect: (_code: string) => true,
|
|
68
82
|
},
|
|
69
83
|
setup(props, { emit, slots }) {
|
|
70
84
|
const { t } = useI18n();
|
|
@@ -72,8 +86,6 @@ export const HkAdminHeader = defineComponent({
|
|
|
72
86
|
const userTriggerRef = ref<HTMLElement>();
|
|
73
87
|
const avatarModalOpen = ref(false);
|
|
74
88
|
const avatarFailed = ref(false);
|
|
75
|
-
const localeMenuOpen = ref(false);
|
|
76
|
-
const localeTriggerRef = ref<HTMLElement | null>(null);
|
|
77
89
|
|
|
78
90
|
function onAvatarClick(e: MouseEvent) {
|
|
79
91
|
e.stopPropagation();
|
|
@@ -84,6 +96,72 @@ export const HkAdminHeader = defineComponent({
|
|
|
84
96
|
userMenuOpen.value = !userMenuOpen.value;
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
/** The dropdown is the SAME engine as the chat frontend's user menu
|
|
100
|
+
* (HkMenu): identity header via the header slot, cascading Language
|
|
101
|
+
* children, danger logout row — every account surface (chat header,
|
|
102
|
+
* console header, mobile drawer) reads identically. */
|
|
103
|
+
const userMenuItems = computed<HkMenuItem[]>(() => {
|
|
104
|
+
// Empty identity (fetchUser race on a hard refresh): a single
|
|
105
|
+
// force-sign-out escape row — action items floating above no
|
|
106
|
+
// identity read as a broken menu.
|
|
107
|
+
if (!props.username && !props.userEmail) {
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
key: "force-signout",
|
|
111
|
+
label: props.forceSignOutLabel ?? t("hikari::adminHeader.forceSignOut", "Sign out"),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
const items: HkMenuItem[] = [
|
|
116
|
+
{
|
|
117
|
+
key: "avatar",
|
|
118
|
+
label: props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar"),
|
|
119
|
+
icon: Camera,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
if (props.localeOptions.length > 0) {
|
|
123
|
+
items.push({
|
|
124
|
+
key: "locale",
|
|
125
|
+
label: props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language"),
|
|
126
|
+
icon: Languages,
|
|
127
|
+
children: props.localeOptions.map((o) => ({
|
|
128
|
+
key: `locale:${o.code}`,
|
|
129
|
+
label: o.label,
|
|
130
|
+
checked: o.code === props.currentLocale,
|
|
131
|
+
})),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// Frontend link — mirrors the drawer user panel's "go to frontend"
|
|
135
|
+
// row (same position: directly above logout). Opt-in via the label
|
|
136
|
+
// prop so admin-only panels keep the menu at avatar/language/logout.
|
|
137
|
+
if (props.goToFrontendLabel) {
|
|
138
|
+
items.push({ key: "go-to-frontend", label: props.goToFrontendLabel, icon: ExternalLink });
|
|
139
|
+
}
|
|
140
|
+
items.push({
|
|
141
|
+
key: "logout",
|
|
142
|
+
label: props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout"),
|
|
143
|
+
icon: LogOut,
|
|
144
|
+
danger: true,
|
|
145
|
+
});
|
|
146
|
+
return items;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Leaf selection IS the action; HkMenu closes the menu around the
|
|
150
|
+
* emit, so each branch only fires its own side effect. */
|
|
151
|
+
function onUserMenuSelect(key: string) {
|
|
152
|
+
if (key === "avatar") {
|
|
153
|
+
avatarModalOpen.value = true;
|
|
154
|
+
} else if (key.startsWith("locale:")) {
|
|
155
|
+
emit("localeSelect", key.slice("locale:".length));
|
|
156
|
+
} else if (key === "go-to-frontend") {
|
|
157
|
+
emit("goToFrontend");
|
|
158
|
+
} else if (key === "logout") {
|
|
159
|
+
emit("logout");
|
|
160
|
+
} else if (key === "force-signout") {
|
|
161
|
+
props.onForceSignOut?.();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
87
165
|
return () => (
|
|
88
166
|
<header
|
|
89
167
|
class={[
|
|
@@ -103,12 +181,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
103
181
|
|
|
104
182
|
<div ref={userTriggerRef} class="flex items-center gap-2 min-w-0">
|
|
105
183
|
<button
|
|
106
|
-
class=
|
|
107
|
-
"w-7 h-7 rounded-full overflow-hidden shrink-0 cursor-pointer transition-opacity relative group p-0 border-0",
|
|
108
|
-
props.avatarUrl
|
|
109
|
-
? ""
|
|
110
|
-
: "bg-primary/10 border-2 border-primary/15 hover:border-primary/30",
|
|
111
|
-
]}
|
|
184
|
+
class="w-7 h-7 rounded-full overflow-hidden shrink-0 cursor-pointer transition-opacity relative group p-0 border-0"
|
|
112
185
|
aria-label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
|
|
113
186
|
aria-haspopup={props.avatarAction === "drawer" ? "dialog" : "menu"}
|
|
114
187
|
aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
|
|
@@ -122,7 +195,12 @@ export const HkAdminHeader = defineComponent({
|
|
|
122
195
|
onError={() => { avatarFailed.value = true; }}
|
|
123
196
|
/>
|
|
124
197
|
) : (
|
|
125
|
-
|
|
198
|
+
// Letter fallback on the shared avatar primitive — the
|
|
199
|
+
// same filled-circle grammar as the chat frontend's
|
|
200
|
+
// trigger. The old border-2 ring made the console's
|
|
201
|
+
// avatar read as a different control (user report
|
|
202
|
+
// 2026-09-12: 前后台头像不一致).
|
|
203
|
+
<span class="s-user-avatar">
|
|
126
204
|
{props.username?.charAt(0).toUpperCase() || "?"}
|
|
127
205
|
</span>
|
|
128
206
|
)}
|
|
@@ -132,142 +210,85 @@ export const HkAdminHeader = defineComponent({
|
|
|
132
210
|
</div>
|
|
133
211
|
)}
|
|
134
212
|
</button>
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
213
|
+
{/* WHERE am I — the open view's title, not the nickname. The
|
|
214
|
+
explicit 1.5 line-height keeps descenders (g, y, p) inside
|
|
215
|
+
the truncate clip box: the default text-sm box is exactly
|
|
216
|
+
the em advance, so zoom/subpixel rounding in a scaled root
|
|
217
|
+
shaves the ink off at the bottom (user report 2026-09-12:
|
|
218
|
+
the "g" tail of the nickname was visibly cut). */}
|
|
219
|
+
{props.avatarAction === "menu" && props.title && (
|
|
220
|
+
<span
|
|
221
|
+
class="text-sm font-semibold text-text truncate max-w-[8rem]"
|
|
222
|
+
style={{ lineHeight: "1.5" }}
|
|
223
|
+
>
|
|
224
|
+
{props.title}
|
|
138
225
|
</span>
|
|
139
226
|
)}
|
|
140
227
|
</div>
|
|
141
228
|
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
onUpdate:
|
|
145
|
-
userMenuOpen.value = v;
|
|
146
|
-
// Closing the outer menu must also close the locale submenu,
|
|
147
|
-
// or it stays orphaned for the next open.
|
|
148
|
-
if (!v) localeMenuOpen.value = false;
|
|
149
|
-
}}
|
|
150
|
-
placement="bottom-start"
|
|
229
|
+
<HkMenu
|
|
230
|
+
open={userMenuOpen.value}
|
|
231
|
+
onUpdate:open={(v: boolean) => { userMenuOpen.value = v; }}
|
|
151
232
|
anchorRef={userTriggerRef.value ?? null}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
233
|
+
placement="bottom-start"
|
|
234
|
+
title={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
|
|
235
|
+
items={userMenuItems.value}
|
|
236
|
+
onSelect={(key: string) => onUserMenuSelect(key)}
|
|
155
237
|
>
|
|
156
|
-
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
>
|
|
190
|
-
{g
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
238
|
+
{{
|
|
239
|
+
header: () => (
|
|
240
|
+
!props.username && !props.userEmail ? (
|
|
241
|
+
// Pending identity: a spinner beside the label reads as
|
|
242
|
+
// "actively working", not a frozen panel.
|
|
243
|
+
<div class="s-user-header s-user-header--pending">
|
|
244
|
+
<HSpinner size="sm" />
|
|
245
|
+
<div class="s-user-header-email">{props.signingInLabel ?? t("hikari::adminHeader.signingIn", "Signing in…")}</div>
|
|
246
|
+
</div>
|
|
247
|
+
) : (
|
|
248
|
+
// Identity block FIRST — avatar, nickname, login email,
|
|
249
|
+
// permission badges — the same grammar the chat
|
|
250
|
+
// frontend's user-menu header uses (the shared
|
|
251
|
+
// s-user-header profile variant).
|
|
252
|
+
<div class="s-user-header s-user-header--profile">
|
|
253
|
+
<span class="s-user-avatar-chip" aria-hidden="true">
|
|
254
|
+
{props.avatarUrl && !avatarFailed.value ? (
|
|
255
|
+
<img
|
|
256
|
+
src={props.avatarUrl}
|
|
257
|
+
alt=""
|
|
258
|
+
class="s-user-avatar-img"
|
|
259
|
+
onError={() => { avatarFailed.value = true; }}
|
|
260
|
+
/>
|
|
261
|
+
) : (
|
|
262
|
+
<span class="s-user-avatar">
|
|
263
|
+
{props.username?.charAt(0).toUpperCase() || "?"}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
</span>
|
|
267
|
+
<div class="s-user-header-body">
|
|
268
|
+
{props.username && <div class="s-user-header-name">{props.username}</div>}
|
|
269
|
+
{props.userEmail && <div class="s-user-header-email">{props.userEmail}</div>}
|
|
270
|
+
{props.userGroups && props.userGroups.length > 0 && (
|
|
271
|
+
<div class="s-user-header-groups">
|
|
272
|
+
{props.userGroups.map((g: { id: string; name: string }) => (
|
|
273
|
+
<HBadge
|
|
274
|
+
key={g.id}
|
|
275
|
+
variant={g.name === "Administrators" ? "error" : "primary"}
|
|
276
|
+
size="sm"
|
|
277
|
+
>
|
|
278
|
+
{g.name === "Administrators"
|
|
279
|
+
? (props.adminGroupLabel ?? t("hikari::adminHeader.adminGroup", "Administrators"))
|
|
280
|
+
: g.name}
|
|
281
|
+
</HBadge>
|
|
282
|
+
))}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
195
285
|
</div>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
userMenuOpen.value = false;
|
|
202
|
-
avatarModalOpen.value = true;
|
|
203
|
-
}}
|
|
204
|
-
>
|
|
205
|
-
<span class="hk-menu-item-icon"><Camera size={14} /></span>
|
|
206
|
-
{props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar")}
|
|
207
|
-
</button>
|
|
208
|
-
{/* The language trigger owns the locale anchor: the ref sits
|
|
209
|
-
on the BUTTON itself (not a wrapper div) so the picker
|
|
210
|
-
popup anchors to it instead of falling back to inline
|
|
211
|
-
rendering. The ref OBJECT is passed through the slot —
|
|
212
|
-
reading .value here would freeze null from the first
|
|
213
|
-
render before the ref attached. */}
|
|
214
|
-
<button
|
|
215
|
-
ref={localeTriggerRef}
|
|
216
|
-
class="s-popup-menu-item"
|
|
217
|
-
onClick={() => (localeMenuOpen.value = !localeMenuOpen.value)}
|
|
218
|
-
>
|
|
219
|
-
<span class="hk-menu-item-icon"><Languages size={14} /></span>
|
|
220
|
-
{props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language")}
|
|
221
|
-
</button>
|
|
222
|
-
{slots["locale-picker"]?.({
|
|
223
|
-
open: localeMenuOpen.value,
|
|
224
|
-
onUpdateOpen: (v: boolean) => {
|
|
225
|
-
localeMenuOpen.value = v;
|
|
226
|
-
if (!v) userMenuOpen.value = false;
|
|
227
|
-
},
|
|
228
|
-
triggerRef: localeTriggerRef,
|
|
229
|
-
})}
|
|
230
|
-
{slots["user-menu-extra"]?.()}
|
|
231
|
-
{/* Frontend link — mirrors the drawer user panel's
|
|
232
|
-
"go to frontend" row (same icon/position: directly
|
|
233
|
-
above logout). Opt-in via the label prop so admin-only
|
|
234
|
-
panels keep the menu at avatar/language/logout. */}
|
|
235
|
-
{props.goToFrontendLabel ? (
|
|
236
|
-
<button
|
|
237
|
-
class="s-popup-menu-item"
|
|
238
|
-
onClick={() => {
|
|
239
|
-
userMenuOpen.value = false;
|
|
240
|
-
emit("goToFrontend");
|
|
241
|
-
}}
|
|
242
|
-
>
|
|
243
|
-
<span class="hk-menu-item-icon"><ExternalLink size={14} /></span>
|
|
244
|
-
{props.goToFrontendLabel}
|
|
245
|
-
</button>
|
|
246
|
-
) : null}
|
|
247
|
-
<button
|
|
248
|
-
class="s-popup-menu-item"
|
|
249
|
-
onClick={() => {
|
|
250
|
-
userMenuOpen.value = false;
|
|
251
|
-
emit("logout");
|
|
252
|
-
}}
|
|
253
|
-
>
|
|
254
|
-
<span class="hk-menu-item-icon"><LogOut size={14} /></span>
|
|
255
|
-
{props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout")}
|
|
256
|
-
</button>
|
|
257
|
-
</div>
|
|
258
|
-
)}
|
|
259
|
-
</HPopover>
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
),
|
|
289
|
+
}}
|
|
290
|
+
</HkMenu>
|
|
260
291
|
|
|
261
|
-
{/* The page title lives INSIDE each page in the HPageHeader
|
|
262
|
-
convention (big title left, tool buttons right). The bar
|
|
263
|
-
renders an optional context title only when one is given —
|
|
264
|
-
empty hides the node, keeping the bar to identity + theme
|
|
265
|
-
controls. */}
|
|
266
|
-
{props.title && (
|
|
267
|
-
<h2 class="text-sm font-semibold text-text truncate min-w-0">
|
|
268
|
-
{props.title}
|
|
269
|
-
</h2>
|
|
270
|
-
)}
|
|
271
292
|
<div class="ml-auto flex items-center gap-1.5 shrink-0">
|
|
272
293
|
{props.showEmergencyStop && (
|
|
273
294
|
<button
|
|
@@ -789,6 +789,85 @@ describe("HkMenu sidebar variant", () => {
|
|
|
789
789
|
expect(container.textContent).toContain("Products");
|
|
790
790
|
expect(container.textContent).not.toContain("General");
|
|
791
791
|
});
|
|
792
|
+
|
|
793
|
+
it("renders the header slot above the rows", () => {
|
|
794
|
+
const container = document.createElement("div");
|
|
795
|
+
document.body.appendChild(container);
|
|
796
|
+
containers.push(container);
|
|
797
|
+
const app = createApp({
|
|
798
|
+
render: () =>
|
|
799
|
+
h(
|
|
800
|
+
HkMenu,
|
|
801
|
+
{ variant: "sidebar", open: true, items: navItems },
|
|
802
|
+
{ header: () => h("div", { class: "sidebar-identity" }, "me@example.com") },
|
|
803
|
+
),
|
|
804
|
+
});
|
|
805
|
+
mounts.push(app);
|
|
806
|
+
app.mount(container);
|
|
807
|
+
|
|
808
|
+
// The identity block leads the same inline surface (drawer account
|
|
809
|
+
// menu grammar) — first child of the nav, before any row.
|
|
810
|
+
const nav = container.querySelector(".hk-menu-sidebar");
|
|
811
|
+
const identity = nav?.querySelector(".sidebar-identity");
|
|
812
|
+
expect(identity).not.toBeNull();
|
|
813
|
+
expect(nav!.firstElementChild).toBe(identity);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("renders the check column on rows that opt in (checked !== undefined)", async () => {
|
|
817
|
+
const selected: string[] = [];
|
|
818
|
+
const container = document.createElement("div");
|
|
819
|
+
document.body.appendChild(container);
|
|
820
|
+
containers.push(container);
|
|
821
|
+
const app = createApp({
|
|
822
|
+
render: () =>
|
|
823
|
+
h(HkMenu, {
|
|
824
|
+
variant: "sidebar",
|
|
825
|
+
open: true,
|
|
826
|
+
items: [
|
|
827
|
+
{
|
|
828
|
+
key: "locale",
|
|
829
|
+
label: "Language",
|
|
830
|
+
children: [
|
|
831
|
+
{ key: "en", label: "English", checked: false },
|
|
832
|
+
{ key: "zh", label: "中文", checked: true },
|
|
833
|
+
],
|
|
834
|
+
},
|
|
835
|
+
{ key: "logout", label: "Log out", danger: true },
|
|
836
|
+
],
|
|
837
|
+
onSelect: (key: string) => selected.push(key),
|
|
838
|
+
}),
|
|
839
|
+
});
|
|
840
|
+
mounts.push(app);
|
|
841
|
+
app.mount(container);
|
|
842
|
+
await settle();
|
|
843
|
+
|
|
844
|
+
// Groups default collapsed — expand the locale group.
|
|
845
|
+
const toggle = container.querySelector(
|
|
846
|
+
".hk-menu-sidebar-group-toggle",
|
|
847
|
+
) as HTMLButtonElement;
|
|
848
|
+
toggle.click();
|
|
849
|
+
await settle();
|
|
850
|
+
|
|
851
|
+
const rowOf = (label: string) =>
|
|
852
|
+
[...container.querySelectorAll(".hk-menu-sidebar-row")].find(
|
|
853
|
+
(r) => r.textContent?.includes(label),
|
|
854
|
+
) as HTMLButtonElement;
|
|
855
|
+
const en = rowOf("English");
|
|
856
|
+
const zh = rowOf("中文");
|
|
857
|
+
const logout = rowOf("Log out");
|
|
858
|
+
|
|
859
|
+
// Checked rows carry the cell; true renders the mark, false keeps
|
|
860
|
+
// the empty placeholder so labels stay aligned. Rows without the
|
|
861
|
+
// opt-in get no cell at all.
|
|
862
|
+
expect(en.querySelector(".hk-menu-check")).not.toBeNull();
|
|
863
|
+
expect(en.querySelector(".hk-menu-check")?.hasAttribute("data-on")).toBe(false);
|
|
864
|
+
expect(zh.querySelector(".hk-menu-check")?.hasAttribute("data-on")).toBe(true);
|
|
865
|
+
expect(logout.querySelector(".hk-menu-check")).toBeNull();
|
|
866
|
+
|
|
867
|
+
en.click();
|
|
868
|
+
await settle();
|
|
869
|
+
expect(selected).toEqual(["en"]);
|
|
870
|
+
});
|
|
792
871
|
});
|
|
793
872
|
|
|
794
873
|
describe("HkMenu close linger (leave-transition window)", () => {
|
|
@@ -723,6 +723,7 @@ export default defineComponent({
|
|
|
723
723
|
class="hk-menu-sidebar-row"
|
|
724
724
|
data-depth={depth || undefined}
|
|
725
725
|
data-active={active || undefined}
|
|
726
|
+
data-checked={item.checked || undefined}
|
|
726
727
|
data-danger={item.danger || undefined}
|
|
727
728
|
data-disabled={item.disabled || undefined}
|
|
728
729
|
style={
|
|
@@ -735,6 +736,15 @@ export default defineComponent({
|
|
|
735
736
|
emit("select", item.key, item);
|
|
736
737
|
}}
|
|
737
738
|
>
|
|
739
|
+
{/* Check column on opt-in rows (checked !== undefined): the
|
|
740
|
+
same fixed cell grammar the popup rows use, so a sidebar
|
|
741
|
+
locale/account picker shows its active entry in place.
|
|
742
|
+
false renders the empty placeholder to keep labels aligned. */}
|
|
743
|
+
{item.checked !== undefined && (
|
|
744
|
+
<span class="hk-menu-check" data-on={item.checked || undefined} aria-hidden="true">
|
|
745
|
+
{item.checked ? <Check size={12} /> : null}
|
|
746
|
+
</span>
|
|
747
|
+
)}
|
|
738
748
|
{item.icon && (
|
|
739
749
|
<span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
|
|
740
750
|
)}
|
|
@@ -782,6 +792,11 @@ export default defineComponent({
|
|
|
782
792
|
function renderSidebar() {
|
|
783
793
|
return (
|
|
784
794
|
<nav class="hk-menu-sidebar" aria-label={props.title || "menu"}>
|
|
795
|
+
{/* Header slot (identity block, section label, …) above the
|
|
796
|
+
rows — mirrors the popup variant's level-0 header, so an
|
|
797
|
+
account menu renders the same grammar whether it pops up
|
|
798
|
+
on desktop or lives inside a drawer on mobile. */}
|
|
799
|
+
{slots.header?.()}
|
|
785
800
|
{props.items.map((it) => renderSidebarItem(it, 0))}
|
|
786
801
|
</nav>
|
|
787
802
|
);
|
|
@@ -364,6 +364,55 @@
|
|
|
364
364
|
margin-bottom: var(--space-4);
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/* Profile variant — avatar chip beside the identity column, the ONE
|
|
368
|
+
identity grammar the chat frontend's user menu and the admin
|
|
369
|
+
header's dropdown share (moved up from chest's ChatLayout so both
|
|
370
|
+
surfaces render from the same sheet). */
|
|
371
|
+
.s-user-header--profile {
|
|
372
|
+
flex-direction: row;
|
|
373
|
+
align-items: center;
|
|
374
|
+
gap: var(--space-10);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* Avatar primitives shared by the trigger button and the identity
|
|
378
|
+
chip: the letter fallback fills its box, images cover. */
|
|
379
|
+
.s-user-avatar {
|
|
380
|
+
display: flex;
|
|
381
|
+
align-items: center;
|
|
382
|
+
justify-content: center;
|
|
383
|
+
width: 100%;
|
|
384
|
+
height: 100%;
|
|
385
|
+
border-radius: 50%;
|
|
386
|
+
background: var(--c-primary-medium);
|
|
387
|
+
color: rgb(var(--color-primary));
|
|
388
|
+
font-size: var(--text-xs);
|
|
389
|
+
font-weight: 700;
|
|
390
|
+
letter-spacing: 0.02em;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.s-user-avatar-img {
|
|
394
|
+
width: 100%;
|
|
395
|
+
height: 100%;
|
|
396
|
+
border-radius: 50%;
|
|
397
|
+
object-fit: cover;
|
|
398
|
+
display: block;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.s-user-avatar-chip {
|
|
402
|
+
flex-shrink: 0;
|
|
403
|
+
width: 32px;
|
|
404
|
+
height: 32px;
|
|
405
|
+
border-radius: 50%;
|
|
406
|
+
overflow: hidden;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.s-user-header-body {
|
|
410
|
+
min-width: 0;
|
|
411
|
+
display: flex;
|
|
412
|
+
flex-direction: column;
|
|
413
|
+
gap: 2px;
|
|
414
|
+
}
|
|
415
|
+
|
|
367
416
|
/* Pending variant (identity not yet hydrated): spinner beside the
|
|
368
417
|
label reads as "actively working", not a frozen panel. */
|
|
369
418
|
.s-user-header--pending {
|