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 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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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 文件到此导入',
|
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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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">
|
||||||
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
|
<div>
|
||||||
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user