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:
Dongchuan Fu
2026-05-10 22:31:31 +08:00
committed by GitHub
parent 5273bbb23f
commit f412127fb0
18 changed files with 1728 additions and 7 deletions

View File

@@ -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,
}),
),
);

View File

@@ -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 (

View File

@@ -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;
}
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -44,6 +44,8 @@ const enUS = {
success: 'Success',
save: 'Save',
saving: 'Saving...',
recommend: 'Recommended',
start: 'Start',
confirm: 'Confirm',
confirmDelete: 'Confirm Delete',
deleteConfirmation: 'Are you sure you want to delete this?',
@@ -1336,6 +1338,51 @@ const enUS = {
backToWorkbench: 'Back to Workbench',
},
},
feishu: {
createApp: 'One-Click Create Feishu App',
scanQRCode:
'Scan the QR code below with Feishu to authorize and automatically create the app',
waitingForScan: 'Waiting for scan',
createSuccess: 'App created successfully! Credentials have been filled in',
createFailed: 'Creation failed',
connecting: 'Connecting to Feishu service...',
expired: 'QR code expired, please try again',
denied: 'Authorization denied by user',
connectionLost: 'Connection lost, please try again',
reconnecting: 'Reconnecting...',
retry: 'Retry',
},
weixin: {
scanLogin: 'Scan QR Login',
scanQRCode:
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
loginSuccess: 'Login successful! Token has been filled in',
loginFailed: 'Login failed',
},
dingtalk: {
createApp: 'One-Click Create DingTalk App',
scanQRCode:
'Scan the QR code below with DingTalk to authorize and automatically create the app',
waitingForScan: 'Waiting for scan',
createSuccess: 'App created successfully! Credentials have been filled in',
createFailed: 'Creation failed',
connecting: 'Connecting to DingTalk service...',
retry: 'Retry',
robotCodeNote:
'Robot Code cannot be obtained automatically. Please go to DingTalk Developer Backend > Robot Configuration to copy it manually. It is required for features like image recognition and file upload.',
},
wecombot: {
createBot: 'One-Click Create WeCom Bot',
scanQRCode:
'Scan the QR code below with WeCom to authorize and automatically create the bot',
waitingForScan: 'Waiting for scan',
createSuccess: 'Bot created successfully! Credentials have been filled in',
createFailed: 'Creation failed',
connecting: 'Connecting to WeCom service...',
retry: 'Retry',
robotNameNote:
'Robot Name cannot be obtained automatically. Please fill it in manually.',
},
pluginPages: {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',

View File

@@ -45,6 +45,8 @@
success: '成功',
save: '保存',
saving: '保存中...',
recommend: 'おすすめ',
start: '開始',
confirm: '確認',
confirmDelete: '削除の確認',
deleteConfirmation: '本当に削除しますか?',
@@ -1343,6 +1345,46 @@
backToWorkbench: 'ワークベンチに戻る',
},
},
feishu: {
createApp: 'ワンクリックでFeishuアプリ作成',
scanQRCode: '以下のQRコードをFeishuでスキャンし、アプリを自動作成',
waitingForScan: 'スキャン待ち',
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
createFailed: '作成失敗',
connecting: 'Feishuサービスに接続中...',
expired: 'QRコードの有効期限が切れました。もう一度お試しください',
denied: 'ユーザーが承認を拒否しました',
connectionLost: '接続が切断されました。もう一度お試しください',
reconnecting: '再接続中...',
retry: '再試行',
},
weixin: {
scanLogin: 'QRコードでWeChatログイン',
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
loginSuccess: 'ログイン成功!トークンが自動入力されました',
loginFailed: 'ログイン失敗',
},
dingtalk: {
createApp: 'ワンクリックでDingTalkアプリ作成',
scanQRCode: '以下のQRコードをDingTalkでスキャンし、アプリを自動作成',
waitingForScan: 'スキャン待ち',
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
createFailed: '作成失敗',
connecting: 'DingTalkサービスに接続中...',
retry: '再試行',
robotCodeNote:
'ロボットコードは自動取得できません。DingTalk開発者バックエンド > ロボット設定から手動でコピーしてください。画像認識やファイルアップロードなどの機能に必要です。',
},
wecombot: {
createBot: 'ワンクリックでWeComボット作成',
scanQRCode: '以下のQRコードをWeComでスキャンし、ボットを自動作成',
waitingForScan: 'スキャン待ち',
createSuccess: 'ボット作成成功!認証情報が自動入力されました',
createFailed: '作成失敗',
connecting: 'WeComサービスに接続中...',
retry: '再試行',
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
},
pluginPages: {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',

View File

@@ -43,6 +43,8 @@ const zhHans = {
success: '成功',
save: '保存',
saving: '保存中...',
recommend: '推荐',
start: '开始',
confirm: '确认',
confirmDelete: '确认删除',
deleteConfirmation: '你确定要删除这个吗?',
@@ -1276,6 +1278,47 @@ const zhHans = {
backToWorkbench: '返回工作台',
},
},
feishu: {
createApp: '一键创建飞书应用',
scanQRCode: '请使用飞书扫描以下二维码,授权后将自动创建应用并填写凭据',
waitingForScan: '等待扫码中',
createSuccess: '应用创建成功!凭据已自动填入',
createFailed: '创建失败',
connecting: '正在连接飞书服务...',
expired: '二维码已过期,请重试',
denied: '用户已拒绝授权',
connectionLost: '连接已断开,请重试',
reconnecting: '正在重新连接...',
retry: '重试',
},
weixin: {
scanLogin: '扫码登录微信',
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
loginSuccess: '登录成功!令牌已自动填入',
loginFailed: '登录失败',
},
dingtalk: {
createApp: '一键创建钉钉应用',
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
waitingForScan: '等待扫码中',
createSuccess: '应用创建成功!凭据已自动填入',
createFailed: '创建失败',
connecting: '正在连接钉钉服务...',
retry: '重试',
robotCodeNote:
'机器人代码无法自动获取,请前往钉钉开发者后台 > 机器人配置中手动复制。识图、上传文件等功能需要填写此字段。',
},
wecombot: {
createBot: '一键创建企业微信机器人',
scanQRCode:
'请使用企业微信扫描以下二维码,授权后将自动创建机器人并填写凭据',
waitingForScan: '等待扫码中',
createSuccess: '机器人创建成功!凭据已自动填入',
createFailed: '创建失败',
connecting: '正在连接企业微信服务...',
retry: '重试',
robotNameNote: '机器人名称无法自动获取,请手动填写。',
},
pluginPages: {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',

View File

@@ -43,6 +43,8 @@ const zhHant = {
success: '成功',
save: '儲存',
saving: '儲存中...',
recommend: '推薦',
start: '開始',
confirm: '確認',
confirmDelete: '確認刪除',
deleteConfirmation: '您確定要刪除這個嗎?',
@@ -1276,6 +1278,47 @@ const zhHant = {
backToWorkbench: '返回工作台',
},
},
feishu: {
createApp: '一鍵建立飛書應用',
scanQRCode: '請使用飛書掃描以下 QR Code授權後將自動建立應用並填寫憑證',
waitingForScan: '等待掃描中',
createSuccess: '應用建立成功!憑證已自動填入',
createFailed: '建立失敗',
connecting: '正在連線飛書服務...',
expired: 'QR Code 已過期,請重試',
denied: '使用者已拒絕授權',
connectionLost: '連線已斷開,請重試',
reconnecting: '正在重新連線...',
retry: '重試',
},
weixin: {
scanLogin: '掃碼登入微信',
scanQRCode: '請使用微信掃描以下 QR Code授權後將自動登入並填寫令牌',
loginSuccess: '登入成功!令牌已自動填入',
loginFailed: '登入失敗',
},
dingtalk: {
createApp: '一鍵建立釘釘應用',
scanQRCode: '請使用釘釘掃描以下 QR Code授權後將自動建立應用並填寫憑證',
waitingForScan: '等待掃碼中',
createSuccess: '應用建立成功!憑證已自動填入',
createFailed: '建立失敗',
connecting: '正在連線釘釘服務...',
retry: '重試',
robotCodeNote:
'機器人代碼無法自動取得,請前往釘釘開發者後台 > 機器人設定中手動複製。識圖、上傳檔案等功能需要填寫此欄位。',
},
wecombot: {
createBot: '一鍵建立企業微信機器人',
scanQRCode:
'請使用企業微信掃描以下 QR Code授權後將自動建立機器人並填寫憑證',
waitingForScan: '等待掃碼中',
createSuccess: '機器人建立成功!憑證已自動填入',
createFailed: '建立失敗',
connecting: '正在連線企業微信服務...',
retry: '重試',
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
},
pluginPages: {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',