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 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-end justify-between">
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
- } catch (err) {
 
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
  },