mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 12:47:25 +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: '下一页',
|
memory_next: '下一页',
|
||||||
channels_title: '通道管理',
|
channels_title: '通道管理',
|
||||||
channels_desc: '查看和管理消息通道',
|
channels_desc: '查看和管理消息通道',
|
||||||
channels_add: '连接',
|
channels_add: '添加通道',
|
||||||
channels_connected: '已连接',
|
channels_connected: '已连接',
|
||||||
channels_disconnected: '未连接',
|
channels_disconnected: '未连接',
|
||||||
channels_connect: '连接',
|
channels_connect: '连接',
|
||||||
channels_disconnect: '断开',
|
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_title: '定时任务',
|
||||||
tasks_desc: '查看和管理定时任务',
|
tasks_desc: '查看和管理定时任务',
|
||||||
tasks_active: '运行中',
|
tasks_active: '运行中',
|
||||||
@@ -417,11 +448,42 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
memory_next: 'Next',
|
memory_next: 'Next',
|
||||||
channels_title: 'Channels',
|
channels_title: 'Channels',
|
||||||
channels_desc: 'View and manage messaging channels',
|
channels_desc: 'View and manage messaging channels',
|
||||||
channels_add: 'Connect',
|
channels_add: 'Add channel',
|
||||||
channels_connected: 'Connected',
|
channels_connected: 'Connected',
|
||||||
channels_disconnected: 'Disconnected',
|
channels_disconnected: 'Disconnected',
|
||||||
channels_connect: 'Connect',
|
channels_connect: 'Connect',
|
||||||
channels_disconnect: 'Disconnect',
|
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_title: 'Scheduled Tasks',
|
||||||
tasks_desc: 'View and manage scheduled tasks',
|
tasks_desc: 'View and manage scheduled tasks',
|
||||||
tasks_active: 'Active',
|
tasks_active: 'Active',
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { t } from '../i18n'
|
import { Loader2, Plug, QrCode } from 'lucide-react'
|
||||||
|
import { t, localizedLabel } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
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 {
|
interface ChannelsPageProps {
|
||||||
baseUrl: string
|
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 ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||||
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
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 () => {
|
const loadChannels = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -25,112 +27,245 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
setChannels(data || [])
|
setChannels(data || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load channels:', err)
|
console.error('Failed to load channels:', err)
|
||||||
|
setChannels([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFieldChange = (ch: string, key: string, val: string) => {
|
useEffect(() => {
|
||||||
setConfigValues((prev) => ({ ...prev, [ch]: { ...prev[ch], [key]: val } }))
|
apiClient.setBaseUrl(baseUrl)
|
||||||
}
|
void loadChannels()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
const handleAction = async (channel: ChannelInfo, action: 'save' | 'connect' | 'disconnect') => {
|
const { connected, available } = useMemo(() => {
|
||||||
setActionLoading(`${channel.name}-${action}`)
|
const connected = channels.filter((c) => c.active)
|
||||||
try {
|
const available = channels.filter((c) => !c.active)
|
||||||
await apiClient.channelAction(action, channel.name, configValues[channel.name])
|
return { connected, available }
|
||||||
await loadChannels()
|
}, [channels])
|
||||||
} catch (err) {
|
|
||||||
console.error(`Action failed:`, err)
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="px-6 pt-5 pb-3 flex-shrink-0">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
|
||||||
<div>
|
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
|
||||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('channels_title')}</h2>
|
</div>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('channels_desc')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||||
<div className="flex items-center justify-center py-20 text-slate-400">
|
<div className="max-w-3xl mx-auto px-6 py-5">
|
||||||
<i className="fas fa-spinner fa-spin mr-2" />Loading...
|
{loading ? (
|
||||||
</div>
|
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||||
) : (
|
<Loader2 size={18} className="animate-spin mr-2" />
|
||||||
<div className="grid gap-4">
|
{t('channels_loading')}
|
||||||
{channels.map((ch) => (
|
</div>
|
||||||
<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="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<Section title={t('channels_connected_section')}>
|
||||||
<div className="w-9 h-9 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
{connected.length === 0 ? (
|
||||||
<i className={`fas ${ch.icon || 'fa-tower-broadcast'} text-blue-500 text-sm`} />
|
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
||||||
</div>
|
) : (
|
||||||
<span className="font-semibold text-slate-800 dark:text-slate-100">{ch.label?.zh || ch.label?.en || ch.name}</span>
|
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{available.length > 0 && (
|
||||||
<button
|
<Section title={t('channels_available_section')}>
|
||||||
onClick={() => handleAction(ch, 'save')}
|
{available.map((ch) => (
|
||||||
disabled={actionLoading === `${ch.name}-save`}
|
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
|
||||||
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"
|
))}
|
||||||
>
|
</Section>
|
||||||
<i className="fas fa-save text-xs mr-1.5" />{t('config_save')}
|
)}
|
||||||
</button>
|
</div>
|
||||||
{ch.active ? (
|
)}
|
||||||
<button
|
</div>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</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
|
export default ChannelsPage
|
||||||
|
|||||||
Reference in New Issue
Block a user