feat(web): 通道/会话切换移至设置页 ConnectionTab,修复通道失效回退;连接状态上移 Logo 行并支持窄侧栏 compact 降级
This commit is contained in:
parent
eaee29841d
commit
f705536fb1
@ -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>
|
||||||
|
|||||||
@ -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':
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user