Expose Overview IPC load failures inline

Overview previously treated rejected history and credential IPC calls like empty data, which made backend read failures indistinguishable from a clean first run. The page now keeps separate error flags, shows provider status as unreadable when credential status cannot load, and replaces history-derived metrics/recent rows with retryable inline failure states.

Constraint: Issue #311 asks for per-card inline feedback without changing backend handler signatures or adding global toast UI.\nRejected: Global error boundary or toast | broader UI behavior than needed for the Overview failure path.\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && git diff --check && npm run build\nNot-tested: Live Tauri IPC failure injection.\nRelated: https://github.com/appergb/openless/issues/311
This commit is contained in:
H-Chris233 committed 2026-05-08 07:25:38 +08:00
1 parent c4a7ebe62c
commit d32da5c36c
6 files changed
+88 -22

No files matched your search

+5
View File
@@ -122,12 +122,15 @@ export const en: typeof zhCN = {
llmNotConfigured: 'Not configured',
statusConfigured: 'Configured',
statusNotConfigured: 'Not configured',
statusUnknown: 'Unavailable',
credentialsLoadError: 'Could not read credential status',
metricChars: 'Characters today',
metricSegments: '{{count}} segments',
metricDuration: 'Total duration today',
metricAvg: 'Avg per segment',
metricAvgTrend: "Today's average",
metricNoData: 'No data',
historyLoadError: 'History load failed',
metricTotal: 'Total records',
metricTotalTrend: 'Local archive (max 200)',
weekTitle: 'Last 7 days',
@@ -135,6 +138,8 @@ export const en: typeof zhCN = {
recentTitle: 'Recent transcripts',
recentAll: 'View all →',
recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.',
recentLoadFailed: 'Could not load recent transcripts. Please retry.',
historyRetry: 'Retry',
weekDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
},
history: {
+5
View File
@@ -124,12 +124,15 @@ export const ja: typeof zhCN = {
llmNotConfigured: '未設定',
statusConfigured: '設定済み',
statusNotConfigured: '未設定',
statusUnknown: '読み取れません',
credentialsLoadError: '認証情報の状態を読み取れません',
metricChars: '本日の文字数',
metricSegments: '{{count}} セグメント',
metricDuration: '本日の合計時間',
metricAvg: '平均セグメント',
metricAvgTrend: '本日の平均',
metricNoData: 'データなし',
historyLoadError: '履歴の読み込みに失敗',
metricTotal: '累計記録',
metricTotalTrend: 'ローカル保存(上限 200)',
weekTitle: '直近 7 日',
@@ -137,6 +140,8 @@ export const ja: typeof zhCN = {
recentTitle: '最近の認識',
recentAll: 'すべて表示 →',
recentEmpty: '記録がありません。{{trigger}} を押して最初の録音を始めましょう。',
recentLoadFailed: '最近の認識を読み込めません。再試行してください。',
historyRetry: '再試行',
weekDays: ['日', '月', '火', '水', '木', '金', '土'],
},
history: {
+5
View File
@@ -124,12 +124,15 @@ export const ko: typeof zhCN = {
llmNotConfigured: '구성되지 않음',
statusConfigured: '구성됨',
statusNotConfigured: '구성되지 않음',
statusUnknown: '읽을 수 없음',
credentialsLoadError: '자격 증명 상태를 읽을 수 없습니다',
metricChars: '오늘 글자 수',
metricSegments: '{{count}} 세그먼트',
metricDuration: '오늘 총 시간',
metricAvg: '평균 세그먼트',
metricAvgTrend: '오늘 평균',
metricNoData: '데이터 없음',
historyLoadError: '기록 로드 실패',
metricTotal: '누적 기록',
metricTotalTrend: '로컬 보관(상한 200)',
weekTitle: '최근 7일',
@@ -137,6 +140,8 @@ export const ko: typeof zhCN = {
recentTitle: '최근 인식',
recentAll: '전체 보기 →',
recentEmpty: '아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.',
recentLoadFailed: '최근 인식 기록을 불러올 수 없습니다. 다시 시도해 주세요.',
historyRetry: '다시 시도',
weekDays: ['일', '월', '화', '수', '목', '금', '토'],
},
history: {
+5
View File
@@ -120,12 +120,15 @@ export const zhCN = {
llmNotConfigured: '未配置',
statusConfigured: '已配置',
statusNotConfigured: '未配置',
statusUnknown: '无法读取',
credentialsLoadError: '无法读取凭据状态',
metricChars: '今日字数',
metricSegments: '{{count}} 段',
metricDuration: '今日总时长',
metricAvg: '平均段落',
metricAvgTrend: '今日均值',
metricNoData: '暂无数据',
historyLoadError: '历史读取失败',
metricTotal: '累计记录',
metricTotalTrend: '本机存档 (上限 200)',
weekTitle: '近 7 天',
@@ -133,6 +136,8 @@ export const zhCN = {
recentTitle: '最近识别',
recentAll: '全部记录 →',
recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。',
recentLoadFailed: '无法读取最近识别,请重试。',
historyRetry: '重试',
weekDays: ['日', '一', '二', '三', '四', '五', '六'],
},
history: {
+5
View File
@@ -122,12 +122,15 @@ export const zhTW: typeof zhCN = {
llmNotConfigured: '未配置',
statusConfigured: '已配置',
statusNotConfigured: '未配置',
statusUnknown: '無法讀取',
credentialsLoadError: '無法讀取憑據狀態',
metricChars: '今日字數',
metricSegments: '{{count}} 段',
metricDuration: '今日總時長',
metricAvg: '平均段落',
metricAvgTrend: '今日均值',
metricNoData: '暫無數據',
historyLoadError: '歷史讀取失敗',
metricTotal: '累計記錄',
metricTotalTrend: '本機存檔 (上限 200)',
weekTitle: '近 7 天',
@@ -135,6 +138,8 @@ export const zhTW: typeof zhCN = {
recentTitle: '最近識別',
recentAll: '全部記錄 →',
recentEmpty: '還沒有記錄。按 {{trigger}} 開始第一次錄音。',
recentLoadFailed: '無法讀取最近識別,請重試。',
historyRetry: '重試',
weekDays: ['日', '一', '二', '三', '四', '五', '六'],
},
history: {
+63 -22
View File
@@ -1,6 +1,6 @@
// Overview.tsx — 真实指标,从 listHistory + getCredentials 派生。
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { formatComboLabel } from '../lib/hotkey';
@@ -50,6 +50,8 @@ export function Overview({ onOpenHistory }: OverviewProps) {
const { t } = useTranslation();
const modeLabel = useModeLabels();
const [history, setHistory] = useState<DictationSession[]>([]);
const [historyError, setHistoryError] = useState(false);
const [credsError, setCredsError] = useState(false);
const [creds, setCreds] = useState<CredentialsStatus>({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
@@ -60,11 +62,29 @@ export function Overview({ onOpenHistory }: OverviewProps) {
});
const { prefs } = useHotkeySettings();
useEffect(() => {
listHistory().then(setHistory);
getCredentials().then(setCreds);
const refreshHistory = useCallback(() => {
setHistoryError(false);
listHistory()
.then(setHistory)
.catch(error => {
console.error('[overview] failed to load history', error);
setHistoryError(true);
});
}, []);
useEffect(() => {
refreshHistory();
getCredentials()
.then(status => {
setCreds(status);
setCredsError(false);
})
.catch(error => {
console.error('[overview] failed to load credentials status', error);
setCredsError(true);
});
}, [refreshHistory]);
const metrics = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -138,21 +158,21 @@ export function Overview({ onOpenHistory }: OverviewProps) {
kind={t('overview.asrKind')}
name={asrProviderName}
subname={asrProviderId}
configured={creds.asrConfigured}
status={credsError ? 'error' : creds.asrConfigured ? 'configured' : 'notConfigured'}
/>
<ProviderCard
kind={t('overview.llmKind')}
name={llmProviderName}
subname={llmProviderId}
configured={creds.llmConfigured}
status={credsError ? 'error' : creds.llmConfigured ? 'configured' : 'notConfigured'}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 18 }}>
<Metric icon="hash" label={t('overview.metricChars')} value={metrics.charsToday.toLocaleString()} trend={t('overview.metricSegments', { count: metrics.segmentsToday })} />
<Metric icon="mic" label={t('overview.metricDuration')} value={formatDuration(metrics.totalDurationMs, t)} trend="" />
<Metric icon="clock" label={t('overview.metricAvg')} value={formatDuration(metrics.avgLatencyMs, t)} trend={metrics.segmentsToday > 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
<Metric icon="bolt" label={t('overview.metricTotal')} value={String(history.length)} trend={t('overview.metricTotalTrend')} accent />
<Metric icon="hash" label={t('overview.metricChars')} value={historyError ? '—' : metrics.charsToday.toLocaleString()} trend={historyError ? t('overview.historyLoadError') : t('overview.metricSegments', { count: metrics.segmentsToday })} />
<Metric icon="mic" label={t('overview.metricDuration')} value={historyError ? '—' : formatDuration(metrics.totalDurationMs, t)} trend={historyError ? t('overview.historyLoadError') : ''} />
<Metric icon="clock" label={t('overview.metricAvg')} value={historyError ? '—' : formatDuration(metrics.avgLatencyMs, t)} trend={historyError ? t('overview.historyLoadError') : metrics.segmentsToday > 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
<Metric icon="bolt" label={t('overview.metricTotal')} value={historyError ? '—' : String(history.length)} trend={historyError ? t('overview.historyLoadError') : t('overview.metricTotalTrend')} accent />
</div>
{/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。
@@ -164,7 +184,13 @@ export function Overview({ onOpenHistory }: OverviewProps) {
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.weekTitle')}</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('overview.weekUnit')}</span>
</div>
<WeekChart data={weekly} />
{historyError ? (
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.historyLoadError')}
</div>
) : (
<WeekChart data={weekly} />
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--ol-ink-4)', marginTop: 8 }}>
{weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => <span key={i}>{d}</span>)}
</div>
@@ -176,14 +202,23 @@ export function Overview({ onOpenHistory }: OverviewProps) {
<Btn size="sm" variant="ghost" onClick={onOpenHistory}>{t('overview.recentAll')}</Btn>
</div>
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{history.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.recentEmpty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
{historyError ? (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<span>{t('overview.recentLoadFailed')}</span>
<Btn size="sm" variant="ghost" onClick={refreshHistory}>{t('overview.historyRetry')}</Btn>
</div>
) : (
<>
{history.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.recentEmpty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
</div>
)}
{history.slice(0, 5).map(s => (
<RecentRow key={s.id} session={s} modeLabel={modeLabel} />
))}
</>
)}
{history.slice(0, 5).map(s => (
<RecentRow key={s.id} session={s} modeLabel={modeLabel} />
))}
</div>
</Card>
</div>
@@ -195,10 +230,10 @@ interface ProviderCardProps {
kind: string;
name: string;
subname: string;
configured: boolean;
status: 'configured' | 'notConfigured' | 'error';
}
function ProviderCard({ kind, name, subname, configured }: ProviderCardProps) {
function ProviderCard({ kind, name, subname, status }: ProviderCardProps) {
const { t } = useTranslation();
// ASR 卡用 mic 图标,其他用 sparkle —— 通过比较译文判断会随语言改变,故改用本地化无关的字面量比较。
const isAsr = kind === t('overview.asrKind');
@@ -217,17 +252,23 @@ function ProviderCard({ kind, name, subname, configured }: ProviderCardProps) {
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase' }}>{kind}</span>
{configured ? (
{status === 'configured' && (
<Pill tone="ok" size="sm">
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--ol-ok)' }} />
{t('overview.statusConfigured')}
</Pill>
) : (
)}
{status === 'notConfigured' && (
<Pill tone="outline" size="sm">{t('overview.statusNotConfigured')}</Pill>
)}
{status === 'error' && (
<Pill tone="outline" size="sm" style={{ color: 'var(--ol-red, #ef4444)', borderColor: 'rgba(239,68,68,0.24)' }}>{t('overview.statusUnknown')}</Pill>
)}
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{name}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', marginTop: 1, fontFamily: 'var(--ol-font-mono)' }}>{subname}</div>
<div style={{ fontSize: 11.5, color: status === 'error' ? 'var(--ol-red, #ef4444)' : 'var(--ol-ink-3)', marginTop: 1, fontFamily: status === 'error' ? undefined : 'var(--ol-font-mono)' }}>
{status === 'error' ? t('overview.credentialsLoadError') : subname}
</div>
</div>
</Card>
);