mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
fix(web): unify load error states and retry actions
This commit is contained in:
@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||
@@ -363,11 +364,7 @@ export default function PluginProcessorDetailContent({
|
||||
const logsContent = (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
|
||||
{failed && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('agents.eventProcessor.loadError')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState compact title={t('agents.eventProcessor.loadError')} onRetry={refreshLatestRun} />
|
||||
)}
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
@@ -263,9 +264,7 @@ export default function AgentMonitoringTab({
|
||||
</Button>
|
||||
</div>
|
||||
{listError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{t('monitoring.loadError')}</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState compact title={t('monitoring.loadError')} onRetry={refresh} />
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<section
|
||||
@@ -494,17 +493,7 @@ export default function AgentMonitoringTab({
|
||||
)
|
||||
)}
|
||||
{traceError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('monitoring.loadError')}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setTraceRevision((value) => value + 1)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState title={t('monitoring.loadError')} onRetry={() => setTraceRevision((value) => value + 1)} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -738,19 +739,7 @@ const BotSessionMonitor = forwardRef<
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : sessionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 space-y-2 text-sm text-destructive"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSessions}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
<LoadErrorState compact title={t('monitoring.loadError')} onRetry={loadSessions} />
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
@@ -922,45 +911,14 @@ const BotSessionMonitor = forwardRef<
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{analysisError && !loadingMessages && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>
|
||||
{t('monitoring.toolCalls.title')}:{' '}
|
||||
{t('monitoring.loadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
<LoadErrorState compact title={`${t('monitoring.toolCalls.title')}: ${t('monitoring.loadError')}`} onRetry={() => loadMessages(selectedSessionId, messagePage)} />
|
||||
)}
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messageError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
<LoadErrorState title={t('monitoring.loadError')} onRetry={() => loadMessages(selectedSessionId, messagePage)} />
|
||||
) : timelineItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
@@ -270,9 +271,7 @@ export default function ProviderForm({
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="py-8 text-sm text-destructive">
|
||||
{t('models.loadError')}
|
||||
</p>
|
||||
<LoadErrorState compact title={t('models.loadError')} />
|
||||
)}
|
||||
<DialogFooter>
|
||||
{loadState === 'error' && (
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { MetricCard } from './overview-cards/MetricCard';
|
||||
import { Alert, AlertTitle } from '@/components/ui/alert';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { TabState } from './TabState';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getErrorMessage } from '../utils';
|
||||
@@ -174,9 +174,7 @@ export default function TokenMonitoring({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('monitoring.tokens.loadError', { error })}</AlertTitle>
|
||||
</Alert>
|
||||
<LoadErrorState title={t('monitoring.loadError')} description={error} onRetry={fetchStats} busy={loading} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -297,9 +298,7 @@ export default function ExecutionDetailSheet({
|
||||
<div key={`${item.id}-${i}`}>{renderItem(section, item)}</div>
|
||||
))}
|
||||
{sectionError === section && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</p>
|
||||
<LoadErrorState compact title={t('monitoring.execution.detail.loadError')} onRetry={() => loadMore(section)} />
|
||||
)}
|
||||
{page.has_more && (
|
||||
<Button
|
||||
@@ -375,9 +374,7 @@ export default function ExecutionDetailSheet({
|
||||
</div>
|
||||
{loading && <Skeleton className="h-36 w-full" />}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</p>
|
||||
<LoadErrorState title={t('monitoring.execution.detail.loadError')} onRetry={() => setRefresh((value) => value + 1)} />
|
||||
)}
|
||||
{record && (
|
||||
<>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -266,14 +266,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Content Area */}
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('monitoring.loadError')}</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState title={t('monitoring.loadError')} onRetry={handleRefresh} busy={refreshing} />
|
||||
) : (
|
||||
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
||||
{/* Overview Section: the execution metrics and the runtime status share
|
||||
@@ -365,20 +358,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{executionError ? (
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState title={t('monitoring.execution.detail.loadError')} onRetry={handleRefresh} busy={refreshing} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
@@ -202,9 +203,7 @@ function PluginPageIframe({
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{assetError ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('plugins.loadFailed')}
|
||||
</div>
|
||||
<LoadErrorState title={t('plugins.loadFailed')} className="flex-1" onRetry={() => window.location.reload()} />
|
||||
) : loading || !assetUrl ? (
|
||||
<EntityLoadState />
|
||||
) : null}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
@@ -54,6 +55,8 @@ function PluginListView() {
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
const [statusRevision, setStatusRevision] = useState(0);
|
||||
const [debugInfo, setDebugInfo] = useState<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
@@ -70,10 +73,12 @@ function PluginListView() {
|
||||
const fetchPluginSystemStatus = async () => {
|
||||
try {
|
||||
setStatusLoading(true);
|
||||
setStatusError(false);
|
||||
const status = await httpClient.getPluginSystemStatus();
|
||||
setPluginSystemStatus(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugin system status:', error);
|
||||
setStatusError(true);
|
||||
toast.error(t('plugins.failedToGetStatus'));
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
@@ -81,7 +86,7 @@ function PluginListView() {
|
||||
};
|
||||
|
||||
void fetchPluginSystemStatus();
|
||||
}, [t]);
|
||||
}, [t, statusRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
const onComplete = (
|
||||
@@ -141,13 +146,13 @@ function PluginListView() {
|
||||
);
|
||||
|
||||
const renderPluginConnectionErrorState = () => (
|
||||
<div className="flex justify-center pt-[10vh] px-4">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<Unlink />
|
||||
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
|
||||
<AlertDescription>{t('plugins.connectionErrorDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<LoadErrorState
|
||||
className="pt-[10vh]"
|
||||
icon={<Unlink className="size-5" aria-hidden="true" />}
|
||||
title={t('plugins.connectionError')}
|
||||
description={t('plugins.connectionErrorDesc')}
|
||||
onRetry={() => setStatusRevision((value) => value + 1)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderLoadingState = () => (
|
||||
@@ -162,6 +167,10 @@ function PluginListView() {
|
||||
return renderLoadingState();
|
||||
}
|
||||
|
||||
if (statusError) {
|
||||
return <LoadErrorState title={t('plugins.failedToGetStatus')} onRetry={() => setStatusRevision((value) => value + 1)} />;
|
||||
}
|
||||
|
||||
if (!pluginSystemStatus?.is_enable) {
|
||||
return renderPluginDisabledState();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
|
||||
export default function EntityLoadState({
|
||||
error = false,
|
||||
@@ -10,27 +10,15 @@ export default function EntityLoadState({
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (error) return <LoadErrorState onRetry={onRetry} className="flex-1" />;
|
||||
return (
|
||||
<div
|
||||
role={error ? 'alert' : 'status'}
|
||||
aria-busy={!error}
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
className="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground"
|
||||
>
|
||||
{error ? (
|
||||
<>
|
||||
<p>{t('common.loadFailed')}</p>
|
||||
{onRetry && (
|
||||
<Button type="button" variant="outline" onClick={onRetry}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
|
||||
<p>{t('common.loading')}</p>
|
||||
</>
|
||||
)}
|
||||
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { FileWarning, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function LoadErrorState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onRetry,
|
||||
busy = false,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
onRetry?: () => unknown;
|
||||
busy?: boolean;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
async function retry() {
|
||||
if (!onRetry || retrying || busy) return;
|
||||
setRetrying(true);
|
||||
try {
|
||||
await onRetry();
|
||||
} catch {
|
||||
// The owning view retains its error state if the request fails again.
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-busy={retrying || busy}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center px-4',
|
||||
compact ? 'py-4' : 'min-h-64 py-10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-md items-start gap-4 rounded-xl border bg-card p-5 text-card-foreground shadow-sm sm:p-6">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
{icon ?? <FileWarning className="size-5" aria-hidden="true" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="break-words text-sm font-medium leading-6">
|
||||
{title ?? t('common.loadFailedTitle')}
|
||||
</p>
|
||||
<p className="break-words text-sm leading-6 text-muted-foreground">
|
||||
{description ?? t('common.loadFailedHint')}
|
||||
</p>
|
||||
{onRetry && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={retrying || busy}
|
||||
onClick={() => void retry()}
|
||||
>
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className={cn('size-3.5', (retrying || busy) && 'animate-spin motion-reduce:animate-none')}
|
||||
/>
|
||||
{retrying || busy ? t('common.loading') : t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -340,6 +340,8 @@ const enUS = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: 'Unable to load this content',
|
||||
loadFailedHint: 'Please try again in a moment. If this continues, check your connection and service status.',
|
||||
customValue: 'Custom',
|
||||
loadFailed: 'Failed to load. Please try again.',
|
||||
search: 'Search',
|
||||
|
||||
@@ -343,6 +343,8 @@ const esES = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: 'No se pudo cargar el contenido',
|
||||
loadFailedHint: 'Inténtalo de nuevo. Si el problema persiste, comprueba la conexión y el estado del servicio.',
|
||||
customValue: 'Personalizado',
|
||||
loadFailed: 'No se pudo cargar. Inténtalo de nuevo.',
|
||||
search: 'Buscar',
|
||||
|
||||
@@ -339,6 +339,8 @@ const jaJP = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: '読み込めませんでした',
|
||||
loadFailedHint: 'しばらくしてから再試行してください。解決しない場合は、接続とサービスの状態を確認してください。',
|
||||
customValue: 'カスタム',
|
||||
loadFailed: '読み込みに失敗しました。再試行してください。',
|
||||
search: '検索',
|
||||
|
||||
@@ -342,6 +342,8 @@ const ruRU = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: 'Не удалось загрузить данные',
|
||||
loadFailedHint: 'Повторите попытку позже. Если проблема остаётся, проверьте подключение и состояние сервиса.',
|
||||
customValue: 'Свой вариант',
|
||||
loadFailed: 'Не удалось загрузить. Попробуйте ещё раз.',
|
||||
search: 'Поиск',
|
||||
|
||||
@@ -335,6 +335,8 @@ const thTH = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: 'ไม่สามารถโหลดเนื้อหาได้',
|
||||
loadFailedHint: 'โปรดลองอีกครั้ง หากยังโหลดไม่ได้ ให้ตรวจสอบการเชื่อมต่อและสถานะบริการ',
|
||||
customValue: 'กำหนดเอง',
|
||||
loadFailed: 'โหลดไม่สำเร็จ โปรดลองอีกครั้ง',
|
||||
search: 'ค้นหา',
|
||||
|
||||
@@ -339,6 +339,8 @@ const viVN = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: 'Không thể tải nội dung',
|
||||
loadFailedHint: 'Vui lòng thử lại sau. Nếu vẫn không tải được, hãy kiểm tra kết nối và trạng thái dịch vụ.',
|
||||
customValue: 'Tùy chỉnh',
|
||||
loadFailed: 'Không tải được. Vui lòng thử lại.',
|
||||
search: 'Tìm kiếm',
|
||||
|
||||
@@ -319,6 +319,8 @@ const zhHans = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: '暂时无法加载',
|
||||
loadFailedHint: '请稍后重试。如果仍无法加载,请检查网络连接与服务状态。',
|
||||
customValue: '自定义',
|
||||
loadFailed: '加载失败,请重试。',
|
||||
search: '搜索',
|
||||
|
||||
@@ -316,6 +316,8 @@ const zhHant = {
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
loadFailedTitle: '暫時無法載入',
|
||||
loadFailedHint: '請稍後重試。如果仍無法載入,請檢查網路連線與服務狀態。',
|
||||
customValue: '自訂',
|
||||
loadFailed: '載入失敗,請重試。',
|
||||
search: '搜尋',
|
||||
|
||||
Reference in New Issue
Block a user