Stabilize channels UX, reload flow, and i18n consistency

This commit is contained in:
ashione
2026-03-08 00:30:26 +08:00
parent 4651f8ec56
commit 72585589af
9 changed files with 311 additions and 54 deletions

View File

@@ -11,6 +11,10 @@
"gatewayWarning": "Gateway service is not running. Channels cannot connect.",
"available": "Available Channels",
"availableDesc": "Connect a new channel",
"configured": "Configured Channels",
"configuredDesc": "Manage channels that are already configured",
"configuredBadge": "Configured",
"deleteConfirm": "Are you sure you want to delete this channel?",
"showAll": "Show All",
"pluginBadge": "Plugin",
"toast": {
@@ -37,6 +41,8 @@
"viewDocs": "View Documentation",
"channelName": "Channel Name",
"channelNamePlaceholder": "My {{name}}",
"enableChannel": "Enable Channel",
"enableChannelDesc": "When off, config is saved but the channel stays disabled",
"credentialsVerified": "Credentials Verified",
"validationFailed": "Validation Failed",
"warnings": "Warnings",
@@ -293,4 +299,4 @@
}
},
"viewDocs": "View Documentation"
}
}

View File

@@ -11,6 +11,10 @@
"gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。",
"available": "利用可能なチャンネル",
"availableDesc": "新しいチャンネルを接続",
"configured": "設定済みチャンネル",
"configuredDesc": "すでに設定済みのチャンネルを管理",
"configuredBadge": "設定済み",
"deleteConfirm": "このチャンネルを削除してもよろしいですか?",
"showAll": "すべて表示",
"pluginBadge": "プラグイン",
"toast": {
@@ -37,6 +41,8 @@
"viewDocs": "ドキュメントを表示",
"channelName": "チャンネル名",
"channelNamePlaceholder": "マイ {{name}}",
"enableChannel": "チャンネルを有効化",
"enableChannelDesc": "オフの場合、設定のみ保存しチャンネルは起動しません",
"credentialsVerified": "認証情報が確認されました",
"validationFailed": "検証に失敗しました",
"warnings": "警告",
@@ -293,4 +299,4 @@
}
},
"viewDocs": "ドキュメントを表示"
}
}

View File

@@ -11,6 +11,10 @@
"gatewayWarning": "网关服务未运行,频道无法连接。",
"available": "可用频道",
"availableDesc": "连接一个新的频道",
"configured": "已配置频道",
"configuredDesc": "管理已完成配置的频道",
"configuredBadge": "已配置",
"deleteConfirm": "确定要删除此频道吗?",
"showAll": "显示全部",
"pluginBadge": "插件",
"toast": {
@@ -37,6 +41,8 @@
"viewDocs": "查看文档",
"channelName": "频道名称",
"channelNamePlaceholder": "我的 {{name}}",
"enableChannel": "启用频道",
"enableChannelDesc": "关闭后会保存配置,但不会启动该频道",
"credentialsVerified": "凭证已验证",
"validationFailed": "验证失败",
"warnings": "警告",
@@ -293,4 +299,4 @@
}
},
"viewDocs": "查看文档"
}
}

View File

@@ -2,7 +2,7 @@
* Channels Page
* Manage messaging channel connections with configuration UI
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Plus,
Radio,
@@ -25,6 +25,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
@@ -55,9 +56,13 @@ export function Channels() {
const [showAddDialog, setShowAddDialog] = useState(false);
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
const [channelSnapshot, setChannelSnapshot] = useState<Channel[]>([]);
const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState<string[]>([]);
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastGatewayStateRef = useRef(gatewayStatus.state);
// Fetch channels on mount
useEffect(() => {
@@ -104,11 +109,60 @@ export function Channels() {
};
}, [fetchChannels, fetchConfiguredTypes]);
useEffect(() => {
if (gatewayStatus.state === 'running') {
setChannelSnapshot(channels);
setConfiguredTypesSnapshot(configuredTypes);
}
}, [gatewayStatus.state, channels, configuredTypes]);
useEffect(() => {
const previousState = lastGatewayStateRef.current;
const currentState = gatewayStatus.state;
const justReconnected =
currentState === 'running' &&
previousState !== 'running';
lastGatewayStateRef.current = currentState;
if (!justReconnected) return;
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
}, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]);
// Delay warning to avoid flicker during expected short reload/restart windows.
useEffect(() => {
const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error';
const timer = setTimeout(() => {
setShowGatewayWarning(shouldWarn);
}, shouldWarn ? 1800 : 0);
return () => clearTimeout(timer);
}, [gatewayStatus.state]);
// Get channel types to display
const displayedChannelTypes = getPrimaryChannels();
const isGatewayTransitioning =
gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting';
const channelsForView =
isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels;
const configuredTypesForView =
isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes;
// Single source of truth for configured status across cards, stats and badges.
const configuredTypeSet = useMemo(() => {
const set = new Set<string>(configuredTypesForView);
if (set.size === 0 && channelsForView.length > 0) {
channelsForView.forEach((channel) => set.add(channel.type));
}
return set;
}, [configuredTypesForView, channelsForView]);
const configuredChannels = useMemo(
() => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)),
[channelsForView, configuredTypeSet]
);
// Connected/disconnected channel counts
const connectedCount = channels.filter((c) => c.status === 'connected').length;
const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length;
if (loading && channels.length === 0) {
return (
@@ -161,7 +215,7 @@ export function Channels() {
<Radio className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{channels.length}</p>
<p className="text-2xl font-bold">{configuredChannels.length}</p>
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
</div>
</div>
@@ -187,7 +241,7 @@ export function Channels() {
<PowerOff className="h-6 w-6 text-slate-600" />
</div>
<div>
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
</div>
</div>
@@ -196,7 +250,7 @@ export function Channels() {
</div>
{/* Gateway Warning */}
{gatewayStatus.state !== 'running' && (
{showGatewayWarning && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-500" />
@@ -217,7 +271,7 @@ export function Channels() {
)}
{/* Configured Channels */}
{channels.length > 0 && (
{configuredChannels.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('configured')}</CardTitle>
@@ -225,7 +279,7 @@ export function Channels() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
{configuredChannels.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
@@ -253,7 +307,7 @@ export function Channels() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{displayedChannelTypes.map((type) => {
const meta = CHANNEL_META[type];
const isConfigured = configuredTypes.includes(type);
const isConfigured = configuredTypeSet.has(type);
return (
<button
key={type}
@@ -297,6 +351,10 @@ export function Channels() {
onChannelAdded={() => {
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
setTimeout(() => {
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
}, 2200);
setShowAddDialog(false);
setSelectedChannelType(null);
}}
@@ -305,14 +363,16 @@ export function Channels() {
<ConfirmDialog
open={!!channelToDelete}
title={t('common.confirm', 'Confirm')}
title={t('common:actions.confirm', 'Confirm')}
message={t('deleteConfirm')}
confirmLabel={t('common.delete', 'Delete')}
cancelLabel={t('common.cancel', 'Cancel')}
confirmLabel={t('common:actions.delete', 'Delete')}
cancelLabel={t('common:actions.cancel', 'Cancel')}
variant="destructive"
onConfirm={async () => {
if (channelToDelete) {
await deleteChannel(channelToDelete.id);
await fetchConfiguredTypes();
await fetchChannels({ probe: false, silent: true });
setChannelToDelete(null);
}
}}
@@ -378,9 +438,9 @@ interface AddChannelDialogProps {
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
const { t } = useTranslation('channels');
const { addChannel } = useChannelsStore();
const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState('');
const [enabled, setEnabled] = useState(true);
const [connecting, setConnecting] = useState(false);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [qrCode, setQrCode] = useState<string | null>(null);
@@ -402,6 +462,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
setConfigValues({});
setChannelName('');
setIsExistingConfig(false);
setEnabled(true);
setChannelName('');
setIsExistingConfig(false);
// Ensure we clean up any pending QR session if switching away
@@ -414,24 +475,45 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
(async () => {
try {
const result = await invokeIpc(
'channel:getFormValues',
selectedType
) as { success: boolean; values?: Record<string, string> };
const [result, configResult] = await Promise.all([
invokeIpc(
'channel:getFormValues',
selectedType
) as Promise<{ success: boolean; values?: Record<string, string> }>,
invokeIpc(
'channel:getConfig',
selectedType
) as Promise<{ success: boolean; config?: Record<string, unknown> }>,
]);
if (cancelled) return;
if (result.success && result.values && Object.keys(result.values).length > 0) {
setConfigValues(result.values);
setIsExistingConfig(true);
} else {
setConfigValues({});
}
const existingConfig = configResult.success ? configResult.config : undefined;
if (existingConfig && typeof existingConfig.enabled === 'boolean') {
setEnabled(existingConfig.enabled);
} else {
setEnabled(true);
}
if (
(result.success && result.values && Object.keys(result.values).length > 0) ||
Boolean(existingConfig)
) {
setIsExistingConfig(true);
} else {
setIsExistingConfig(false);
}
} catch {
if (!cancelled) {
setConfigValues({});
setIsExistingConfig(false);
setEnabled(true);
}
} finally {
if (!cancelled) setLoadingConfig(false);
@@ -465,7 +547,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
const saveResult = await invokeIpc(
'channel:saveConfig',
'whatsapp',
{ enabled: true }
{ enabled }
) as { success?: boolean; error?: string };
if (!saveResult?.success) {
console.error('Failed to save WhatsApp config:', saveResult?.error);
@@ -475,15 +557,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
} catch (error) {
console.error('Failed to save WhatsApp config:', error);
}
// Register the channel locally so it shows up immediately
addChannel({
type: 'whatsapp',
name: channelName || 'WhatsApp',
}).then(() => {
// Restart gateway to pick up the new session
invokeIpc('gateway:restart').catch(console.error);
onChannelAdded();
});
// channel:saveConfig triggers main-process reload/restart handling.
// UI state refresh is handled by parent onChannelAdded().
onChannelAdded();
};
const onError = (...args: unknown[]) => {
@@ -505,7 +581,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
// Cancel when unmounting or switching types
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
};
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
}, [selectedType, channelName, enabled, onChannelAdded, t]);
const handleValidate = async () => {
if (!selectedType) return;
@@ -614,7 +690,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
}
// Step 2: Save channel configuration via IPC
const config: Record<string, unknown> = { ...configValues };
const config: Record<string, unknown> = { ...configValues, enabled };
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
success?: boolean;
error?: string;
@@ -628,20 +704,13 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
toast.warning(saveResult.warning);
}
// Step 3: Add a local channel entry for the UI
await addChannel({
type: selectedType,
name: channelName || CHANNEL_NAMES[selectedType],
token: configValues[meta.configFields[0]?.key] || undefined,
});
// Step 3: Do not call channels.add from renderer; this races with
// gateway reload/restart windows and can create stale local entries.
toast.success(t('toast.channelSaved', { name: meta.name }));
// Gateway restart is now handled server-side via debouncedRestart()
// inside the channel:saveConfig IPC handler, so we don't need to
// trigger it explicitly here. This avoids cascading restarts when
// multiple config changes happen in quick succession (e.g. during
// the setup wizard).
// Gateway reload/restart is handled in the main-process save handler.
// Renderer should only persist config and refresh local UI state.
toast.success(t('toast.channelConnecting', { name: meta.name }));
// Brief delay so user can see the success state before dialog closes
@@ -806,6 +875,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">{t('dialog.enableChannel')}</p>
<p className="text-xs text-muted-foreground">{t('dialog.enableChannelDesc')}</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
{/* Configuration fields */}
{meta?.configFields.map((field) => (
<ConfigField