diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index c917b22c4..3cbe54d85 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -523,6 +523,8 @@ const en: Messages = { 'proxyProfiles.action.add': 'Add Proxy', 'proxyProfiles.search.placeholder': 'Search proxies...', + 'proxyProfiles.section.proxies': 'Proxies', + 'proxyProfiles.count.items': '{count} items', 'proxyProfiles.empty.title': 'No Proxies', 'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.', 'proxyProfiles.usage': '{count} linked', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index ff5033dc0..cbcd5310f 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -332,6 +332,8 @@ const zhCN: Messages = { 'proxyProfiles.action.add': '添加代理', 'proxyProfiles.search.placeholder': '搜索代理...', + 'proxyProfiles.section.proxies': '代理', + 'proxyProfiles.count.items': '{count} 项', 'proxyProfiles.empty.title': '暂无代理', 'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。', 'proxyProfiles.usage': '已关联 {count} 处', diff --git a/components/ProxyProfilesManager.test.tsx b/components/ProxyProfilesManager.test.tsx new file mode 100644 index 000000000..7a7264295 --- /dev/null +++ b/components/ProxyProfilesManager.test.tsx @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "../application/i18n/I18nProvider.tsx"; +import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE } from "../infrastructure/config/storageKeys.ts"; +import type { ProxyProfile } from "../types.ts"; +import { ProxyProfilesManager } from "./ProxyProfilesManager.tsx"; + +const proxyProfile: ProxyProfile = { + id: "proxy-1", + label: "Office Proxy", + config: { + type: "http", + host: "127.0.0.1", + port: 8080, + }, + createdAt: 1, +}; + +const installStorageStub = (viewMode: string | null = null) => { + const values = new Map(); + if (viewMode) { + values.set(STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, viewMode); + } + + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + }, + }); +}; + +const renderManager = (viewMode: string | null = null) => { + installStorageStub(viewMode); + return renderToStaticMarkup( + React.createElement( + I18nProvider, + { locale: "en" }, + React.createElement(ProxyProfilesManager, { + proxyProfiles: [proxyProfile], + hosts: [], + groupConfigs: [], + onUpdateProxyProfiles: () => {}, + onUpdateHosts: () => {}, + onUpdateGroupConfigs: () => {}, + }), + ), + ); +}; + +test("ProxyProfilesManager uses the shared Vault grid card style by default", () => { + const markup = renderManager(); + + assert.match(markup, /soft-card elevate rounded-xl h-\[68px\] px-3 py-2/); + assert.match(markup, /Office Proxy/); + assert.match(markup, /127\.0\.0\.1:8080/); +}); + +test("ProxyProfilesManager uses the shared Vault list row style when persisted", () => { + const markup = renderManager("list"); + + assert.match(markup, /h-14 px-3 py-2 hover:bg-secondary\/60 rounded-lg transition-colors/); + assert.doesNotMatch(markup, /soft-card elevate rounded-xl h-\[68px\] px-3 py-2/); +}); diff --git a/components/ProxyProfilesManager.tsx b/components/ProxyProfilesManager.tsx index 1601a3572..0658474f9 100644 --- a/components/ProxyProfilesManager.tsx +++ b/components/ProxyProfilesManager.tsx @@ -1,7 +1,23 @@ -import { AlertTriangle, Check, Copy, Edit2, Globe, Plus, Search, Trash2 } from "lucide-react"; +import { + AlertTriangle, + Check, + ChevronDown, + Copy, + Globe, + LayoutGrid, + List as ListIcon, + Pencil, + Plus, + Search, + Trash2, +} from "lucide-react"; import React, { useMemo, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; +import { useStoredViewMode } from "../application/state/useStoredViewMode"; import { removeProxyProfileReferences } from "../domain/proxyProfiles"; +import { + STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, +} from "../infrastructure/config/storageKeys"; import { cn } from "../lib/utils"; import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "../types"; import { @@ -12,6 +28,13 @@ import { import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Card } from "./ui/card"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "./ui/context-menu"; import { Dialog, DialogContent, @@ -20,6 +43,7 @@ import { DialogHeader, DialogTitle, } from "./ui/dialog"; +import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown"; import { Input } from "./ui/input"; import { toast } from "./ui/toast"; @@ -55,6 +79,97 @@ const getProfileUsageCount = ( hosts.filter((host) => host.proxyProfileId === profileId).length + groupConfigs.filter((config) => config.proxyProfileId === profileId).length; +type ProxyProfilesViewMode = "grid" | "list"; + +interface ProxyProfileCardProps { + profile: ProxyProfile; + usageCount: number; + viewMode: ProxyProfilesViewMode; + isSelected: boolean; + onClick: () => void; + onEdit: () => void; + onDuplicate: () => void; + onDelete: () => void; +} + +const ProxyProfileCard: React.FC = ({ + profile, + usageCount, + viewMode, + isSelected, + onClick, + onEdit, + onDuplicate, + onDelete, +}) => { + const { t } = useI18n(); + + return ( + + +
+
+
+ +
+
+
+
{profile.label}
+ + {profile.config.type.toUpperCase()} + +
+
+ {profile.config.host}:{profile.config.port} -{" "} + {t("proxyProfiles.usage", { count: usageCount })} +
+
+
+ {viewMode === "list" && ( + + )} +
+
+
+
+ + + + {t("action.edit")} + + + + {t("action.duplicate")} + + + + + {t("action.delete")} + + +
+ ); +}; + export const ProxyProfilesManager: React.FC = ({ proxyProfiles, hosts, @@ -65,6 +180,12 @@ export const ProxyProfilesManager: React.FC = ({ }) => { const { t } = useI18n(); const [search, setSearch] = useState(""); + const [viewMode, setViewMode] = useStoredViewMode( + STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, + "grid", + ); + const proxyProfilesViewMode: ProxyProfilesViewMode = + viewMode === "list" ? "list" : "grid"; const [draft, setDraft] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -170,96 +291,109 @@ export const ProxyProfilesManager: React.FC = ({ return (
-
-
- - setSearch(event.target.value)} - placeholder={t("proxyProfiles.search.placeholder")} - className="h-10 pl-9" - /> -
-
- -
-
- -
- {filteredProfiles.length === 0 ? ( -
-
- +
+
+ + setSearch(event.target.value)} + placeholder={t("proxyProfiles.search.placeholder")} + className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm" + />
-

- {t("proxyProfiles.empty.title")} -

-

- {t("proxyProfiles.empty.desc")} -

- -
- ) : ( -
- {filteredProfiles.map((profile) => { - const usageCount = usageByProfileId.get(profile.id) ?? 0; - return ( - + + -
- - - -
-
- ); - })} + {proxyProfilesViewMode === "grid" ? ( + + ) : ( + + )} + + + + + + + +
- )} +
+ + +
+
+
+

+ {t("proxyProfiles.section.proxies")} +

+ + {t("proxyProfiles.count.items", { count: filteredProfiles.length })} + +
+ + {filteredProfiles.length === 0 ? ( +
+
+ +
+

+ {t("proxyProfiles.empty.title")} +

+

+ {t("proxyProfiles.empty.desc")} +

+ +
+ ) : ( +
+ {filteredProfiles.map((profile) => ( + openEdit(profile)} + onEdit={() => openEdit(profile)} + onDuplicate={() => duplicateProfile(profile)} + onDelete={() => setDeleteTarget(profile)} + /> + ))} +
+ )} +
diff --git a/infrastructure/config/storageKeys.ts b/infrastructure/config/storageKeys.ts index 5570ac835..277e02b8b 100644 --- a/infrastructure/config/storageKeys.ts +++ b/infrastructure/config/storageKeys.ts @@ -33,6 +33,7 @@ export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1'; export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1'; export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1'; +export const STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE = 'netcatty_vault_proxy_profiles_view_mode_v1'; export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1'; export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';