diff --git a/ui/src/hooks/index.ts b/ui/src/hooks/index.ts index 6f1547012..612cde271 100644 --- a/ui/src/hooks/index.ts +++ b/ui/src/hooks/index.ts @@ -1,8 +1,9 @@ import useAntdForm from "./useAntdForm"; import useAntdFormName from "./useAntdFormName"; +import useAppSettings from "./useAppSettings"; import useBrowserTheme from "./useBrowserTheme"; import useTriggerElement from "./useTriggerElement"; import useVersionChecker from "./useVersionChecker"; import useZustandShallowSelector from "./useZustandShallowSelector"; -export { useAntdForm, useAntdFormName, useBrowserTheme, useTriggerElement, useVersionChecker, useZustandShallowSelector }; +export { useAntdForm, useAntdFormName, useAppSettings, useBrowserTheme, useTriggerElement, useVersionChecker, useZustandShallowSelector }; diff --git a/ui/src/hooks/useAppSettings.ts b/ui/src/hooks/useAppSettings.ts new file mode 100644 index 000000000..4f203e8b3 --- /dev/null +++ b/ui/src/hooks/useAppSettings.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import { useLocalStorageState } from "ahooks"; + +const LOCAL_STORAGE_KEY = "certimate-ui-appsettings"; +if (!localStorage.getItem(LOCAL_STORAGE_KEY)) { + localStorage.setItem(LOCAL_STORAGE_KEY, "{}"); +} + +type AppSettings = { + // 每页显示的默认条目数 + defaultPerPage?: number; +}; + +export type UseAppSettingsReturns = { + appSettings: AppSettings; + setAppSettings: (value: AppSettings) => void; + resetAppSettings: () => void; +}; + +/** + * 获取并设置当前应用全局配置。 + * @returns {UseAppSettingsReturns} + */ +const useAppSettings = (): UseAppSettingsReturns => { + const [state, setState] = useLocalStorageState(LOCAL_STORAGE_KEY, { + defaultValue: {}, + }); + state.defaultPerPage ??= 15; + + const resetState = useCallback(() => { + localStorage.removeItem(LOCAL_STORAGE_KEY); + setState({}); + }, [setState]); + + return { + appSettings: state, + setAppSettings: setState, + resetAppSettings: resetState, + }; +}; + +export default useAppSettings; diff --git a/ui/src/i18n/locales/en/nls.settings.json b/ui/src/i18n/locales/en/nls.settings.json index b484db88f..cb87e7d01 100644 --- a/ui/src/i18n/locales/en/nls.settings.json +++ b/ui/src/i18n/locales/en/nls.settings.json @@ -25,6 +25,10 @@ "settings.appearance.theme.form.value.extra": "Reload the page for the change to take effect.", "settings.appearance.language.title": "Language", "settings.appearance.language.form.value.extra": "Reload the page for the change to take effect.", + "settings.appearance.pagination.title": "Pagination", + "settings.appearance.pagination.form.default_per_page.label": "Default items per page", + "settings.appearance.pagination.form.default_per_page.placeholder": "Please enter default items per page", + "settings.appearance.pagination.form.default_per_page.unit": "items per page", "settings.sslprovider.tab": "Certificate authority", "settings.sslprovider.ca.title": "System-wide CA", diff --git a/ui/src/i18n/locales/zh/nls.settings.json b/ui/src/i18n/locales/zh/nls.settings.json index bec04b3ea..6b5148d02 100644 --- a/ui/src/i18n/locales/zh/nls.settings.json +++ b/ui/src/i18n/locales/zh/nls.settings.json @@ -25,6 +25,10 @@ "settings.appearance.theme.form.value.extra": "重新加载页面生效。", "settings.appearance.language.title": "语言", "settings.appearance.language.form.value.extra": "重新加载页面生效。", + "settings.appearance.pagination.title": "分页", + "settings.appearance.pagination.form.default_per_page.label": "列表页默认显示数量", + "settings.appearance.pagination.form.default_per_page.placeholder": "请输入列表页默认显示数量", + "settings.appearance.pagination.form.default_per_page.unit": "条每页", "settings.sslprovider.tab": "证书颁发机构", "settings.sslprovider.ca.title": "全局证书颁发机构", diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index df0967171..3b098fb3b 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -11,32 +11,34 @@ import AccessEditDrawer, { type AccessEditDrawerProps } from "@/components/acces import Empty from "@/components/Empty"; import { type AccessModel } from "@/domain/access"; import { ACCESS_USAGES, accessProvidersMap } from "@/domain/provider"; -import { useZustandShallowSelector } from "@/hooks"; +import { useAppSettings, useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; import { getErrMsg } from "@/utils/error"; -type AccessUsageProp = AccessEditDrawerProps["usage"]; +type AccessUsages = AccessEditDrawerProps["usage"]; const AccessList = () => { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(); const { modal, notification } = App.useApp(); + const { appSettings: globalAppSettings } = useAppSettings(); + const { accesses, loadedAtOnce, fetchAccesses, deleteAccess } = useAccessesStore( useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses", "deleteAccess"]) ); const [filters, setFilters] = useState>(() => { return { - usage: "dns-hosting" satisfies AccessUsageProp, + usage: searchParams.get("usage") || ("dns-hosting" satisfies AccessUsages), keyword: searchParams.get("keyword"), }; }); const [page, setPage] = useState(() => parseInt(+searchParams.get("page")! + "") || 1); - const [pageSize, setPageSize] = useState(() => parseInt(+searchParams.get("perPage")! + "") || 15); + const [pageSize, setPageSize] = useState(() => parseInt(+searchParams.get("perPage")! + "") || globalAppSettings.defaultPerPage!); const tableColumns: TableProps["columns"] = [ { @@ -144,7 +146,11 @@ const AccessList = () => { }) .filter((e) => { const provider = accessProvidersMap.get(e.provider); - switch (filters["usage"] as AccessUsageProp) { + switch (filters["usage"] as AccessUsages) { + case "dns": + return !e.reserve && provider?.usages?.includes(ACCESS_USAGES.DNS); + case "hosting": + return !e.reserve && provider?.usages?.includes(ACCESS_USAGES.HOSTING); case "dns-hosting": return !e.reserve && (provider?.usages?.includes(ACCESS_USAGES.DNS) || provider?.usages?.includes(ACCESS_USAGES.HOSTING)); case "ca": @@ -160,6 +166,22 @@ const AccessList = () => { }, { refreshDeps: [accesses, filters, page, pageSize], + onBefore: () => { + setSearchParams((prev) => { + if (filters["keyword"]) { + prev.set("keyword", filters["keyword"] as string); + } else { + prev.delete("keyword"); + } + + prev.set("usage", filters["usage"] as string); + + prev.set("page", page.toString()); + prev.set("perPage", pageSize.toString()); + + return prev; + }); + }, onSuccess: (res) => { setTableData(res.items); setTableTotal(res.totalItems); @@ -183,6 +205,11 @@ const AccessList = () => { fetchAccesses(); }; + const handlePaginationChange = (page: number, pageSize: number) => { + setPage(page); + setPageSize(pageSize); + }; + const handleCreateClick = () => { navigate(`/accesses/new?usage=${filters["usage"]}`); }; @@ -302,14 +329,8 @@ const AccessList = () => { pageSize: pageSize, total: tableTotal, showSizeChanger: true, - onChange: (page: number, pageSize: number) => { - setPage(page); - setPageSize(pageSize); - }, - onShowSizeChange: (page: number, pageSize: number) => { - setPage(page); - setPageSize(pageSize); - }, + onChange: handlePaginationChange, + onShowSizeChange: handlePaginationChange, }} rowClassName="cursor-pointer" rowKey={(record) => record.id} @@ -321,7 +342,7 @@ const AccessList = () => { })} /> - + ); diff --git a/ui/src/pages/certificates/CertificateList.tsx b/ui/src/pages/certificates/CertificateList.tsx index 665b14e0d..c44058928 100644 --- a/ui/src/pages/certificates/CertificateList.tsx +++ b/ui/src/pages/certificates/CertificateList.tsx @@ -10,17 +10,20 @@ import { ClientResponseError } from "pocketbase"; import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer"; import Empty from "@/components/Empty"; import { type CertificateModel } from "@/domain/certificate"; +import { useAppSettings } from "@/hooks"; import { list as listCertificates, type ListRequest as listCertificatesRequest, remove as removeCertificate } from "@/repository/certificate"; import { getErrMsg } from "@/utils/error"; const CertificateList = () => { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(); const { modal, notification } = App.useApp(); + const { appSettings: globalAppSettings } = useAppSettings(); + const [filters, setFilters] = useState>(() => { return { keyword: searchParams.get("keyword"), @@ -31,7 +34,7 @@ const CertificateList = () => { return {}; }); const [page, setPage] = useState(() => parseInt(+searchParams.get("page")! + "") || 1); - const [pageSize, setPageSize] = useState(() => parseInt(+searchParams.get("perPage")! + "") || 15); + const [pageSize, setPageSize] = useState(() => parseInt(+searchParams.get("perPage")! + "") || globalAppSettings.defaultPerPage!); const tableColumns: TableProps["columns"] = [ { @@ -186,6 +189,26 @@ const CertificateList = () => { }, { refreshDeps: [filters, sorter, page, pageSize], + onBefore: () => { + setSearchParams((prev) => { + if (filters["keyword"]) { + prev.set("keyword", filters["keyword"] as string); + } else { + prev.delete("keyword"); + } + + if (filters["state"]) { + prev.set("state", filters["state"] as string); + } else { + prev.delete("state"); + } + + prev.set("page", page.toString()); + prev.set("perPage", pageSize.toString()); + + return prev; + }); + }, onSuccess: (res) => { setTableData(res.items); setTableTotal(res.totalItems); @@ -214,6 +237,11 @@ const CertificateList = () => { refreshData(); }; + const handlePaginationChange = (page: number, pageSize: number) => { + setPage(page); + setPageSize(pageSize); + }; + const [detailRecord, setDetailRecord] = useState(); const [detailOpen, setDetailOpen] = useState(false); @@ -320,14 +348,8 @@ const CertificateList = () => { pageSize: pageSize, total: tableTotal, showSizeChanger: true, - onChange: (page: number, pageSize: number) => { - setPage(page); - setPageSize(pageSize); - }, - onShowSizeChange: (page: number, pageSize: number) => { - setPage(page); - setPageSize(pageSize); - }, + onChange: handlePaginationChange, + onShowSizeChange: handlePaginationChange, }} rowClassName="cursor-pointer" rowKey={(record) => record.id} diff --git a/ui/src/pages/settings/SettingsAccount.tsx b/ui/src/pages/settings/SettingsAccount.tsx index f7baedcac..acfe71022 100644 --- a/ui/src/pages/settings/SettingsAccount.tsx +++ b/ui/src/pages/settings/SettingsAccount.tsx @@ -15,16 +15,12 @@ const SettingsAccount = () => { return ( <>

{t("settings.account.username.title")}

-
- -
+

{t("settings.account.password.title")}

-
- -
+ diff --git a/ui/src/pages/settings/SettingsAppearance.tsx b/ui/src/pages/settings/SettingsAppearance.tsx index e22c0403e..52c46b87c 100644 --- a/ui/src/pages/settings/SettingsAppearance.tsx +++ b/ui/src/pages/settings/SettingsAppearance.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Divider, Form, Radio, type RadioChangeEvent, Select, theme } from "antd"; +import { produce } from "immer"; import { useAppLocaleMenuItems } from "@/components/AppLocale"; import { useAppThemeMenuItems } from "@/components/AppTheme"; -import { useBrowserTheme } from "@/hooks"; +import { useAppSettings, useBrowserTheme } from "@/hooks"; const SettingsAppearance = () => { const { t } = useTranslation(); @@ -12,16 +13,17 @@ const SettingsAppearance = () => { return ( <>

{t("settings.appearance.theme.title")}

-
- -
+

{t("settings.appearance.language.title")}

-
- -
+ + + + +

{t("settings.appearance.pagination.title")}

+ ); }; @@ -35,7 +37,7 @@ const SettingsAppearanceTheme = ({ className, style }: { className?: string; sty const themeItems = useAppThemeMenuItems(); const [themeChanged, setThemeChanged] = useState(false); - const handleThemeChange = (e: RadioChangeEvent) => { + const handleChange = (e: RadioChangeEvent) => { if (e.target.value !== themeMode) { setThemeChanged(true); setThemeMode(e.target.value); @@ -46,7 +48,7 @@ const SettingsAppearanceTheme = ({ className, style }: { className?: string; sty
- +
{themeItems.map((item) => (
@@ -72,7 +74,7 @@ const SettingsAppearanceLanguage = ({ className, style }: { className?: string; const localeItems = useAppLocaleMenuItems(); const [localeChanged, setLocaleChanged] = useState(false); - const handleLocaleChange = (value: string) => { + const handleChange = (value: string) => { if (value !== i18n.language) { setLocaleChanged(true); i18n.changeLanguage(value); @@ -90,7 +92,40 @@ const SettingsAppearanceLanguage = ({ className, style }: { className?: string; label: item.label, }))} value={i18n.language} - onChange={handleLocaleChange} + onChange={handleChange} + /> + + +
+ ); +}; + +const SettingsPaginationLanguage = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { + const { t } = useTranslation(); + + const { appSettings: globalAppSettings, setAppSettings: setGlobalAppSettings } = useAppSettings(); + + const handleChange = (value: number) => { + setGlobalAppSettings( + produce(globalAppSettings, (draft) => { + draft.defaultPerPage = value; + }) + ); + }; + + return ( +
+
+ +