@nextclaw/ui 0.9.15 → 0.9.17

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 (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assets/ChannelsList-D75wfbDS.js +8 -0
  3. package/dist/assets/{ChatPage-Dmpau_7n.js → ChatPage-gWZ3rDTy.js} +14 -14
  4. package/dist/assets/{DocBrowser-C3ijFxFF.js → DocBrowser-CebTdor0.js} +1 -1
  5. package/dist/assets/{LogoBadge-BgjXmBcw.js → LogoBadge-gdbraoaZ.js} +1 -1
  6. package/dist/assets/MarketplacePage-C-zz0lBT.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-DPtH1xcY.js → McpMarketplacePage-tfpLh6Zz.js} +1 -1
  8. package/dist/assets/ModelConfig-j74dn-5k.js +1 -0
  9. package/dist/assets/{ProvidersList-DnWsJqMQ.js → ProvidersList-BGI9EgVV.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-BrXq-x0-.js → RemoteAccessPage-CusGQmZE.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-UE9VaFO7.js → RuntimeConfig-pmhW8ifz.js} +1 -1
  12. package/dist/assets/{SearchConfig-CP-RM3V3.js → SearchConfig-rrD2_F5u.js} +1 -1
  13. package/dist/assets/{SecretsConfig-CfN_bazs.js → SecretsConfig-D7onb-hv.js} +1 -1
  14. package/dist/assets/{SessionsConfig-CgkKzKGv.js → SessionsConfig-m-6RSeja.js} +1 -1
  15. package/dist/assets/{chat-message-CGL3sMsS.js → chat-message-BO-s2mvl.js} +1 -1
  16. package/dist/assets/index-BsL1YIJ1.js +8 -0
  17. package/dist/assets/index-C63mHRbE.css +1 -0
  18. package/dist/assets/{label-CbOSodIL.js → label-CDSYExvV.js} +1 -1
  19. package/dist/assets/{page-layout-BtDnyNLf.js → page-layout-BMCVAnQM.js} +1 -1
  20. package/dist/assets/{popover-DGlUjPQc.js → popover-DfywyUDH.js} +1 -1
  21. package/dist/assets/{security-config-D6Bs1yoK.js → security-config-BU-K2EOM.js} +1 -1
  22. package/dist/assets/skeleton-Cg9CRkOt.js +1 -0
  23. package/dist/assets/{status-dot-C8vM3IN1.js → status-dot-2vau2Xtc.js} +1 -1
  24. package/dist/assets/{switch-AuwUiga3.js → switch-CJRPF2V6.js} +1 -1
  25. package/dist/assets/{tabs-custom-CTS7SaFG.js → tabs-custom-B-2uSCfW.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-DrMAdNfN.js → useConfirmDialog-CfOpdypA.js} +1 -1
  27. package/dist/assets/{vendor-TJ2hy_Lv.js → vendor-DJt0Azq5.js} +90 -80
  28. package/dist/index.html +3 -3
  29. package/package.json +7 -6
  30. package/src/api/channel-auth.ts +35 -0
  31. package/src/api/channel-auth.types.ts +28 -0
  32. package/src/api/config.ts +2 -4
  33. package/src/api/types.ts +7 -26
  34. package/src/components/chat/ChatSidebar.test.tsx +1 -1
  35. package/src/components/chat/chat-sidebar-session-item.tsx +0 -1
  36. package/src/components/chat/ncp/ncp-session-adapter.ts +0 -1
  37. package/src/components/config/ChannelForm.tsx +41 -128
  38. package/src/components/config/ChannelsList.test.tsx +79 -10
  39. package/src/components/config/ModelConfig.test.tsx +78 -0
  40. package/src/components/config/ModelConfig.tsx +4 -1
  41. package/src/components/config/channel-form-fields-section.tsx +155 -0
  42. package/src/components/config/weixin-channel-auth-section.test.tsx +90 -0
  43. package/src/components/config/weixin-channel-auth-section.tsx +301 -0
  44. package/src/components/layout/Sidebar.tsx +128 -120
  45. package/src/components/layout/sidebar.layout.test.tsx +99 -0
  46. package/src/hooks/use-channel-auth.ts +16 -0
  47. package/src/lib/i18n.channel-auth.ts +37 -0
  48. package/src/lib/i18n.ts +2 -4
  49. package/src/qrcode.d.ts +10 -0
  50. package/src/transport/app-client.ts +22 -6
  51. package/dist/assets/ChannelsList-Cu_hLbps.js +0 -1
  52. package/dist/assets/MarketplacePage-CAIdEiw8.js +0 -49
  53. package/dist/assets/ModelConfig-D-pqArCg.js +0 -1
  54. package/dist/assets/index-D4alkESd.js +0 -8
  55. package/dist/assets/index-SGSkQCPi.css +0 -1
  56. package/dist/assets/skeleton-BLV99JbX.js +0 -1
@@ -0,0 +1,155 @@
1
+ import type { Dispatch, SetStateAction } from 'react';
2
+ import { Input } from '@/components/ui/input';
3
+ import { Label } from '@/components/ui/label';
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
5
+ import { Switch } from '@/components/ui/switch';
6
+ import { TagInput } from '@/components/common/TagInput';
7
+ import { hintForPath } from '@/lib/config-hints';
8
+ import { t } from '@/lib/i18n';
9
+ import { Globe, Hash, KeyRound, Mail, Settings, ToggleLeft } from 'lucide-react';
10
+ import type { ConfigUiHints } from '@/api/types';
11
+ import type { ChannelField } from './channel-form-fields';
12
+
13
+ function getFieldIcon(fieldName: string) {
14
+ if (fieldName.includes('token') || fieldName.includes('secret') || fieldName.includes('password')) {
15
+ return <KeyRound className="h-3.5 w-3.5 text-gray-500" />;
16
+ }
17
+ if (fieldName.includes('url') || fieldName.includes('host')) {
18
+ return <Globe className="h-3.5 w-3.5 text-gray-500" />;
19
+ }
20
+ if (fieldName.includes('email') || fieldName.includes('mail')) {
21
+ return <Mail className="h-3.5 w-3.5 text-gray-500" />;
22
+ }
23
+ if (fieldName.includes('id') || fieldName.includes('from')) {
24
+ return <Hash className="h-3.5 w-3.5 text-gray-500" />;
25
+ }
26
+ if (fieldName === 'enabled' || fieldName === 'consentGranted') {
27
+ return <ToggleLeft className="h-3.5 w-3.5 text-gray-500" />;
28
+ }
29
+ return <Settings className="h-3.5 w-3.5 text-gray-500" />;
30
+ }
31
+
32
+ type ChannelFormFieldsSectionProps = {
33
+ channelName: string;
34
+ fields: ChannelField[];
35
+ formData: Record<string, unknown>;
36
+ jsonDrafts: Record<string, string>;
37
+ setJsonDrafts: Dispatch<SetStateAction<Record<string, string>>>;
38
+ updateField: (name: string, value: unknown) => void;
39
+ uiHints?: ConfigUiHints;
40
+ };
41
+
42
+ export function ChannelFormFieldsSection({
43
+ channelName,
44
+ fields,
45
+ formData,
46
+ jsonDrafts,
47
+ setJsonDrafts,
48
+ updateField,
49
+ uiHints
50
+ }: ChannelFormFieldsSectionProps) {
51
+ return (
52
+ <>
53
+ {fields.map((field) => {
54
+ const hint = hintForPath(`channels.${channelName}.${field.name}`, uiHints);
55
+ const label = hint?.label ?? field.label;
56
+ const placeholder = hint?.placeholder;
57
+
58
+ return (
59
+ <div key={field.name} className="space-y-2.5">
60
+ <Label htmlFor={field.name} className="flex items-center gap-2 text-sm font-medium text-gray-900">
61
+ {getFieldIcon(field.name)}
62
+ {label}
63
+ </Label>
64
+
65
+ {field.type === 'boolean' && (
66
+ <div className="flex items-center justify-between rounded-xl bg-gray-50 p-3">
67
+ <span className="text-sm text-gray-500">
68
+ {(formData[field.name] as boolean) ? t('enabled') : t('disabled')}
69
+ </span>
70
+ <Switch
71
+ id={field.name}
72
+ checked={(formData[field.name] as boolean) || false}
73
+ onCheckedChange={(checked) => updateField(field.name, checked)}
74
+ className="data-[state=checked]:bg-emerald-500"
75
+ />
76
+ </div>
77
+ )}
78
+
79
+ {(field.type === 'text' || field.type === 'email') && (
80
+ <Input
81
+ id={field.name}
82
+ type={field.type}
83
+ value={(formData[field.name] as string) || ''}
84
+ onChange={(event) => updateField(field.name, event.target.value)}
85
+ placeholder={placeholder}
86
+ className="rounded-xl"
87
+ />
88
+ )}
89
+
90
+ {field.type === 'password' && (
91
+ <Input
92
+ id={field.name}
93
+ type="password"
94
+ value={(formData[field.name] as string) || ''}
95
+ onChange={(event) => updateField(field.name, event.target.value)}
96
+ placeholder={placeholder ?? t('leaveBlankToKeepUnchanged')}
97
+ className="rounded-xl"
98
+ />
99
+ )}
100
+
101
+ {field.type === 'number' && (
102
+ <Input
103
+ id={field.name}
104
+ type="number"
105
+ value={(formData[field.name] as number) || 0}
106
+ onChange={(event) => updateField(field.name, parseInt(event.target.value, 10) || 0)}
107
+ placeholder={placeholder}
108
+ className="rounded-xl"
109
+ />
110
+ )}
111
+
112
+ {field.type === 'tags' && (
113
+ <TagInput
114
+ value={(formData[field.name] as string[]) || []}
115
+ onChange={(tags) => updateField(field.name, tags)}
116
+ />
117
+ )}
118
+
119
+ {field.type === 'select' && (
120
+ <Select
121
+ value={(formData[field.name] as string) || ''}
122
+ onValueChange={(value) => updateField(field.name, value)}
123
+ >
124
+ <SelectTrigger className="rounded-xl">
125
+ <SelectValue />
126
+ </SelectTrigger>
127
+ <SelectContent>
128
+ {(field.options ?? []).map((option) => (
129
+ <SelectItem key={option.value} value={option.value}>
130
+ {option.label}
131
+ </SelectItem>
132
+ ))}
133
+ </SelectContent>
134
+ </Select>
135
+ )}
136
+
137
+ {field.type === 'json' && (
138
+ <textarea
139
+ id={field.name}
140
+ value={jsonDrafts[field.name] ?? '{}'}
141
+ onChange={(event) =>
142
+ setJsonDrafts((prev) => ({
143
+ ...prev,
144
+ [field.name]: event.target.value
145
+ }))
146
+ }
147
+ className="min-h-[120px] w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-mono"
148
+ />
149
+ )}
150
+ </div>
151
+ );
152
+ })}
153
+ </>
154
+ );
155
+ }
@@ -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
+ });
@@ -0,0 +1,301 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { toDataURL } from 'qrcode';
4
+ import { Button } from '@/components/ui/button';
5
+ import { formatDateTime, t } from '@/lib/i18n';
6
+ import { cn } from '@/lib/utils';
7
+ import { toast } from 'sonner';
8
+ import { ExternalLink, Loader2, MessageCircleMore, QrCode } from 'lucide-react';
9
+ import { usePollChannelAuth, useStartChannelAuth } from '@/hooks/use-channel-auth';
10
+ import type { ChannelAuthPollResult, ChannelAuthStartResult } from '@/api/channel-auth.types';
11
+
12
+ type WeixinChannelAuthSectionProps = {
13
+ channelConfig: Record<string, unknown>;
14
+ formData: Record<string, unknown>;
15
+ disabled?: boolean;
16
+ };
17
+
18
+ function resolveConnectedAccountIds(channelConfig: Record<string, unknown>): string[] {
19
+ const accounts = channelConfig.accounts;
20
+ const ids = new Set<string>();
21
+ if (typeof channelConfig.defaultAccountId === 'string' && channelConfig.defaultAccountId.trim()) {
22
+ ids.add(channelConfig.defaultAccountId.trim());
23
+ }
24
+ if (accounts && typeof accounts === 'object' && !Array.isArray(accounts)) {
25
+ for (const accountId of Object.keys(accounts)) {
26
+ const trimmed = accountId.trim();
27
+ if (trimmed) {
28
+ ids.add(trimmed);
29
+ }
30
+ }
31
+ }
32
+ return [...ids];
33
+ }
34
+
35
+ function resolveBaseUrl(formData: Record<string, unknown>, channelConfig: Record<string, unknown>): string | undefined {
36
+ if (typeof formData.baseUrl === 'string' && formData.baseUrl.trim()) {
37
+ return formData.baseUrl.trim();
38
+ }
39
+ if (typeof channelConfig.baseUrl === 'string' && channelConfig.baseUrl.trim()) {
40
+ return channelConfig.baseUrl.trim();
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ export function WeixinChannelAuthSection({
46
+ channelConfig,
47
+ formData,
48
+ disabled = false
49
+ }: WeixinChannelAuthSectionProps) {
50
+ const queryClient = useQueryClient();
51
+ const startChannelAuth = useStartChannelAuth();
52
+ const pollChannelAuth = usePollChannelAuth();
53
+ const [activeSession, setActiveSession] = useState<ChannelAuthStartResult | null>(null);
54
+ const [authState, setAuthState] = useState<ChannelAuthPollResult | null>(null);
55
+ const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
56
+
57
+ const connectedAccountIds = useMemo(() => resolveConnectedAccountIds(channelConfig), [channelConfig]);
58
+ const primaryAccountId = connectedAccountIds[0];
59
+ const baseUrl = resolveBaseUrl(formData, channelConfig);
60
+ const hasConnectedAccount = connectedAccountIds.length > 0;
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
+
110
+ useEffect(() => {
111
+ if (!activeSession) {
112
+ return;
113
+ }
114
+
115
+ let cancelled = false;
116
+ let timer: ReturnType<typeof setTimeout> | null = null;
117
+
118
+ const runPoll = async () => {
119
+ try {
120
+ const result = await pollChannelAuth.mutateAsync({
121
+ channel: 'weixin',
122
+ data: { sessionId: activeSession.sessionId }
123
+ });
124
+ if (cancelled) {
125
+ return;
126
+ }
127
+
128
+ setAuthState(result);
129
+
130
+ if (result.status === 'authorized') {
131
+ await queryClient.invalidateQueries({ queryKey: ['config'] });
132
+ await queryClient.invalidateQueries({ queryKey: ['config-meta'] });
133
+ toast.success(result.message || t('weixinAuthAuthorized'));
134
+ setActiveSession(null);
135
+ return;
136
+ }
137
+
138
+ if (result.status === 'expired' || result.status === 'error') {
139
+ toast.error(result.message || t('weixinAuthRetryRequired'));
140
+ setActiveSession(null);
141
+ return;
142
+ }
143
+
144
+ timer = setTimeout(runPoll, result.nextPollMs ?? activeSession.intervalMs);
145
+ } catch (error) {
146
+ if (cancelled) {
147
+ return;
148
+ }
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ toast.error(`${t('error')}: ${message}`);
151
+ setActiveSession(null);
152
+ }
153
+ };
154
+
155
+ timer = setTimeout(runPoll, activeSession.intervalMs);
156
+
157
+ return () => {
158
+ cancelled = true;
159
+ if (timer) {
160
+ clearTimeout(timer);
161
+ }
162
+ };
163
+ }, [activeSession, pollChannelAuth, queryClient]);
164
+
165
+ const handleStartAuth = async () => {
166
+ try {
167
+ const result = await startChannelAuth.mutateAsync({
168
+ channel: 'weixin',
169
+ data: {
170
+ baseUrl,
171
+ accountId:
172
+ typeof formData.defaultAccountId === 'string' && formData.defaultAccountId.trim()
173
+ ? formData.defaultAccountId.trim()
174
+ : undefined
175
+ }
176
+ });
177
+ setActiveSession(result);
178
+ setAuthState({
179
+ channel: 'weixin',
180
+ status: 'pending',
181
+ message: result.note,
182
+ nextPollMs: result.intervalMs
183
+ });
184
+ } catch (error) {
185
+ const message = error instanceof Error ? error.message : String(error);
186
+ toast.error(`${t('error')}: ${message}`);
187
+ }
188
+ };
189
+
190
+ const statusLabel = activeSession
191
+ ? authState?.status === 'scanned'
192
+ ? t('weixinAuthScanned')
193
+ : t('weixinAuthWaiting')
194
+ : hasConnectedAccount
195
+ ? t('weixinAuthAuthorized')
196
+ : t('weixinAuthNotConnected');
197
+
198
+ const connectButtonLabel = startChannelAuth.isPending
199
+ ? t('weixinAuthStarting')
200
+ : activeSession
201
+ ? t('weixinAuthWaiting')
202
+ : hasConnectedAccount
203
+ ? t('weixinAuthReconnect')
204
+ : t('weixinAuthConnect');
205
+
206
+ return (
207
+ <section className="rounded-2xl border border-primary/20 bg-gradient-to-br from-primary-50/70 via-white to-emerald-50/60 p-5">
208
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
209
+ <div className="space-y-3">
210
+ <div className="inline-flex items-center gap-2 rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-primary shadow-sm">
211
+ <QrCode className="h-3.5 w-3.5" />
212
+ {t('weixinAuthTitle')}
213
+ </div>
214
+ <div>
215
+ <h4 className="text-base font-semibold text-gray-900">{t('weixinAuthDescription')}</h4>
216
+ <p className="mt-1 text-sm text-gray-600">{t('weixinAuthHint')}</p>
217
+ </div>
218
+ <div
219
+ className={cn(
220
+ 'inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium',
221
+ activeSession
222
+ ? 'bg-amber-50 text-amber-700'
223
+ : hasConnectedAccount
224
+ ? 'bg-emerald-50 text-emerald-700'
225
+ : 'bg-gray-100 text-gray-600'
226
+ )}
227
+ >
228
+ {activeSession ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <MessageCircleMore className="h-3.5 w-3.5" />}
229
+ {statusLabel}
230
+ </div>
231
+ <div className="space-y-1 text-sm text-gray-600">
232
+ <p>{t('weixinAuthCapabilityHint')}</p>
233
+ {primaryAccountId ? (
234
+ <p>
235
+ {t('weixinAuthPrimaryAccount')}: <span className="font-mono text-xs text-gray-900">{primaryAccountId}</span>
236
+ </p>
237
+ ) : null}
238
+ {connectedAccountIds.length > 1 ? (
239
+ <p>
240
+ {t('weixinAuthConnectedAccounts')}: <span className="font-mono text-xs text-gray-900">{connectedAccountIds.join(', ')}</span>
241
+ </p>
242
+ ) : null}
243
+ {baseUrl ? (
244
+ <p>
245
+ {t('weixinAuthBaseUrl')}: <span className="font-mono text-xs text-gray-900">{baseUrl}</span>
246
+ </p>
247
+ ) : null}
248
+ </div>
249
+ <Button
250
+ type="button"
251
+ onClick={handleStartAuth}
252
+ disabled={disabled || startChannelAuth.isPending || Boolean(activeSession)}
253
+ className="rounded-xl"
254
+ >
255
+ {connectButtonLabel}
256
+ </Button>
257
+ </div>
258
+
259
+ <div className="w-full max-w-sm rounded-2xl border border-dashed border-primary/25 bg-white/85 p-4 shadow-sm">
260
+ {activeSession ? (
261
+ <div className="space-y-3">
262
+ <div className="overflow-hidden rounded-2xl border border-gray-100 bg-white p-3">
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
+ )}
273
+ </div>
274
+ <div className="space-y-1 text-xs text-gray-500">
275
+ <p>{authState?.message || activeSession.note || t('weixinAuthScanPrompt')}</p>
276
+ <p>
277
+ {t('weixinAuthExpiresAt')}: {formatDateTime(activeSession.expiresAt)}
278
+ </p>
279
+ </div>
280
+ <a
281
+ href={activeSession.qrCodeUrl}
282
+ target="_blank"
283
+ rel="noreferrer"
284
+ className="inline-flex items-center gap-1.5 text-xs text-primary transition-colors hover:text-primary-hover"
285
+ >
286
+ <ExternalLink className="h-3.5 w-3.5" />
287
+ {t('weixinAuthOpenQr')}
288
+ </a>
289
+ </div>
290
+ ) : (
291
+ <div className="flex min-h-[280px] flex-col items-center justify-center rounded-2xl bg-gray-50/80 px-6 text-center">
292
+ <QrCode className="h-9 w-9 text-gray-300" />
293
+ <p className="mt-3 text-sm font-medium text-gray-700">{t('weixinAuthReadyTitle')}</p>
294
+ <p className="mt-1 text-xs leading-5 text-gray-500">{t('weixinAuthReadyDescription')}</p>
295
+ </div>
296
+ )}
297
+ </div>
298
+ </div>
299
+ </section>
300
+ );
301
+ }