feat(admin-ui): add sites search and sort
This commit is contained in:
1 parent
f3bb4d435c
commit
9b3a91daae
5 files changed
+247
-45
No files matched your search
+37
-3
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- 样式基于 Tailwind v4(结合全局主题变量)
|
||||
- 前端状态集中在单页
|
||||
- 站点管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
|
||||
- 站点管理支持名称/URL 搜索与列头排序,搜索/排序后再分页
|
||||
- 站点类型支持 new-api / done-hub / subapi / chatgpt / claude / gemini,类型驱动表单字段
|
||||
- 站点表单提供系统令牌、userid、自动签到开关与外部签到地址(new-api 默认关闭)
|
||||
- 站点支持多调用令牌(备注 + 令牌值)
|
||||
|
||||
Reference in new issue
Block a user