mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): add QR scan login for channels
This commit is contained in:
222
desktop/src/renderer/src/components/QrLoginModal.tsx
Normal file
222
desktop/src/renderer/src/components/QrLoginModal.tsx
Normal file
@@ -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<QrLoginModalProps> = ({ provider, onClose, onConnected }) => {
|
||||
const [phase, setPhase] = useState<Phase>('loading')
|
||||
const [qr, setQr] = useState('')
|
||||
const [openLink, setOpenLink] = useState('')
|
||||
const [errMsg, setErrMsg] = useState('')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<Modal open title={title} onClose={onClose}>
|
||||
<div className="flex flex-col items-center py-2">
|
||||
{phase === 'loading' && (
|
||||
<div className="flex items-center text-content-tertiary py-10">
|
||||
<Loader2 size={18} className="animate-spin mr-2" />
|
||||
{provider === 'weixin' ? t('weixin_scan_loading') : t('feishu_scan_loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phase === 'waiting' || phase === 'scanned') && (
|
||||
<>
|
||||
<p className="text-sm text-content-secondary mb-4 text-center">{desc}</p>
|
||||
<div className="bg-white p-3 rounded-card border border-subtle mb-3">
|
||||
{qr ? (
|
||||
<img src={qr} alt="QR" className="w-48 h-48" style={{ imageRendering: 'pixelated' }} />
|
||||
) : (
|
||||
<div className="w-48 h-48 flex items-center justify-center text-content-tertiary text-xs">QR</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-xs mb-1 ${phase === 'scanned' ? 'text-accent' : 'text-warning'}`}>{statusText()}</p>
|
||||
<p className="text-xs text-content-tertiary">{tip}</p>
|
||||
{openLink && (
|
||||
<a
|
||||
href={openLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-info hover:underline mt-2"
|
||||
>
|
||||
{t('feishu_scan_open_link')}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'success' && (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-soft flex items-center justify-center mb-3">
|
||||
<Check size={22} className="text-accent" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-accent">
|
||||
{provider === 'weixin' ? t('weixin_scan_success') : t('feishu_scan_success')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<p className="text-sm text-danger text-center mb-3">{errMsg}</p>
|
||||
<button
|
||||
onClick={start}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn border border-strong text-sm text-content-secondary hover:bg-inset cursor-pointer transition-colors"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
{t('feishu_scan_retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default QrLoginModal
|
||||
@@ -184,11 +184,42 @@ const translations: Record<string, Record<string, string>> = {
|
||||
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<string, Record<string, string>> = {
|
||||
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',
|
||||
|
||||
@@ -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<string, 'weixin' | 'feishu'> = { 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<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [configValues, setConfigValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
loadChannels()
|
||||
}, [baseUrl])
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
@@ -25,112 +27,245 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ 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 (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('channels_title')}</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('channels_desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 pt-5 pb-3 flex-shrink-0">
|
||||
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
|
||||
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-slate-400">
|
||||
<i className="fas fa-spinner fa-spin mr-2" />Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{channels.map((ch) => (
|
||||
<div key={ch.name} className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<i className={`fas ${ch.icon || 'fa-tower-broadcast'} text-blue-500 text-sm`} />
|
||||
</div>
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-100">{ch.label?.zh || ch.label?.en || ch.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2.5 py-1 rounded-full ${
|
||||
ch.active
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
|
||||
: 'bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400'
|
||||
}`}>
|
||||
{ch.active ? t('channels_connected') : t('channels_disconnected')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ch.fields && ch.fields.length > 0 && (
|
||||
<div className="space-y-3 mb-4">
|
||||
{ch.fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{field.label || field.key}
|
||||
</label>
|
||||
<input
|
||||
type={field.type === 'secret' ? 'password' : 'text'}
|
||||
value={configValues[ch.name]?.[field.key] || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||
<div className="max-w-3xl mx-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||
<Loader2 size={18} className="animate-spin mr-2" />
|
||||
{t('channels_loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Section title={t('channels_connected_section')}>
|
||||
{connected.length === 0 ? (
|
||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
||||
) : (
|
||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleAction(ch, 'save')}
|
||||
disabled={actionLoading === `${ch.name}-save`}
|
||||
className="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<i className="fas fa-save text-xs mr-1.5" />{t('config_save')}
|
||||
</button>
|
||||
{ch.active ? (
|
||||
<button
|
||||
onClick={() => handleAction(ch, 'disconnect')}
|
||||
disabled={actionLoading === `${ch.name}-disconnect`}
|
||||
className="px-3 py-1.5 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm hover:bg-red-100 dark:hover:bg-red-900/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<i className="fas fa-unlink text-xs mr-1.5" />{t('channels_disconnect')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAction(ch, 'connect')}
|
||||
disabled={actionLoading === `${ch.name}-connect`}
|
||||
className="px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm cursor-pointer transition-colors"
|
||||
>
|
||||
<i className="fas fa-link text-xs mr-1.5" />{t('channels_connect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{available.length > 0 && (
|
||||
<Section title={t('channels_available_section')}>
|
||||
{available.map((ch) => (
|
||||
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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<Record<string, string>>(() =>
|
||||
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<Record<string, boolean>>(() =>
|
||||
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<string, unknown> => {
|
||||
const cfg: Record<string, unknown> = {}
|
||||
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 (
|
||||
<div className="rounded-card border border-default bg-surface p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
|
||||
{isQrLogin ? <QrCode size={16} className="text-content-secondary" /> : <Plug size={16} className="text-content-secondary" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
|
||||
<span className={`w-2 h-2 rounded-full ${channel.active ? 'bg-accent' : 'bg-content-tertiary'}`} />
|
||||
</div>
|
||||
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
|
||||
</div>
|
||||
|
||||
{channel.active ? (
|
||||
<Btn variant="danger" onClick={() => run('disconnect')} disabled={busy}>
|
||||
{t('channels_disconnect')}
|
||||
</Btn>
|
||||
) : qrProvider ? (
|
||||
<Btn variant="primary" onClick={() => setShowQr(true)}>
|
||||
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
|
||||
</Btn>
|
||||
) : isQrLogin ? null : (
|
||||
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
||||
{t('channels_add')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR-login channels with no desktop support fall back to the web console. */}
|
||||
{isQrLogin && !channel.active && !qrProvider && (
|
||||
<p className="text-xs text-content-tertiary mt-3 pl-12">{t('channels_qr_hint')}</p>
|
||||
)}
|
||||
|
||||
{/* Field-bearing QR channels (feishu) can also be configured manually. */}
|
||||
{!isQrLogin && qrProvider && !channel.active && !expanded && (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-xs text-content-tertiary hover:text-content-secondary mt-3 pl-12 cursor-pointer transition-colors"
|
||||
>
|
||||
{t('channels_add')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Field editor: always for connected channels with fields, on-demand for available ones. */}
|
||||
{!isQrLogin && (channel.active || expanded) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{channel.fields.map((f) => (
|
||||
<FieldRow
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(v) => setField(f.key, v)}
|
||||
onFocusSecret={() => {
|
||||
if (f.type === 'secret' && masked[f.key]) {
|
||||
setField(f.key, '')
|
||||
setMasked((p) => ({ ...p, [f.key]: false }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<span className={`text-xs transition-opacity ${status ? 'opacity-100' : 'opacity-0'} ${status === t('channels_save_ok') ? 'text-accent' : 'text-danger'}`}>
|
||||
{status || '\u00a0'}
|
||||
</span>
|
||||
{channel.active ? (
|
||||
<Btn variant="primary" onClick={() => run('save')} disabled={busy}>
|
||||
{t('channels_save')}
|
||||
</Btn>
|
||||
) : (
|
||||
<Btn variant="primary" onClick={() => run('connect')} disabled={busy}>
|
||||
{t('channels_connect')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQr && qrProvider && (
|
||||
<QrLoginModal
|
||||
provider={qrProvider}
|
||||
onClose={() => setShowQr(false)}
|
||||
onConnected={() => {
|
||||
setShowQr(false)
|
||||
onChanged()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FieldRow: React.FC<{
|
||||
field: ChannelField
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onFocusSecret: () => void
|
||||
}> = ({ field, value, onChange, onFocusSecret }) => {
|
||||
if (field.type === 'bool') {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-content-secondary">{field.label}</span>
|
||||
<Toggle checked={value === 'true' || value === '1'} onChange={(v) => onChange(v ? 'true' : 'false')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{field.label}</label>
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={value}
|
||||
placeholder={field.label}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelsPage
|
||||
|
||||
Reference in New Issue
Block a user