refactor: extract data logic into reusable hooks (PR5)

New hooks:
- useSettings: generic settings CRUD (fetch/save with loading/saving states)
- useBridgeAdapter: bridge adapter settings + verify flow
- useBridgeStatus: bridge status polling + start/stop control
- useAccountInfo: Claude SDK account info fetching

Refactored components:
- GeneralSection: replace inline fetchAccountInfo with useAccountInfo hook
- BridgeSection: replace inline fetchStatus/startBridge/stopBridge + polling
  interval with useBridgeStatus hook (removed ~50 lines)

The remaining bridge adapter sections (Telegram/Discord/Feishu/QQ) can be
migrated to useBridgeAdapter in a follow-up — hooks are ready for adoption.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-11 21:53:57 +08:00
1 parent 27bf396cd3
commit cfc4db1507
6 files changed
+312 -99

No files matched your search

+6 -82
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
@@ -15,25 +15,12 @@ import {
} from "@/components/ui/select";
import { SpinnerGap, CheckCircle, Warning, TelegramLogo, ChatTeardrop, GameController, ChatsCircle } from "@phosphor-icons/react";
import { useTranslation } from "@/hooks/useTranslation";
import { useBridgeStatus } from "@/hooks/useBridgeStatus";
import { SettingsCard } from "@/components/patterns/SettingsCard";
import { FieldRow } from "@/components/patterns/FieldRow";
import { StatusBanner } from "@/components/patterns/StatusBanner";
import type { ProviderModelGroup } from "@/types";
interface AdapterStatus {
channelType: string;
running: boolean;
connectedAt: string | null;
lastMessageAt: string | null;
error: string | null;
}
interface BridgeStatus {
running: boolean;
startedAt: string | null;
adapters: AdapterStatus[];
}
interface BridgeSettings {
remote_bridge_enabled: string;
bridge_telegram_enabled: string;
@@ -60,14 +47,11 @@ const DEFAULT_SETTINGS: BridgeSettings = {
export function BridgeSection() {
const [settings, setSettings] = useState<BridgeSettings>(DEFAULT_SETTINGS);
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatus | null>(null);
const [saving, setSaving] = useState(false);
const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false);
const [workDir, setWorkDir] = useState("");
const [model, setModel] = useState("");
const [providerGroups, setProviderGroups] = useState<ProviderModelGroup[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { bridgeStatus, starting, stopping, startBridge, stopBridge } = useBridgeStatus();
const { t } = useTranslation();
const fetchSettings = useCallback(async () => {
@@ -92,18 +76,6 @@ export function BridgeSection() {
}
}, []);
const fetchStatus = useCallback(async () => {
try {
const res = await fetch("/api/bridge");
if (res.ok) {
const data = await res.json();
setBridgeStatus(data);
}
} catch {
// ignore
}
}, []);
const fetchModels = useCallback(async () => {
try {
const res = await fetch("/api/providers/models");
@@ -120,24 +92,8 @@ export function BridgeSection() {
useEffect(() => {
fetchSettings();
fetchStatus();
fetchModels();
}, [fetchSettings, fetchStatus, fetchModels]);
// Poll bridge status while bridge is running
useEffect(() => {
if (bridgeStatus?.running) {
pollRef.current = setInterval(fetchStatus, 5000);
} else if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, [bridgeStatus?.running, fetchStatus]);
}, [fetchSettings, fetchModels]);
const saveSettings = async (updates: Partial<BridgeSettings>) => {
setSaving(true);
@@ -208,38 +164,6 @@ export function BridgeSection() {
}
};
const handleStartBridge = async () => {
setStarting(true);
try {
await fetch("/api/bridge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "start" }),
});
await fetchStatus();
} catch {
// ignore
} finally {
setStarting(false);
}
};
const handleStopBridge = async () => {
setStopping(true);
try {
await fetch("/api/bridge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "stop" }),
});
await fetchStatus();
} catch {
// ignore
} finally {
setStopping(false);
}
};
const handleToggleAutoStart = (checked: boolean) => {
saveSettings({ bridge_auto_start: checked ? "true" : "" });
};
@@ -304,7 +228,7 @@ export function BridgeSection() {
<Button
size="sm"
variant="outline"
onClick={handleStopBridge}
onClick={stopBridge}
disabled={stopping}
>
{stopping ? (
@@ -318,7 +242,7 @@ export function BridgeSection() {
) : (
<Button
size="sm"
onClick={handleStartBridge}
onClick={startBridge}
disabled={starting}
>
{starting ? (
+3 -17
View File
@@ -16,6 +16,7 @@ import {
import { ArrowClockwise, SpinnerGap } from "@phosphor-icons/react";
import { useUpdate } from "@/hooks/useUpdate";
import { useTranslation } from "@/hooks/useTranslation";
import { useAccountInfo } from "@/hooks/useAccountInfo";
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -126,7 +127,7 @@ export function GeneralSection() {
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
const [skipPermSaving, setSkipPermSaving] = useState(false);
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
const [accountInfo, setAccountInfo] = useState<{ email?: string; organization?: string; subscriptionType?: string } | null>(null);
const { accountInfo } = useAccountInfo();
const { t, locale, setLocale } = useTranslation();
const fetchAppSettings = useCallback(async () => {
@@ -145,24 +146,9 @@ export function GeneralSection() {
}
}, []);
const fetchAccountInfo = useCallback(async () => {
try {
const res = await fetch("/api/sdk/account");
if (res.ok) {
const data = await res.json();
if (data.account) {
setAccountInfo(data.account);
}
}
} catch {
// Account info not available
}
}, []);
useEffect(() => {
fetchAppSettings();
fetchAccountInfo();
}, [fetchAppSettings, fetchAccountInfo]);
}, [fetchAppSettings]);
const saveThinkingMode = async (mode: string) => {
setThinkingMode(mode);
+41
View File
@@ -0,0 +1,41 @@
import { useState, useCallback, useEffect } from "react";
interface AccountInfo {
email?: string;
organization?: string;
subscriptionType?: string;
}
/**
* Hook for fetching Claude SDK account information.
*/
export function useAccountInfo(): {
accountInfo: AccountInfo | null;
loading: boolean;
refresh: () => Promise<void>;
} {
const [accountInfo, setAccountInfo] = useState<AccountInfo | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await fetch("/api/sdk/account");
if (res.ok) {
const data = await res.json();
if (data.account) {
setAccountInfo(data.account);
}
}
} catch {
// Account info not available
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
return { accountInfo, loading, refresh };
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useCallback, useEffect } from "react";
interface VerifyResult {
ok: boolean;
message: string;
}
/**
* Generic hook for bridge adapter settings management.
* Encapsulates the fetch/save/verify pattern shared by all bridge adapters
* (Telegram, Discord, Feishu, QQ).
*/
export function useBridgeAdapter<T extends Record<string, string>>(
platform: string,
defaults: T
): {
settings: T;
loading: boolean;
saving: boolean;
save: (updates: Partial<T>) => Promise<void>;
refresh: () => Promise<void>;
verify: (params: Record<string, string>) => Promise<void>;
verifying: boolean;
verifyResult: VerifyResult | null;
setVerifyResult: (result: VerifyResult | null) => void;
} {
const [settings, setSettings] = useState<T>(defaults);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
const endpoint = `/api/settings/${platform}`;
const refresh = useCallback(async () => {
try {
const res = await fetch(endpoint);
if (res.ok) {
const data = await res.json();
setSettings({ ...defaults, ...data.settings });
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [endpoint, defaults]);
useEffect(() => {
refresh();
}, [refresh]);
const save = useCallback(async (updates: Partial<T>) => {
setSaving(true);
try {
const res = await fetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: updates }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, ...updates }));
}
} catch {
// ignore
} finally {
setSaving(false);
}
}, [endpoint]);
const verify = useCallback(async (params: Record<string, string>) => {
setVerifying(true);
setVerifyResult(null);
try {
const res = await fetch(`${endpoint}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const data = await res.json();
if (data.verified) {
setVerifyResult({
ok: true,
message: data.botName
? `Verified as ${data.botName}`
: "Verification successful",
});
} else {
setVerifyResult({
ok: false,
message: data.error || "Verification failed",
});
}
} catch {
setVerifyResult({ ok: false, message: "Verification failed" });
} finally {
setVerifying(false);
}
}, [endpoint]);
return {
settings, loading, saving, save, refresh,
verify, verifying, verifyResult, setVerifyResult,
};
}
+98
View File
@@ -0,0 +1,98 @@
import { useState, useCallback, useEffect, useRef } from "react";
interface AdapterStatus {
channelType: string;
running: boolean;
connectedAt: string | null;
lastMessageAt: string | null;
error: string | null;
}
interface BridgeStatus {
running: boolean;
startedAt: string | null;
adapters: AdapterStatus[];
}
/**
* Hook for polling bridge status and controlling bridge start/stop.
* Automatically polls every 5 seconds while the bridge is running.
*/
export function useBridgeStatus(): {
bridgeStatus: BridgeStatus | null;
starting: boolean;
stopping: boolean;
startBridge: () => Promise<void>;
stopBridge: () => Promise<void>;
refreshStatus: () => Promise<void>;
} {
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatus | null>(null);
const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshStatus = useCallback(async () => {
try {
const res = await fetch("/api/bridge");
if (res.ok) {
const data = await res.json();
setBridgeStatus(data);
}
} catch {
// ignore
}
}, []);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
// Poll bridge status while bridge is running
useEffect(() => {
if (bridgeStatus?.running) {
pollRef.current = setInterval(refreshStatus, 5000);
} else if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, [bridgeStatus?.running, refreshStatus]);
const startBridge = useCallback(async () => {
setStarting(true);
try {
await fetch("/api/bridge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "start" }),
});
await refreshStatus();
} catch {
// ignore
} finally {
setStarting(false);
}
}, [refreshStatus]);
const stopBridge = useCallback(async () => {
setStopping(true);
try {
await fetch("/api/bridge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "stop" }),
});
await refreshStatus();
} catch {
// ignore
} finally {
setStopping(false);
}
}, [refreshStatus]);
return { bridgeStatus, starting, stopping, startBridge, stopBridge, refreshStatus };
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, useCallback, useEffect } from "react";
/**
* Generic hook for fetching and saving settings from an API endpoint.
* Handles loading/saving states and provides a clean CRUD interface.
*/
export function useSettings<T extends Record<string, string>>(
endpoint: string,
defaults: T
): {
settings: T;
loading: boolean;
saving: boolean;
save: (updates: Partial<T>) => Promise<void>;
refresh: () => Promise<void>;
} {
const [settings, setSettings] = useState<T>(defaults);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await fetch(endpoint);
if (res.ok) {
const data = await res.json();
setSettings({ ...defaults, ...data.settings });
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [endpoint, defaults]);
useEffect(() => {
refresh();
}, [refresh]);
const save = useCallback(async (updates: Partial<T>) => {
setSaving(true);
try {
const res = await fetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: updates }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, ...updates }));
}
} catch {
// ignore
} finally {
setSaving(false);
}
}, [endpoint]);
return { settings, loading, saving, save, refresh };
}