From 9b3a91daaeb4d23d400eec529450474de2fd013e Mon Sep 17 00:00:00 2001 From: lsy Date: Mon, 23 Feb 2026 00:14:13 +0800 Subject: [PATCH] feat(admin-ui): add sites search and sort --- apps/ui/src/App.tsx | 40 ++++++++- apps/ui/src/core/sites.ts | 109 +++++++++++++++++++++++ apps/ui/src/features/SitesView.tsx | 134 ++++++++++++++++++++--------- helloagents/CHANGELOG.md | 8 ++ helloagents/modules/admin-ui.md | 1 + 5 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 apps/ui/src/core/sites.ts diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 14f363a..93f40f5 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -13,6 +13,11 @@ import { initialSiteForm, tabs, } from "./core/constants"; +import { + filterSites, + sortSites, + type SiteSortState, +} from "./core/sites"; import type { AdminData, CheckinSummary, @@ -97,6 +102,11 @@ const App = () => { useState(initialSettingsForm); const [sitePage, setSitePage] = useState(1); const [sitePageSize, setSitePageSize] = useState(10); + const [siteSearch, setSiteSearch] = useState(""); + const [siteSort, setSiteSort] = useState({ + key: "name", + direction: "asc", + }); const [tokenPage, setTokenPage] = useState(1); const [tokenPageSize, setTokenPageSize] = useState(10); const [editingSite, setEditingSite] = useState(null); @@ -286,6 +296,14 @@ const App = () => { setSitePage(1); }, []); + const handleSiteSearchChange = useCallback((next: string) => { + setSiteSearch(next); + }, []); + + const handleSiteSortChange = useCallback((next: SiteSortState) => { + setSiteSort(next); + }, []); + const handleTokenPageChange = useCallback((next: number) => { setTokenPage(next); }, []); @@ -656,15 +674,23 @@ const App = () => { } }, [loadUsage]); - const siteTotal = data.sites.length; + const filteredSites = useMemo( + () => filterSites(data.sites, siteSearch), + [data.sites, siteSearch], + ); + const sortedSites = useMemo( + () => sortSites(filteredSites, siteSort), + [filteredSites, siteSort], + ); + const siteTotal = sortedSites.length; const siteTotalPages = useMemo( () => Math.max(1, Math.ceil(siteTotal / sitePageSize)), [siteTotal, sitePageSize], ); const pagedSites = useMemo(() => { const start = (sitePage - 1) * sitePageSize; - return data.sites.slice(start, start + sitePageSize); - }, [sitePage, sitePageSize, data.sites]); + return sortedSites.slice(start, start + sitePageSize); + }, [sitePage, sitePageSize, sortedSites]); const tokenTotal = data.tokens.length; const tokenTotalPages = useMemo( () => Math.max(1, Math.ceil(tokenTotal / tokenPageSize)), @@ -679,6 +705,10 @@ const App = () => { setSitePage((prev) => Math.min(prev, siteTotalPages)); }, [siteTotalPages]); + useEffect(() => { + setSitePage(1); + }, [siteSearch, siteSort.key, siteSort.direction]); + useEffect(() => { setTokenPage((prev) => Math.min(prev, tokenTotalPages)); }, [tokenTotalPages]); @@ -712,6 +742,8 @@ const App = () => { isSiteModalOpen={isSiteModalOpen} summary={checkinSummary} lastRun={checkinLastRun} + siteSearch={siteSearch} + siteSort={siteSort} onCreate={openSiteCreate} onCloseModal={closeSiteModal} onEdit={startSiteEdit} @@ -721,6 +753,8 @@ const App = () => { onDelete={handleSiteDelete} onPageChange={handleSitePageChange} onPageSizeChange={handleSitePageSizeChange} + onSearchChange={handleSiteSearchChange} + onSortChange={handleSiteSortChange} onFormChange={handleSiteFormChange} onRunAll={handleCheckinRunAll} /> diff --git a/apps/ui/src/core/sites.ts b/apps/ui/src/core/sites.ts new file mode 100644 index 0000000..0446fdd --- /dev/null +++ b/apps/ui/src/core/sites.ts @@ -0,0 +1,109 @@ +import type { Site } from "./types"; +import { getBeijingDateString } from "./utils"; + +export type SiteSortKey = + | "name" + | "type" + | "status" + | "weight" + | "tokens" + | "checkin"; + +export type SiteSortDirection = "asc" | "desc"; + +export type SiteSortState = { + key: SiteSortKey; + direction: SiteSortDirection; +}; + +export const SITE_TYPE_LABELS: Record = { + "new-api": "New API", + "done-hub": "Done Hub", + subapi: "Sub API", + chatgpt: "ChatGPT", + claude: "Claude", + gemini: "Gemini", +}; + +export const getSiteTypeLabel = (siteType: Site["site_type"]) => + SITE_TYPE_LABELS[siteType] ?? siteType; + +export const getSiteStatusLabel = (status: string) => + status === "active" ? "启用" : "禁用"; + +export const getSiteCheckinLabel = (site: Site, today?: string) => { + const shouldShow = + site.site_type === "new-api" && Boolean(site.checkin_enabled); + if (!shouldShow) { + return "-"; + } + const day = today ?? getBeijingDateString(); + const isToday = site.last_checkin_date === day; + const status = isToday ? site.last_checkin_status : null; + if (!status) { + return "未签到"; + } + if (status === "success") { + return "成功"; + } + if (status === "skipped") { + return "已签"; + } + return "签到失败"; +}; + +export const filterSites = (sites: Site[], query: string) => { + const keyword = query.trim().toLowerCase(); + if (!keyword) { + return sites; + } + return sites.filter((site) => { + const name = String(site.name ?? "").toLowerCase(); + const url = String(site.base_url ?? "").toLowerCase(); + return name.includes(keyword) || url.includes(keyword); + }); +}; + +const toSortableText = (value: string) => value.trim().toLowerCase(); + +const getSortValue = (site: Site, key: SiteSortKey, today: string) => { + switch (key) { + case "name": + return String(site.name ?? ""); + case "type": + return getSiteTypeLabel(site.site_type); + case "status": + return getSiteStatusLabel(site.status); + case "weight": + return Number(site.weight ?? 0); + case "tokens": + return Number(site.call_tokens?.length ?? 0); + case "checkin": + return getSiteCheckinLabel(site, today); + default: + return ""; + } +}; + +export const sortSites = (sites: Site[], sort: SiteSortState) => { + const today = getBeijingDateString(); + const items = sites.map((site, index) => { + const raw = getSortValue(site, sort.key, today); + const value = + typeof raw === "number" ? raw : toSortableText(String(raw ?? "")); + return { site, index, value }; + }); + items.sort((left, right) => { + if (left.value === right.value) { + return left.index - right.index; + } + if (typeof left.value === "number" && typeof right.value === "number") { + return sort.direction === "asc" + ? left.value - right.value + : right.value - left.value; + } + const comparison = String(left.value).localeCompare(String(right.value)); + return sort.direction === "asc" ? comparison : -comparison; + }); + return items.map((item) => item.site); +}; diff --git a/apps/ui/src/features/SitesView.tsx b/apps/ui/src/features/SitesView.tsx index ec01d51..a52926c 100644 --- a/apps/ui/src/features/SitesView.tsx +++ b/apps/ui/src/features/SitesView.tsx @@ -1,4 +1,10 @@ import type { CheckinSummary, Site, SiteForm } from "../core/types"; +import { + getSiteCheckinLabel, + getSiteTypeLabel, + type SiteSortKey, + type SiteSortState, +} from "../core/sites"; import { buildPageItems, formatDateTime, @@ -16,6 +22,8 @@ type SitesViewProps = { isSiteModalOpen: boolean; summary: CheckinSummary | null; lastRun: string | null; + siteSearch: string; + siteSort: SiteSortState; onCreate: () => void; onCloseModal: () => void; onEdit: (site: Site) => void; @@ -25,20 +33,21 @@ type SitesViewProps = { onDelete: (id: string) => void; onPageChange: (next: number) => void; onPageSizeChange: (next: number) => void; + onSearchChange: (next: string) => void; + onSortChange: (next: SiteSortState) => void; onFormChange: (patch: Partial) => void; onRunAll: () => void; }; const pageSizeOptions = [10, 20, 50]; - -const typeLabelMap: Record = { - "new-api": "New API", - "done-hub": "Done Hub", - subapi: "Sub API", - chatgpt: "ChatGPT", - claude: "Claude", - gemini: "Gemini", -}; +const sortableColumns: Array<{ key: SiteSortKey; label: string }> = [ + { key: "name", label: "站点" }, + { key: "type", label: "类型" }, + { key: "status", label: "状态" }, + { key: "weight", label: "权重" }, + { key: "tokens", label: "令牌" }, + { key: "checkin", label: "签到" }, +]; export const SitesView = ({ siteForm, @@ -51,6 +60,8 @@ export const SitesView = ({ isSiteModalOpen, summary, lastRun, + siteSearch, + siteSort, onCreate, onCloseModal, onEdit, @@ -60,6 +71,8 @@ export const SitesView = ({ onDelete, onPageChange, onPageSizeChange, + onSearchChange, + onSortChange, onFormChange, onRunAll, }: SitesViewProps) => { @@ -98,6 +111,22 @@ export const SitesView = ({ const next = siteForm.call_tokens.filter((_, idx) => idx !== index); onFormChange({ call_tokens: next }); }; + const toggleSort = (key: SiteSortKey) => { + if (siteSort.key === key) { + onSortChange({ + key, + direction: siteSort.direction === "asc" ? "desc" : "asc", + }); + return; + } + onSortChange({ key, direction: "asc" }); + }; + const sortIndicator = (key: SiteSortKey) => { + if (siteSort.key !== key) { + return "↕"; + } + return siteSort.direction === "asc" ? "▲" : "▼"; + }; return (
@@ -135,6 +164,37 @@ export const SitesView = ({
+
+ +
+ {sortableColumns.map((column) => ( + + ))} +
+
{pagedSites.length === 0 ? ( @@ -145,7 +205,6 @@ export const SitesView = ({ pagedSites.map((site) => { const isActive = site.status === "active"; const isToday = site.last_checkin_date === today; - const status = isToday ? site.last_checkin_status : null; const message = isToday ? site.last_checkin_message : null; const systemReady = Boolean( site.system_token && site.system_userid, @@ -183,12 +242,12 @@ export const SitesView = ({ {isActive ? "启用" : "禁用"}
-
- 类型 - - {typeLabelMap[site.site_type]} - -
+
+ 类型 + + {getSiteTypeLabel(site.site_type)} + +
权重 @@ -211,15 +270,7 @@ export const SitesView = ({

今日签到

- {!showCheckin - ? "-" - : status - ? status === "success" - ? "成功" - : status === "skipped" - ? "已签" - : "签到失败" - : "未签到"} + {getSiteCheckinLabel(site, today)}

{message && showCheckin && (

@@ -265,12 +316,20 @@ export const SitesView = ({

- {typeLabelMap[site.site_type]} + {getSiteTypeLabel(site.site_type)}
0 ? `${callTokenCount} 个` : "-"}
- {!showCheckin - ? "-" - : status - ? status === "success" - ? "成功" - : status === "skipped" - ? "已签" - : "签到失败" - : "未签到"} + {getSiteCheckinLabel(site, today)}