cc-haha/desktop/src/pages/AdapterSettings.tsx
程序员阿江(Relakkes) 81845fbc49 fix: finalize Electron migration boundaries
Complete the Electron replacement boundary before merging by removing the renderer-side Tauri host fallback, tightening H5/browser access so only desktop navigation is tokenless, and moving desktop release publication to a tag-driven GitHub Actions matrix with a single final publish job.

Constraint: H5/browser capability access must not gain tokenless access through localhost or retired Tauri origins

Constraint: Desktop release artifacts must be built by GitHub Actions from version tags, not treated as local build outputs

Rejected: Keep localhost browser origins trusted for convenience | local browser contexts can access loopback services and must use the H5 token path

Rejected: Publish from each matrix job | partial releases can be created before all platforms finish

Confidence: high

Scope-risk: broad

Directive: Do not reintroduce Tauri origins or localhost browser origins into the trusted desktop origin set without a reviewed security design

Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/diagnostics-service.test.ts src/server/middleware/cors.test.ts

Tested: bun test scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts

Tested: bun run check:desktop

Tested: bun run check:native

Tested: git diff --check

Not-tested: bun run check:server is blocked by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
2026-06-02 22:42:53 +08:00

847 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from 'react'
import { useAdapterStore } from '../stores/adapterStore'
import { useTranslation } from '../i18n'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import QRCode from 'qrcode'
type ImTab = 'feishu' | 'wechat' | 'dingtalk' | 'telegram'
type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk'
type AdapterUnbindTarget = 'wechatAccount' | 'dingtalkBot'
const FEISHU_CREATE_BOT_URL = 'https://open.feishu.cn/page/openclaw?form=multiAgent'
export function AdapterSettings() {
const t = useTranslation()
const {
config,
isLoading,
fetchConfig,
updateConfig,
generatePairingCode,
startWechatLogin,
pollWechatLogin,
removePairedUser,
beginDingtalkRegistration,
pollDingtalkRegistration,
unbindWechatAccount,
unbindDingtalkBot,
} = useAdapterStore()
// Active IM tab —— Feishu 默认展示,在前
const [activeIm, setActiveIm] = useState<ImTab>('feishu')
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
// 桌面端用 sidecar env var 注入动态端口。
const [defaultProjectDir, setDefaultProjectDir] = useState('')
// Telegram
const [tgBotToken, setTgBotToken] = useState('')
const [tgAllowedUsers, setTgAllowedUsers] = useState('')
// Feishu
const [fsAppId, setFsAppId] = useState('')
const [fsAppSecret, setFsAppSecret] = useState('')
const [fsEncryptKey, setFsEncryptKey] = useState('')
const [fsVerificationToken, setFsVerificationToken] = useState('')
const [fsAllowedUsers, setFsAllowedUsers] = useState('')
const [fsStreamingCard, setFsStreamingCard] = useState(false)
// WeChat
const [wcAllowedUsers, setWcAllowedUsers] = useState('')
const [wechatQrUrl, setWechatQrUrl] = useState<string | null>(null)
const [wechatSessionKey, setWechatSessionKey] = useState<string | null>(null)
const [wechatStatus, setWechatStatus] = useState('')
const [isWechatBinding, setIsWechatBinding] = useState(false)
const [isUnbindingWechatAccount, setIsUnbindingWechatAccount] = useState(false)
// DingTalk
const [dtClientId, setDtClientId] = useState('')
const [dtClientSecret, setDtClientSecret] = useState('')
const [dtAllowedUsers, setDtAllowedUsers] = useState('')
const [dtEndpoint, setDtEndpoint] = useState('')
const [dtPermissionCardTemplateId, setDtPermissionCardTemplateId] = useState('')
const [dtRegistration, setDtRegistration] = useState<{
deviceCode: string
verificationUriComplete: string
qrDataUrl?: string
intervalSeconds: number
expiresAt: number
} | null>(null)
const [dtAuthStatus, setDtAuthStatus] = useState<'idle' | 'waiting' | 'bound' | 'error'>('idle')
const [dtAuthError, setDtAuthError] = useState('')
const [isStartingDtAuth, setIsStartingDtAuth] = useState(false)
const [isUnbindingDtBot, setIsUnbindingDtBot] = useState(false)
const [isSaving, setIsSaving] = useState(false)
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)
const [pendingUnbind, setPendingUnbind] = useState<{ platform: ImPlatform; userId: string | number } | null>(null)
const [pendingAdapterUnbind, setPendingAdapterUnbind] = useState<AdapterUnbindTarget | null>(null)
const [isUnbinding, setIsUnbinding] = useState(false)
useEffect(() => {
fetchConfig()
}, [])
// Sync form state when config is loaded
useEffect(() => {
setDefaultProjectDir(config.defaultProjectDir ?? '')
setTgBotToken(config.telegram?.botToken ?? '')
setTgAllowedUsers(config.telegram?.allowedUsers?.join(', ') ?? '')
setFsAppId(config.feishu?.appId ?? '')
setFsAppSecret(config.feishu?.appSecret ?? '')
setFsEncryptKey(config.feishu?.encryptKey ?? '')
setFsVerificationToken(config.feishu?.verificationToken ?? '')
setFsAllowedUsers(config.feishu?.allowedUsers?.join(', ') ?? '')
setFsStreamingCard(config.feishu?.streamingCard ?? false)
setWcAllowedUsers(config.wechat?.allowedUsers?.join(', ') ?? '')
setDtClientId(config.dingtalk?.clientId ?? '')
setDtClientSecret(config.dingtalk?.clientSecret ?? '')
setDtAllowedUsers(config.dingtalk?.allowedUsers?.join(', ') ?? '')
setDtEndpoint(config.dingtalk?.endpoint ?? '')
setDtPermissionCardTemplateId(config.dingtalk?.permissionCardTemplateId ?? '')
}, [config])
useEffect(() => {
if (!wechatSessionKey) return
let cancelled = false
let timer: number | null = null
const poll = async () => {
try {
const result = await pollWechatLogin(wechatSessionKey)
if (cancelled) return
if (result.connected) {
setWechatStatus(t('settings.adapters.wechatBindSuccess'))
setWechatQrUrl(null)
setWechatSessionKey(null)
setIsWechatBinding(false)
return
}
if (result.message) {
setWechatStatus(result.message)
}
if (result.status === 'expired' || result.status === 'not_started') {
setWechatQrUrl(null)
setWechatSessionKey(null)
setIsWechatBinding(false)
return
}
} catch (err) {
if (!cancelled) setWechatStatus(err instanceof Error ? err.message : 'WeChat bind failed')
}
if (!cancelled) {
timer = window.setTimeout(() => void poll(), 1200)
}
}
timer = window.setTimeout(() => void poll(), 1200)
return () => {
cancelled = true
if (timer != null) window.clearTimeout(timer)
}
}, [wechatSessionKey, pollWechatLogin, t])
useEffect(() => {
if (!dtRegistration || dtAuthStatus !== 'waiting') return
let cancelled = false
const poll = async () => {
if (Date.now() > dtRegistration.expiresAt) {
setDtAuthStatus('error')
setDtAuthError(t('settings.adapters.dingtalkAuthExpired'))
setDtRegistration(null)
return
}
try {
const result = await pollDingtalkRegistration(dtRegistration.deviceCode)
if (cancelled) return
if (result.status === 'SUCCESS') {
setDtAuthStatus('bound')
setDtRegistration(null)
setDtAuthError('')
await fetchConfig()
} else if (result.status === 'FAIL' || result.status === 'EXPIRED') {
setDtAuthStatus('error')
setDtAuthError(result.failReason || t('settings.adapters.dingtalkAuthFailed'))
setDtRegistration(null)
}
} catch (err) {
if (!cancelled) {
setDtAuthStatus('error')
setDtAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed'))
}
}
}
const timer = window.setInterval(poll, Math.max(1, dtRegistration.intervalSeconds) * 1000)
void poll()
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [dtRegistration, dtAuthStatus, pollDingtalkRegistration, fetchConfig, t])
async function handleSave() {
setIsSaving(true)
setSaveStatus('idle')
setSaveError('')
try {
const patch: Record<string, unknown> = {}
if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir
const tgUsers = tgAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => !isNaN(n))
patch.telegram = {
botToken: tgBotToken || undefined,
allowedUsers: tgUsers.length ? tgUsers : [],
}
const fsUsers = fsAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
patch.feishu = {
appId: fsAppId || undefined,
appSecret: fsAppSecret || undefined,
encryptKey: fsEncryptKey || undefined,
verificationToken: fsVerificationToken || undefined,
allowedUsers: fsUsers.length ? fsUsers : [],
streamingCard: fsStreamingCard,
}
const wcUsers = wcAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
patch.wechat = {
...config.wechat,
allowedUsers: wcUsers.length ? wcUsers : [],
}
const dtUsers = dtAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
patch.dingtalk = {
clientId: dtClientId || undefined,
clientSecret: dtClientSecret || undefined,
allowedUsers: dtUsers.length ? dtUsers : [],
endpoint: dtEndpoint || undefined,
permissionCardTemplateId: dtPermissionCardTemplateId || undefined,
}
await updateConfig(patch)
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 2000)
} catch (err) {
setSaveStatus('error')
setSaveError(err instanceof Error ? err.message : 'Save failed')
} finally {
setIsSaving(false)
}
}
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 handleWechatBind = useCallback(async () => {
setIsWechatBinding(true)
setWechatStatus('')
try {
const result = await startWechatLogin()
if (!result.qrcodeUrl) {
throw new Error(result.message || 'WeChat QR URL missing')
}
const qrDataUrl = await QRCode.toDataURL(result.qrcodeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
scale: 8,
})
setWechatQrUrl(qrDataUrl)
setWechatSessionKey(result.sessionKey)
setWechatStatus(result.message)
} catch (err) {
setWechatStatus(err instanceof Error ? err.message : 'WeChat bind failed')
setIsWechatBinding(false)
}
}, [startWechatLogin])
const handleStartDingtalkAuth = useCallback(async () => {
setIsStartingDtAuth(true)
setDtAuthStatus('idle')
setDtAuthError('')
try {
const begin = await beginDingtalkRegistration()
setDtRegistration({
deviceCode: begin.deviceCode,
verificationUriComplete: begin.verificationUriComplete,
qrDataUrl: begin.qrDataUrl,
intervalSeconds: begin.intervalSeconds,
expiresAt: Date.now() + begin.expiresInSeconds * 1000,
})
setDtAuthStatus('waiting')
} catch (err) {
setDtAuthStatus('error')
setDtAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed'))
} finally {
setIsStartingDtAuth(false)
}
}, [beginDingtalkRegistration, t])
const handleUnbindWechatAccount = useCallback(async () => {
setIsUnbindingWechatAccount(true)
setWechatStatus('')
try {
await unbindWechatAccount()
await fetchConfig()
setWechatQrUrl(null)
setWechatSessionKey(null)
setWechatStatus(t('settings.adapters.wechatUnbound'))
} catch (err) {
setWechatStatus(err instanceof Error ? err.message : t('settings.adapters.wechatUnbindFailed'))
} finally {
setIsUnbindingWechatAccount(false)
setIsWechatBinding(false)
setPendingAdapterUnbind(null)
}
}, [unbindWechatAccount, fetchConfig, t])
const handleUnbindDingtalkBot = useCallback(async () => {
setIsUnbindingDtBot(true)
setDtAuthError('')
try {
await unbindDingtalkBot()
setDtAuthStatus('idle')
setDtRegistration(null)
await fetchConfig()
} catch (err) {
setDtAuthStatus('error')
setDtAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkUnbindFailed'))
} finally {
setIsUnbindingDtBot(false)
setPendingAdapterUnbind(null)
}
}, [unbindDingtalkBot, fetchConfig, t])
const handleUnbind = useCallback(async (platform: ImPlatform, userId: string | number) => {
setPendingUnbind({ platform, userId })
}, [])
const confirmUnbind = useCallback(async () => {
if (!pendingUnbind) return
setIsUnbinding(true)
try {
await removePairedUser(pendingUnbind.platform, pendingUnbind.userId)
await fetchConfig()
setPendingUnbind(null)
} finally {
setIsUnbinding(false)
}
}, [pendingUnbind, removePairedUser, fetchConfig])
// 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 })),
...(config.wechat?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'wechat' as const })),
...(config.dingtalk?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'dingtalk' 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
const hasSavedFeishuCredentials = Boolean(config.feishu?.appId && config.feishu?.appSecret)
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[20px] mr-2">progress_activity</span>
Loading...
</div>
)
}
return (
<div className="max-w-2xl space-y-8">
{/* Description */}
<div>
<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 —— 之前是个手填字段,但桌面端启动 adapter sidecar
时已经把 server 的动态端口通过 ADAPTER_SERVER_URL env var 注进去了,
loadConfig() 里 env 优先级高于这里的 file value所以这个字段在桌面
运行时完全不会被读到。用户也根本不知道该填什么端口(每次启动随机)。
Standalone 模式(直接 bun run adapters/...)保留 file 字段兜底就够了。 */}
{/* Default Project */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.adapters.defaultProject')}
</label>
<DirectoryPicker value={defaultProjectDir} onChange={setDefaultProjectDir} />
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.defaultProjectHint')}
</p>
</div>
{/* IM Adapter Tabs —— Feishu 默认在前Telegram 在后 */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div role="tablist" aria-label="IM adapter" className="flex items-stretch border-b border-[var(--color-border)] bg-[var(--color-surface-hover)]">
<ImTabButton
label={t('settings.adapters.feishu')}
active={activeIm === 'feishu'}
onClick={() => setActiveIm('feishu')}
/>
<ImTabButton
label={t('settings.adapters.wechat')}
active={activeIm === 'wechat'}
onClick={() => setActiveIm('wechat')}
/>
<ImTabButton
label={t('settings.adapters.dingtalk')}
active={activeIm === 'dingtalk'}
onClick={() => setActiveIm('dingtalk')}
/>
<ImTabButton
label={t('settings.adapters.telegram')}
active={activeIm === 'telegram'}
onClick={() => setActiveIm('telegram')}
/>
</div>
{activeIm === 'feishu' && (
<div className="p-4 space-y-4">
{!hasSavedFeishuCredentials && (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 gap-3">
<span className="material-symbols-outlined mt-0.5 text-[20px] text-[var(--color-brand)]">smart_toy</span>
<div className="min-w-0">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.feishuCreateBotTitle')}</h4>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.adapters.feishuCreateBotDesc')}</p>
<ol className="mt-2 space-y-1 text-xs leading-5 text-[var(--color-text-secondary)]">
<li>1. {t('settings.adapters.feishuCreateBotStepCreate')}</li>
<li>2. {t('settings.adapters.feishuCreateBotStepFill')}</li>
</ol>
</div>
</div>
<a
href={FEISHU_CREATE_BOT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-[var(--radius-md)] bg-[image:var(--gradient-btn-primary)] px-3 text-xs font-medium text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-colors hover:bg-[image:var(--gradient-btn-primary-hover)] hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
{t('settings.adapters.feishuCreateBotAction')}
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.appId')}
value={fsAppId}
onChange={(e) => setFsAppId(e.target.value)}
placeholder={t('settings.adapters.appIdPlaceholder')}
/>
<Input
label={t('settings.adapters.appSecret')}
type="password"
value={fsAppSecret}
onChange={(e) => setFsAppSecret(e.target.value)}
placeholder={t('settings.adapters.appSecretPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.encryptKey')}
type="password"
value={fsEncryptKey}
onChange={(e) => setFsEncryptKey(e.target.value)}
placeholder={t('settings.adapters.encryptKeyPlaceholder')}
/>
<Input
label={t('settings.adapters.verificationToken')}
type="password"
value={fsVerificationToken}
onChange={(e) => setFsVerificationToken(e.target.value)}
placeholder={t('settings.adapters.verificationTokenPlaceholder')}
/>
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={fsAllowedUsers}
onChange={(e) => setFsAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={fsStreamingCard}
onChange={(e) => setFsStreamingCard(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<div>
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.streamingCard')}</span>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.streamingCardDesc')}</p>
</div>
</label>
</div>
)}
{activeIm === 'wechat' && (
<div className="p-4 space-y-4">
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{config.wechat?.accountId ? t('settings.adapters.wechatConnected') : t('settings.adapters.wechatNotConnected')}
</div>
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.wechatQrHint')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button onClick={handleWechatBind} loading={isWechatBinding && !wechatQrUrl} size="sm">
{config.wechat?.accountId ? t('settings.adapters.wechatRebind') : t('settings.adapters.wechatBind')}
</Button>
{config.wechat?.accountId && (
<Button onClick={() => setPendingAdapterUnbind('wechatAccount')} loading={isUnbindingWechatAccount} size="sm" variant="danger">
{t('settings.adapters.wechatUnbindAccount')}
</Button>
)}
</div>
</div>
{wechatQrUrl && (
<div className="flex items-start gap-4">
<img
src={wechatQrUrl}
alt={t('settings.adapters.wechatQrAlt')}
className="h-40 w-40 rounded-lg border border-[var(--color-border)] bg-white object-contain p-2"
/>
<div className="pt-2 text-sm text-[var(--color-text-secondary)]">
{wechatStatus || t('settings.adapters.wechatWaiting')}
</div>
</div>
)}
{!wechatQrUrl && wechatStatus && (
<p className="text-sm text-[var(--color-text-secondary)]">{wechatStatus}</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={wcAllowedUsers}
onChange={(e) => setWcAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.wcAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.wechatAllowedUsersHint')}</p>
</div>
</div>
)}
{activeIm === 'dingtalk' && (
<div className="p-4 space-y-4">
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.dingtalkQrTitle')}</h4>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.dingtalkQrDesc')}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button onClick={handleStartDingtalkAuth} loading={isStartingDtAuth} size="sm">
{t('settings.adapters.dingtalkStartAuth')}
</Button>
{(config.dingtalk?.clientId || dtClientId) && (
<Button onClick={() => setPendingAdapterUnbind('dingtalkBot')} loading={isUnbindingDtBot} size="sm" variant="danger">
{t('settings.adapters.dingtalkUnbindBot')}
</Button>
)}
</div>
</div>
{dtRegistration && (
<div className="flex flex-wrap items-center gap-4">
{dtRegistration.qrDataUrl ? (
<img
src={dtRegistration.qrDataUrl}
alt={t('settings.adapters.dingtalkQrAlt')}
className="h-40 w-40 rounded-lg border border-[var(--color-border)] bg-white object-contain p-2"
/>
) : null}
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.dingtalkWaiting')}</p>
<a
href={dtRegistration.verificationUriComplete}
target="_blank"
rel="noreferrer"
className="block truncate text-xs text-[var(--color-brand)] hover:underline"
>
{dtRegistration.verificationUriComplete}
</a>
</div>
</div>
)}
{dtAuthStatus === 'bound' && (
<p className="text-sm text-[var(--color-success)]">{t('settings.adapters.dingtalkBound')}</p>
)}
{dtAuthStatus === 'error' && (
<p className="text-sm text-[var(--color-error)]">{dtAuthError}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.dingtalkClientId')}
value={dtClientId}
onChange={(e) => setDtClientId(e.target.value)}
placeholder={t('settings.adapters.dingtalkClientIdPlaceholder')}
/>
<Input
label={t('settings.adapters.dingtalkClientSecret')}
type="password"
value={dtClientSecret}
onChange={(e) => setDtClientSecret(e.target.value)}
placeholder={t('settings.adapters.dingtalkClientSecretPlaceholder')}
/>
</div>
<Input
label={t('settings.adapters.dingtalkEndpoint')}
value={dtEndpoint}
onChange={(e) => setDtEndpoint(e.target.value)}
placeholder={t('settings.adapters.dingtalkEndpointPlaceholder')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.dingtalkPermissionCardTemplateId')}
value={dtPermissionCardTemplateId}
onChange={(e) => setDtPermissionCardTemplateId(e.target.value)}
placeholder={t('settings.adapters.dingtalkPermissionCardTemplateIdPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.dingtalkPermissionCardTemplateIdHint')}</p>
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={dtAllowedUsers}
onChange={(e) => setDtAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.dtAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
</div>
)}
{activeIm === 'telegram' && (
<div className="p-4 space-y-4">
<Input
label={t('settings.adapters.botToken')}
type="password"
value={tgBotToken}
onChange={(e) => setTgBotToken(e.target.value)}
placeholder={t('settings.adapters.botTokenPlaceholder')}
/>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={tgAllowedUsers}
onChange={(e) => setTgAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
</div>
</div>
)}
</section>
{/* Save */}
<div className="flex items-center gap-3">
<Button onClick={handleSave} loading={isSaving}>
{saveStatus === 'saved' ? t('settings.adapters.saved') : t('settings.adapters.save')}
</Button>
{saveStatus === 'saved' && (
<span className="text-sm text-[var(--color-success)]">
<span className="material-symbols-outlined text-[16px] align-middle mr-1">check_circle</span>
{t('settings.adapters.saved')}
</span>
)}
{saveStatus === 'error' && (
<span className="text-sm text-[var(--color-error)]">
<span className="material-symbols-outlined text-[16px] align-middle mr-1">error</span>
{saveError}
</span>
)}
</div>
<ConfirmDialog
open={pendingUnbind !== null}
onClose={() => {
if (isUnbinding) return
setPendingUnbind(null)
}}
onConfirm={confirmUnbind}
title={t('settings.adapters.unbind')}
body={t('settings.adapters.unbindConfirm')}
confirmLabel={t('settings.adapters.unbind')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isUnbinding}
/>
<ConfirmDialog
open={pendingAdapterUnbind !== null}
onClose={() => {
if (isUnbindingWechatAccount || isUnbindingDtBot) return
setPendingAdapterUnbind(null)
}}
onConfirm={pendingAdapterUnbind === 'wechatAccount' ? handleUnbindWechatAccount : handleUnbindDingtalkBot}
title={pendingAdapterUnbind === 'wechatAccount'
? t('settings.adapters.wechatUnbindAccount')
: t('settings.adapters.dingtalkUnbindBot')}
body={pendingAdapterUnbind === 'wechatAccount'
? t('settings.adapters.wechatUnbindAccountConfirm')
: t('settings.adapters.dingtalkUnbindBotConfirm')}
confirmLabel={pendingAdapterUnbind === 'wechatAccount'
? t('settings.adapters.wechatUnbindAccount')
: t('settings.adapters.dingtalkUnbindBot')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isUnbindingWechatAccount || isUnbindingDtBot}
/>
</div>
)
}
function ImTabButton({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`relative px-4 py-2.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-inset ${
active
? 'text-[var(--color-text-primary)] font-semibold after:absolute after:left-3 after:right-3 after:bottom-0 after:h-[2px] after:bg-[var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
>
{label}
</button>
)
}