mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: add one-click app creation for Feishu/dingding/wexin/wecombot with QR code support (#2165)
* feat: add one-click app creation for Feishu with QR code support * feat: implement WeChat QR code login functionality and update related configurations * feat: add qrcode dependency for QR code generation support * feat: enhance QR code login UI and add internationalization support for new labels * feat: new ui back * feat: add DingTalk one-click app creation and QR code login support * feat: add WeComBot one-click creation support and enhance QR code login functionality * feat: Update the robot creation function and bind the most recently updated pipeline
This commit is contained in:
@@ -267,6 +267,7 @@ export default function BotForm({
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,13 +11,16 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Globe } from 'lucide-react';
|
||||
import { Copy, Check, Globe, QrCode } from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
|
||||
@@ -255,7 +258,10 @@ export default function DynamicFormComponent({
|
||||
const editableItems = useMemo(
|
||||
() =>
|
||||
itemConfigList.filter(
|
||||
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
|
||||
(item) =>
|
||||
item.type !== 'webhook-url' &&
|
||||
item.type !== 'embed-code' &&
|
||||
item.type !== 'qr-code-login',
|
||||
),
|
||||
[itemConfigList],
|
||||
);
|
||||
@@ -449,9 +455,28 @@ export default function DynamicFormComponent({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, editableItems]);
|
||||
|
||||
// State for QR code login dialog
|
||||
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||
const [qrDialogPlatform, setQrDialogPlatform] =
|
||||
useState<QrLoginPlatform>('feishu');
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="space-y-4">
|
||||
{/* QR code login dialog */}
|
||||
<QrCodeLoginDialog
|
||||
open={qrDialogOpen}
|
||||
onOpenChange={setQrDialogOpen}
|
||||
platform={qrDialogPlatform}
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{itemConfigList.map((config) => {
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
@@ -538,6 +563,66 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
}
|
||||
|
||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||
if (config.type === 'qr-code-login') {
|
||||
return (
|
||||
<FormItem key={config.id}>
|
||||
<div
|
||||
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
background:
|
||||
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||
<QrCode className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{extractI18nObject(config.label)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||
{t('common.recommend')}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!!isEditing}
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||
{t('common.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (config.type === 'boolean') {
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
show_if?: IShowIfCondition;
|
||||
login_platform?: string;
|
||||
|
||||
constructor(params: IDynamicFormItemSchema) {
|
||||
this.id = params.id;
|
||||
@@ -27,6 +28,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
this.description = params.description;
|
||||
this.options = params.options;
|
||||
this.show_if = params.show_if;
|
||||
this.login_platform = params.login_platform;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
connectingKey: string;
|
||||
scanQRCodeKey: string;
|
||||
waitingKey: string;
|
||||
successKey: string;
|
||||
failedKey: string;
|
||||
retryKey: string;
|
||||
apiBase: string;
|
||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||
successNoteKey?: string;
|
||||
}
|
||||
|
||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
feishu: {
|
||||
titleKey: 'feishu.createApp',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'feishu.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'feishu.createSuccess',
|
||||
failedKey: 'feishu.createFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/lark/create-app',
|
||||
extractSuccess: (data) => ({
|
||||
app_id: data.app_id,
|
||||
app_secret: data.app_secret,
|
||||
...(data.app_name ? { app_name: data.app_name } : {}),
|
||||
}),
|
||||
},
|
||||
weixin: {
|
||||
titleKey: 'weixin.scanLogin',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'weixin.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'weixin.loginSuccess',
|
||||
failedKey: 'weixin.loginFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||
extractSuccess: (data) => ({
|
||||
token: data.token,
|
||||
base_url: data.base_url,
|
||||
...(data.account_id ? { account_id: data.account_id } : {}),
|
||||
}),
|
||||
},
|
||||
dingtalk: {
|
||||
titleKey: 'dingtalk.createApp',
|
||||
connectingKey: 'dingtalk.connecting',
|
||||
scanQRCodeKey: 'dingtalk.scanQRCode',
|
||||
waitingKey: 'dingtalk.waitingForScan',
|
||||
successKey: 'dingtalk.createSuccess',
|
||||
failedKey: 'dingtalk.createFailed',
|
||||
retryKey: 'dingtalk.retry',
|
||||
apiBase: '/api/v1/platform/adapters/dingtalk/create-app',
|
||||
extractSuccess: (data) => ({
|
||||
client_id: data.client_id,
|
||||
client_secret: data.client_secret,
|
||||
}),
|
||||
successNoteKey: 'dingtalk.robotCodeNote',
|
||||
},
|
||||
wecombot: {
|
||||
titleKey: 'wecombot.createBot',
|
||||
connectingKey: 'wecombot.connecting',
|
||||
scanQRCodeKey: 'wecombot.scanQRCode',
|
||||
waitingKey: 'wecombot.waitingForScan',
|
||||
successKey: 'wecombot.createSuccess',
|
||||
failedKey: 'wecombot.createFailed',
|
||||
retryKey: 'wecombot.retry',
|
||||
apiBase: '/api/v1/platform/adapters/wecombot/create-bot',
|
||||
extractSuccess: (data) => ({
|
||||
BotId: data.botid,
|
||||
Secret: data.secret,
|
||||
}),
|
||||
successNoteKey: 'wecombot.robotNameNote',
|
||||
},
|
||||
};
|
||||
|
||||
interface QrCodeLoginDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
platform: QrLoginPlatform;
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
export default function QrCodeLoginDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
platform,
|
||||
onSuccess,
|
||||
}: QrCodeLoginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const platformConfig = PLATFORM_CONFIGS[platform];
|
||||
|
||||
const [state, setState] = useState<DialogState>('connecting');
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const cleanedRef = useRef(false);
|
||||
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
onSuccessRef.current = onSuccess;
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const platformConfigRef = useRef(platformConfig);
|
||||
platformConfigRef.current = platformConfig;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (cleanedRef.current) return;
|
||||
cleanedRef.current = true;
|
||||
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
// Cancel backend session
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
fetch(
|
||||
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startLogin = useCallback(async () => {
|
||||
cleanup();
|
||||
cleanedRef.current = false;
|
||||
setState('connecting');
|
||||
setQrDataUrl('');
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const cfg = platformConfigRef.current;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
|
||||
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
|
||||
// qr_data_url is a pre-rendered data URL (WeChat);
|
||||
// qr_url is a plain URL string (Feishu) that needs local QR generation.
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
} else if (qr_url) {
|
||||
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||
width: 224,
|
||||
margin: 2,
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
// Calculate remaining seconds
|
||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||
setExpireIn(remaining);
|
||||
|
||||
// Start countdown
|
||||
countdownRef.current = setInterval(() => {
|
||||
setExpireIn((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Start polling
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
|
||||
const pollJson = await pollRes.json();
|
||||
if (pollJson.code !== 0) return;
|
||||
|
||||
const { status, error, ...rest } = pollJson.data;
|
||||
|
||||
if (status === 'success') {
|
||||
sessionIdRef.current = null; // backend already cleaned up
|
||||
cleanup();
|
||||
setState('success');
|
||||
setTimeout(() => {
|
||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||
onOpenChangeRef.current(false);
|
||||
}, 1500);
|
||||
} else if (status === 'error') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('error');
|
||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors, will retry next interval
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
setState('error');
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
|
||||
);
|
||||
}
|
||||
}, [cleanup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
startLogin();
|
||||
}
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [open, startLogin, cleanup]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
cleanup();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m > 0) {
|
||||
return `${m}m${s.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
return `${s}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(platformConfig.titleKey)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4 space-y-4">
|
||||
{/* Connecting */}
|
||||
{state === 'connecting' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(platformConfig.connectingKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR code area */}
|
||||
{state === 'waiting' && qrDataUrl && (
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(platformConfig.scanQRCodeKey)}
|
||||
</p>
|
||||
<div className="border rounded-lg p-2 bg-white">
|
||||
<img src={qrDataUrl} alt="QR Code" className="w-56 h-56" />
|
||||
</div>
|
||||
{expireIn > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(platformConfig.waitingKey)} ({formatTime(expireIn)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{state === 'success' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||
<p className="text-sm text-green-600 font-medium">
|
||||
{t(platformConfig.successKey)}
|
||||
</p>
|
||||
{platformConfig.successNoteKey && (
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
{t(platformConfig.successNoteKey)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{state === 'error' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
<XCircle className="h-12 w-12 text-red-500" />
|
||||
<p className="text-sm text-red-600 text-center">
|
||||
{errorMessage || t(platformConfig.failedKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state === 'error' && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => startLogin()}>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t(platformConfig.retryKey)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ScanLine,
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
connectingKey: string;
|
||||
scanQRCodeKey: string;
|
||||
waitingKey: string;
|
||||
successKey: string;
|
||||
failedKey: string;
|
||||
retryKey: string;
|
||||
apiBase: string;
|
||||
brandColor: string;
|
||||
adapterName: string;
|
||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||
}
|
||||
|
||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
feishu: {
|
||||
titleKey: 'feishu.createApp',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'feishu.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'feishu.createSuccess',
|
||||
failedKey: 'feishu.createFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/lark/create-app',
|
||||
brandColor: '#3370ff',
|
||||
adapterName: 'lark',
|
||||
extractSuccess: (data) => ({
|
||||
app_id: data.app_id,
|
||||
app_secret: data.app_secret,
|
||||
...(data.app_name ? { app_name: data.app_name } : {}),
|
||||
}),
|
||||
},
|
||||
weixin: {
|
||||
titleKey: 'weixin.scanLogin',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'weixin.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'weixin.loginSuccess',
|
||||
failedKey: 'weixin.loginFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||
brandColor: '#07c160',
|
||||
adapterName: 'openclaw-weixin',
|
||||
extractSuccess: (data) => ({
|
||||
token: data.token,
|
||||
base_url: data.base_url,
|
||||
...(data.account_id ? { account_id: data.account_id } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
interface QrCodeLoginDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
platform: QrLoginPlatform;
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
export default function QrCodeLoginDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
platform,
|
||||
onSuccess,
|
||||
}: QrCodeLoginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const platformConfig = PLATFORM_CONFIGS[platform];
|
||||
|
||||
const [state, setState] = useState<DialogState>('connecting');
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
onSuccessRef.current = onSuccess;
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const platformConfigRef = useRef(platformConfig);
|
||||
platformConfigRef.current = platformConfig;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
fetch(
|
||||
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startLogin = useCallback(async () => {
|
||||
cleanup();
|
||||
setState('connecting');
|
||||
setQrDataUrl('');
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const cfg = platformConfigRef.current;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
|
||||
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
} else if (qr_url) {
|
||||
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||
width: 280,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||
setExpireIn(remaining);
|
||||
|
||||
countdownRef.current = setInterval(() => {
|
||||
setExpireIn((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
|
||||
const pollJson = await pollRes.json();
|
||||
if (pollJson.code !== 0) return;
|
||||
|
||||
const { status, error, ...rest } = pollJson.data;
|
||||
|
||||
if (status === 'success') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('success');
|
||||
setTimeout(() => {
|
||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||
onOpenChangeRef.current(false);
|
||||
}, 1500);
|
||||
} else if (status === 'error') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('error');
|
||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
setState('error');
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
|
||||
);
|
||||
}
|
||||
}, [cleanup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
startLogin();
|
||||
}
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [open, startLogin, cleanup]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
cleanup();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m > 0) {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `0:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md p-0 overflow-hidden">
|
||||
{/* Brand header */}
|
||||
<div className="flex items-center gap-3 px-6 pt-6 pb-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(platformConfig.adapterName)}
|
||||
alt={platform}
|
||||
className="h-10 w-10 rounded-lg"
|
||||
/>
|
||||
<div>
|
||||
<DialogTitle className="text-lg">
|
||||
{t(platformConfig.titleKey)}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center px-6 pb-6 space-y-4">
|
||||
{/* Connecting */}
|
||||
{state === 'connecting' && (
|
||||
<div className="flex flex-col items-center space-y-4 py-12">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full animate-ping opacity-20"
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
/>
|
||||
<Loader2
|
||||
className="h-10 w-10 animate-spin relative"
|
||||
style={{ color: platformConfig.brandColor }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
{t(platformConfig.connectingKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR code area */}
|
||||
{state === 'waiting' && qrDataUrl && (
|
||||
<div className="flex flex-col items-center space-y-4 py-2">
|
||||
{/* Instruction */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: `${platformConfig.brandColor}10`,
|
||||
color: platformConfig.brandColor,
|
||||
}}
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
{t(platformConfig.scanQRCodeKey)}
|
||||
</div>
|
||||
|
||||
{/* QR Code with border animation */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute -inset-1 rounded-2xl opacity-30 animate-pulse"
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl p-3 shadow-lg">
|
||||
<img src={qrDataUrl} alt="QR Code" className="w-64 h-64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Countdown */}
|
||||
{expireIn > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t(platformConfig.waitingKey)}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono font-semibold tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
expireIn < 60 ? '#ef4444' : platformConfig.brandColor,
|
||||
}}
|
||||
>
|
||||
{formatTime(expireIn)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{state === 'success' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-12">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-green-100 animate-ping opacity-30" />
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500 relative" />
|
||||
</div>
|
||||
<p className="text-base text-green-600 font-semibold">
|
||||
{t(platformConfig.successKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{state === 'error' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-12">
|
||||
<XCircle className="h-16 w-16 text-red-400" />
|
||||
<p className="text-sm text-red-500 text-center max-w-xs">
|
||||
{errorMessage || t(platformConfig.failedKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error footer with retry */}
|
||||
{state === 'error' && (
|
||||
<DialogFooter className="px-6 pb-6 pt-0">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => startLogin()}
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t(platformConfig.retryKey)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface IDynamicFormItemSchema {
|
||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||
scopes?: string[];
|
||||
accept?: string; // For file type: accepted MIME types
|
||||
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
||||
}
|
||||
|
||||
export enum DynamicFormItemType {
|
||||
@@ -46,6 +47,7 @@ export enum DynamicFormItemType {
|
||||
TOOLS_SELECTOR = 'tools-selector',
|
||||
WEBHOOK_URL = 'webhook-url',
|
||||
EMBED_CODE = 'embed-code',
|
||||
QR_CODE_LOGIN = 'qr-code-login',
|
||||
}
|
||||
|
||||
export interface IFileConfig {
|
||||
|
||||
@@ -228,6 +228,7 @@ export default function WizardPage() {
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
}),
|
||||
);
|
||||
}, [adapters, selectedAdapter]);
|
||||
@@ -247,6 +248,7 @@ export default function WizardPage() {
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
}),
|
||||
);
|
||||
}, [selectedRunnerConfigStage]);
|
||||
|
||||
Reference in New Issue
Block a user