Spaces:
Paused
Paused
icebear0828 Claude Sonnet 4.6 commited on
Commit ·
e6a1587
1
Parent(s): 9d65acf
feat: add manual refresh button with timestamp to account list
Browse files- Separate `refreshing` state from initial `loading` so the list stays
visible while re-fetching (no blank flash on refresh)
- Track `lastUpdated` timestamp, shown as "Updated at HH:MM:SS" next to
the button (hidden on mobile, visible sm+)
- Refresh button spins during fetch and is disabled to prevent double-tap
- i18n: add `refresh` / `updatedAt` keys in both en and zh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- web/src/App.tsx +3 -0
- web/src/components/AccountList.tsx +34 -3
- web/src/hooks/use-accounts.ts +9 -1
- web/src/i18n/translations.ts +4 -0
web/src/App.tsx
CHANGED
|
@@ -29,6 +29,9 @@ function Dashboard() {
|
|
| 29 |
accounts={accounts.list}
|
| 30 |
loading={accounts.loading}
|
| 31 |
onDelete={accounts.deleteAccount}
|
|
|
|
|
|
|
|
|
|
| 32 |
/>
|
| 33 |
<ApiConfig
|
| 34 |
baseUrl={status.baseUrl}
|
|
|
|
| 29 |
accounts={accounts.list}
|
| 30 |
loading={accounts.loading}
|
| 31 |
onDelete={accounts.deleteAccount}
|
| 32 |
+
onRefresh={accounts.refresh}
|
| 33 |
+
refreshing={accounts.refreshing}
|
| 34 |
+
lastUpdated={accounts.lastUpdated}
|
| 35 |
/>
|
| 36 |
<ApiConfig
|
| 37 |
baseUrl={status.baseUrl}
|
web/src/components/AccountList.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useT } from "../i18n/context";
|
| 2 |
import { AccountCard } from "./AccountCard";
|
| 3 |
import type { Account } from "../hooks/use-accounts";
|
| 4 |
|
|
@@ -6,18 +6,49 @@ interface AccountListProps {
|
|
| 6 |
accounts: Account[];
|
| 7 |
loading: boolean;
|
| 8 |
onDelete: (id: string) => Promise<string | null>;
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
export function AccountList({ accounts, loading, onDelete }: AccountListProps) {
|
| 12 |
const t = useT();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
return (
|
| 15 |
<section class="flex flex-col gap-4">
|
| 16 |
-
<div class="flex items-
|
| 17 |
<div class="flex flex-col gap-1">
|
| 18 |
<h2 class="text-[0.95rem] font-bold tracking-tight">{t("connectedAccounts")}</h2>
|
| 19 |
<p class="text-slate-500 dark:text-text-dim text-[0.8rem]">{t("connectedAccountsDesc")}</p>
|
| 20 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</div>
|
| 22 |
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 23 |
{loading ? (
|
|
|
|
| 1 |
+
import { useI18n, useT } from "../i18n/context";
|
| 2 |
import { AccountCard } from "./AccountCard";
|
| 3 |
import type { Account } from "../hooks/use-accounts";
|
| 4 |
|
|
|
|
| 6 |
accounts: Account[];
|
| 7 |
loading: boolean;
|
| 8 |
onDelete: (id: string) => Promise<string | null>;
|
| 9 |
+
onRefresh: () => void;
|
| 10 |
+
refreshing: boolean;
|
| 11 |
+
lastUpdated: Date | null;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated }: AccountListProps) {
|
| 15 |
const t = useT();
|
| 16 |
+
const { lang } = useI18n();
|
| 17 |
+
|
| 18 |
+
const updatedAtText = lastUpdated
|
| 19 |
+
? lastUpdated.toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
| 20 |
+
: null;
|
| 21 |
|
| 22 |
return (
|
| 23 |
<section class="flex flex-col gap-4">
|
| 24 |
+
<div class="flex items-center justify-between">
|
| 25 |
<div class="flex flex-col gap-1">
|
| 26 |
<h2 class="text-[0.95rem] font-bold tracking-tight">{t("connectedAccounts")}</h2>
|
| 27 |
<p class="text-slate-500 dark:text-text-dim text-[0.8rem]">{t("connectedAccountsDesc")}</p>
|
| 28 |
</div>
|
| 29 |
+
<div class="flex items-center gap-2 shrink-0">
|
| 30 |
+
{updatedAtText && (
|
| 31 |
+
<span class="text-[0.75rem] text-slate-400 dark:text-text-dim hidden sm:inline">
|
| 32 |
+
{t("updatedAt")} {updatedAtText}
|
| 33 |
+
</span>
|
| 34 |
+
)}
|
| 35 |
+
<button
|
| 36 |
+
onClick={onRefresh}
|
| 37 |
+
disabled={refreshing}
|
| 38 |
+
title={t("refresh")}
|
| 39 |
+
class="p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-primary/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
| 40 |
+
>
|
| 41 |
+
<svg
|
| 42 |
+
class={`size-[18px] ${refreshing ? "animate-spin" : ""}`}
|
| 43 |
+
viewBox="0 0 24 24"
|
| 44 |
+
fill="none"
|
| 45 |
+
stroke="currentColor"
|
| 46 |
+
stroke-width="1.5"
|
| 47 |
+
>
|
| 48 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
| 49 |
+
</svg>
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
</div>
|
| 53 |
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 54 |
{loading ? (
|
web/src/hooks/use-accounts.ts
CHANGED
|
@@ -24,19 +24,24 @@ export interface Account {
|
|
| 24 |
export function useAccounts() {
|
| 25 |
const [list, setList] = useState<Account[]>([]);
|
| 26 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
| 27 |
const [addVisible, setAddVisible] = useState(false);
|
| 28 |
const [addInfo, setAddInfo] = useState("");
|
| 29 |
const [addError, setAddError] = useState("");
|
| 30 |
|
| 31 |
const loadAccounts = useCallback(async () => {
|
|
|
|
| 32 |
try {
|
| 33 |
const resp = await fetch("/auth/accounts?quota=true");
|
| 34 |
const data = await resp.json();
|
| 35 |
setList(data.accounts || []);
|
| 36 |
-
|
|
|
|
| 37 |
setList([]);
|
| 38 |
} finally {
|
| 39 |
setLoading(false);
|
|
|
|
| 40 |
}
|
| 41 |
}, []);
|
| 42 |
|
|
@@ -146,9 +151,12 @@ export function useAccounts() {
|
|
| 146 |
return {
|
| 147 |
list,
|
| 148 |
loading,
|
|
|
|
|
|
|
| 149 |
addVisible,
|
| 150 |
addInfo,
|
| 151 |
addError,
|
|
|
|
| 152 |
startAdd,
|
| 153 |
submitRelay,
|
| 154 |
deleteAccount,
|
|
|
|
| 24 |
export function useAccounts() {
|
| 25 |
const [list, setList] = useState<Account[]>([]);
|
| 26 |
const [loading, setLoading] = useState(true);
|
| 27 |
+
const [refreshing, setRefreshing] = useState(false);
|
| 28 |
+
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
| 29 |
const [addVisible, setAddVisible] = useState(false);
|
| 30 |
const [addInfo, setAddInfo] = useState("");
|
| 31 |
const [addError, setAddError] = useState("");
|
| 32 |
|
| 33 |
const loadAccounts = useCallback(async () => {
|
| 34 |
+
setRefreshing(true);
|
| 35 |
try {
|
| 36 |
const resp = await fetch("/auth/accounts?quota=true");
|
| 37 |
const data = await resp.json();
|
| 38 |
setList(data.accounts || []);
|
| 39 |
+
setLastUpdated(new Date());
|
| 40 |
+
} catch {
|
| 41 |
setList([]);
|
| 42 |
} finally {
|
| 43 |
setLoading(false);
|
| 44 |
+
setRefreshing(false);
|
| 45 |
}
|
| 46 |
}, []);
|
| 47 |
|
|
|
|
| 151 |
return {
|
| 152 |
list,
|
| 153 |
loading,
|
| 154 |
+
refreshing,
|
| 155 |
+
lastUpdated,
|
| 156 |
addVisible,
|
| 157 |
addInfo,
|
| 158 |
addError,
|
| 159 |
+
refresh: loadAccounts,
|
| 160 |
startAdd,
|
| 161 |
submitRelay,
|
| 162 |
deleteAccount,
|
web/src/i18n/translations.ts
CHANGED
|
@@ -52,6 +52,8 @@ export const translations = {
|
|
| 52 |
networkError: "Network error: ",
|
| 53 |
copied: "Copied!",
|
| 54 |
copyFailed: "Failed",
|
|
|
|
|
|
|
| 55 |
footer: "\u00a9 2025 Codex Proxy. All rights reserved.",
|
| 56 |
},
|
| 57 |
zh: {
|
|
@@ -109,6 +111,8 @@ export const translations = {
|
|
| 109 |
networkError: "\u7f51\u7edc\u9519\u8bef\uff1a",
|
| 110 |
copied: "\u5df2\u590d\u5236\uff01",
|
| 111 |
copyFailed: "\u5931\u8d25",
|
|
|
|
|
|
|
| 112 |
footer:
|
| 113 |
"\u00a9 2025 Codex Proxy\u3002\u4fdd\u7559\u6240\u6709\u6743\u5229\u3002",
|
| 114 |
},
|
|
|
|
| 52 |
networkError: "Network error: ",
|
| 53 |
copied: "Copied!",
|
| 54 |
copyFailed: "Failed",
|
| 55 |
+
refresh: "Refresh",
|
| 56 |
+
updatedAt: "Updated at",
|
| 57 |
footer: "\u00a9 2025 Codex Proxy. All rights reserved.",
|
| 58 |
},
|
| 59 |
zh: {
|
|
|
|
| 111 |
networkError: "\u7f51\u7edc\u9519\u8bef\uff1a",
|
| 112 |
copied: "\u5df2\u590d\u5236\uff01",
|
| 113 |
copyFailed: "\u5931\u8d25",
|
| 114 |
+
refresh: "\u5237\u65b0",
|
| 115 |
+
updatedAt: "\u66f4\u65b0\u4e8e",
|
| 116 |
footer:
|
| 117 |
"\u00a9 2025 Codex Proxy\u3002\u4fdd\u7559\u6240\u6709\u6743\u5229\u3002",
|
| 118 |
},
|