feat(web): 通道/会话切换移至设置页 ConnectionTab,修复通道失效回退;连接状态上移 Logo 行并支持窄侧栏 compact 降级

This commit is contained in:
oudecheng 2026-08-15 08:38:12 +08:00
parent eaee29841d
commit f705536fb1
5 changed files with 118 additions and 24 deletions

View File

@ -28,8 +28,6 @@ import {
} from './components/Settings/SettingsModal'; } from './components/Settings/SettingsModal';
import { ConfigPage } from './components/Settings/ConfigPage'; import { ConfigPage } from './components/Settings/ConfigPage';
import { ConnectionStatus } from './components/ConnectionStatus'; import { ConnectionStatus } from './components/ConnectionStatus';
import { ChannelSelector } from './components/Header/ChannelSelector';
import { SessionSelector } from './components/Header/SessionSelector';
import { getVersion } from './api/config'; import { getVersion } from './api/config';
import { useWebSocket } from './hooks/useWebSocket'; import { useWebSocket } from './hooks/useWebSocket';
import { useChat } from './hooks/useChat'; import { useChat } from './hooks/useChat';
@ -719,6 +717,8 @@ function App() {
v{appVersion} v{appVersion}
</span> </span>
)} )}
{/* 窄侧栏时降级为 compact 图标,避免 Logo 行溢出裁剪折叠按钮 */}
<ConnectionStatus status={status} compact={sidebarWidth < 264} />
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--text-primary)] transition-colors" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--text-primary)] transition-colors"
@ -788,22 +788,6 @@ function App() {
</div> </div>
) : ( ) : (
<> <>
<div className="shrink-0 px-3 pb-2">
<ConnectionStatus status={status} />
</div>
<div className="flex shrink-0 flex-col gap-1.5 px-3 pb-2 text-sm text-[var(--text-secondary)]">
<ChannelSelector
channels={channels}
selectedChannel={selectedChannel}
onSelectChannel={handleSwitchChannel}
/>
<SessionSelector
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelectSession={handleSelectSession}
/>
</div>
{/* Tab 栏 */} {/* Tab 栏 */}
<div className="flex shrink-0 border-b border-[var(--border-color)] px-3"> <div className="flex shrink-0 border-b border-[var(--border-color)] px-3">
<button <button
@ -1148,6 +1132,12 @@ function App() {
}} }}
onSaveConnection={handleSaveConnection} onSaveConnection={handleSaveConnection}
initialTab={configInitialTab} initialTab={configInitialTab}
channels={channels}
selectedChannel={selectedChannel}
onSelectChannel={handleSwitchChannel}
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelectSession={handleSelectSession}
/> />
)} )}
</div> </div>

View File

@ -42,7 +42,17 @@ function TabLoading() {
); );
} }
export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { export function ConfigPage({
onClose,
onSaveConnection,
initialTab,
channels,
selectedChannel,
onSelectChannel,
sessions,
selectedSessionId,
onSelectSession,
}: ConfigPageProps) {
const [config, setConfig] = useState<AppConfig | null>(null); const [config, setConfig] = useState<AppConfig | null>(null);
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? 'providers'); const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? 'providers');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -192,7 +202,18 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const commonProps = { config, update }; const commonProps = { config, update };
switch (activeTab) { switch (activeTab) {
case 'connection': case 'connection':
return <ConnectionTab onSaveConnection={onSaveConnection} setToast={showToast} />; return (
<ConnectionTab
onSaveConnection={onSaveConnection}
setToast={showToast}
channels={channels}
selectedChannel={selectedChannel}
onSelectChannel={onSelectChannel}
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelectSession={onSelectSession}
/>
);
case 'gateway': case 'gateway':
return <GatewayTab {...commonProps} />; return <GatewayTab {...commonProps} />;
case 'providers': case 'providers':

View File

@ -1,17 +1,34 @@
// ConnectionTab - WebSocket 连接设置localStorage 持久化,不走后端 config // ConnectionTab - WebSocket 连接设置localStorage 持久化,不走后端 config
// 同时承载通道/会话切换(立即生效,无需保存)
import { useState } from 'react'; import { useState } from 'react';
import { Wifi, KeyRound } from 'lucide-react'; import { Wifi, KeyRound } from 'lucide-react';
import { Field, SectionCard } from '../ui'; import { Field, SectionCard } from '../ui';
import { inputCls } from '../constants'; import { inputCls, selectCls } from '../constants';
import { ErrorBanner } from '../shared'; import { ErrorBanner } from '../shared';
import { getAuthToken, setAuthToken } from '../../../api/client'; import { getAuthToken, setAuthToken } from '../../../api/client';
import type { Channel, SessionSummary } from '../../../types/protocol';
interface ConnectionTabProps { interface ConnectionTabProps {
onSaveConnection?: (host: string, port: number) => void; onSaveConnection?: (host: string, port: number) => void;
setToast: (msg: string) => void; setToast: (msg: string) => void;
channels?: Channel[];
selectedChannel?: string | null;
onSelectChannel?: (id: string) => void;
sessions?: SessionSummary[];
selectedSessionId?: string | null;
onSelectSession?: (id: string) => void;
} }
export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps) { export function ConnectionTab({
onSaveConnection,
setToast,
channels,
selectedChannel,
onSelectChannel,
sessions,
selectedSessionId,
onSelectSession,
}: ConnectionTabProps) {
const [connHost, setConnHost] = useState(() => { const [connHost, setConnHost] = useState(() => {
try { try {
return localStorage.getItem('picobot-gateway-host') || '127.0.0.1'; return localStorage.getItem('picobot-gateway-host') || '127.0.0.1';
@ -55,6 +72,48 @@ export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<SectionCard title="通道与会话">
<Field label="当前通道">
<select
value={selectedChannel ?? ''}
onChange={(e) => onSelectChannel?.(e.target.value)}
disabled={!channels || channels.length === 0}
className={selectCls}
>
{channels && channels.length > 0 ? (
channels.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.isWritable ? '' : '(只读)'}
</option>
))
) : (
<option value=""></option>
)}
</select>
</Field>
<Field label="当前会话">
<select
value={selectedSessionId ?? ''}
onChange={(e) => onSelectSession?.(e.target.value)}
disabled={!sessions || sessions.length <= 1}
className={selectCls}
>
{sessions && sessions.length > 0 ? (
sessions.map((s) => (
<option key={s.session_id} value={s.session_id}>
{s.title}
</option>
))
) : (
<option value=""></option>
)}
</select>
</Field>
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2">
</div>
</SectionCard>
<SectionCard title="WebSocket 连接"> <SectionCard title="WebSocket 连接">
<Field label="主机地址"> <Field label="主机地址">
<input <input

View File

@ -1,5 +1,7 @@
// Config-related type definitions extracted from ConfigPage.tsx // Config-related type definitions extracted from ConfigPage.tsx
import type { Channel, SessionSummary } from '../../types/protocol';
export interface ProviderConfig { export interface ProviderConfig {
type: string; type: string;
base_url: string; base_url: string;
@ -282,6 +284,12 @@ export interface ConfigPageProps {
onClose: () => void; onClose: () => void;
onSaveConnection?: (host: string, port: number) => void; onSaveConnection?: (host: string, port: number) => void;
initialTab?: TabId; initialTab?: TabId;
channels?: Channel[];
selectedChannel?: string | null;
onSelectChannel?: (id: string) => void;
sessions?: SessionSummary[];
selectedSessionId?: string | null;
onSelectSession?: (id: string) => void;
} }
export interface KnownSource { export interface KnownSource {

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'; import { useCallback, useMemo, useRef, type Dispatch, type SetStateAction } from 'react';
import type { import type {
Command, Command,
ChatMessage, ChatMessage,
@ -166,6 +166,10 @@ export function useChat(): UseChatReturn {
}); });
const scheduler = useSchedulerView(); const scheduler = useSchedulerView();
// selectedChannel 的 ref 镜像handleServerMessage 空依赖闭包中读取当前值,
// 用于 channel_list 到达时校正失效的选中通道(如默认 'websocket' 在后端不存在)
const selectedChannelRef = useRef(sideData.selectedChannel);
// ---- handleServerMessage: 纯路由分发Tier 1 → Tier 2 → Tier 3 ---- // ---- handleServerMessage: 纯路由分发Tier 1 → Tier 2 → Tier 3 ----
// 所有被调用的方法都是稳定引用useState setter 或 useCallback([]) // 所有被调用的方法都是稳定引用useState setter 或 useCallback([])
// 因此空依赖数组安全,不会产生过期闭包。 // 因此空依赖数组安全,不会产生过期闭包。
@ -252,9 +256,20 @@ export function useChat(): UseChatReturn {
sideData.setTodos(message.todos); sideData.setTodos(message.todos);
return; return;
case 'channel_list': case 'channel_list': {
sideData.setChannels(message.channels); sideData.setChannels(message.channels);
// 当前选中通道不在列表中(默认 'websocket' 在后端不存在、或通道被移除)时,
// 回退到第一个通道,避免 ConnectionTab 通道下拉空白及 session 请求携带无效通道
if (
message.channels.length > 0 &&
!message.channels.some((c) => c.id === selectedChannelRef.current)
) {
const fallback = message.channels[0].id;
selectedChannelRef.current = fallback;
sideData.setSelectedChannel(fallback);
}
return; return;
}
case 'pong': case 'pong':
return; return;
@ -300,6 +315,7 @@ export function useChat(): UseChatReturn {
const selectChannel = useCallback( const selectChannel = useCallback(
(channelId: string) => { (channelId: string) => {
if (channelId === sideData.selectedChannel) return; if (channelId === sideData.selectedChannel) return;
selectedChannelRef.current = channelId;
sideData.setSelectedChannel(channelId); sideData.setSelectedChannel(channelId);
sessions.setSessions([]); sessions.setSessions([]);
sessions.setSelectedSessionId(null); sessions.setSelectedSessionId(null);