mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
The desktop settings and sidebar still had deletion flows that either used browser-native confirms or deleted immediately. This change consolidates destructive confirmations behind a shared dialog component, applies it to provider deletion, plugin uninstall, adapter unbind, and sidebar session deletion, and adds regression coverage so delete actions require an explicit second confirmation before mutating state. Constraint: Other in-progress desktop work in the tree had to stay out of this commit Constraint: Existing MCP and task confirmations needed to keep their current behavior Rejected: Leave confirmations embedded per-page with browser dialogs | inconsistent UX and easy to regress Rejected: Add confirmations only to provider deletion | leaves other destructive desktop flows unsafe Confidence: high Scope-risk: narrow Reversibility: clean Directive: Any new desktop delete, uninstall, or unbind action should use the shared ConfirmDialog instead of browser-native dialogs or one-click deletion Tested: bun run test src/components/layout/Sidebar.test.tsx src/__tests__/generalSettings.test.tsx; bun run lint Not-tested: Manual desktop click-through of every destructive action after this refactor
404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
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'
|
||
|
||
type ImTab = 'feishu' | 'telegram'
|
||
|
||
export function AdapterSettings() {
|
||
const t = useTranslation()
|
||
const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore()
|
||
|
||
// Active IM tab —— Feishu 默认展示,在前
|
||
const [activeIm, setActiveIm] = useState<ImTab>('feishu')
|
||
|
||
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
|
||
// 桌面端用 Tauri 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)
|
||
|
||
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: 'telegram' | 'feishu'; userId: string | number } | 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)
|
||
}, [config])
|
||
|
||
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,
|
||
}
|
||
|
||
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 handleUnbind = useCallback(async (platform: 'telegram' | 'feishu', 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 })),
|
||
]
|
||
|
||
// 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)]">
|
||
<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 —— 之前是个手填字段,但桌面端 Tauri 启动 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.telegram')}
|
||
active={activeIm === 'telegram'}
|
||
onClick={() => setActiveIm('telegram')}
|
||
/>
|
||
</div>
|
||
|
||
{activeIm === 'feishu' && (
|
||
<div className="p-4 space-y-4">
|
||
<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 === '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}
|
||
/>
|
||
</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>
|
||
)
|
||
}
|