@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.
- package/CHANGELOG.md +18 -0
- package/dist/assets/ChannelsList-D75wfbDS.js +8 -0
- package/dist/assets/{ChatPage-Dmpau_7n.js → ChatPage-gWZ3rDTy.js} +14 -14
- package/dist/assets/{DocBrowser-C3ijFxFF.js → DocBrowser-CebTdor0.js} +1 -1
- package/dist/assets/{LogoBadge-BgjXmBcw.js → LogoBadge-gdbraoaZ.js} +1 -1
- package/dist/assets/MarketplacePage-C-zz0lBT.js +49 -0
- package/dist/assets/{McpMarketplacePage-DPtH1xcY.js → McpMarketplacePage-tfpLh6Zz.js} +1 -1
- package/dist/assets/ModelConfig-j74dn-5k.js +1 -0
- package/dist/assets/{ProvidersList-DnWsJqMQ.js → ProvidersList-BGI9EgVV.js} +1 -1
- package/dist/assets/{RemoteAccessPage-BrXq-x0-.js → RemoteAccessPage-CusGQmZE.js} +1 -1
- package/dist/assets/{RuntimeConfig-UE9VaFO7.js → RuntimeConfig-pmhW8ifz.js} +1 -1
- package/dist/assets/{SearchConfig-CP-RM3V3.js → SearchConfig-rrD2_F5u.js} +1 -1
- package/dist/assets/{SecretsConfig-CfN_bazs.js → SecretsConfig-D7onb-hv.js} +1 -1
- package/dist/assets/{SessionsConfig-CgkKzKGv.js → SessionsConfig-m-6RSeja.js} +1 -1
- package/dist/assets/{chat-message-CGL3sMsS.js → chat-message-BO-s2mvl.js} +1 -1
- package/dist/assets/index-BsL1YIJ1.js +8 -0
- package/dist/assets/index-C63mHRbE.css +1 -0
- package/dist/assets/{label-CbOSodIL.js → label-CDSYExvV.js} +1 -1
- package/dist/assets/{page-layout-BtDnyNLf.js → page-layout-BMCVAnQM.js} +1 -1
- package/dist/assets/{popover-DGlUjPQc.js → popover-DfywyUDH.js} +1 -1
- package/dist/assets/{security-config-D6Bs1yoK.js → security-config-BU-K2EOM.js} +1 -1
- package/dist/assets/skeleton-Cg9CRkOt.js +1 -0
- package/dist/assets/{status-dot-C8vM3IN1.js → status-dot-2vau2Xtc.js} +1 -1
- package/dist/assets/{switch-AuwUiga3.js → switch-CJRPF2V6.js} +1 -1
- package/dist/assets/{tabs-custom-CTS7SaFG.js → tabs-custom-B-2uSCfW.js} +1 -1
- package/dist/assets/{useConfirmDialog-DrMAdNfN.js → useConfirmDialog-CfOpdypA.js} +1 -1
- package/dist/assets/{vendor-TJ2hy_Lv.js → vendor-DJt0Azq5.js} +90 -80
- package/dist/index.html +3 -3
- package/package.json +7 -6
- package/src/api/channel-auth.ts +35 -0
- package/src/api/channel-auth.types.ts +28 -0
- package/src/api/config.ts +2 -4
- package/src/api/types.ts +7 -26
- package/src/components/chat/ChatSidebar.test.tsx +1 -1
- package/src/components/chat/chat-sidebar-session-item.tsx +0 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +0 -1
- package/src/components/config/ChannelForm.tsx +41 -128
- package/src/components/config/ChannelsList.test.tsx +79 -10
- package/src/components/config/ModelConfig.test.tsx +78 -0
- package/src/components/config/ModelConfig.tsx +4 -1
- package/src/components/config/channel-form-fields-section.tsx +155 -0
- package/src/components/config/weixin-channel-auth-section.test.tsx +90 -0
- package/src/components/config/weixin-channel-auth-section.tsx +301 -0
- package/src/components/layout/Sidebar.tsx +128 -120
- package/src/components/layout/sidebar.layout.test.tsx +99 -0
- package/src/hooks/use-channel-auth.ts +16 -0
- package/src/lib/i18n.channel-auth.ts +37 -0
- package/src/lib/i18n.ts +2 -4
- package/src/qrcode.d.ts +10 -0
- package/src/transport/app-client.ts +22 -6
- package/dist/assets/ChannelsList-Cu_hLbps.js +0 -1
- package/dist/assets/MarketplacePage-CAIdEiw8.js +0 -49
- package/dist/assets/ModelConfig-D-pqArCg.js +0 -1
- package/dist/assets/index-D4alkESd.js +0 -8
- package/dist/assets/index-SGSkQCPi.css +0 -1
- 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
|
+
}
|