fix: keep storage probe feedback near its account

Show connection-test success and failure inline in the account card instead of at the top of the settings page.
This commit is contained in:
hicocos committed 2026-08-25 14:20:47 +08:00
1 parent 15c3518940
commit 07cd95a2d7
2 files changed
+43 -24

No files matched your search

+32 -23
View File
@@ -68,7 +68,21 @@ const SettingsRow = ({ icon: Icon, label, value, action, onClick, description }:
</div>
);
const StorageProbeStatus = ({ account, busy, onProbe }: { account: StorageAccount; busy: boolean; onProbe: () => void }) => {
interface ActionNoticeState {
title: string;
message: string;
tone: 'success' | 'error' | 'info';
}
interface ActionDialogState {
mode: 'confirm' | 'prompt';
title: string;
message: string;
inputType?: 'text' | 'password';
resolve?: (value: boolean | string | null) => void;
}
interface ProbeFeedbackState { accountId: string; tone: 'success' | 'error'; message: string; sequence: number; }
const StorageProbeStatus = ({ account, busy, feedback, onProbe }: { account: StorageAccount; busy: boolean; feedback: ProbeFeedbackState | null; onProbe: () => void }) => {
const status = account.last_probe_status;
const Icon = status === 'available' ? CheckCircle : status === 'failed' ? XCircle : CircleHelp;
const label = status === 'available' ? '连接可用' : status === 'failed' ? '连接失败' : '尚未测试';
@@ -88,25 +102,12 @@ const StorageProbeStatus = ({ account, busy, onProbe }: { account: StorageAccoun
{busy ? <IndeterminateSpinner label="正在测试存储连接" size="sm" /> : <RefreshCw className="h-3.5 w-3.5" />}
测试连接
</Button>
{status === 'failed' && account.last_probe_error && <p className="min-w-0 basis-full [overflow-wrap:anywhere] text-red-700">{account.last_probe_error}</p>}
{feedback && <span className={cn("min-w-0 basis-full rounded-md px-2 py-1.5 font-medium [overflow-wrap:anywhere]", feedback.tone === 'success' ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700")} role="status" aria-live="polite">{feedback.tone === 'success' ? <CheckCircle className="mr-1 inline h-3.5 w-3.5" /> : <XCircle className="mr-1 inline h-3.5 w-3.5" />}{feedback.message}</span>}
{!feedback && status === 'failed' && account.last_probe_error && <p className="min-w-0 basis-full [overflow-wrap:anywhere] text-red-700">{account.last_probe_error}</p>}
</div>
);
};
interface ActionDialogState {
mode: 'confirm' | 'prompt';
title: string;
message: string;
inputType?: 'text' | 'password';
resolve?: (value: boolean | string | null) => void;
}
interface ActionNoticeState {
title: string;
message: string;
tone: 'success' | 'error' | 'info';
}
const ActionNotice = ({ state, onClose }: { state: ActionNoticeState; onClose: () => void }) => (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.98 }}
@@ -206,6 +207,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
const [config, setConfig] = useState<StorageConfig | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [probingAccountId, setProbingAccountId] = useState<string | null>(null);
const [probeFeedback, setProbeFeedback] = useState<ProbeFeedbackState | null>(null);
const [showOneDriveForm, setShowOneDriveForm] = useState(false);
// OneDrive Form State (for adding new account)
@@ -322,16 +324,23 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
return data;
};
useEffect(() => {
if (!probeFeedback) return;
const timer = window.setTimeout(() => setProbeFeedback(null), 4_000);
return () => window.clearTimeout(timer);
}, [probeFeedback?.sequence]);
const handleProbeAccount = async (account: StorageAccount) => {
if (probingAccountId) return;
setProbingAccountId(account.id);
setProbeFeedback(null);
try {
await fileApi.probeStorageAccount(account.id);
await reloadStorageConfig();
await showNotice(`“${account.name}”连接测试成功`);
setProbeFeedback(previous => ({ accountId: account.id, tone: 'success', message: '连接测试成功', sequence: (previous?.sequence ?? 0) + 1 }));
} catch (error: any) {
await reloadStorageConfig().catch(() => undefined);
await showNotice(error?.message || '存储账户连接测试失败', '连接测试失败');
setProbeFeedback(previous => ({ accountId: account.id, tone: 'error', message: error?.message || '连接测试失败,请稍后重试', sequence: (previous?.sequence ?? 0) + 1 }));
} finally {
setProbingAccountId(null);
}
@@ -1234,7 +1243,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{account.name || "未命名账户"}</p>
<p className="break-all text-[10px] text-muted-foreground font-mono opacity-60">{account.id}</p>
<StorageProbeStatus account={account} busy={probingAccountId === account.id} onProbe={() => void handleProbeAccount(account)} />
<StorageProbeStatus account={account} busy={probingAccountId === account.id} feedback={probeFeedback?.accountId === account.id ? probeFeedback : null} onProbe={() => void handleProbeAccount(account)} />
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 self-stretch sm:self-auto">
@@ -1415,7 +1424,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{account.name || "未命名账户"}</p>
<p className="break-all text-[10px] text-muted-foreground font-mono opacity-60">{account.id}</p>
<StorageProbeStatus account={account} busy={probingAccountId === account.id} onProbe={() => void handleProbeAccount(account)} />
<StorageProbeStatus account={account} busy={probingAccountId === account.id} feedback={probeFeedback?.accountId === account.id ? probeFeedback : null} onProbe={() => void handleProbeAccount(account)} />
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 self-stretch sm:self-auto">
@@ -1600,7 +1609,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{account.name || "未命名账户"}</p>
<p className="break-all text-[10px] text-muted-foreground font-mono opacity-60">{account.id}</p>
<StorageProbeStatus account={account} busy={probingAccountId === account.id} onProbe={() => void handleProbeAccount(account)} />
<StorageProbeStatus account={account} busy={probingAccountId === account.id} feedback={probeFeedback?.accountId === account.id ? probeFeedback : null} onProbe={() => void handleProbeAccount(account)} />
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 self-stretch sm:self-auto">
@@ -1786,7 +1795,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{account.name || "未命名账户"}</p>
<p className="break-all text-[10px] text-muted-foreground font-mono opacity-60">{account.id}</p>
<StorageProbeStatus account={account} busy={probingAccountId === account.id} onProbe={() => void handleProbeAccount(account)} />
<StorageProbeStatus account={account} busy={probingAccountId === account.id} feedback={probeFeedback?.accountId === account.id ? probeFeedback : null} onProbe={() => void handleProbeAccount(account)} />
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 self-stretch sm:self-auto">
@@ -1994,7 +2003,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{account.name || "未命名账户"}</p>
<p className="break-all text-[10px] text-muted-foreground font-mono opacity-60">{account.id}</p>
<StorageProbeStatus account={account} busy={probingAccountId === account.id} onProbe={() => void handleProbeAccount(account)} />
<StorageProbeStatus account={account} busy={probingAccountId === account.id} feedback={probeFeedback?.accountId === account.id ? probeFeedback : null} onProbe={() => void handleProbeAccount(account)} />
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 self-stretch sm:self-auto">
@@ -25,6 +25,16 @@ test('the global header owns the theme switch on every page', () => {
assert.doesNotMatch(settings, /label=\{t\("settings\.general\.theme"\)\}/);
});
test('storage connection test feedback stays beside the account that triggered it', () => {
assert.match(settings, /interface ProbeFeedbackState \{ accountId: string; tone: 'success' \| 'error'; message: string; sequence: number; \}/);
assert.match(settings, /probeFeedback\?\.accountId === account\.id \? probeFeedback : null/);
assert.match(settings, /role="status" aria-live="polite"/);
assert.match(settings, /setProbeFeedback\(previous => \(\{ accountId: account\.id, tone: 'success', message: '连接测试成功', sequence: \(previous\?\.sequence \?\? 0\) \+ 1 \}\)\)/);
assert.match(settings, /tone: 'error'/);
assert.doesNotMatch(settings, /showNotice\(`“\$\{account\.name\}”连接测试成功`\)/);
assert.doesNotMatch(settings, /showNotice\(error\?\.message \|\| '存储账户连接测试失败', '连接测试失败'\)/);
});
test('settings uses non-modal transient feedback for notices but keeps confirmations and prompts modal', () => {
assert.match(settings, /interface ActionNoticeState/);
assert.match(settings, /role="status"/);
@@ -33,7 +43,7 @@ test('settings uses non-modal transient feedback for notices but keeps confirmat
assert.match(settings, /setActionNotice\(\{ title, message, tone:[^\n]*\}\);/);
assert.match(settings, /return Promise\.resolve\(\);/);
assert.doesNotMatch(settings, /window\.location\.reload\(\)/);
assert.doesNotMatch(settings, /interface ActionNoticeState[\s\S]*resolve\?:/);
assert.doesNotMatch(settings, /interface ActionNoticeState\s*\{[^}]*resolve\?:/);
assert.doesNotMatch(settings, /mode:\s*'notice'\s*\|\s*'confirm'\s*\|\s*'prompt'/);
assert.doesNotMatch(settings, /state\.mode === 'notice' \? '知道了'/);
});