@nextclaw/ui 0.9.16 → 0.10.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/assets/ChannelsList-VSRZzxx2.js +8 -0
  3. package/dist/assets/{ChatPage-4niJBFCu.js → ChatPage-CX0ZKE5i.js} +1 -1
  4. package/dist/assets/{DocBrowser-DpXDQNhb.js → DocBrowser-C65Hbvnb.js} +1 -1
  5. package/dist/assets/{LogoBadge-nqabOtgk.js → LogoBadge-4qtguXEJ.js} +1 -1
  6. package/dist/assets/{MarketplacePage-CrkTftqZ.js → MarketplacePage-DPCYptfD.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-DH1qKJqo.js → McpMarketplacePage-CHLkD8yX.js} +1 -1
  8. package/dist/assets/{ModelConfig-CrrxPK_y.js → ModelConfig-CjsGdmZa.js} +1 -1
  9. package/dist/assets/{ProvidersList-BG36JlSJ.js → ProvidersList-aXp_mo4J.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-Dcj2Pzpt.js → RemoteAccessPage-rOZCnH1x.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-BrxgUzjJ.js → RuntimeConfig-CmJh6g0R.js} +1 -1
  12. package/dist/assets/{SearchConfig-D-NLwowp.js → SearchConfig-C_hUuzR4.js} +1 -1
  13. package/dist/assets/{SecretsConfig-DjNqBB05.js → SecretsConfig-Bu_zIRlQ.js} +1 -1
  14. package/dist/assets/{SessionsConfig-DdlsWXQc.js → SessionsConfig-DA_nqkM_.js} +1 -1
  15. package/dist/assets/{chat-message-B7THd1Mh.js → chat-message-BOdA4h43.js} +1 -1
  16. package/dist/assets/{index-UC08nscf.css → index-C63mHRbE.css} +1 -1
  17. package/dist/assets/index-DS7D1-KS.js +8 -0
  18. package/dist/assets/{label-B-TkPZRF.js → label-BYZ62ajO.js} +1 -1
  19. package/dist/assets/{page-layout-BTVBRo6H.js → page-layout-UC-h92sU.js} +1 -1
  20. package/dist/assets/{popover-DBZvpGcL.js → popover-DASCEr3G.js} +1 -1
  21. package/dist/assets/{security-config-DotxwVFR.js → security-config-Cvujq4fH.js} +1 -1
  22. package/dist/assets/{skeleton-DGtduHZV.js → skeleton-DlYEKkkj.js} +1 -1
  23. package/dist/assets/{status-dot-BCUTVN2R.js → status-dot-C1AvPwDD.js} +1 -1
  24. package/dist/assets/{switch-Bp2mda29.js → switch-D3wVuCSh.js} +1 -1
  25. package/dist/assets/{tabs-custom-BE8yZ2kE.js → tabs-custom-CbgS7tu0.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-DCy-eYnV.js → useConfirmDialog-BYbFEIbQ.js} +1 -1
  27. package/dist/index.html +2 -2
  28. package/package.json +7 -6
  29. package/src/components/chat/ChatSidebar.test.tsx +1 -1
  30. package/src/components/chat/chat-sidebar-session-item.tsx +0 -1
  31. package/src/components/config/ChannelsList.test.tsx +8 -0
  32. package/src/components/config/channel-form-fields.ts +9 -1
  33. package/src/components/config/weixin-channel-auth-section.test.tsx +90 -0
  34. package/src/components/config/weixin-channel-auth-section.tsx +60 -1
  35. package/src/components/layout/Sidebar.tsx +128 -120
  36. package/src/components/layout/sidebar.layout.test.tsx +99 -0
  37. package/src/lib/i18n.channels.ts +1 -0
  38. package/src/qrcode.d.ts +10 -0
  39. package/dist/assets/ChannelsList-DhM0gvDV.js +0 -1
  40. package/dist/assets/index-CgqD0Jfg.js +0 -8
@@ -53,6 +53,10 @@ const mocks = vi.hoisted(() => ({
53
53
  }
54
54
  }));
55
55
 
56
+ vi.mock('qrcode', () => ({
57
+ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,weixin-qr')
58
+ }));
59
+
56
60
  vi.mock('@tanstack/react-query', async () => {
57
61
  const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query');
58
62
  return {
@@ -129,6 +133,10 @@ describe('ChannelsList', () => {
129
133
  })
130
134
  });
131
135
  });
136
+
137
+ await waitFor(() => {
138
+ expect(screen.getByAltText('Weixin login QR code').getAttribute('src')).toBe('data:image/png;base64,weixin-qr');
139
+ });
132
140
  });
133
141
 
134
142
  it('saves weixin advanced settings from the advanced section', async () => {
@@ -70,7 +70,15 @@ export function buildChannelFields(): Record<string, ChannelField[]> {
70
70
  { name: 'appSecret', type: 'password', label: t('appSecret') },
71
71
  { name: 'encryptKey', type: 'password', label: t('encryptKey') },
72
72
  { name: 'verificationToken', type: 'password', label: t('verificationToken') },
73
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
73
+ { name: 'domain', type: 'text', label: 'Domain' },
74
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
75
+ { name: 'dmPolicy', type: 'select', label: t('dmPolicy'), options: DM_POLICY_OPTIONS },
76
+ { name: 'groupPolicy', type: 'select', label: t('groupPolicy'), options: GROUP_POLICY_OPTIONS },
77
+ { name: 'groupAllowFrom', type: 'tags', label: t('groupAllowFrom') },
78
+ { name: 'requireMention', type: 'boolean', label: t('requireMention') },
79
+ { name: 'mentionPatterns', type: 'tags', label: t('mentionPatterns') },
80
+ { name: 'groups', type: 'json', label: t('groupRulesJson') },
81
+ { name: 'accounts', type: 'json', label: t('accountsJson') }
74
82
  ],
75
83
  dingtalk: [
76
84
  { name: 'enabled', type: 'boolean', label: t('enabled') },
@@ -0,0 +1,90 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { WeixinChannelAuthSection } from './weixin-channel-auth-section';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ startChannelAuthMutateAsync: vi.fn(),
7
+ pollChannelAuthMutateAsync: vi.fn(),
8
+ invalidateQueries: vi.fn().mockResolvedValue(undefined)
9
+ }));
10
+
11
+ vi.mock('@tanstack/react-query', async () => {
12
+ const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query');
13
+ return {
14
+ ...actual,
15
+ useQueryClient: () => ({
16
+ invalidateQueries: mocks.invalidateQueries
17
+ })
18
+ };
19
+ });
20
+
21
+ vi.mock('qrcode', () => ({
22
+ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,weixin-qr')
23
+ }));
24
+
25
+ vi.mock('@/hooks/use-channel-auth', () => ({
26
+ useStartChannelAuth: () => ({
27
+ mutateAsync: mocks.startChannelAuthMutateAsync,
28
+ isPending: false
29
+ }),
30
+ usePollChannelAuth: () => ({
31
+ mutateAsync: mocks.pollChannelAuthMutateAsync,
32
+ isPending: false
33
+ })
34
+ }));
35
+
36
+ describe('WeixinChannelAuthSection', () => {
37
+ beforeEach(() => {
38
+ mocks.startChannelAuthMutateAsync.mockReset();
39
+ mocks.pollChannelAuthMutateAsync.mockReset();
40
+ mocks.invalidateQueries.mockClear();
41
+ });
42
+
43
+ it('switches to connected state when channel config becomes authorized during an active session', async () => {
44
+ const user = userEvent.setup();
45
+ mocks.startChannelAuthMutateAsync.mockResolvedValue({
46
+ channel: 'weixin',
47
+ kind: 'qr_code',
48
+ sessionId: 'session-1',
49
+ qrCode: 'qr-token',
50
+ qrCodeUrl: 'https://example.com/weixin-qr.png',
51
+ expiresAt: '2026-03-24T10:00:00.000Z',
52
+ intervalMs: 60_000,
53
+ note: '请扫码'
54
+ });
55
+ mocks.pollChannelAuthMutateAsync.mockImplementation(() => new Promise(() => {}));
56
+
57
+ const { rerender } = render(
58
+ <WeixinChannelAuthSection
59
+ channelConfig={{ enabled: false }}
60
+ formData={{}}
61
+ />
62
+ );
63
+
64
+ await user.click(screen.getByRole('button', { name: 'Scan QR to connect Weixin' }));
65
+
66
+ await waitFor(() => {
67
+ expect(screen.getByRole('button', { name: 'Waiting for scan confirmation' })).toBeTruthy();
68
+ });
69
+
70
+ rerender(
71
+ <WeixinChannelAuthSection
72
+ channelConfig={{
73
+ enabled: true,
74
+ defaultAccountId: 'bot-1@im.bot',
75
+ accounts: {
76
+ 'bot-1@im.bot': {
77
+ enabled: true
78
+ }
79
+ }
80
+ }}
81
+ formData={{}}
82
+ />
83
+ );
84
+
85
+ await waitFor(() => {
86
+ expect(screen.getByText('Connected')).toBeTruthy();
87
+ expect(screen.getByRole('button', { name: 'Reconnect with QR' })).toBeTruthy();
88
+ });
89
+ });
90
+ });
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import { useQueryClient } from '@tanstack/react-query';
3
+ import { toDataURL } from 'qrcode';
3
4
  import { Button } from '@/components/ui/button';
4
5
  import { formatDateTime, t } from '@/lib/i18n';
5
6
  import { cn } from '@/lib/utils';
@@ -51,12 +52,61 @@ export function WeixinChannelAuthSection({
51
52
  const pollChannelAuth = usePollChannelAuth();
52
53
  const [activeSession, setActiveSession] = useState<ChannelAuthStartResult | null>(null);
53
54
  const [authState, setAuthState] = useState<ChannelAuthPollResult | null>(null);
55
+ const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
54
56
 
55
57
  const connectedAccountIds = useMemo(() => resolveConnectedAccountIds(channelConfig), [channelConfig]);
56
58
  const primaryAccountId = connectedAccountIds[0];
57
59
  const baseUrl = resolveBaseUrl(formData, channelConfig);
58
60
  const hasConnectedAccount = connectedAccountIds.length > 0;
59
61
 
62
+ useEffect(() => {
63
+ if (!hasConnectedAccount) {
64
+ return;
65
+ }
66
+
67
+ setActiveSession(null);
68
+ setAuthState((prev) => {
69
+ if (prev?.status === 'authorized') {
70
+ return prev;
71
+ }
72
+ return {
73
+ channel: 'weixin',
74
+ status: 'authorized',
75
+ message: t('weixinAuthAuthorized'),
76
+ accountId: primaryAccountId ?? null
77
+ };
78
+ });
79
+ }, [hasConnectedAccount, primaryAccountId]);
80
+
81
+ useEffect(() => {
82
+ if (!activeSession) {
83
+ setQrDataUrl(null);
84
+ return;
85
+ }
86
+
87
+ let cancelled = false;
88
+
89
+ void toDataURL(activeSession.qrCodeUrl, {
90
+ errorCorrectionLevel: 'M',
91
+ margin: 1,
92
+ width: 480
93
+ })
94
+ .then((dataUrl: string) => {
95
+ if (!cancelled) {
96
+ setQrDataUrl(dataUrl);
97
+ }
98
+ })
99
+ .catch(() => {
100
+ if (!cancelled) {
101
+ setQrDataUrl(null);
102
+ }
103
+ });
104
+
105
+ return () => {
106
+ cancelled = true;
107
+ };
108
+ }, [activeSession]);
109
+
60
110
  useEffect(() => {
61
111
  if (!activeSession) {
62
112
  return;
@@ -210,7 +260,16 @@ export function WeixinChannelAuthSection({
210
260
  {activeSession ? (
211
261
  <div className="space-y-3">
212
262
  <div className="overflow-hidden rounded-2xl border border-gray-100 bg-white p-3">
213
- <img src={activeSession.qrCodeUrl} alt={t('weixinAuthQrAlt')} className="mx-auto aspect-square w-full max-w-[240px] object-contain" />
263
+ {qrDataUrl ? (
264
+ <img src={qrDataUrl} alt={t('weixinAuthQrAlt')} className="mx-auto aspect-square w-full max-w-[240px] object-contain" />
265
+ ) : (
266
+ <div className="flex aspect-square w-full items-center justify-center rounded-xl bg-gray-50 text-gray-500">
267
+ <div className="flex flex-col items-center gap-2 text-center">
268
+ <Loader2 className="h-5 w-5 animate-spin" />
269
+ <p className="text-xs">{t('weixinAuthStarting')}</p>
270
+ </div>
271
+ </div>
272
+ )}
214
273
  </div>
215
274
  <div className="space-y-1 text-xs text-gray-500">
216
275
  <p>{authState?.message || activeSession.note || t('weixinAuthScanPrompt')}</p>
@@ -122,147 +122,155 @@ export function Sidebar({ mode }: SidebarProps) {
122
122
  const navItems = mode === 'main' ? mainNavItems : settingsNavItems;
123
123
 
124
124
  return (
125
- <aside className="w-[240px] shrink-0 flex flex-col h-full py-6 px-4 bg-secondary">
125
+ <aside className="w-[240px] shrink-0 flex h-full min-h-0 flex-col overflow-hidden bg-secondary px-4 py-6">
126
126
  {mode === 'settings' ? (
127
- <div className="px-2 mb-6">
128
- <NavLink
129
- to="/chat"
130
- className="group inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900"
127
+ <div className="shrink-0 px-2 pb-3">
128
+ <div
129
+ className="flex items-center gap-2 px-1 py-1"
130
+ data-testid="settings-sidebar-header"
131
131
  >
132
- <ArrowLeft className="h-3.5 w-3.5 text-gray-500 group-hover:text-gray-800" />
133
- <span>{t('backToMain')}</span>
134
- </NavLink>
135
- <div className="mt-5 px-1">
136
- <div className="flex items-center gap-2.5">
137
- <Settings className="h-5 w-5 text-gray-700" />
138
- <h1 className="text-[28px] leading-none font-semibold tracking-[-0.02em] text-gray-900">{t('settings')}</h1>
139
- </div>
132
+ <NavLink
133
+ to="/chat"
134
+ className="group inline-flex min-w-0 items-center gap-1.5 rounded-lg px-1 py-1 text-[12px] font-medium text-gray-500 transition-colors hover:text-gray-900"
135
+ >
136
+ <ArrowLeft className="h-3.5 w-3.5 shrink-0 text-gray-400 group-hover:text-gray-700" />
137
+ <span className="truncate">{t('backToMain')}</span>
138
+ </NavLink>
139
+ <span className="h-4 w-px shrink-0 bg-[#dddfe6]" aria-hidden="true" />
140
+ <h1 className="truncate text-[15px] font-semibold tracking-[-0.01em] text-gray-800">{t('settings')}</h1>
140
141
  </div>
141
142
  </div>
142
143
  ) : (
143
- <div className="px-2 mb-8">
144
+ <div className="shrink-0 px-2 pb-8">
144
145
  <BrandHeader className="flex items-center gap-2.5 cursor-pointer" />
145
146
  </div>
146
147
  )}
147
148
 
148
- {/* Navigation */}
149
- <nav className="flex-1 flex flex-col">
150
- <ul className="space-y-1">
151
- {navItems.map((item) => {
152
- const Icon = item.icon;
149
+ <div className="flex min-h-0 flex-1 flex-col">
150
+ {/* Navigation */}
151
+ <nav className="custom-scrollbar min-h-0 flex-1 overflow-y-auto pr-1">
152
+ <ul className="space-y-1 pb-4">
153
+ {navItems.map((item) => {
154
+ const Icon = item.icon;
153
155
 
154
- return (
155
- <li key={item.target}>
156
- <NavLink
157
- to={item.target}
158
- className={({ isActive }) => cn(
159
- 'group w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[14px] font-medium transition-all duration-base',
160
- isActive
161
- ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
162
- : 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
163
- )}
164
- >
165
- {({ isActive }) => (
166
- <>
167
- <Icon className={cn(
168
- 'h-[17px] w-[17px] transition-colors',
169
- isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
170
- )} />
171
- <span className="flex-1 text-left">{item.label}</span>
172
- </>
173
- )}
174
- </NavLink>
175
- </li>
156
+ return (
157
+ <li key={item.target}>
158
+ <NavLink
159
+ to={item.target}
160
+ className={({ isActive }) =>
161
+ cn(
162
+ 'group w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-[14px] font-medium transition-all duration-base',
163
+ isActive
164
+ ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
165
+ : 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
166
+ )
167
+ }
168
+ >
169
+ {({ isActive }) => (
170
+ <>
171
+ <Icon
172
+ className={cn(
173
+ 'h-[17px] w-[17px] transition-colors',
174
+ isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
175
+ )}
176
+ />
177
+ <span className="flex-1 text-left">{item.label}</span>
178
+ </>
179
+ )}
180
+ </NavLink>
181
+ </li>
176
182
  );
177
183
  })}
178
184
  </ul>
179
- </nav>
185
+ </nav>
180
186
 
181
- {/* Help Button */}
182
- <div className="pt-3 border-t border-[#dde0ea] mt-3">
183
- {mode === 'settings' ? (
184
- <button
185
- onClick={() => presenter.accountManager.openAccountPanel()}
186
- className="mb-2 w-full rounded-xl px-3 py-2.5 text-left transition-all duration-base text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-900"
187
- >
188
- <div className="flex items-start gap-3">
189
- <KeyRound className={cn('mt-0.5 h-[17px] w-[17px]', accountConnected ? 'text-emerald-600' : 'text-gray-400')} />
190
- <div className="min-w-0 flex-1">
191
- <div className="flex items-center justify-between gap-3">
192
- <p className="truncate text-[14px] font-medium text-gray-900">
193
- {accountEmail || t('remoteAccountEntryManage')}
187
+ {/* Footer actions stay reachable while the nav scrolls independently. */}
188
+ <div className="mt-3 shrink-0 border-t border-[#dde0ea] bg-secondary pt-3">
189
+ {mode === 'settings' ? (
190
+ <button
191
+ onClick={() => presenter.accountManager.openAccountPanel()}
192
+ className="mb-2 w-full rounded-xl px-3 py-2.5 text-left text-gray-600 transition-all duration-base hover:bg-[#e4e7ef] hover:text-gray-900"
193
+ data-testid="settings-sidebar-account-entry"
194
+ >
195
+ <div className="flex items-start gap-3">
196
+ <KeyRound className="mt-0.5 h-[17px] w-[17px] shrink-0 text-gray-400" />
197
+ <div className="min-w-0 flex-1">
198
+ <p className="truncate text-[14px] font-medium text-gray-600">
199
+ {t('remoteAccountEntryManage')}
200
+ </p>
201
+ <p className="mt-1 truncate text-xs text-gray-500">
202
+ {accountConnected ? accountEmail || t('remoteAccountEntryConnected') : t('remoteAccountEntryDisconnected')}
194
203
  </p>
195
204
  </div>
196
- <p className="mt-1 truncate text-xs text-gray-500">
197
- {accountConnected ? t('remoteAccountEntryConnected') : t('remoteAccountEntryDisconnected')}
198
- </p>
199
205
  </div>
206
+ </button>
207
+ ) : null}
208
+ {mode === 'main' && (
209
+ <div className="mb-2">
210
+ <NavLink
211
+ to="/settings"
212
+ className={({ isActive }) =>
213
+ cn(
214
+ 'group w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-[14px] font-medium transition-all duration-base',
215
+ isActive
216
+ ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
217
+ : 'text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-900'
218
+ )
219
+ }
220
+ >
221
+ {({ isActive }) => (
222
+ <>
223
+ <Settings className={cn('h-[17px] w-[17px] transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
224
+ <span className="flex-1 text-left">{t('settings')}</span>
225
+ </>
226
+ )}
227
+ </NavLink>
200
228
  </div>
201
- </button>
202
- ) : null}
203
- {mode === 'main' && (
229
+ )}
204
230
  <div className="mb-2">
205
- <NavLink
206
- to="/settings"
207
- className={({ isActive }) => cn(
208
- 'group w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[14px] font-medium transition-all duration-base',
209
- isActive
210
- ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
211
- : 'text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-900'
212
- )}
213
- >
214
- {({ isActive }) => (
215
- <>
216
- <Settings className={cn('h-[17px] w-[17px] transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
217
- <span className="flex-1 text-left">{t('settings')}</span>
218
- </>
219
- )}
220
- </NavLink>
231
+ <Select value={theme} onValueChange={(value) => handleThemeSwitch(value as UiTheme)}>
232
+ <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent px-3 py-2.5 text-[14px] font-medium text-gray-600 shadow-none hover:bg-[#e4e7ef] focus:ring-0">
233
+ <div className="flex min-w-0 items-center gap-3">
234
+ <Palette className="h-[17px] w-[17px] text-gray-400" />
235
+ <span className="text-left">{t('theme')}</span>
236
+ </div>
237
+ <span className="ml-auto text-xs text-gray-500">{currentThemeLabel}</span>
238
+ </SelectTrigger>
239
+ <SelectContent>
240
+ {THEME_OPTIONS.map((option) => (
241
+ <SelectItem key={option.value} value={option.value} className="text-xs">
242
+ {t(option.labelKey)}
243
+ </SelectItem>
244
+ ))}
245
+ </SelectContent>
246
+ </Select>
221
247
  </div>
222
- )}
223
- <div className="mb-2">
224
- <Select value={theme} onValueChange={(value) => handleThemeSwitch(value as UiTheme)}>
225
- <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2.5 text-[14px] font-medium text-gray-600 hover:bg-[#e4e7ef] focus:ring-0">
226
- <div className="flex items-center gap-3 min-w-0">
227
- <Palette className="h-[17px] w-[17px] text-gray-400" />
228
- <span className="text-left">{t('theme')}</span>
229
- </div>
230
- <span className="ml-auto text-xs text-gray-500">{currentThemeLabel}</span>
231
- </SelectTrigger>
232
- <SelectContent>
233
- {THEME_OPTIONS.map((option) => (
234
- <SelectItem key={option.value} value={option.value} className="text-xs">
235
- {t(option.labelKey)}
236
- </SelectItem>
237
- ))}
238
- </SelectContent>
239
- </Select>
240
- </div>
241
- <div className="mb-2">
242
- <Select value={language} onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}>
243
- <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2.5 text-[14px] font-medium text-gray-600 hover:bg-[#e4e7ef] focus:ring-0">
244
- <div className="flex items-center gap-3 min-w-0">
245
- <Languages className="h-[17px] w-[17px] text-gray-400" />
246
- <span className="text-left">{t('language')}</span>
247
- </div>
248
- <span className="ml-auto text-xs text-gray-500">{currentLanguageLabel}</span>
249
- </SelectTrigger>
250
- <SelectContent>
251
- {LANGUAGE_OPTIONS.map((option) => (
252
- <SelectItem key={option.value} value={option.value} className="text-xs">
253
- {option.label}
254
- </SelectItem>
255
- ))}
256
- </SelectContent>
257
- </Select>
248
+ <div className="mb-2">
249
+ <Select value={language} onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}>
250
+ <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent px-3 py-2.5 text-[14px] font-medium text-gray-600 shadow-none hover:bg-[#e4e7ef] focus:ring-0">
251
+ <div className="flex min-w-0 items-center gap-3">
252
+ <Languages className="h-[17px] w-[17px] text-gray-400" />
253
+ <span className="text-left">{t('language')}</span>
254
+ </div>
255
+ <span className="ml-auto text-xs text-gray-500">{currentLanguageLabel}</span>
256
+ </SelectTrigger>
257
+ <SelectContent>
258
+ {LANGUAGE_OPTIONS.map((option) => (
259
+ <SelectItem key={option.value} value={option.value} className="text-xs">
260
+ {option.label}
261
+ </SelectItem>
262
+ ))}
263
+ </SelectContent>
264
+ </Select>
265
+ </div>
266
+ <button
267
+ onClick={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
268
+ className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-[14px] font-medium text-gray-600 transition-all duration-base hover:bg-[#e4e7ef] hover:text-gray-800"
269
+ >
270
+ <BookOpen className="h-[17px] w-[17px] text-gray-400" />
271
+ <span className="flex-1 text-left">{t('docBrowserHelp')}</span>
272
+ </button>
258
273
  </div>
259
- <button
260
- onClick={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
261
- className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[14px] font-medium transition-all duration-base text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-800"
262
- >
263
- <BookOpen className="h-[17px] w-[17px] text-gray-400" />
264
- <span className="flex-1 text-left">{t('docBrowserHelp')}</span>
265
- </button>
266
274
  </div>
267
275
  </aside>
268
276
  );
@@ -0,0 +1,99 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { Sidebar } from '@/components/layout/Sidebar';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ openAccountPanel: vi.fn(),
8
+ docOpen: vi.fn(),
9
+ remoteStatus: {
10
+ data: {
11
+ account: {
12
+ loggedIn: true,
13
+ email: 'user@example.com'
14
+ }
15
+ }
16
+ }
17
+ }));
18
+
19
+ vi.mock('@/components/doc-browser', () => ({
20
+ useDocBrowser: () => ({
21
+ open: mocks.docOpen
22
+ })
23
+ }));
24
+
25
+ vi.mock('@/presenter/app-presenter-context', () => ({
26
+ useAppPresenter: () => ({
27
+ accountManager: {
28
+ openAccountPanel: mocks.openAccountPanel
29
+ }
30
+ })
31
+ }));
32
+
33
+ vi.mock('@/hooks/useRemoteAccess', () => ({
34
+ useRemoteStatus: () => mocks.remoteStatus
35
+ }));
36
+
37
+ vi.mock('@/components/providers/I18nProvider', () => ({
38
+ useI18n: () => ({
39
+ language: 'en',
40
+ setLanguage: vi.fn()
41
+ })
42
+ }));
43
+
44
+ vi.mock('@/components/providers/ThemeProvider', () => ({
45
+ useTheme: () => ({
46
+ theme: 'warm',
47
+ setTheme: vi.fn()
48
+ })
49
+ }));
50
+
51
+ describe('Sidebar', () => {
52
+ it('keeps the settings sidebar bounded and lets the navigation scroll independently', () => {
53
+ const { container } = render(
54
+ <MemoryRouter initialEntries={['/model']}>
55
+ <Sidebar mode="settings" />
56
+ </MemoryRouter>
57
+ );
58
+
59
+ const aside = container.querySelector('aside');
60
+ const nav = container.querySelector('nav');
61
+
62
+ expect(aside?.className).toContain('min-h-0');
63
+ expect(aside?.className).toContain('overflow-hidden');
64
+ expect(nav?.className).toContain('flex-1');
65
+ expect(nav?.className).toContain('min-h-0');
66
+ expect(nav?.className).toContain('overflow-y-auto');
67
+ });
68
+
69
+ it('uses a compact single-row header for settings mode', () => {
70
+ render(
71
+ <MemoryRouter initialEntries={['/model']}>
72
+ <Sidebar mode="settings" />
73
+ </MemoryRouter>
74
+ );
75
+
76
+ const header = screen.getByTestId('settings-sidebar-header');
77
+
78
+ expect(header).toBeTruthy();
79
+ expect(screen.getByRole('heading', { name: 'Settings' })).toBeTruthy();
80
+ expect(screen.getByRole('link', { name: 'Back to Main' })).toBeTruthy();
81
+ expect(header.className).not.toContain('bg-white');
82
+ expect(header.className).not.toContain('rounded-2xl');
83
+ });
84
+
85
+ it('renders the account entry with the same neutral visual tone as other footer items', () => {
86
+ render(
87
+ <MemoryRouter initialEntries={['/model']}>
88
+ <Sidebar mode="settings" />
89
+ </MemoryRouter>
90
+ );
91
+
92
+ const accountEntry = screen.getByTestId('settings-sidebar-account-entry');
93
+
94
+ expect(accountEntry).toBeTruthy();
95
+ expect(screen.getByText('Account and Device Entry')).toBeTruthy();
96
+ expect(screen.getByText('user@example.com')).toBeTruthy();
97
+ expect(accountEntry.className).toContain('text-gray-600');
98
+ });
99
+ });
@@ -29,6 +29,7 @@ export const CHANNEL_LABELS: Record<string, { zh: string; en: string }> = {
29
29
  botToken: { zh: 'Bot Token', en: 'Bot Token' },
30
30
  appToken: { zh: 'App Token', en: 'App Token' },
31
31
  appId: { zh: 'App ID', en: 'App ID' },
32
+ domain: { zh: '域名', en: 'Domain' },
32
33
  corpId: { zh: '企业 ID', en: 'Corp ID' },
33
34
  agentId: { zh: '应用 Agent ID', en: 'Agent ID' },
34
35
  appSecret: { zh: 'App Secret', en: 'App Secret' },
@@ -0,0 +1,10 @@
1
+ declare module 'qrcode' {
2
+ export function toDataURL(
3
+ text: string,
4
+ options?: {
5
+ errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
6
+ margin?: number;
7
+ width?: number;
8
+ }
9
+ ): Promise<string>;
10
+ }