diff --git a/i18n/locales/en.json b/i18n/locales/en.json index e0109bd..15fba20 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -122,6 +122,9 @@ "InvalidSiteError": "Login failed: site is unreachable or invalid. Please check and try again.", "AddAccount": "Add Site", "SwitchSite": "Switch Site", + "RecentSites": "Recent sites", + "ClearRecentSites": "Clear", + "RemoveRecentSite": "Remove", "LoginAuthenticationExpired": "Login authentication has expired. Please log in again", "LoginAuthenticationExpiredDescription": "If there are multiple sites, you will be automatically switched to the next site", "Logout": "Logout", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 7d9fc4e..5b6b519 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -120,6 +120,9 @@ "InvalidSiteError": "登录失败:站点不可用或地址不正确,请检查后重试", "AddAccount": "新增账号", "SwitchSite": "切换站点", + "RecentSites": "最近站点", + "ClearRecentSites": "清空", + "RemoveRecentSite": "移除", "LoginAuthenticationExpired": "登录认证已失效,请重新登录", "LoginAuthenticationExpiredDescription": "若存在多个站点,将会自动为您自动切换到下一个站点", "Logout": "退出登录", diff --git a/ui/components/SideBar/profile.vue b/ui/components/SideBar/profile.vue index b236be1..9c71eed 100644 --- a/ui/components/SideBar/profile.vue +++ b/ui/components/SideBar/profile.vue @@ -5,6 +5,7 @@ import type { LangType, ThemeType, UserData } from "~/types/index"; import { useSettingManager } from "~/composables/useSettingManager"; import { useUserInfoStore } from "~/store/modules/userInfo"; +import RecentSites from "./RecentSites.vue"; interface VersionAlertPayload { type: string; @@ -13,6 +14,8 @@ interface VersionAlertPayload { const props = defineProps<{ collapse: boolean }>(); +const recentSiteLimit = 5; + const toast = useToast(); const appConfig = useAppConfig(); const localePath = useLocalePath(); @@ -21,7 +24,16 @@ const userInfoStore = useUserInfoStore(); const { t, locales, locale } = useI18n(); const { loggedIn, currentSite, userMap, currentUser } = storeToRefs(userInfoStore); -const { setLang, theme, themeMode, primaryColorLight, primaryColorDark } = useSettingManager(); +const { + setLang, + theme, + themeMode, + primaryColorLight, + primaryColorDark, + recentSites, + setRecentSites, + hydrationPromise +} = useSettingManager(); const { manualSetTheme, enableFollowSystem, followSystem, userTheme } = useThemeAdapter(); const { applyPrimaryColor } = useColor(); @@ -30,15 +42,45 @@ const errorMessage = ref(""); const loginBtn = ref(false); const openModal = ref(false); const hasValidationError = ref(false); -let loginBtnUnlockTimer: ReturnType | null = null; +const recentSitesDismissed = ref(false); const unlistenErrorPageRef = ref(null); const unlistenLoginFailedRef = ref(null); const inputRef = ref(null); +let loginBtnUnlockTimer: ReturnType | null = null; + useEventBus().on("login", openLoginPage); const normalizedInputSite = computed(() => normalizeSite(inputSite.value)); +const normalizedRecentSites = computed(() => { + const raw = Array.isArray(recentSites.value) ? recentSites.value : []; + const normalized: string[] = []; + const seen = new Set(); + + for (const site of raw) { + const value = normalizeSite(site); + if (!value || seen.has(value)) continue; + seen.add(value); + normalized.push(value); + if (normalized.length >= recentSiteLimit) break; + } + + return normalized; +}); + +const filteredRecentSites = computed(() => { + const query = normalizeSite(inputSite.value).toLowerCase(); + const list = normalizedRecentSites.value; + if (!query) return list; + + return list.filter((site) => site.toLowerCase().includes(query)); +}); + +const showRecentSites = computed( + () => openModal.value && !recentSitesDismissed.value && filteredRecentSites.value.length > 0 +); + const clearLoginBtnUnlockTimer = () => { if (loginBtnUnlockTimer) { clearTimeout(loginBtnUnlockTimer); @@ -208,6 +250,65 @@ function normalizeSite(value: string): string { return s.replace(/\/+$/, ""); } +const ensureRecentSitesReady = async () => { + if (hydrationPromise.value) { + await hydrationPromise.value; + } +}; + +const saveRecentSite = async (site: string) => { + try { + const normalized = normalizeSite(site); + if (!normalized) return; + + await ensureRecentSitesReady(); + const next = [normalized, ...normalizedRecentSites.value.filter((item) => item !== normalized)].slice( + 0, + recentSiteLimit + ); + setRecentSites(next); + } catch (err) { + console.error("save recent sites failed", err); + } +}; + +const removeRecentSite = async (site: string) => { + try { + const normalized = normalizeSite(site); + if (!normalized) return; + + await ensureRecentSitesReady(); + const next = normalizedRecentSites.value.filter((item) => item !== normalized); + setRecentSites(next); + } catch (err) { + console.error("remove recent site failed", err); + } +}; + +const clearRecentSites = async () => { + try { + await ensureRecentSitesReady(); + setRecentSites([]); + } catch (err) { + console.error("clear recent sites failed", err); + } +}; + +const selectRecentSite = (site: string) => { + inputSite.value = site; + clearValidationError(); + recentSitesDismissed.value = true; + nextTick(() => { + inputRef.value?.$el?.querySelector("input")?.focus(); + }); +}; + +const handleClearInput = () => { + inputSite.value = ""; + clearValidationError(); + recentSitesDismissed.value = false; +}; + function normalizeVersionMessage(raw: string) { if (raw === "incompatible") { return { status: "incompatible" as const, versions: [] as string[] }; @@ -294,6 +395,7 @@ const checkVersionBeforeOAuth = async (site: string) => { */ function openLoginPage() { openModal.value = true; + recentSitesDismissed.value = false; hasValidationError.value = false; errorMessage.value = ""; nextTick(() => { @@ -395,6 +497,7 @@ const handleInputSanitize = (event: Event) => { } inputSite.value = sanitized; + recentSitesDismissed.value = false; clearValidationError(); }; @@ -404,6 +507,7 @@ const handleInputSanitize = (event: Event) => { */ const handleClipboard = (value: string) => { inputSite.value = normalizeSite(value); + recentSitesDismissed.value = false; }; /** @@ -458,6 +562,7 @@ const handleConfirm = async () => { await useTauriCoreInvoke("auth_login", { site: normalizedSite }); + void saveRecentSite(normalizedSite); } catch (e: any) { const raw = (e?.message || e || "").toString(); const looksLikeSiteIssue = [ @@ -622,13 +727,7 @@ onBeforeUnmount(() => { - + {{ t("Common.Login") }} @@ -668,14 +767,19 @@ onBeforeUnmount(() => { size="sm" icon="i-lucide-circle-x" aria-label="Clear input" - @click=" - inputSite = ''; - clearValidationError(); - " + @click="handleClearInput" /> + +
{{ errorMessage }}
diff --git a/ui/components/SideBar/recentSites.vue b/ui/components/SideBar/recentSites.vue new file mode 100644 index 0000000..bc2728a --- /dev/null +++ b/ui/components/SideBar/recentSites.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/ui/composables/useSettingManager.ts b/ui/composables/useSettingManager.ts index fbf3466..911dbfd 100644 --- a/ui/composables/useSettingManager.ts +++ b/ui/composables/useSettingManager.ts @@ -225,6 +225,11 @@ export const useSettingManager = () => { persist({ rdpSmartSize: state.rdpSmartSize }); }; + const setRecentSites = (sites: string[]) => { + state.recentSites = Array.isArray(sites) ? [...sites] : []; + persist({ recentSites: state.recentSites }); + }; + return { ...toRefs(state), @@ -248,6 +253,7 @@ export const useSettingManager = () => { setKeyboardLayoutPreference, setRdpClientOptionPreference, setRdpColorQualityPreference, - setRdpSmartSizePreference + setRdpSmartSizePreference, + setRecentSites }; }; diff --git a/ui/composables/useSettingStorage.ts b/ui/composables/useSettingStorage.ts index 0c856d9..0313d2c 100644 --- a/ui/composables/useSettingStorage.ts +++ b/ui/composables/useSettingStorage.ts @@ -26,6 +26,7 @@ export interface UserSettingPersistedState { rdpClientOption: string[] rdpColorQuality: string rdpSmartSize: string + recentSites: string[] } const STORE_PATH = "user-setting.json"; @@ -50,7 +51,8 @@ const DEFAULT_STATE: UserSettingPersistedState = { keyboardLayout: "en-us-qwerty", rdpClientOption: [], rdpColorQuality: "32", - rdpSmartSize: "0" + rdpSmartSize: "0", + recentSites: [] }; let storeInstance: Store | null = null;