desktop: improve channels page and titlebar UI

This commit is contained in:
zhayujie
2026-06-29 19:52:46 +08:00
parent f82eb39d23
commit fd90a89b45
7 changed files with 267 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState, useCallback, useEffect } from 'react' import React, { useState, useCallback, useEffect } from 'react'
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom' import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
import { PanelLeftOpen } from 'lucide-react' import { History } from 'lucide-react'
import NavRail from './layout/NavRail' import NavRail from './layout/NavRail'
import SessionList from './layout/SessionList' import SessionList from './layout/SessionList'
import WindowControls from './layout/WindowControls' import WindowControls from './layout/WindowControls'
@@ -27,8 +27,8 @@ const App: React.FC = () => {
const backend = useBackend() const backend = useBackend()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { isWin } = usePlatform() const { isWin, isMac } = usePlatform()
const { sessionsCollapsed, toggleSessions } = useUIStore() const { sessionsCollapsed, toggleSessions, navCollapsed } = useUIStore()
const onboardingOpen = useOnboardingStore((s) => s.open) const onboardingOpen = useOnboardingStore((s) => s.open)
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen) const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
const [, forceUpdate] = useState(0) const [, forceUpdate] = useState(0)
@@ -107,10 +107,13 @@ const App: React.FC = () => {
{isChat && sessionsCollapsed && ( {isChat && sessionsCollapsed && (
<button <button
onClick={toggleSessions} onClick={toggleSessions}
title={t('nav_expand')} title={t('session_history')}
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors" // Keep aligned with the SessionList history button: only nudge
// right of the macOS traffic lights when the nav rail is collapsed
// (otherwise the lights stay within the rail and don't overlap).
className={`titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors ${isMac ? 'mt-1' : ''} ${isMac && navCollapsed ? 'ml-2' : ''}`}
> >
<PanelLeftOpen size={16} /> <History size={16} />
</button> </button>
)} )}
<div className="flex-1 min-w-0" /> <div className="flex-1 min-w-0" />

View File

@@ -3,14 +3,7 @@ import { Plus, Paperclip, Square, X, File as FileIcon, Loader2, Trash2 } from 'l
import { t } from '../i18n' import { t } from '../i18n'
import type { Attachment } from '../types' import type { Attachment } from '../types'
import apiClient from '../api/client' import apiClient from '../api/client'
import { PaperPlaneIcon } from './icons'
// Solid paper-plane icon (Font Awesome's fa-paper-plane path) so the send
// button matches the web console exactly.
const PaperPlaneIcon: React.FC<{ size?: number }> = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 512 512" fill="currentColor" aria-hidden="true">
<path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480l0-83.6c0-4 1.5-7.8 4.2-10.8L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z"/>
</svg>
)
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void export type ChatInputHandle = (text: string, attachments: Attachment[]) => void

View File

@@ -0,0 +1,16 @@
import React from 'react'
// Solid paper-plane icon (Font Awesome's fa-paper-plane path) so the send
// button and the feishu/telegram channels match the web console exactly.
export const PaperPlaneIcon: React.FC<{ size?: number; className?: string }> = ({ size = 16, className }) => (
<svg
width={size}
height={size}
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480l0-83.6c0-4 1.5-7.8 4.2-10.8L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z" />
</svg>
)

View File

@@ -68,6 +68,7 @@ const translations: Record<string, Record<string, string>> = {
knowledge_drop_hint: '拖放 .md / .txt 文件到此导入', knowledge_drop_hint: '拖放 .md / .txt 文件到此导入',
nav_expand: '展开侧栏', nav_expand: '展开侧栏',
nav_collapse: '收起侧栏', nav_collapse: '收起侧栏',
session_history: '历史会话',
update_available: '发现新版本', update_available: '发现新版本',
update_download: '下载更新', update_download: '下载更新',
update_downloading: '正在下载', update_downloading: '正在下载',
@@ -259,7 +260,10 @@ const translations: Record<string, Record<string, string>> = {
memory_next: '下一页', memory_next: '下一页',
channels_title: '通道管理', channels_title: '通道管理',
channels_desc: '查看和管理消息通道', channels_desc: '查看和管理消息通道',
channels_add: '添加通道', channels_add: '接入通道',
channels_select_label: '选择要接入的通道',
channels_select_placeholder: '请选择通道...',
channels_add_close: '关闭',
channels_connected: '已连接', channels_connected: '已连接',
channels_disconnected: '未连接', channels_disconnected: '未连接',
channels_connect: '连接', channels_connect: '连接',
@@ -424,6 +428,7 @@ const translations: Record<string, Record<string, string>> = {
menu_settings: 'Settings', menu_settings: 'Settings',
nav_expand: 'Expand sidebar', nav_expand: 'Expand sidebar',
nav_collapse: 'Collapse sidebar', nav_collapse: 'Collapse sidebar',
session_history: 'Chat history',
update_available: 'New version available', update_available: 'New version available',
update_download: 'Download update', update_download: 'Download update',
update_downloading: 'Downloading', update_downloading: 'Downloading',
@@ -616,6 +621,9 @@ const translations: Record<string, Record<string, string>> = {
channels_title: 'Channels', channels_title: 'Channels',
channels_desc: 'View and manage messaging channels', channels_desc: 'View and manage messaging channels',
channels_add: 'Add channel', channels_add: 'Add channel',
channels_select_label: 'Select a channel to add',
channels_select_placeholder: 'Select a channel...',
channels_add_close: 'Close',
channels_connected: 'Connected', channels_connected: 'Connected',
channels_disconnected: 'Disconnected', channels_disconnected: 'Disconnected',
channels_connect: 'Connect', channels_connect: 'Connect',

View File

@@ -57,9 +57,9 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
return ( return (
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}> <aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
{/* Top: full-width drag strip; reserve space for macOS traffic lights. {/* Top: full-width drag strip; bottom border continues the header divider
No right border here so the divider doesn't cut across the traffic lights. */} across the whole window. No right border so it doesn't cut the lights. */}
<div className="titlebar-drag h-[44px] flex-shrink-0" /> <div className="titlebar-drag h-[44px] flex-shrink-0 border-b border-default" />
{/* Content area carries the right divider, starting below the titlebar */} {/* Content area carries the right divider, starting below the titlebar */}
<div className="flex-1 flex flex-col min-h-0 border-r border-default"> <div className="flex-1 flex flex-col min-h-0 border-r border-default">

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-react' import { Plus, MessageSquare, Pencil, Trash2, Check, X, History } from 'lucide-react'
import { t } from '../i18n' import { t } from '../i18n'
import { useSessionStore } from '../store/sessionStore' import { useSessionStore } from '../store/sessionStore'
import { useUIStore } from '../store/uiStore' import { useUIStore } from '../store/uiStore'
import { usePlatform } from '../hooks/usePlatform'
import type { SessionItem } from '../types' import type { SessionItem } from '../types'
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] { function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
@@ -32,6 +33,14 @@ const SessionList: React.FC = () => {
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } = const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
useSessionStore() useSessionStore()
const toggleSessions = useUIStore((s) => s.toggleSessions) const toggleSessions = useUIStore((s) => s.toggleSessions)
const navCollapsed = useUIStore((s) => s.navCollapsed)
const { isMac } = usePlatform()
// When the nav rail is collapsed on macOS, the native traffic lights spill
// past it, so nudge the history button right to keep it (and its sibling in
// the main header) clear of the lights and aligned across states.
const trafficOffset = isMac && navCollapsed ? 'ml-2' : ''
// Nudge header buttons down a touch to sit level with the macOS traffic lights.
const trafficDrop = isMac ? 'mt-1' : ''
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
@@ -56,18 +65,18 @@ const SessionList: React.FC = () => {
return ( return (
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default"> <div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag"> <div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag border-b border-default">
<button <button
onClick={toggleSessions} onClick={toggleSessions}
title={t('nav_collapse')} title={t('session_history')}
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors" className={`titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors ${trafficDrop} ${trafficOffset}`}
> >
<PanelLeftClose size={16} /> <History size={16} />
</button> </button>
<button <button
onClick={() => newSession()} onClick={() => newSession()}
title={t('session_new')} title={t('session_new')}
className="titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors" className={`titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors ${trafficDrop}`}
> >
<Plus size={15} /> <Plus size={15} />
{t('session_new')} {t('session_new')}

View File

@@ -1,14 +1,52 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Loader2, Plug, QrCode } from 'lucide-react' import {
Loader2,
Plug,
Plus,
X,
ChevronDown,
Check,
MessageCircle,
MessageSquare,
Bot,
Building2,
Headset,
Hash,
AtSign,
} from 'lucide-react'
import { t, localizedLabel } from '../i18n' import { t, localizedLabel } from '../i18n'
import apiClient from '../api/client' import apiClient from '../api/client'
import type { ChannelInfo, ChannelField } from '../types' import type { ChannelInfo, ChannelField } from '../types'
import { Toggle, Btn } from './settings/primitives' import { Toggle, Btn } from './settings/primitives'
import QrLoginModal from '../components/QrLoginModal' import QrLoginModal from '../components/QrLoginModal'
import { PaperPlaneIcon } from '../components/icons'
// Channels that connect via QR scanning rather than credential fields. // Channels that connect via QR scanning rather than credential fields.
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' } const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
// An icon component that takes a `size` prop (lucide icons and our PaperPlaneIcon).
type IconComponent = React.FC<{ size?: number }>
// Per-channel icon + accent color, mirroring the web console's FontAwesome
// icon + Tailwind color palette (we use lucide here, with hex colors so the
// tinted icon background isn't purged by Tailwind's JIT). Feishu/Telegram use
// the same paper-plane as the web console.
const CHANNEL_STYLE: Record<string, { Icon: IconComponent; color: string }> = {
weixin: { Icon: MessageCircle, color: '#10b981' },
feishu: { Icon: PaperPlaneIcon, color: '#3b82f6' },
dingtalk: { Icon: MessageSquare, color: '#3b82f6' },
wecom_bot: { Icon: Bot, color: '#10b981' },
qq: { Icon: MessageCircle, color: '#3b82f6' },
wechatcom_app: { Icon: Building2, color: '#10b981' },
wechat_kf: { Icon: Headset, color: '#10b981' },
wechatmp: { Icon: MessageCircle, color: '#10b981' },
telegram: { Icon: PaperPlaneIcon, color: '#0ea5e9' },
slack: { Icon: Hash, color: '#a855f7' },
discord: { Icon: AtSign, color: '#6366f1' },
}
const channelStyle = (name: string) => CHANNEL_STYLE[name] ?? { Icon: Plug, color: '#94a3b8' }
interface ChannelsPageProps { interface ChannelsPageProps {
baseUrl: string baseUrl: string
} }
@@ -19,6 +57,12 @@ 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)
// Whether the "add channel" panel is open, and the channel chosen in it.
// `selected` starts empty so the user must pick a channel themselves.
const [addOpen, setAddOpen] = useState(false)
const [selected, setSelected] = useState<string>('')
const scrollRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const loadChannels = async () => { const loadChannels = async () => {
try { try {
@@ -45,14 +89,55 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
return { connected, available } return { connected, available }
}, [channels]) }, [channels])
// If the selected channel got connected (or vanished), clear the selection.
useEffect(() => {
if (selected && !available.some((c) => c.name === selected)) setSelected('')
}, [available, selected])
const openAdd = () => {
setSelected('')
setAddOpen(true)
// Scroll the new panel into view at the bottom of the list.
requestAnimationFrame(() => {
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
const addingChannel = available.find((c) => c.name === selected)
const onAdded = () => {
setAddOpen(false)
setSelected('')
void loadChannels()
}
// Keep the config form in view as it grows after picking a channel.
useEffect(() => {
if (selected) {
requestAnimationFrame(() => {
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
}, [selected])
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
<div className="px-6 pt-5 pb-3 flex-shrink-0"> <div className="px-6 pt-5 pb-3 flex-shrink-0 flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2> <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> <p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
</div> </div>
{!loading && available.length > 0 && !addOpen && (
<Btn variant="primary" onClick={openAdd}>
<span className="flex items-center gap-1.5">
<Plus size={15} />
{t('channels_add')}
</span>
</Btn>
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-default"> <div ref={scrollRef} className="flex-1 overflow-y-auto border-t border-default">
<div className="max-w-3xl mx-auto px-6 py-5"> <div className="max-w-3xl mx-auto px-6 py-5">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary"> <div className="flex items-center justify-center py-20 text-content-tertiary">
@@ -60,21 +145,37 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
{t('channels_loading')} {t('channels_loading')}
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-3">
<Section title={t('channels_connected_section')}> {connected.length === 0 && !addOpen ? (
{connected.length === 0 ? (
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p> <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} />) connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
)} )}
</Section>
{available.length > 0 && ( {/* Add-channel panel lives at the bottom of the list: pick a
<Section title={t('channels_available_section')}> channel from the dropdown, then configure/connect it inline. */}
{available.map((ch) => ( {addOpen && available.length > 0 && (
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} /> <div ref={panelRef} className="rounded-card border border-accent/40 bg-surface p-4 space-y-4">
))} <div className="flex items-center justify-between gap-3">
</Section> <label className="text-sm font-medium text-content">{t('channels_select_label')}</label>
<button
onClick={() => setAddOpen(false)}
className="text-content-tertiary hover:text-content cursor-pointer"
title={t('channels_add_close')}
>
<X size={16} />
</button>
</div>
<ChannelDropdown
channels={available}
value={selected}
onChange={setSelected}
placeholder={t('channels_select_placeholder')}
/>
{addingChannel && (
<ChannelCard key={addingChannel.name} channel={addingChannel} onChanged={onAdded} defaultExpanded />
)}
</div>
)} )}
</div> </div>
)} )}
@@ -84,20 +185,101 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
) )
} }
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( // Custom dropdown styled like the web console's `.cfg-dropdown` (rounded,
<div> // green focus ring, hover/active states) instead of a native <select>.
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3> const ChannelDropdown: React.FC<{
<div className="space-y-3">{children}</div> channels: ChannelInfo[]
</div> value: string
) onChange: (name: string) => void
placeholder: string
}> = ({ channels, value, onChange, placeholder }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => { useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
const current = channels.find((c) => c.name === value)
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 h-10 px-3 rounded-btn border bg-inset text-sm cursor-pointer transition-colors ${
open ? 'border-accent ring-2 ring-accent/15' : 'border-strong hover:border-content-tertiary'
} ${current ? 'text-content' : 'text-content-tertiary'}`}
>
{current ? (
<span className="flex items-center gap-2 min-w-0">
<ChannelIcon name={current.name} size={26} />
<span className="truncate">{localizedLabel(current.label)}</span>
<span className="text-content-tertiary font-mono text-xs">({current.name})</span>
</span>
) : (
<span>{placeholder}</span>
)}
<ChevronDown size={14} className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute top-[calc(100%+4px)] left-0 right-0 z-50 max-h-60 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg p-1">
{channels.map((ch) => {
const active = ch.name === value
return (
<button
key={ch.name}
type="button"
onClick={() => {
onChange(ch.name)
setOpen(false)
}}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-sm cursor-pointer transition-colors ${
active ? 'bg-accent-soft text-accent font-medium' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<ChannelIcon name={ch.name} size={26} />
<span className="truncate">{localizedLabel(ch.label)}</span>
<span className="text-content-tertiary font-mono text-xs">({ch.name})</span>
{active && <Check size={14} className="ml-auto flex-shrink-0" />}
</button>
)
})}
</div>
)}
</div>
)
}
// A tinted square with the channel's icon (web-console style).
const ChannelIcon: React.FC<{ name: string; size?: number }> = ({ name, size = 36 }) => {
const { Icon, color } = channelStyle(name)
return (
<span
className="rounded-lg flex items-center justify-center flex-shrink-0"
style={{ width: size, height: size, backgroundColor: `${color}1a`, color }}
>
<Icon size={Math.round(size * 0.45)} />
</span>
)
}
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void; defaultExpanded?: boolean }> = ({
channel,
onChanged,
defaultExpanded = false,
}) => {
// Channels with no fields connect purely via QR (e.g. weixin). // Channels with no fields connect purely via QR (e.g. weixin).
const isQrLogin = channel.fields.length === 0 const isQrLogin = channel.fields.length === 0
// QR provider supported by the desktop scan modal (weixin / feishu). // QR provider supported by the desktop scan modal (weixin / feishu).
const qrProvider = QR_PROVIDERS[channel.name] const qrProvider = QR_PROVIDERS[channel.name]
const [showQr, setShowQr] = useState(false) const [showQr, setShowQr] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(defaultExpanded)
const [values, setValues] = useState<Record<string, string>>(() => const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : ''])) Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
) )
@@ -148,15 +330,14 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
} }
return ( return (
<div className="rounded-card border border-default bg-surface p-4"> <div className={defaultExpanded ? '' : 'rounded-card border border-default bg-surface p-4'}>
<div className="flex items-center gap-3"> <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"> <ChannelIcon name={channel.name} size={40} />
{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-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span> <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'}`} /> <span className={`w-2 h-2 rounded-full ${channel.active ? 'bg-accent' : 'bg-content-tertiary'}`} />
{channel.active && <span className="text-xs text-accent">{t('channels_connected')}</span>}
</div> </div>
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p> <p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
</div> </div>
@@ -169,7 +350,7 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
<Btn variant="primary" onClick={() => setShowQr(true)}> <Btn variant="primary" onClick={() => setShowQr(true)}>
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')} {qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
</Btn> </Btn>
) : isQrLogin ? null : ( ) : isQrLogin || defaultExpanded ? null : (
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}> <Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
{t('channels_add')} {t('channels_add')}
</Btn> </Btn>