feat(pairing): add pairing management UI to AdapterSettings page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-08 20:12:13 +08:00
parent 14a8322355
commit 2523ce0cd7
3 changed files with 132 additions and 2 deletions

View File

@ -124,6 +124,20 @@ export const en = {
'settings.adapters.saved': 'Saved',
'settings.adapters.saving': 'Saving...',
'settings.adapters.startHint': 'After saving, run adapter: cd adapters && bun run {platform}',
'settings.adapters.pairing': 'Pairing',
'settings.adapters.pairingDesc': 'Generate a pairing code and send it to the Bot via IM private chat to bind your identity.',
'settings.adapters.generateCode': 'Generate Code',
'settings.adapters.regenerateCode': 'Regenerate',
'settings.adapters.codeExpired': 'Expired',
'settings.adapters.codeExpiresIn': 'Expires in',
'settings.adapters.minutes': 'min',
'settings.adapters.pairingCodeHint': 'Send this code to the Bot in Feishu or Telegram private chat.',
'settings.adapters.pairedUsers': 'Paired Users',
'settings.adapters.noPairedUsers': 'No paired users yet',
'settings.adapters.unbind': 'Unbind',
'settings.adapters.unbindConfirm': 'Are you sure you want to unbind this user? They will need to re-pair to use the bot.',
'settings.adapters.platform.telegram': 'Telegram',
'settings.adapters.platform.feishu': 'Feishu',
// Settings > General
'settings.general.languageTitle': 'Language',

View File

@ -126,6 +126,20 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.saved': '已保存',
'settings.adapters.saving': '保存中...',
'settings.adapters.startHint': '保存后运行适配器cd adapters && bun run {platform}',
'settings.adapters.pairing': '配对管理',
'settings.adapters.pairingDesc': '生成配对码后在 IM 私聊中发送给 Bot完成身份绑定。',
'settings.adapters.generateCode': '生成配对码',
'settings.adapters.regenerateCode': '重新生成',
'settings.adapters.codeExpired': '已过期',
'settings.adapters.codeExpiresIn': '有效期剩余',
'settings.adapters.minutes': '分钟',
'settings.adapters.pairingCodeHint': '请在飞书或 Telegram 私聊中发送此配对码给 Bot。',
'settings.adapters.pairedUsers': '已配对用户',
'settings.adapters.noPairedUsers': '暂无已配对用户',
'settings.adapters.unbind': '解绑',
'settings.adapters.unbindConfirm': '确定要解绑该用户吗?解绑后需重新配对才能使用。',
'settings.adapters.platform.telegram': 'Telegram',
'settings.adapters.platform.feishu': '飞书',
// Settings > General
'settings.general.languageTitle': '语言',

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useAdapterStore } from '../stores/adapterStore'
import { useTranslation } from '../i18n'
import { Input } from '../components/shared/Input'
@ -7,7 +7,7 @@ import { DirectoryPicker } from '../components/shared/DirectoryPicker'
export function AdapterSettings() {
const t = useTranslation()
const { config, isLoading, fetchConfig, updateConfig } = useAdapterStore()
const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore()
// Server
const [serverUrl, setServerUrl] = useState('')
@ -29,6 +29,10 @@ export function AdapterSettings() {
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle')
const [saveError, setSaveError] = useState('')
// Pairing
const [pairingCode, setPairingCode] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
useEffect(() => {
fetchConfig()
}, [])
@ -94,6 +98,35 @@ export function AdapterSettings() {
}
}
const handleGenerateCode = useCallback(async () => {
setIsGenerating(true)
try {
const code = await generatePairingCode()
setPairingCode(code)
} catch (err) {
console.error('Failed to generate pairing code:', err)
} finally {
setIsGenerating(false)
}
}, [generatePairingCode])
const handleUnbind = useCallback(async (platform: 'telegram' | 'feishu', userId: string | number) => {
if (!confirm(t('settings.adapters.unbindConfirm'))) return
await removePairedUser(platform, userId)
await fetchConfig()
}, [removePairedUser, fetchConfig, t])
// Collect all paired users across platforms
const allPairedUsers = [
...(config.telegram?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'telegram' as const })),
...(config.feishu?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'feishu' as const })),
]
// Check pairing expiry
const pairingExpiry = config.pairing?.expiresAt
const isPairingActive = pairingExpiry ? Date.now() < pairingExpiry : false
const minutesLeft = pairingExpiry ? Math.max(0, Math.ceil((pairingExpiry - Date.now()) / 60000)) : 0
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]">
@ -110,6 +143,75 @@ export function AdapterSettings() {
<p className="text-sm text-[var(--color-text-secondary)]">{t('settings.adapters.description')}</p>
</div>
{/* Pairing */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">link</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.pairing')}</span>
</div>
<div className="p-4 space-y-4">
<p className="text-sm text-[var(--color-text-secondary)]">{t('settings.adapters.pairingDesc')}</p>
{/* Generate code */}
<div className="flex items-center gap-3">
<Button onClick={handleGenerateCode} loading={isGenerating}>
{pairingCode || isPairingActive ? t('settings.adapters.regenerateCode') : t('settings.adapters.generateCode')}
</Button>
{pairingCode && (
<div className="flex items-center gap-2">
<span className="font-mono text-2xl font-bold tracking-[0.3em] text-[var(--color-brand)]">
{pairingCode}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.codeExpiresIn')} 60 {t('settings.adapters.minutes')}
</span>
</div>
)}
{!pairingCode && isPairingActive && (
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.codeExpiresIn')} {minutesLeft} {t('settings.adapters.minutes')}
</span>
)}
</div>
{pairingCode && (
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.pairingCodeHint')}</p>
)}
{/* Paired users list */}
<div>
<h4 className="text-sm font-medium text-[var(--color-text-primary)] mb-2">{t('settings.adapters.pairedUsers')}</h4>
{allPairedUsers.length === 0 ? (
<p className="text-sm text-[var(--color-text-tertiary)]">{t('settings.adapters.noPairedUsers')}</p>
) : (
<div className="space-y-2">
{allPairedUsers.map((user) => (
<div
key={`${user.platform}-${user.userId}`}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-surface-hover)]"
>
<div className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
{t(`settings.adapters.platform.${user.platform}`)}
</span>
<span className="text-sm text-[var(--color-text-primary)]">{user.displayName}</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
{new Date(user.pairedAt).toLocaleDateString()}
</span>
</div>
<button
onClick={() => handleUnbind(user.platform, user.userId)}
className="text-xs text-[var(--color-error)] hover:underline cursor-pointer"
>
{t('settings.adapters.unbind')}
</button>
</div>
))}
</div>
)}
</div>
</div>
</section>
{/* Server URL */}
<Input
label={t('settings.adapters.serverUrl')}