From e1e29b32e9608fb72b0f7173814ecb482b2c9328 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 21 Jun 2026 17:27:18 +0800 Subject: [PATCH] feat(desktop): add QR scan login for channels --- .../renderer/src/components/QrLoginModal.tsx | 222 ++++++++++++ desktop/src/renderer/src/i18n.ts | 66 +++- .../src/renderer/src/pages/ChannelsPage.tsx | 339 ++++++++++++------ 3 files changed, 523 insertions(+), 104 deletions(-) create mode 100644 desktop/src/renderer/src/components/QrLoginModal.tsx diff --git a/desktop/src/renderer/src/components/QrLoginModal.tsx b/desktop/src/renderer/src/components/QrLoginModal.tsx new file mode 100644 index 00000000..71ca484b --- /dev/null +++ b/desktop/src/renderer/src/components/QrLoginModal.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Loader2, Check, RotateCcw } from 'lucide-react' +import { t } from '../i18n' +import apiClient from '../api/client' +import { Modal } from '../pages/settings/primitives' + +type Provider = 'weixin' | 'feishu' +type Phase = 'loading' | 'waiting' | 'scanned' | 'success' | 'error' + +interface QrLoginModalProps { + provider: Provider + onClose: () => void + // Fired once the channel is connected so the page can refresh. + onConnected: () => void +} + +const POLL_INTERVAL = 2000 + +// Shared QR-login / QR-register modal for WeChat and Feishu. Mirrors the web +// console flow: fetch a QR, poll status, then connect the channel on success. +const QrLoginModal: React.FC = ({ provider, onClose, onConnected }) => { + const [phase, setPhase] = useState('loading') + const [qr, setQr] = useState('') + const [openLink, setOpenLink] = useState('') + const [errMsg, setErrMsg] = useState('') + const timerRef = useRef | null>(null) + const aliveRef = useRef(true) + + const stopPoll = () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + + const fail = (msg: string) => { + if (!aliveRef.current) return + setPhase('error') + setErrMsg(msg) + } + + // ---- WeChat: GET qr, POST poll {scaned|confirmed|expired} ----------------- + const pollWeixin = () => { + timerRef.current = setTimeout(async () => { + if (!aliveRef.current) return + try { + const data = await apiClient.weixinQrAction('poll') + if (!aliveRef.current) return + if (data.status !== 'success') return pollWeixin() + const s = data.qr_status as string + if (s === 'confirmed') { + setPhase('success') + await apiClient.channelAction('connect', 'weixin', {}) + if (aliveRef.current) onConnected() + } else if (s === 'expired' && (data.qr_image || data.qrcode_url)) { + setQr((data.qr_image as string) || (data.qrcode_url as string)) + setPhase('waiting') + pollWeixin() + } else if (s === 'scaned') { + setPhase('scanned') + pollWeixin() + } else { + pollWeixin() + } + } catch { + pollWeixin() + } + }, POLL_INTERVAL) + } + + const startWeixin = async () => { + setPhase('loading') + try { + const data = await apiClient.getWeixinQr() + if (!aliveRef.current) return + if (data.status !== 'success') return fail(data.message || t('weixin_scan_fail')) + setQr(data.qr_image || data.qrcode_url || '') + setPhase('waiting') + pollWeixin() + } catch { + fail(t('weixin_scan_fail')) + } + } + + // ---- Feishu: GET qr, POST poll {done|expired|denied|error} ---------------- + const pollFeishu = () => { + timerRef.current = setTimeout(async () => { + if (!aliveRef.current) return + try { + const data = await apiClient.feishuRegisterPoll() + if (!aliveRef.current) return + if (data.status !== 'success') return fail((data.message as string) || t('feishu_scan_fail')) + const rs = data.register_status as string + if (rs === 'done') { + setPhase('success') + await apiClient.channelAction('connect', 'feishu', { + feishu_app_id: data.app_id, + feishu_app_secret: data.app_secret, + }) + if (aliveRef.current) onConnected() + } else if (rs === 'expired') { + fail(t('feishu_scan_expired')) + } else if (rs === 'denied') { + fail(t('feishu_scan_denied')) + } else if (rs === 'error') { + fail((data.message as string) || t('feishu_scan_fail')) + } else { + pollFeishu() + } + } catch { + pollFeishu() + } + }, POLL_INTERVAL) + } + + const startFeishu = async () => { + setPhase('loading') + try { + const data = await apiClient.getFeishuRegister() + if (!aliveRef.current) return + if (data.status !== 'success') return fail(data.message || t('feishu_scan_fail')) + setQr(data.qr_image || data.qrcode_url || '') + setOpenLink(data.qrcode_url || '') + setPhase('waiting') + pollFeishu() + } catch { + fail(t('feishu_scan_fail')) + } + } + + const start = () => { + stopPoll() + if (provider === 'weixin') void startWeixin() + else void startFeishu() + } + + useEffect(() => { + aliveRef.current = true + start() + return () => { + aliveRef.current = false + stopPoll() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider]) + + const title = provider === 'weixin' ? t('weixin_scan_title') : t('feishu_scan_title') + const desc = provider === 'weixin' ? t('weixin_scan_desc') : t('feishu_scan_desc') + const tip = provider === 'weixin' ? t('weixin_qr_tip') : t('feishu_scan_tip') + + const statusText = (): string => { + if (provider === 'weixin') { + if (phase === 'scanned') return t('weixin_scan_scanned') + return t('weixin_scan_waiting') + } + return t('feishu_scan_waiting') + } + + return ( + +
+ {phase === 'loading' && ( +
+ + {provider === 'weixin' ? t('weixin_scan_loading') : t('feishu_scan_loading')} +
+ )} + + {(phase === 'waiting' || phase === 'scanned') && ( + <> +

{desc}

+
+ {qr ? ( + QR + ) : ( +
QR
+ )} +
+

{statusText()}

+

{tip}

+ {openLink && ( + + {t('feishu_scan_open_link')} + + )} + + )} + + {phase === 'success' && ( +
+
+ +
+

+ {provider === 'weixin' ? t('weixin_scan_success') : t('feishu_scan_success')} +

+
+ )} + + {phase === 'error' && ( +
+

{errMsg}

+ +
+ )} +
+
+ ) +} + +export default QrLoginModal diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index a6159dd7..dd62c324 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -184,11 +184,42 @@ const translations: Record> = { memory_next: '下一页', channels_title: '通道管理', channels_desc: '查看和管理消息通道', - channels_add: '连接', + channels_add: '添加通道', channels_connected: '已连接', channels_disconnected: '未连接', channels_connect: '连接', channels_disconnect: '断开', + channels_save: '保存', + channels_loading: '加载通道中...', + channels_connected_section: '已连接', + channels_available_section: '可添加', + channels_empty_connected: '暂无已连接的通道', + channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接', + channels_save_ok: '已保存', + channels_save_error: '保存失败', + channels_connect_error: '连接失败', + channels_scan_login: '扫码登录', + channels_scan_register: '扫码注册', + weixin_scan_title: '微信扫码登录', + weixin_scan_desc: '使用微信扫描下方二维码登录', + weixin_scan_loading: '正在获取二维码...', + weixin_scan_waiting: '等待扫码', + weixin_scan_scanned: '已扫描,请在手机上确认', + weixin_scan_success: '登录成功', + weixin_scan_expired: '二维码已过期,正在刷新...', + weixin_scan_fail: '获取二维码失败', + weixin_qr_tip: '请使用登录的微信扫码', + feishu_scan_title: '飞书扫码注册', + feishu_scan_desc: '扫码后将自动创建并连接飞书机器人', + feishu_scan_loading: '正在生成二维码...', + feishu_scan_waiting: '请使用飞书扫码授权', + feishu_scan_tip: '扫码后在飞书中确认授权', + feishu_scan_open_link: '打开授权链接', + feishu_scan_success: '注册成功', + feishu_scan_expired: '二维码已过期', + feishu_scan_denied: '授权被拒绝', + feishu_scan_fail: '注册失败', + feishu_scan_retry: '重试', tasks_title: '定时任务', tasks_desc: '查看和管理定时任务', tasks_active: '运行中', @@ -417,11 +448,42 @@ const translations: Record> = { memory_next: 'Next', channels_title: 'Channels', channels_desc: 'View and manage messaging channels', - channels_add: 'Connect', + channels_add: 'Add channel', channels_connected: 'Connected', channels_disconnected: 'Disconnected', channels_connect: 'Connect', channels_disconnect: 'Disconnect', + channels_save: 'Save', + channels_loading: 'Loading channels...', + channels_connected_section: 'Connected', + channels_available_section: 'Available', + channels_empty_connected: 'No connected channels yet', + channels_qr_hint: 'This channel uses QR login — please connect it from the Web console', + channels_save_ok: 'Saved', + channels_save_error: 'Failed to save', + channels_connect_error: 'Failed to connect', + channels_scan_login: 'QR login', + channels_scan_register: 'QR register', + weixin_scan_title: 'WeChat QR Login', + weixin_scan_desc: 'Scan the QR code below with WeChat to log in', + weixin_scan_loading: 'Fetching QR code...', + weixin_scan_waiting: 'Waiting for scan', + weixin_scan_scanned: 'Scanned, please confirm on your phone', + weixin_scan_success: 'Logged in', + weixin_scan_expired: 'QR code expired, refreshing...', + weixin_scan_fail: 'Failed to fetch QR code', + weixin_qr_tip: 'Scan with the WeChat account to log in', + feishu_scan_title: 'Feishu QR Register', + feishu_scan_desc: 'Scanning will create and connect a Feishu bot automatically', + feishu_scan_loading: 'Generating QR code...', + feishu_scan_waiting: 'Scan with Feishu to authorize', + feishu_scan_tip: 'Confirm the authorization in Feishu after scanning', + feishu_scan_open_link: 'Open authorization link', + feishu_scan_success: 'Registered', + feishu_scan_expired: 'QR code expired', + feishu_scan_denied: 'Authorization denied', + feishu_scan_fail: 'Registration failed', + feishu_scan_retry: 'Retry', tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks', tasks_active: 'Active', diff --git a/desktop/src/renderer/src/pages/ChannelsPage.tsx b/desktop/src/renderer/src/pages/ChannelsPage.tsx index b6a747e2..78e86f91 100644 --- a/desktop/src/renderer/src/pages/ChannelsPage.tsx +++ b/desktop/src/renderer/src/pages/ChannelsPage.tsx @@ -1,22 +1,24 @@ -import React, { useState, useEffect } from 'react' -import { t } from '../i18n' +import React, { useEffect, useMemo, useState } from 'react' +import { Loader2, Plug, QrCode } from 'lucide-react' +import { t, localizedLabel } from '../i18n' import apiClient from '../api/client' -import type { ChannelInfo } from '../types' +import type { ChannelInfo, ChannelField } from '../types' +import { Toggle, Btn } from './settings/primitives' +import QrLoginModal from '../components/QrLoginModal' + +// Channels that connect via QR scanning rather than credential fields. +const QR_PROVIDERS: Record = { weixin: 'weixin', feishu: 'feishu' } interface ChannelsPageProps { baseUrl: string } +// A masked secret looks like "abcd****wxyz"; the backend skips such values. +const MASK_RE = /\*{2,}/ + const ChannelsPage: React.FC = ({ baseUrl }) => { const [channels, setChannels] = useState([]) const [loading, setLoading] = useState(true) - const [configValues, setConfigValues] = useState>>({}) - const [actionLoading, setActionLoading] = useState(null) - - useEffect(() => { - apiClient.setBaseUrl(baseUrl) - loadChannels() - }, [baseUrl]) const loadChannels = async () => { try { @@ -25,112 +27,245 @@ const ChannelsPage: React.FC = ({ baseUrl }) => { setChannels(data || []) } catch (err) { console.error('Failed to load channels:', err) + setChannels([]) } finally { setLoading(false) } } - const handleFieldChange = (ch: string, key: string, val: string) => { - setConfigValues((prev) => ({ ...prev, [ch]: { ...prev[ch], [key]: val } })) - } + useEffect(() => { + apiClient.setBaseUrl(baseUrl) + void loadChannels() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseUrl]) - const handleAction = async (channel: ChannelInfo, action: 'save' | 'connect' | 'disconnect') => { - setActionLoading(`${channel.name}-${action}`) - try { - await apiClient.channelAction(action, channel.name, configValues[channel.name]) - await loadChannels() - } catch (err) { - console.error(`Action failed:`, err) - } finally { - setActionLoading(null) - } - } + const { connected, available } = useMemo(() => { + const connected = channels.filter((c) => c.active) + const available = channels.filter((c) => !c.active) + return { connected, available } + }, [channels]) return ( -
-
-
-
-

{t('channels_title')}

-

{t('channels_desc')}

-
-
+
+
+

{t('channels_title')}

+

{t('channels_desc')}

+
- {loading ? ( -
- Loading... -
- ) : ( -
- {channels.map((ch) => ( -
-
-
-
- -
- {ch.label?.zh || ch.label?.en || ch.name} -
- - {ch.active ? t('channels_connected') : t('channels_disconnected')} - -
- - {ch.fields && ch.fields.length > 0 && ( -
- {ch.fields.map((field) => ( -
- - handleFieldChange(ch.name, field.key, e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 transition-colors" - /> -
- ))} -
+
+
+ {loading ? ( +
+ + {t('channels_loading')} +
+ ) : ( +
+
+ {connected.length === 0 ? ( +

{t('channels_empty_connected')}

+ ) : ( + connected.map((ch) => ) )} +
-
- - {ch.active ? ( - - ) : ( - - )} -
-
- ))} -
- )} + {available.length > 0 && ( +
+ {available.map((ch) => ( + + ))} +
+ )} +
+ )} +
) } +const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+

{title}

+
{children}
+
+) + +const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => { + // Channels with no fields connect purely via QR (e.g. weixin). + const isQrLogin = channel.fields.length === 0 + // QR provider supported by the desktop scan modal (weixin / feishu). + const qrProvider = QR_PROVIDERS[channel.name] + const [showQr, setShowQr] = useState(false) + const [expanded, setExpanded] = useState(false) + const [values, setValues] = useState>(() => + Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : ''])) + ) + // Track which secret fields still hold the server-provided mask. + const [masked, setMasked] = useState>(() => + Object.fromEntries( + channel.fields.map((f) => [f.key, f.type === 'secret' && !!f.value && MASK_RE.test(String(f.value))]) + ) + ) + const [busy, setBusy] = useState(false) + const [status, setStatus] = useState('') + + const setField = (key: string, val: string) => setValues((p) => ({ ...p, [key]: val })) + + // Only send fields the user actually changed; masked secrets are skipped so + // the backend keeps the stored value (mirrors the web console behavior). + const buildConfig = (): Record => { + const cfg: Record = {} + channel.fields.forEach((f) => { + const v = values[f.key] + if (f.type === 'secret' && masked[f.key]) return + if (v === '' || v == null) return + cfg[f.key] = f.type === 'number' ? Number(v) : v + }) + return cfg + } + + const run = async (action: 'save' | 'connect' | 'disconnect') => { + setBusy(true) + setStatus('') + try { + const cfg = action === 'disconnect' ? undefined : buildConfig() + const res = await apiClient.channelAction(action, channel.name, cfg) + if (res.status === 'success') { + if (action === 'save') { + setStatus(t('channels_save_ok')) + setTimeout(() => setStatus(''), 1600) + } + onChanged() + } else { + setStatus((res.message as string) || t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error')) + } + } catch { + setStatus(t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error')) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+ {isQrLogin ? : } +
+
+
+ {localizedLabel(channel.label)} + +
+

{channel.name}

+
+ + {channel.active ? ( + run('disconnect')} disabled={busy}> + {t('channels_disconnect')} + + ) : qrProvider ? ( + setShowQr(true)}> + {qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')} + + ) : isQrLogin ? null : ( + setExpanded((v) => !v)}> + {t('channels_add')} + + )} +
+ + {/* QR-login channels with no desktop support fall back to the web console. */} + {isQrLogin && !channel.active && !qrProvider && ( +

{t('channels_qr_hint')}

+ )} + + {/* Field-bearing QR channels (feishu) can also be configured manually. */} + {!isQrLogin && qrProvider && !channel.active && !expanded && ( + + )} + + {/* Field editor: always for connected channels with fields, on-demand for available ones. */} + {!isQrLogin && (channel.active || expanded) && ( +
+ {channel.fields.map((f) => ( + setField(f.key, v)} + onFocusSecret={() => { + if (f.type === 'secret' && masked[f.key]) { + setField(f.key, '') + setMasked((p) => ({ ...p, [f.key]: false })) + } + }} + /> + ))} +
+ + {status || '\u00a0'} + + {channel.active ? ( + run('save')} disabled={busy}> + {t('channels_save')} + + ) : ( + run('connect')} disabled={busy}> + {t('channels_connect')} + + )} +
+
+ )} + + {showQr && qrProvider && ( + setShowQr(false)} + onConnected={() => { + setShowQr(false) + onChanged() + }} + /> + )} +
+ ) +} + +const FieldRow: React.FC<{ + field: ChannelField + value: string + onChange: (v: string) => void + onFocusSecret: () => void +}> = ({ field, value, onChange, onFocusSecret }) => { + if (field.type === 'bool') { + return ( +
+ {field.label} + onChange(v ? 'true' : 'false')} /> +
+ ) + } + return ( +
+ + onChange(e.target.value)} + onFocus={onFocusSecret} + className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent font-mono transition-colors" + /> +
+ ) +} + export default ChannelsPage