mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
desktop: improve channels page and titlebar UI
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
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 SessionList from './layout/SessionList'
|
||||
import WindowControls from './layout/WindowControls'
|
||||
@@ -27,8 +27,8 @@ const App: React.FC = () => {
|
||||
const backend = useBackend()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { isWin } = usePlatform()
|
||||
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
||||
const { isWin, isMac } = usePlatform()
|
||||
const { sessionsCollapsed, toggleSessions, navCollapsed } = useUIStore()
|
||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||
const [, forceUpdate] = useState(0)
|
||||
@@ -107,10 +107,13 @@ const App: React.FC = () => {
|
||||
{isChat && sessionsCollapsed && (
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_expand')}
|
||||
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"
|
||||
title={t('session_history')}
|
||||
// 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>
|
||||
)}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
@@ -3,14 +3,7 @@ import { Plus, Paperclip, Square, X, File as FileIcon, Loader2, Trash2 } from 'l
|
||||
import { t } from '../i18n'
|
||||
import type { Attachment } from '../types'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
// 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>
|
||||
)
|
||||
import { PaperPlaneIcon } from './icons'
|
||||
|
||||
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||
|
||||
|
||||
16
desktop/src/renderer/src/components/icons.tsx
Normal file
16
desktop/src/renderer/src/components/icons.tsx
Normal 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>
|
||||
)
|
||||
@@ -68,6 +68,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
knowledge_drop_hint: '拖放 .md / .txt 文件到此导入',
|
||||
nav_expand: '展开侧栏',
|
||||
nav_collapse: '收起侧栏',
|
||||
session_history: '历史会话',
|
||||
update_available: '发现新版本',
|
||||
update_download: '下载更新',
|
||||
update_downloading: '正在下载',
|
||||
@@ -259,7 +260,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
memory_next: '下一页',
|
||||
channels_title: '通道管理',
|
||||
channels_desc: '查看和管理消息通道',
|
||||
channels_add: '添加通道',
|
||||
channels_add: '接入通道',
|
||||
channels_select_label: '选择要接入的通道',
|
||||
channels_select_placeholder: '请选择通道...',
|
||||
channels_add_close: '关闭',
|
||||
channels_connected: '已连接',
|
||||
channels_disconnected: '未连接',
|
||||
channels_connect: '连接',
|
||||
@@ -424,6 +428,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
menu_settings: 'Settings',
|
||||
nav_expand: 'Expand sidebar',
|
||||
nav_collapse: 'Collapse sidebar',
|
||||
session_history: 'Chat history',
|
||||
update_available: 'New version available',
|
||||
update_download: 'Download update',
|
||||
update_downloading: 'Downloading',
|
||||
@@ -616,6 +621,9 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_title: 'Channels',
|
||||
channels_desc: 'View and manage messaging channels',
|
||||
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_disconnected: 'Disconnected',
|
||||
channels_connect: 'Connect',
|
||||
|
||||
@@ -57,9 +57,9 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
||||
|
||||
return (
|
||||
<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.
|
||||
No right border here so the divider doesn't cut across the traffic lights. */}
|
||||
<div className="titlebar-drag h-[44px] flex-shrink-0" />
|
||||
{/* Top: full-width drag strip; bottom border continues the header divider
|
||||
across the whole window. No right border so it doesn't cut the lights. */}
|
||||
<div className="titlebar-drag h-[44px] flex-shrink-0 border-b border-default" />
|
||||
|
||||
{/* Content area carries the right divider, starting below the titlebar */}
|
||||
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import { usePlatform } from '../hooks/usePlatform'
|
||||
import type { SessionItem } from '../types'
|
||||
|
||||
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 } =
|
||||
useSessionStore()
|
||||
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 [editValue, setEditValue] = useState('')
|
||||
|
||||
@@ -56,18 +65,18 @@ const SessionList: React.FC = () => {
|
||||
return (
|
||||
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
|
||||
{/* 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
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_collapse')}
|
||||
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"
|
||||
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 ${trafficDrop} ${trafficOffset}`}
|
||||
>
|
||||
<PanelLeftClose size={16} />
|
||||
<History size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => newSession()}
|
||||
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} />
|
||||
{t('session_new')}
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Plug, QrCode } from 'lucide-react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Plug,
|
||||
Plus,
|
||||
X,
|
||||
ChevronDown,
|
||||
Check,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Building2,
|
||||
Headset,
|
||||
Hash,
|
||||
AtSign,
|
||||
} from 'lucide-react'
|
||||
import { t, localizedLabel } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import type { ChannelInfo, ChannelField } from '../types'
|
||||
import { Toggle, Btn } from './settings/primitives'
|
||||
import QrLoginModal from '../components/QrLoginModal'
|
||||
import { PaperPlaneIcon } from '../components/icons'
|
||||
|
||||
// Channels that connect via QR scanning rather than credential fields.
|
||||
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 {
|
||||
baseUrl: string
|
||||
}
|
||||
@@ -19,6 +57,12 @@ const MASK_RE = /\*{2,}/
|
||||
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
||||
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 () => {
|
||||
try {
|
||||
@@ -45,14 +89,55 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
return { connected, available }
|
||||
}, [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 (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 pt-5 pb-3 flex-shrink-0">
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
|
||||
</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">
|
||||
{loading ? (
|
||||
<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')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Section title={t('channels_connected_section')}>
|
||||
{connected.length === 0 ? (
|
||||
<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} />)
|
||||
)}
|
||||
</Section>
|
||||
<div className="space-y-3">
|
||||
{connected.length === 0 && !addOpen ? (
|
||||
<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} />)
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<Section title={t('channels_available_section')}>
|
||||
{available.map((ch) => (
|
||||
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
|
||||
))}
|
||||
</Section>
|
||||
{/* Add-channel panel lives at the bottom of the list: pick a
|
||||
channel from the dropdown, then configure/connect it inline. */}
|
||||
{addOpen && available.length > 0 && (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -84,20 +185,101 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
// Custom dropdown styled like the web console's `.cfg-dropdown` (rounded,
|
||||
// green focus ring, hover/active states) instead of a native <select>.
|
||||
const ChannelDropdown: React.FC<{
|
||||
channels: ChannelInfo[]
|
||||
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).
|
||||
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 [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
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 (
|
||||
<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="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>
|
||||
<ChannelIcon name={channel.name} size={40} />
|
||||
<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'}`} />
|
||||
{channel.active && <span className="text-xs text-accent">{t('channels_connected')}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
|
||||
</div>
|
||||
@@ -169,7 +350,7 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
|
||||
<Btn variant="primary" onClick={() => setShowQr(true)}>
|
||||
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
|
||||
</Btn>
|
||||
) : isQrLogin ? null : (
|
||||
) : isQrLogin || defaultExpanded ? null : (
|
||||
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
||||
{t('channels_add')}
|
||||
</Btn>
|
||||
|
||||
Reference in New Issue
Block a user