feat(admin-ui): add sites search and sort

This commit is contained in:
lsy committed 2026-02-23 00:14:13 +08:00
1 parent f3bb4d435c
commit 9b3a91daae
5 files changed
+247 -45

No files matched your search

+37 -3
View File
@@ -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<SettingsForm>(initialSettingsForm);
const [sitePage, setSitePage] = useState(1);
const [sitePageSize, setSitePageSize] = useState(10);
const [siteSearch, setSiteSearch] = useState("");
const [siteSort, setSiteSort] = useState<SiteSortState>({
key: "name",
direction: "asc",
});
const [tokenPage, setTokenPage] = useState(1);
const [tokenPageSize, setTokenPageSize] = useState(10);
const [editingSite, setEditingSite] = useState<Site | null>(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}
/>
+109
View File
@@ -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<Site["site_type"], string> = {
"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);
};
+92 -42
View File
@@ -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<SiteForm>) => void;
onRunAll: () => void;
};
const pageSizeOptions = [10, 20, 50];
const typeLabelMap: Record<Site["site_type"], string> = {
"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 (
<div class="space-y-5">
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
@@ -135,6 +164,37 @@ export const SitesView = ({
</button>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3">
<label class="flex w-full items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-3 py-2 text-xs text-stone-500 sm:w-72">
<span>搜索</span>
<input
class="w-full bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
placeholder="站点名称或 URL"
value={siteSearch}
onInput={(event) =>
onSearchChange(
(event.currentTarget as HTMLInputElement).value,
)
}
/>
</label>
<div class="flex flex-wrap items-center gap-2 md:hidden">
{sortableColumns.map((column) => (
<button
class={`h-8 rounded-full border px-3 text-[11px] font-semibold transition-all duration-200 ease-in-out ${
siteSort.key === column.key
? "border-stone-900 bg-stone-900 text-white"
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
}`}
key={column.key}
type="button"
onClick={() => toggleSort(column.key)}
>
{column.label} {sortIndicator(column.key)}
</button>
))}
</div>
</div>
<div class="mt-4">
<div class="space-y-3 md:hidden">
{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 ? "启用" : "禁用"}
</span>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-stone-500">
<span>类型</span>
<span class="font-semibold text-stone-700">
{typeLabelMap[site.site_type]}
</span>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-stone-500">
<span>类型</span>
<span class="font-semibold text-stone-700">
{getSiteTypeLabel(site.site_type)}
</span>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-stone-500">
<span>权重</span>
<span class="font-semibold text-stone-700">
@@ -211,15 +270,7 @@ export const SitesView = ({
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
<p>今日签到</p>
<p class="mt-1 font-semibold text-stone-700">
{!showCheckin
? "-"
: status
? status === "success"
? "成功"
: status === "skipped"
? "已签"
: "签到失败"
: "未签到"}
{getSiteCheckinLabel(site, today)}
</p>
{message && showCheckin && (
<p class="mt-1 truncate text-[11px] text-stone-500">
@@ -265,12 +316,20 @@ export const SitesView = ({
</div>
<div class="hidden overflow-hidden rounded-xl border border-stone-200 md:block">
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,0.6fr)_minmax(0,0.6fr)_minmax(0,0.5fr)_minmax(0,0.6fr)_minmax(0,0.8fr)_minmax(0,1.4fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
<div>站点</div>
<div>类型</div>
<div>状态</div>
<div>权重</div>
<div>令牌</div>
<div>签到</div>
{sortableColumns.map((column) => (
<div key={column.key}>
<button
class="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-widest text-stone-500 hover:text-stone-700"
type="button"
onClick={() => toggleSort(column.key)}
>
{column.label}
<span class="text-[10px]">
{sortIndicator(column.key)}
</span>
</button>
</div>
))}
<div>操作</div>
</div>
{pagedSites.length === 0 ? (
@@ -282,7 +341,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 checkinEnabled =
site.site_type === "new-api" &&
Boolean(site.checkin_enabled);
@@ -310,7 +368,7 @@ export const SitesView = ({
</span>
</div>
<div class="text-xs font-semibold text-stone-700">
{typeLabelMap[site.site_type]}
{getSiteTypeLabel(site.site_type)}
</div>
<div>
<span
@@ -330,15 +388,7 @@ export const SitesView = ({
{callTokenCount > 0 ? `${callTokenCount} 个` : "-"}
</div>
<div class="text-xs text-stone-600">
{!showCheckin
? "-"
: status
? status === "success"
? "成功"
: status === "skipped"
? "已签"
: "签到失败"
: "未签到"}
{getSiteCheckinLabel(site, today)}
</div>
<div class="flex flex-wrap gap-2">
<button
+8
View File
@@ -89,6 +89,14 @@
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/proxy.ts
## [0.8.1] - 2026-02-23
### 变更
- **[admin-ui]**: 站点管理新增搜索与排序,移动端提供排序控件
- 方案: [202602230005_site-sort-search](archive/2026-02/202602230005_site-sort-search/)
- **[tests]**: 增加站点搜索与排序单测
- 方案: [202602230005_site-sort-search](archive/2026-02/202602230005_site-sort-search/)
## [0.8.0] - 2026-02-22
### 变更
+1
View File
@@ -17,6 +17,7 @@
- 样式基于 Tailwind v4(结合全局主题变量)
- 前端状态集中在单页
- 站点管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
- 站点管理支持名称/URL 搜索与列头排序,搜索/排序后再分页
- 站点类型支持 new-api / done-hub / subapi / chatgpt / claude / gemini,类型驱动表单字段
- 站点表单提供系统令牌、userid、自动签到开关与外部签到地址(new-api 默认关闭)
- 站点支持多调用令牌(备注 + 令牌值)