diff --git a/docs/README.md b/docs/README.md
index 8b0c565..53d66df 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,14 +5,15 @@
## 目录索引
```text
-docs/
- README.md
- development/
+ docs/
README.md
- architecture.md
- backend.md
- frontend.md
- release.md
+ development/
+ README.md
+ architecture.md
+ backend.md
+ crud.md
+ frontend.md
+ release.md
user/
README.md
usage.md
@@ -39,18 +40,18 @@ docs/
## 按任务阅读路径
-| 任务 | 必读文档 |
-| -------------------------------- | ----------------------------------------------------------------------------------- |
-| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
-| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
-| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
-| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
-| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
-| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
-| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
-| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
-| 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) |
-| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
+| 任务 | 必读文档 |
+| -------------------------------- | -------------------------------------------------------------------------------------------------------- |
+| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
+| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
+| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
+| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
+| 修改前端 CRUD 管理页面 | [开发文档](development/README.md)、[前端开发](development/frontend.md)、[CRUD 模式](development/crud.md) |
+| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
+| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
+| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
+| 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) |
+| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
## 文档归属矩阵
@@ -63,6 +64,7 @@ docs/
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
| 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` |
| 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` |
+| 管理页面 CRUD 模式(筛选工具条、URL 同步、分页排序约定) | `docs/development/crud.md` |
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
| 快速开始、安装配置 | `docs/user/usage.md` |
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |
diff --git a/docs/development/backend.md b/docs/development/backend.md
index dd0895b..45baa06 100644
--- a/docs/development/backend.md
+++ b/docs/development/backend.md
@@ -8,6 +8,8 @@
- `response.ts`:`createApiError(error, status)`、`createHeaders(mode, init)`、`createMetaResponse(version)`、`formatDuration(ms)`、`jsonResponse(body, options)`
- `url.ts`:`parseIdFromUrl(url)`
+- `list-params.ts`:`parseListParams(url, mode, options?)` — 统一校验分页/排序参数,替代 validatePagination
+- `pagination.ts`:`paginateQuery()` — Drizzle 分页查询封装
`src/server/middleware/`:
@@ -97,4 +99,4 @@ SQLite + bun:sqlite + Drizzle ORM。
## 更新触发条件
-修改后端模块 API、共享工具、数据库 schema、AI 服务层或聊天 API 时,必须更新本文档。
+修改后端模块 API、共享工具、数据库 schema、AI 服务层、聊天 API 或列表查询参数解析时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。
diff --git a/docs/development/crud.md b/docs/development/crud.md
new file mode 100644
index 0000000..cffd774
--- /dev/null
+++ b/docs/development/crud.md
@@ -0,0 +1,167 @@
+# CRUD 管理页面模式
+
+管理类页面(项目管理、模型管理、供应商管理)遵循统一的 CRUD 模式。本文档描述该模式的后端和前端约定。
+
+## 背景
+
+三个管理页面原本各自实现了工具条、筛选、分页、搜索等逻辑,代码重复且交互不一致。统一后所有管理页面使用相同的基础设施。
+
+## 页面结构
+
+每个管理页面的组件结构:
+
+```text
+
+ ← 筛选 + 搜索 + 操作按钮
+ ← 数据表格(分页 + 排序 + 行操作)
+ ← 创建/编辑弹窗
+
+```
+
+## 前端基础设施
+
+### FilterToolbar
+
+`src/web/shared/components/FilterToolbar.tsx` — 统一筛选工具条。
+
+```tsx
+interface FilterConfig {
+ key: string;
+ label: string;
+ placeholder: string;
+ options: Array<{ label: string; value: string }>;
+ value?: string; // 受控值
+ onChange: (value: string | undefined) => void;
+}
+
+interface SearchConfig {
+ placeholder: string;
+ keyword?: string; // 受控关键词(来自 URL)
+ onSearch: (value: string) => void; // 点击搜索按钮
+ onReset: () => void; // 点击重置按钮
+}
+
+interface FilterToolbarProps {
+ filters?: FilterConfig[]; // 左侧筛选下拉框
+ search?: SearchConfig; // 搜索输入框 + 重置按钮
+ actions?: ReactNode; // 右侧操作区(如"新建"按钮)
+}
+```
+
+**布局**:左侧 = 筛选 Select + 搜索框(SearchOutlined 按钮) + 重置按钮(UndoOutlined),右侧 = actions 插槽。
+
+**交互约定**:
+
+- 筛选下拉框:选中即过滤,调用 `onChange`(实时过滤)
+- 搜索框:输入文本,点击搜索按钮或 Enter 触发 `onSearch`(点击搜索)
+- 重置按钮:调用 `onReset` 清除全部筛选条件
+
+### usePageSearchParams
+
+`src/web/shared/hooks/usePageSearchParams.ts` — 将页面筛选/分页/排序状态同步到 URL 查询参数。
+
+```tsx
+const { params, setParams, resetAll } = usePageSearchParams({
+ defaults: { page: "1", pageSize: "20" },
+});
+```
+
+- `params`:`Record`,读取当前 URL 参数(含默认值)
+- `setParams(patch)`:批量更新多个参数(推荐),内部使用函数式更新器 + `replace: true`
+- `setParam(key, value)`:更新单个参数(**注意**:连续多次 `setParam` 会导致闭包快照覆盖,多参数更新必须使用 `setParams`)
+- `resetAll()`:清空所有 URL 参数
+- 默认值(`defaults`)不出现在 URL 中
+
+**核心约束**:react-router 的 `setSearchParams` 函数式更新器使用 `useCallback` 闭包捕获的 `searchParams` 快照,连续调用时第二次的 `prev` 还是第一次更新前的旧值。多参数同时更新必须使用单次 `setParams({...})` 批量操作。
+
+### useConfirmAction
+
+`src/web/shared/hooks/useConfirmAction.ts` — 包装异步操作,提供成功/失败 toast 通知。
+
+```tsx
+const { confirmAction } = useConfirmAction();
+confirmAction(() => deleteMutation.mutateAsync(id), "删除成功");
+```
+
+## 后端基础设施
+
+### parseListParams
+
+`src/server/helpers/list-params.ts` — 统一解析列表请求参数。
+
+```ts
+export interface ParsedListParams {
+ keyword?: string;
+ page: number;
+ pageSize: number;
+ sortBy?: string;
+ sortOrder?: SortOrder;
+}
+
+export function parseListParams(
+ url: URL,
+ mode: RuntimeMode,
+ options?: { allowedSortBy?: string[] },
+): ParsedListParams | Response;
+```
+
+- 校验 page(正整数)、pageSize(正整数,最大 200)
+- 校验 sortBy(白名单,由调用方传入 `allowedSortBy`)
+- 校验 sortOrder(仅 "asc" / "desc")
+- 校验失败返回 400 Response(调用方应直接返回)
+- 替代了旧的 `validatePagination` 中间件
+
+### 数据访问层约定
+
+每个实体的 DB 函数(`listProjects` / `listModels` / `listProviders`):
+
+- 通过 `paginateQuery` 工具执行分页查询
+- 接受 `sortBy` / `sortOrder` 参数,各实体自带白名单(`buildOrderBy`)
+- 默认排序:`desc(createdAt)`
+- 实体专用筛选参数在 DB 层处理(如 `status`、`type`、`capabilities`)
+
+### 路由层约定
+
+每个实体的列表路由:
+
+```ts
+// src/server/routes/{entity}/list.ts
+const parsed = parseListParams(url, mode, { allowedSortBy: [...] });
+if (parsed instanceof Response) return parsed;
+// 解析实体专用筛选参数(如 status、providerId、capabilities、type)
+// 调用 DB 函数
+```
+
+## URL 参数约定
+
+| 参数 | 说明 | 默认值 |
+| -------------- | -------------- | ------ |
+| `page` | 当前页码 | 1 |
+| `pageSize` | 每页条数 | 20 |
+| `keyword` | 搜索关键词 | - |
+| `sortBy` | 排序列 | - |
+| `sortOrder` | 排序方向 | - |
+| `status` | 项目状态筛选 | - |
+| `type` | 供应商类型筛选 | - |
+| `providerId` | 模型供应商筛选 | - |
+| `capabilities` | 模型能力筛选 | - |
+
+默认值不出现在 URL 中。
+
+## 排序约定
+
+- 排序列后端白名单校验,拒绝无效列名
+- 所有列排序通过后端 `ORDER BY` 实现,不使用前端排序
+- Table 组件的 `column.sorter` 设为 `true` 启用排序指示器
+- Table 的 `onChange` 回调统一处理分页 + 排序变更
+
+## 交互约定
+
+| 操作 | 触发时机 | 行为 |
+| -------- | -------------------- | ----------------------------- |
+| 筛选下拉 | 选中选项 / 清除 | 重置到第 1 页,更新 URL |
+| 搜索 | 点击搜索按钮 / Enter | 重置到第 1 页,更新 URL |
+| 重置 | 点击重置按钮 | 清除所有筛选条件,回到第 1 页 |
+| 分页 | 点击分页器 | 更新 URL(page+pageSize) |
+| 排序 | 点击列头排序 | 重置到第 1 页,更新 URL |
+| 删除 | Popconfirm 确认后 | toast 通知,刷新列表 |
diff --git a/docs/development/frontend.md b/docs/development/frontend.md
index 7675881..a02ffb4 100644
--- a/docs/development/frontend.md
+++ b/docs/development/frontend.md
@@ -18,21 +18,21 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
| 功能模块 | 路径 | 说明 |
| -------- | --------------------- | --------------------------- |
| 仪表盘 | `features/dashboard/` | 总览页面 |
-| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索 |
+| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索、排序 |
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
## 页面
-| 页面 | 路径 | 入口 |
-| -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 总览 | `/` | `features/dashboard/index.tsx` |
-| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
-| 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx` + `ProviderListPage.tsx`。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
-| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
-| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
-| 404 | `*` | `features/not-found/index.tsx` |
+| 页面 | 路径 | 入口 |
+| -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 总览 | `/` | `features/dashboard/index.tsx` |
+| 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar(状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
+| 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx`(FilterToolbar + ModelTable) + `ProviderListPage.tsx`(FilterToolbar + ProviderTable)。模型支持供应商/能力筛选和列表排序,供应商支持类型筛选和列表排序。模型表单使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`)。 |
+| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
+| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
+| 404 | `*` | `features/not-found/index.tsx` |
### 聊天页面
@@ -46,40 +46,44 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
### 共享组件
-| 组件 | 路径 | 说明 |
-| ------------- | ------------------------------------- | ------------------------------------ |
-| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳(Provider + Layout) |
-| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
-| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
-| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
+| 组件 | 路径 | 说明 |
+| ------------- | ------------------------------------- | ------------------------------------------------------ |
+| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳(Provider + Layout) |
+| FilterToolbar | `shared/components/FilterToolbar.tsx` | 统一筛选工具条(Select 筛选 + 搜索 + 重置 + 操作按钮) |
+| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
+| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
+| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
### 共享 Hooks
-| Hook | 路径 | 说明 |
-| ----------------------- | --------------------------------------- | ---------------------------------------------------------- |
-| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`(30s 轮询,5s staleTime) |
-| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
-| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
-| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
-| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
-| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
-| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
-| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
-| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
-| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
-| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
+| Hook | 路径 | 说明 |
+| ------------------------ | --------------------------------------- | --------------------------------------------------------------------- |
+| `use-page-search-params` | `shared/hooks/usePageSearchParams.ts` | URL 查询参数同步(筛选/分页/排序),批量更新 `setParams` 避免闭包覆盖 |
+| `use-confirm-action` | `shared/hooks/useConfirmAction.ts` | 包装异步操作 + toast 成功/失败通知 |
+| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`(30s 轮询,5s staleTime) |
+| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
+| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
+| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
+| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
+| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
+| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
+| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
+| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
+| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
+| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
### 共享工具函数
| 文件 | 导出 |
| --------------------- | --------------------------------------------------------------------------------------------- |
| `utils/api.ts` | `handleResponse(response, extract)`、`handleVoidResponse(response)` |
+| `utils/format.ts` | `formatDatetime(iso: string)` — 格式化 ISO 时间字符串为 `YYYY-MM-DD HH:mm` |
| `utils/time.ts` | `formatCountdown`、`formatDurationUnit`、`formatRelativeTime`、`isOlderThan`、`subtractHours` |
| `utils/date-group.ts` | `getDateGroup`、`groupByDate`、`GROUP_LABELS`、`GROUP_ORDER`、`DateGroup`、`DateGroupData` |
## 更新触发条件
-修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引、hooks/工具清单、目录结构或功能模块归属时,必须更新本文档。
+修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引、hooks/工具清单、目录结构或功能模块归属时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。
## 日志模块
diff --git a/src/server/db/models.ts b/src/server/db/models.ts
index 5ba8a13..c28f18b 100644
--- a/src/server/db/models.ts
+++ b/src/server/db/models.ts
@@ -1,8 +1,8 @@
import type Database from "bun:sqlite";
-import { desc, eq, like, or, sql } from "drizzle-orm";
+import { asc, desc, eq, like, or, sql } from "drizzle-orm";
-import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api";
+import type { CreateModelRequest, Model, ModelCapability, SortOrder, UpdateModelRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
@@ -124,7 +124,15 @@ export function getModelWithProvider(
export function listModels(
raw: Database,
- options: { keyword?: string; page: number; pageSize: number; providerId?: string },
+ options: {
+ capabilities?: string;
+ keyword?: string;
+ page: number;
+ pageSize: number;
+ providerId?: string;
+ sortBy?: string;
+ sortOrder?: SortOrder;
+ },
): { items: Model[]; page: number; pageSize: number; total: number } {
const conditions = [];
@@ -137,10 +145,16 @@ export function listModels(
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
}
+ if (options.capabilities) {
+ conditions.push(like(models.capabilities, `%"${options.capabilities}"%`));
+ }
+
+ const orderByFn = buildModelOrderBy(options.sortBy, options.sortOrder);
+
return paginateQuery(raw, models, {
conditions,
mapRow: toModel,
- orderBy: () => desc(models.createdAt),
+ orderBy: orderByFn,
page: options.page,
pageSize: options.pageSize,
});
@@ -212,6 +226,17 @@ export function updateModel(
return { model: toModel(updated!) };
}
+function buildModelOrderBy(
+ sortBy: string | undefined,
+ sortOrder: SortOrder | undefined,
+): ((table: typeof models) => ReturnType) | undefined {
+ if (!sortBy) return (t) => desc(t.createdAt);
+
+ return sortOrder === "asc"
+ ? (t) => asc(t[sortBy as keyof typeof t] as Parameters[0])
+ : (t) => desc(t[sortBy as keyof typeof t] as Parameters[0]);
+}
+
function toModel(row: typeof models.$inferSelect): Model {
return {
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
diff --git a/src/server/db/projects.ts b/src/server/db/projects.ts
index ecf7746..465b5e9 100644
--- a/src/server/db/projects.ts
+++ b/src/server/db/projects.ts
@@ -1,8 +1,8 @@
import type Database from "bun:sqlite";
-import { desc, eq, like, or } from "drizzle-orm";
+import { asc, desc, eq, like, or } from "drizzle-orm";
-import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api";
+import type { CreateProjectRequest, Project, ProjectStatus, SortOrder, UpdateProjectRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
@@ -88,7 +88,14 @@ export function getProject(raw: Database, id: string): { error: string; status:
export function listProjects(
raw: Database,
- options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus },
+ options: {
+ keyword?: string;
+ page: number;
+ pageSize: number;
+ sortBy?: string;
+ sortOrder?: SortOrder;
+ status?: ProjectStatus;
+ },
): { items: Project[]; page: number; pageSize: number; total: number } {
const conditions = [];
@@ -101,10 +108,12 @@ export function listProjects(
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
}
+ const orderByFn = buildProjectOrderBy(options.sortBy, options.sortOrder);
+
return paginateQuery(raw, projects, {
conditions,
mapRow: toProject,
- orderBy: () => desc(projects.createdAt),
+ orderBy: orderByFn,
page: options.page,
pageSize: options.pageSize,
});
@@ -174,6 +183,17 @@ export function updateProject(
return { project: toProject(updated!) };
}
+function buildProjectOrderBy(
+ sortBy: string | undefined,
+ sortOrder: SortOrder | undefined,
+): ((table: typeof projects) => ReturnType) | undefined {
+ if (!sortBy) return (t) => desc(t.createdAt);
+
+ return sortOrder === "asc"
+ ? (t) => asc(t[sortBy as keyof typeof t] as Parameters[0])
+ : (t) => desc(t[sortBy as keyof typeof t] as Parameters[0]);
+}
+
function toProject(row: typeof projects.$inferSelect): Project {
return {
archivedAt: row.archivedAt,
diff --git a/src/server/db/providers.ts b/src/server/db/providers.ts
index f3381de..d0e1a43 100644
--- a/src/server/db/providers.ts
+++ b/src/server/db/providers.ts
@@ -1,8 +1,14 @@
import type Database from "bun:sqlite";
-import { desc, eq, like } from "drizzle-orm";
+import { asc, desc, eq, like } from "drizzle-orm";
-import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api";
+import type {
+ CreateProviderRequest,
+ Provider,
+ ProviderOption,
+ SortOrder,
+ UpdateProviderRequest,
+} from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
@@ -85,7 +91,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
export function listProviders(
raw: Database,
- options: { keyword?: string; page: number; pageSize: number },
+ options: { keyword?: string; page: number; pageSize: number; sortBy?: string; sortOrder?: SortOrder; type?: string },
): { items: Provider[]; page: number; pageSize: number; total: number } {
const conditions = [];
@@ -94,10 +100,16 @@ export function listProviders(
conditions.push(like(providers.name, pattern));
}
+ if (options.type) {
+ conditions.push(eq(providers.type, options.type as Provider["type"]));
+ }
+
+ const orderByFn = buildProviderOrderBy(options.sortBy, options.sortOrder);
+
return paginateQuery(raw, providers, {
conditions,
mapRow: toProvider,
- orderBy: () => desc(providers.createdAt),
+ orderBy: orderByFn,
page: options.page,
pageSize: options.pageSize,
});
@@ -158,6 +170,17 @@ export function updateProvider(
return { provider: toProvider(updated!) };
}
+function buildProviderOrderBy(
+ sortBy: string | undefined,
+ sortOrder: SortOrder | undefined,
+): ((table: typeof providers) => ReturnType) | undefined {
+ if (!sortBy) return (t) => desc(t.createdAt);
+
+ return sortOrder === "asc"
+ ? (t) => asc(t[sortBy as keyof typeof t] as Parameters[0])
+ : (t) => desc(t[sortBy as keyof typeof t] as Parameters[0]);
+}
+
function toProvider(row: typeof providers.$inferSelect): Provider {
return {
apiKey: row.apiKey,
diff --git a/src/server/helpers/index.ts b/src/server/helpers/index.ts
index 3d5ac20..7cdbf8f 100644
--- a/src/server/helpers/index.ts
+++ b/src/server/helpers/index.ts
@@ -1,2 +1,4 @@
+export type { ParsedListParams } from "./list-params";
+export { parseListParams } from "./list-params";
export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response";
export { parseIdFromUrl } from "./url";
diff --git a/src/server/helpers/list-params.ts b/src/server/helpers/list-params.ts
new file mode 100644
index 0000000..414466f
--- /dev/null
+++ b/src/server/helpers/list-params.ts
@@ -0,0 +1,59 @@
+import type { RuntimeMode, SortOrder } from "../../shared/api";
+
+import { createApiError, jsonResponse } from "./index";
+
+export interface ParsedListParams {
+ keyword?: string;
+ page: number;
+ pageSize: number;
+ sortBy?: string;
+ sortOrder?: SortOrder;
+}
+
+export function parseListParams(
+ url: URL,
+ mode: RuntimeMode,
+ options?: { allowedSortBy?: string[] },
+): ParsedListParams | Response {
+ const pageParam = url.searchParams.get("page");
+ const pageSizeParam = url.searchParams.get("pageSize");
+
+ let page = 1;
+ let pageSize = 20;
+
+ if (pageParam !== null) {
+ page = Number(pageParam);
+ if (!Number.isInteger(page) || page <= 0) {
+ return jsonResponse(createApiError("无效的 page 参数", 400), { mode, status: 400 });
+ }
+ }
+
+ if (pageSizeParam !== null) {
+ pageSize = Number(pageSizeParam);
+ if (!Number.isInteger(pageSize) || pageSize <= 0) {
+ return jsonResponse(createApiError("无效的 pageSize 参数", 400), { mode, status: 400 });
+ }
+ if (pageSize > 200) {
+ return jsonResponse(createApiError("pageSize 不能超过 200", 400), { mode, status: 400 });
+ }
+ }
+
+ const keyword = url.searchParams.get("keyword") ?? undefined;
+
+ const sortBy = url.searchParams.get("sortBy") ?? undefined;
+ const sortOrderParam = url.searchParams.get("sortOrder") ?? undefined;
+
+ if (sortBy && options?.allowedSortBy && !options.allowedSortBy.includes(sortBy)) {
+ return jsonResponse(createApiError("无效的 sortBy 参数", 400), { mode, status: 400 });
+ }
+
+ let sortOrder: SortOrder | undefined;
+ if (sortOrderParam) {
+ if (sortOrderParam !== "asc" && sortOrderParam !== "desc") {
+ return jsonResponse(createApiError("无效的 sortOrder 参数", 400), { mode, status: 400 });
+ }
+ sortOrder = sortOrderParam;
+ }
+
+ return { keyword, page, pageSize, sortBy, sortOrder };
+}
diff --git a/src/server/routes/models/list.ts b/src/server/routes/models/list.ts
index 7c1958c..ef92a01 100644
--- a/src/server/routes/models/list.ts
+++ b/src/server/routes/models/list.ts
@@ -4,24 +4,26 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listModels } from "../../db/models";
-import { jsonResponse } from "../../helpers";
-import { validatePagination } from "../../middleware";
+import { jsonResponse, parseListParams } from "../../helpers";
+
+const ALLOWED_SORT_BY = ["createdAt", "name"];
export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
- const pageParam = url.searchParams.get("page");
- const pageSizeParam = url.searchParams.get("pageSize");
- const keyword = url.searchParams.get("keyword");
const providerId = url.searchParams.get("providerId");
+ const capabilities = url.searchParams.get("capabilities");
- const pagination = validatePagination(pageParam, pageSizeParam, mode);
- if (pagination instanceof Response) return pagination;
+ const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
+ if (parsed instanceof Response) return parsed;
const result = listModels(db, {
- keyword: keyword ?? undefined,
- page: pagination.page,
- pageSize: pagination.pageSize,
+ capabilities: capabilities ?? undefined,
+ keyword: parsed.keyword,
+ page: parsed.page,
+ pageSize: parsed.pageSize,
providerId: providerId ?? undefined,
+ sortBy: parsed.sortBy,
+ sortOrder: parsed.sortOrder,
});
return jsonResponse(result, { mode });
diff --git a/src/server/routes/projects/list.ts b/src/server/routes/projects/list.ts
index d63b3c1..9f2f374 100644
--- a/src/server/routes/projects/list.ts
+++ b/src/server/routes/projects/list.ts
@@ -4,27 +4,27 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listProjects } from "../../db/projects";
-import { createApiError, jsonResponse } from "../../helpers";
-import { validatePagination } from "../../middleware";
+import { createApiError, jsonResponse, parseListParams } from "../../helpers";
+
+const ALLOWED_SORT_BY = ["createdAt", "name", "updatedAt"];
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
- const pageParam = url.searchParams.get("page");
- const pageSizeParam = url.searchParams.get("pageSize");
- const keyword = url.searchParams.get("keyword");
const statusParam = url.searchParams.get("status");
- const pagination = validatePagination(pageParam, pageSizeParam, mode);
- if (pagination instanceof Response) return pagination;
-
if (statusParam && statusParam !== "active" && statusParam !== "archived") {
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
}
+ const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
+ if (parsed instanceof Response) return parsed;
+
const result = listProjects(db, {
- keyword: keyword ?? undefined,
- page: pagination.page,
- pageSize: pagination.pageSize,
+ keyword: parsed.keyword,
+ page: parsed.page,
+ pageSize: parsed.pageSize,
+ sortBy: parsed.sortBy,
+ sortOrder: parsed.sortOrder,
status: (statusParam as "active" | "archived") ?? undefined,
});
diff --git a/src/server/routes/providers/list.ts b/src/server/routes/providers/list.ts
index 7dbeefb..7410a2c 100644
--- a/src/server/routes/providers/list.ts
+++ b/src/server/routes/providers/list.ts
@@ -4,22 +4,24 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listProviders } from "../../db/providers";
-import { jsonResponse } from "../../helpers";
-import { validatePagination } from "../../middleware";
+import { jsonResponse, parseListParams } from "../../helpers";
+
+const ALLOWED_SORT_BY = ["createdAt", "name"];
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
- const pageParam = url.searchParams.get("page");
- const pageSizeParam = url.searchParams.get("pageSize");
- const keyword = url.searchParams.get("keyword");
+ const typeParam = url.searchParams.get("type");
- const pagination = validatePagination(pageParam, pageSizeParam, mode);
- if (pagination instanceof Response) return pagination;
+ const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
+ if (parsed instanceof Response) return parsed;
const result = listProviders(db, {
- keyword: keyword ?? undefined,
- page: pagination.page,
- pageSize: pagination.pageSize,
+ keyword: parsed.keyword,
+ page: parsed.page,
+ pageSize: parsed.pageSize,
+ sortBy: parsed.sortBy,
+ sortOrder: parsed.sortOrder,
+ type: typeParam ?? undefined,
});
return jsonResponse(result, { mode });
diff --git a/src/shared/api.ts b/src/shared/api.ts
index 45294fd..c922d13 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -59,6 +59,11 @@ export interface CreateProviderRequest {
// 前后端共享的类型都放在这个文件中
// ==========================================
+export interface ListSortParams {
+ sortBy?: string;
+ sortOrder?: SortOrder;
+}
+
export interface Material {
associatedDate: string;
createdAt: string;
@@ -127,6 +132,8 @@ export type ModelCapability =
| "video-generation"
| "video-recognition";
+export type SortOrder = "asc" | "desc";
+
export interface UpdateConversationRequest {
modelId?: string;
title?: string;
diff --git a/src/web/features/models/ModelListPage.tsx b/src/web/features/models/ModelListPage.tsx
index 576e320..b84ff53 100644
--- a/src/web/features/models/ModelListPage.tsx
+++ b/src/web/features/models/ModelListPage.tsx
@@ -1,8 +1,10 @@
-import { Space } from "antd";
-import { useState } from "react";
+import { PlusOutlined } from "@ant-design/icons";
+import { Button, Space } from "antd";
+import { useCallback, useMemo, useState } from "react";
import type { Model, TestModelRequest } from "../../../shared/api";
+import { FilterToolbar } from "../../shared/components/FilterToolbar";
import {
useCreateModel,
useDeleteModel,
@@ -11,21 +13,46 @@ import {
useUpdateModel,
} from "../../shared/hooks/use-models";
import { useProviderOptions } from "../../shared/hooks/use-providers";
+import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
+import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
import { ModelFormModal } from "./components/ModelFormModal";
import { ModelTable } from "./components/ModelTable";
-import { ModelToolbar } from "./components/ModelToolbar";
+
+const CAPABILITY_OPTIONS = [
+ { label: "文本", value: "text" },
+ { label: "推理", value: "reasoning" },
+ { label: "图片识别", value: "image-recognition" },
+ { label: "图片生成", value: "image-generation" },
+ { label: "音频识别", value: "audio-recognition" },
+ { label: "音频生成", value: "audio-generation" },
+ { label: "视频识别", value: "video-recognition" },
+ { label: "视频生成", value: "video-generation" },
+];
export function ModelListPage() {
- const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(20);
- const [keyword, setKeyword] = useState("");
+ const { confirmAction } = useConfirmAction();
+ const { params, resetAll, setParams } = usePageSearchParams({
+ defaults: { page: "1", pageSize: "20" },
+ });
const [dialogOpen, setDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState(null);
- const { data: modelData, isLoading: modelLoading } = useModelList({
- keyword: keyword || undefined,
- page,
- pageSize,
+ const apiPage = Number(params["page"]) || 1;
+ const apiPageSize = Number(params["pageSize"]) || 20;
+ const keyword = params["keyword"] ?? "";
+ const providerIdFilter = params["providerId"];
+ const capabilitiesFilter = params["capabilities"];
+ const sortBy = params["sortBy"];
+ const sortOrder = params["sortOrder"];
+
+ const { data: modelData, isFetching: modelLoading } = useModelList({
+ capabilities: capabilitiesFilter ?? undefined,
+ keyword: keyword ?? undefined,
+ page: apiPage,
+ pageSize: apiPageSize,
+ providerId: providerIdFilter ?? undefined,
+ sortBy: sortBy ?? undefined,
+ sortOrder: sortOrder,
});
const {
@@ -41,42 +68,107 @@ export function ModelListPage() {
const testModelMutation = useTestModelConnection();
const isSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
- const isActionPending = deleteModelMutation.isPending;
- const modelProviders = providerOptionsData?.items ?? [];
+ const modelProviders = useMemo(() => providerOptionsData?.items ?? [], [providerOptionsData?.items]);
+
+ const providerOptions = useMemo(() => modelProviders.map((p) => ({ label: p.name, value: p.id })), [modelProviders]);
+
+ const handleSearch = useCallback(
+ (value: string) => {
+ setParams({ keyword: value || undefined, page: "1" });
+ },
+ [setParams],
+ );
+
+ const handleReset = useCallback(() => {
+ resetAll();
+ }, [resetAll]);
+
+ const handleTableChange = useCallback(
+ (
+ pagination: { current?: number; pageSize?: number },
+ sorter: { columnKey?: string; field?: string | string[]; order?: string },
+ ) => {
+ const patch: Record = {
+ page: String(pagination.current ?? apiPage),
+ pageSize: String(pagination.pageSize ?? apiPageSize),
+ };
+ const sortField = sorter.columnKey! ?? (typeof sorter.field === "string" ? sorter.field : undefined);
+ if (sorter.order && sortField) {
+ patch["sortBy"] = sortField;
+ patch["sortOrder"] = sorter.order === "ascend" ? "asc" : "desc";
+ } else {
+ patch["sortBy"] = undefined;
+ patch["sortOrder"] = undefined;
+ }
+ setParams(patch);
+ },
+ [setParams, apiPage, apiPageSize],
+ );
+
+ const filters = useMemo(
+ () => [
+ {
+ key: "providerId",
+ label: "供应商",
+ onChange: (value: string | undefined) => {
+ setParams({ page: "1", providerId: value });
+ },
+ options: providerOptions,
+ placeholder: "供应商",
+ value: providerIdFilter,
+ },
+ {
+ key: "capabilities",
+ label: "能力",
+ onChange: (value: string | undefined) => {
+ setParams({ capabilities: value, page: "1" });
+ },
+ options: CAPABILITY_OPTIONS,
+ placeholder: "能力",
+ value: capabilitiesFilter,
+ },
+ ],
+ [setParams, providerOptions, providerIdFilter, capabilitiesFilter],
+ );
+
+ const handleDelete = useCallback(
+ (id: string) => confirmAction(() => deleteModelMutation.mutateAsync(id), "模型已删除"),
+ [confirmAction, deleteModelMutation],
+ );
return (
- {
- setKeyword(value);
- setPage(1);
- }}
- onSearchClear={() => {
- setKeyword("");
- setPage(1);
- }}
- openCreateDialog={() => {
- setEditingModel(null);
- setDialogOpen(true);
- }}
+ }
+ onClick={() => {
+ setEditingModel(null);
+ setDialogOpen(true);
+ }}
+ type="primary"
+ >
+ 新建模型
+
+ }
+ filters={filters}
+ search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索模型名称或 ID" }}
/>
deleteModelMutation.mutateAsync(id)}
+ loading={modelLoading || providerOptionsLoading}
+ onChange={handleTableChange}
+ onDelete={handleDelete}
onEdit={(model) => {
setEditingModel(model);
setDialogOpen(true);
}}
- onPageChange={(p, ps) => {
- setPage(p);
- setPageSize(ps);
- }}
- page={page}
- pageSize={pageSize}
+ page={apiPage}
+ pageSize={apiPageSize}
providers={modelProviders}
+ sortBy={sortBy}
+ sortOrder={sortOrder}
/>
(null);
- const { data: providerData, isLoading: providerLoading } = useProviderList({
+ const apiPage = Number(params["page"]) || 1;
+ const apiPageSize = Number(params["pageSize"]) || 20;
+ const keyword = params["keyword"] ?? "";
+ const typeFilter = params["type"];
+ const sortBy = params["sortBy"];
+ const sortOrder = params["sortOrder"];
+
+ const { data: providerData, isFetching: providerLoading } = useProviderList({
keyword: keyword || undefined,
- page,
- pageSize,
+ page: apiPage,
+ pageSize: apiPageSize,
+ sortBy: sortBy ?? undefined,
+ sortOrder: sortOrder,
+ type: typeFilter ?? undefined,
});
const createProviderMutation = useCreateProvider();
@@ -33,40 +53,93 @@ export function ProviderListPage() {
const testProviderConfigMutation = useTestProviderConfig();
const isSubmitting = createProviderMutation.isPending || updateProviderMutation.isPending;
- const isActionPending = deleteProviderMutation.isPending;
+
+ const handleSearch = useCallback(
+ (value: string) => {
+ setParams({ keyword: value || undefined, page: "1" });
+ },
+ [setParams],
+ );
+
+ const handleReset = useCallback(() => {
+ resetAll();
+ }, [resetAll]);
+
+ const handleTableChange = useCallback(
+ (
+ pagination: { current?: number; pageSize?: number },
+ sorter: { columnKey?: string; field?: string | string[]; order?: string },
+ ) => {
+ const patch: Record = {
+ page: String(pagination.current ?? apiPage),
+ pageSize: String(pagination.pageSize ?? apiPageSize),
+ };
+ const sortField = sorter.columnKey! ?? (typeof sorter.field === "string" ? sorter.field : undefined);
+ if (sorter.order && sortField) {
+ patch["sortBy"] = sortField;
+ patch["sortOrder"] = sorter.order === "ascend" ? "asc" : "desc";
+ } else {
+ patch["sortBy"] = undefined;
+ patch["sortOrder"] = undefined;
+ }
+ setParams(patch);
+ },
+ [setParams, apiPage, apiPageSize],
+ );
+
+ const filters = useMemo(
+ () => [
+ {
+ key: "type",
+ label: "类型",
+ onChange: (value: string | undefined) => {
+ setParams({ page: "1", type: value });
+ },
+ options: TYPE_OPTIONS,
+ placeholder: "类型",
+ value: typeFilter,
+ },
+ ],
+ [setParams, typeFilter],
+ );
+
+ const handleDelete = useCallback(
+ (id: string) => confirmAction(() => deleteProviderMutation.mutateAsync(id), "供应商已删除"),
+ [confirmAction, deleteProviderMutation],
+ );
return (
- {
- setKeyword(value);
- setPage(1);
- }}
- onSearchClear={() => {
- setKeyword("");
- setPage(1);
- }}
- openCreateDialog={() => {
- setEditingProvider(null);
- setDialogOpen(true);
- }}
+ }
+ onClick={() => {
+ setEditingProvider(null);
+ setDialogOpen(true);
+ }}
+ type="primary"
+ >
+ 新建供应商
+
+ }
+ filters={filters}
+ search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索供应商名称" }}
/>
deleteProviderMutation.mutateAsync(id)}
+ loading={providerLoading}
+ onChange={handleTableChange}
+ onDelete={handleDelete}
onEdit={(provider) => {
setEditingProvider(provider);
setDialogOpen(true);
}}
- onPageChange={(p, ps) => {
- setPage(p);
- setPageSize(ps);
- }}
- page={page}
- pageSize={pageSize}
+ page={apiPage}
+ pageSize={apiPageSize}
+ sortBy={sortBy}
+ sortOrder={sortOrder}
/>
Promise;
+ onChange: (
+ pagination: { current?: number; pageSize?: number },
+ sorter: { columnKey?: string; field?: string | string[]; order?: string },
+ ) => void;
+ onDelete: (id: string) => Promise;
onEdit: (model: Model) => void;
- onPageChange: (page: number, pageSize: number) => void;
page: number;
pageSize: number;
providers: ProviderOption[];
+ sortBy?: string;
+ sortOrder?: string;
}
const CAPABILITY_LABELS: Record = {
@@ -28,110 +35,111 @@ const CAPABILITY_LABELS: Record = {
"video-recognition": "视频识别",
};
-function getProviderName(providerId: string, providers: ProviderOption[]): string {
- return providers.find((p) => p.id === providerId)?.name ?? providerId;
-}
-
-const COLUMNS: TableColumnsType = [
- { dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
- {
- dataIndex: "providerId",
- ellipsis: true,
- title: "供应商",
- width: 120,
- },
- {
- dataIndex: "capabilities",
- render: (value: string[]) =>
- value.length > 0 ? (
-
- {value.map((c) => (
- {CAPABILITY_LABELS[c] ?? c}
- ))}
-
- ) : null,
- title: "能力",
- },
-];
-
export function ModelTable({
data,
loading,
+ onChange,
onDelete,
onEdit,
- onPageChange,
page,
pageSize,
providers,
+ sortBy,
+ sortOrder,
}: ModelTableProps) {
- const { message } = AntApp.useApp();
-
- const handleDelete = useCallback(
- async (id: string) => {
- try {
- await onDelete(id);
- message.success("模型已删除");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- },
- [onDelete, message],
- );
-
- const columnsWithProvider = useMemo>(
- () =>
- COLUMNS.map((col) =>
- "dataIndex" in col && col.dataIndex === "providerId"
- ? {
- ...col,
- render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
- }
- : col,
- ),
- [providers],
- );
-
- const operationColumn = useMemo[number]>(
- () => ({
- dataIndex: "op",
- render: (_value: unknown, record: Model) => (
-
- } onClick={() => onEdit(record)} size="small" type="link">
- 编辑
-
- void handleDelete(record.id)}
- title="确认删除此模型?"
- >
- } size="small" type="link">
- 删除
+ const columns = useMemo>(
+ () => [
+ {
+ dataIndex: "name",
+ ellipsis: true,
+ sorter: true,
+ sortOrder:
+ sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
+ title: "名称",
+ width: 180,
+ },
+ {
+ dataIndex: "providerId",
+ ellipsis: true,
+ render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
+ title: "供应商",
+ width: 120,
+ },
+ {
+ dataIndex: "capabilities",
+ render: (value: string[]) =>
+ value.length > 0 ? (
+
+ {value.map((c) => (
+ {CAPABILITY_LABELS[c] ?? c}
+ ))}
+
+ ) : null,
+ title: "能力",
+ },
+ {
+ align: "center",
+ dataIndex: "createdAt",
+ render: (_value: unknown, record: Model) => formatDatetime(record.createdAt),
+ sorter: true,
+ sortOrder:
+ sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
+ title: "创建时间",
+ width: 180,
+ },
+ {
+ dataIndex: "op",
+ render: (_value: unknown, record: Model) => (
+
+ } onClick={() => onEdit(record)} size="small" type="link">
+ 编辑
-
-
- ),
- title: "操作",
- width: 180,
- }),
- [onEdit, handleDelete],
+ void onDelete(record.id)}
+ title="确认删除此模型?"
+ >
+ } size="small" type="link">
+ 删除
+
+
+
+ ),
+ title: "操作",
+ width: 180,
+ },
+ ],
+ [onEdit, onDelete, providers, sortBy, sortOrder],
);
- const columns = useMemo(() => [...columnsWithProvider, operationColumn], [columnsWithProvider, operationColumn]);
+ const handleTableChange: TableProps["onChange"] = (pagination, _filters, sorter) => {
+ const sortInfo =
+ sorter && typeof sorter === "object" && "columnKey" in sorter
+ ? (sorter as { columnKey?: string; field?: string | string[]; order?: string })
+ : {};
+ onChange({ current: pagination.current, pageSize: pagination.pageSize }, sortInfo);
+ };
return (
`共 ${total} 条`,
total: data?.total ?? 0,
}}
rowKey="id"
/>
);
}
+
+function getProviderName(providerId: string, providers: ProviderOption[]): string {
+ return providers.find((p) => p.id === providerId)?.name ?? providerId;
+}
diff --git a/src/web/features/models/components/ModelToolbar.tsx b/src/web/features/models/components/ModelToolbar.tsx
deleted file mode 100644
index 3359004..0000000
--- a/src/web/features/models/components/ModelToolbar.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { PlusOutlined } from "@ant-design/icons";
-import { Button, Flex, Input, Space } from "antd";
-import { useState } from "react";
-
-interface ModelToolbarProps {
- keyword: string;
- onSearch: (value: string) => void;
- onSearchClear: () => void;
- openCreateDialog: () => void;
-}
-
-export function ModelToolbar({ keyword, onSearch, onSearchClear, openCreateDialog }: ModelToolbarProps) {
- const [draftKeyword, setDraftKeyword] = useState(keyword);
-
- return (
-
-
-
- setDraftKeyword(event.target.value)}
- onClear={() => {
- setDraftKeyword("");
- onSearchClear();
- }}
- onSearch={(value) => onSearch(value)}
- placeholder="搜索模型名称或 ID"
- value={draftKeyword}
- />
- } onClick={openCreateDialog} type="primary">
- 新建模型
-
-
-
- );
-}
diff --git a/src/web/features/models/components/ProviderTable.tsx b/src/web/features/models/components/ProviderTable.tsx
index 2867b7d..880649b 100644
--- a/src/web/features/models/components/ProviderTable.tsx
+++ b/src/web/features/models/components/ProviderTable.tsx
@@ -1,91 +1,120 @@
-import type { TableColumnsType } from "antd";
+import type { TableColumnsType, TableProps } from "antd";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
-import { App as AntApp, Button, Popconfirm, Space, Table } from "antd";
-import { useCallback, useMemo } from "react";
+import { Button, Popconfirm, Space, Table } from "antd";
+import { useMemo } from "react";
import type { Provider, ProviderListResponse } from "../../../../shared/api";
+import { formatDatetime } from "../../../shared/utils/format";
+
interface ProviderTableProps {
data: ProviderListResponse | undefined;
loading: boolean;
- onDelete: (id: string) => Promise;
+ onChange: (
+ pagination: { current?: number; pageSize?: number },
+ sorter: { columnKey?: string; field?: string | string[]; order?: string },
+ ) => void;
+ onDelete: (id: string) => Promise;
onEdit: (provider: Provider) => void;
- onPageChange: (page: number, pageSize: number) => void;
page: number;
pageSize: number;
+ sortBy?: string;
+ sortOrder?: string;
}
-const TYPE_LABELS: Record = {
- anthropic: "Anthropic",
- openai: "OpenAI",
- "openai-compatible": "OpenAI 兼容",
-};
-
-const COLUMNS: TableColumnsType = [
- { dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
- {
- dataIndex: "type",
- render: (value: Provider["type"]) => TYPE_LABELS[value] ?? value,
- title: "类型",
- width: 140,
- },
- { dataIndex: "baseUrl", ellipsis: true, title: "Base URL" },
-];
-
-export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) {
- const { message } = AntApp.useApp();
-
- const handleDelete = useCallback(
- async (id: string) => {
- try {
- await onDelete(id);
- message.success("供应商已删除");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- },
- [onDelete, message],
- );
-
- const operationColumn = useMemo[number]>(
- () => ({
- dataIndex: "op",
- render: (_value: unknown, record: Provider) => (
-
- } onClick={() => onEdit(record)} size="small" type="link">
- 编辑
-
- void handleDelete(record.id)}
- title="确认删除此供应商?"
- >
- } size="small" type="link">
- 删除
+export function ProviderTable({
+ data,
+ loading,
+ onChange,
+ onDelete,
+ onEdit,
+ page,
+ pageSize,
+ sortBy,
+ sortOrder,
+}: ProviderTableProps) {
+ const columns = useMemo>(
+ () => [
+ {
+ dataIndex: "name",
+ ellipsis: true,
+ sorter: true,
+ sortOrder:
+ sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
+ title: "名称",
+ width: 180,
+ },
+ {
+ dataIndex: "type",
+ render: (_value: unknown, record: Provider) => {
+ const labels: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ "openai-compatible": "OpenAI 兼容",
+ };
+ return labels[record.type] ?? record.type;
+ },
+ title: "类型",
+ width: 140,
+ },
+ { dataIndex: "baseUrl", ellipsis: true, title: "Base URL" },
+ {
+ align: "center",
+ dataIndex: "createdAt",
+ render: (_value: unknown, record: Provider) => formatDatetime(record.createdAt),
+ sorter: true,
+ sortOrder:
+ sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
+ title: "创建时间",
+ width: 180,
+ },
+ {
+ dataIndex: "op",
+ render: (_value: unknown, record: Provider) => (
+
+ } onClick={() => onEdit(record)} size="small" type="link">
+ 编辑
-
-
- ),
- title: "操作",
- width: 180,
- }),
- [onEdit, handleDelete],
+ void onDelete(record.id)}
+ title="确认删除此供应商?"
+ >
+ } size="small" type="link">
+ 删除
+
+
+
+ ),
+ title: "操作",
+ width: 180,
+ },
+ ],
+ [onEdit, onDelete, sortBy, sortOrder],
);
- const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
+ const handleTableChange: TableProps["onChange"] = (pagination, _filters, sorter) => {
+ const sortInfo =
+ sorter && typeof sorter === "object" && "columnKey" in sorter
+ ? (sorter as { columnKey?: string; field?: string | string[]; order?: string })
+ : {};
+ onChange({ current: pagination.current, pageSize: pagination.pageSize }, sortInfo);
+ };
return (
`共 ${total} 条`,
total: data?.total ?? 0,
}}
rowKey="id"
diff --git a/src/web/features/models/components/ProviderToolbar.tsx b/src/web/features/models/components/ProviderToolbar.tsx
deleted file mode 100644
index b8ad809..0000000
--- a/src/web/features/models/components/ProviderToolbar.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { PlusOutlined } from "@ant-design/icons";
-import { Button, Flex, Input, Space } from "antd";
-import { useState } from "react";
-
-interface ProviderToolbarProps {
- keyword: string;
- onSearch: (value: string) => void;
- onSearchClear: () => void;
- openCreateDialog: () => void;
-}
-
-export function ProviderToolbar({ keyword, onSearch, onSearchClear, openCreateDialog }: ProviderToolbarProps) {
- const [draftKeyword, setDraftKeyword] = useState(keyword);
-
- return (
-
-
-
- setDraftKeyword(event.target.value)}
- onClear={() => {
- setDraftKeyword("");
- onSearchClear();
- }}
- onSearch={(value) => onSearch(value)}
- placeholder="搜索供应商名称"
- value={draftKeyword}
- />
- } onClick={openCreateDialog} type="primary">
- 新建供应商
-
-
-
- );
-}
diff --git a/src/web/features/projects/components/ProjectTable.tsx b/src/web/features/projects/components/ProjectTable.tsx
index ce02af1..4260368 100644
--- a/src/web/features/projects/components/ProjectTable.tsx
+++ b/src/web/features/projects/components/ProjectTable.tsx
@@ -1,184 +1,169 @@
-import type { TableColumnsType } from "antd";
+import type { TableColumnsType, TableProps } from "antd";
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
-import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
-import { useCallback, useMemo } from "react";
+import { Button, Popconfirm, Space, Table, Tag } from "antd";
+import { useMemo } from "react";
import { useNavigate } from "react-router";
-import type { Project, ProjectListResponse, ProjectStatus } from "../../../../shared/api";
+import type { Project, ProjectListResponse } from "../../../../shared/api";
+
+import { formatDatetime } from "../../../shared/utils/format";
interface ProjectTableProps {
data: ProjectListResponse | undefined;
loading: boolean;
- onArchive: (id: string) => Promise;
- onDelete: (id: string) => Promise;
+ onArchive: (id: string) => Promise;
+ onChange: (
+ pagination: { current?: number; pageSize?: number },
+ sorter: { columnKey?: string; field?: string | string[]; order?: string },
+ ) => void;
+ onDelete: (id: string) => Promise;
onEdit: (project: Project) => void;
- onPageChange: (page: number, pageSize: number) => void;
- onRestore: (id: string) => Promise;
+ onRestore: (id: string) => Promise;
page: number;
pageSize: number;
- status: ProjectStatus;
+ sortBy?: string;
+ sortOrder?: string;
}
-const COLUMNS: TableColumnsType = [
- { dataIndex: "name", ellipsis: true, title: "名称", width: 140 },
- { dataIndex: "description", ellipsis: true, title: "描述" },
- {
- align: "center",
- dataIndex: "status",
- render: (_value, record: Project) => {
- if (record.status === "archived") {
- return 已归档;
- }
- return 进行中;
- },
- title: "状态",
- width: 90,
- },
- {
- align: "center",
- dataIndex: "createdAt",
- render: (_value, record: Project) => formatDatetime(record.createdAt),
- title: "创建时间",
- width: 180,
- },
- {
- align: "center",
- dataIndex: "updatedAt",
- render: (_value, record: Project) => formatDatetime(record.updatedAt),
- title: "更新时间",
- width: 180,
- },
-];
-
export function ProjectTable({
data,
loading,
onArchive,
+ onChange,
onDelete,
onEdit,
- onPageChange,
onRestore,
page,
pageSize,
- status,
+ sortBy,
+ sortOrder,
}: ProjectTableProps) {
- const { message } = AntApp.useApp();
const navigate = useNavigate();
- const handleArchive = useCallback(
- async (id: string) => {
- try {
- await onArchive(id);
- message.success("项目已归档");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- },
- [onArchive, message],
- );
-
- const handleRestore = useCallback(
- async (id: string) => {
- try {
- await onRestore(id);
- message.success("项目已恢复");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- },
- [onRestore, message],
- );
-
- const handleDelete = useCallback(
- async (id: string) => {
- try {
- await onDelete(id);
- message.success("项目已永久删除");
- } catch (err: unknown) {
- message.error((err as Error).message);
- }
- },
- [onDelete, message],
- );
-
- const operationColumn = useMemo[number]>(
- () => ({
- dataIndex: "op",
- render: (_value, record: Project) => {
- if (record.status === "active") {
+ const columns = useMemo>(
+ () => [
+ {
+ dataIndex: "name",
+ ellipsis: true,
+ sorter: true,
+ sortOrder:
+ sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
+ title: "名称",
+ width: 140,
+ },
+ { dataIndex: "description", ellipsis: true, title: "描述" },
+ {
+ align: "center",
+ dataIndex: "status",
+ render: (_value: unknown, record: Project) => {
+ if (record.status === "archived") {
+ return 已归档;
+ }
+ return 进行中;
+ },
+ title: "状态",
+ width: 90,
+ },
+ {
+ align: "center",
+ dataIndex: "createdAt",
+ render: (_value: unknown, record: Project) => formatDatetime(record.createdAt),
+ sorter: true,
+ sortOrder:
+ sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
+ title: "创建时间",
+ width: 180,
+ },
+ {
+ align: "center",
+ dataIndex: "updatedAt",
+ render: (_value: unknown, record: Project) => formatDatetime(record.updatedAt),
+ sorter: true,
+ sortOrder:
+ sortBy === "updatedAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
+ title: "更新时间",
+ width: 180,
+ },
+ {
+ dataIndex: "op",
+ render: (_value: unknown, record: Project) => {
+ if (record.status === "active") {
+ return (
+
+ }
+ onClick={() => void navigate(`/workbench/${record.id}`)}
+ size="small"
+ type="link"
+ >
+ 工作台
+
+ } onClick={() => onEdit(record)} size="small" type="link">
+ 编辑
+
+ void onArchive(record.id)}
+ title="确认归档此项目?"
+ >
+ } size="small" variant="link">
+ 归档
+
+
+
+ );
+ }
return (
- }
- onClick={() => void navigate(`/workbench/${record.id}`)}
- size="small"
- type="link"
- >
- 工作台
-
- } onClick={() => onEdit(record)} size="small" type="link">
- 编辑
-
+ void onRestore(record.id)} title="确认恢复此项目?">
+ } size="small" type="link">
+ 恢复
+
+
void handleArchive(record.id)}
- title="确认归档此项目?"
+ description="此操作不可恢复。"
+ onConfirm={() => void onDelete(record.id)}
+ title="确认永久删除此项目?"
>
- } size="small" variant="link">
- 归档
+ } size="small" type="link">
+ 删除
);
- }
- return (
-
- void handleRestore(record.id)} title="确认恢复此项目?">
- } size="small" type="link">
- 恢复
-
-
- void handleDelete(record.id)}
- title="确认永久删除此项目?"
- >
- } size="small" type="link">
- 删除
-
-
-
- );
+ },
+ title: "操作",
+ width: 260,
},
- title: "操作",
- width: status === "active" ? 260 : 160,
- }),
- [navigate, onEdit, handleArchive, handleRestore, handleDelete, status],
+ ],
+ [navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder],
);
- const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
+ const handleTableChange: TableProps["onChange"] = (pagination, _filters, sorter) => {
+ const sortInfo =
+ sorter && typeof sorter === "object" && "columnKey" in sorter
+ ? (sorter as { columnKey?: string; field?: string | string[]; order?: string })
+ : {};
+ onChange({ current: pagination.current, pageSize: pagination.pageSize }, sortInfo);
+ };
return (
`共 ${total} 条`,
total: data?.total ?? 0,
}}
rowKey="id"
/>
);
}
-
-function formatDatetime(dateStr: string): string {
- const d = new Date(dateStr);
- const pad = (n: number) => String(n).padStart(2, "0");
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
-}
diff --git a/src/web/features/projects/components/ProjectToolbar.tsx b/src/web/features/projects/components/ProjectToolbar.tsx
deleted file mode 100644
index fae76f9..0000000
--- a/src/web/features/projects/components/ProjectToolbar.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { PlusOutlined } from "@ant-design/icons";
-import { Button, Flex, Input, Space, Tabs } from "antd";
-import { useState } from "react";
-
-import type { ProjectStatus } from "../../../../shared/api";
-
-interface ProjectToolbarProps {
- activeTab: ProjectStatus;
- keyword: string;
- onSearch: (value: string) => void;
- onSearchClear: () => void;
- onTabChange: (key: string) => void;
- openCreateDialog: () => void;
-}
-
-const STATUS_TAB_ITEMS = [
- { key: "active", label: "进行中" },
- { key: "archived", label: "已归档" },
-];
-
-export function ProjectToolbar({
- activeTab,
- keyword,
- onSearch,
- onSearchClear,
- onTabChange,
- openCreateDialog,
-}: ProjectToolbarProps) {
- const [draftKeyword, setDraftKeyword] = useState(keyword);
-
- return (
-
-
-
- setDraftKeyword(event.target.value)}
- onClear={() => {
- setDraftKeyword("");
- onSearchClear();
- }}
- onSearch={(value) => onSearch(value)}
- placeholder="搜索名称或描述"
- value={draftKeyword}
- />
- {activeTab === "active" && (
- } onClick={openCreateDialog} type="primary">
- 新建项目
-
- )}
-
-
- );
-}
diff --git a/src/web/features/projects/index.tsx b/src/web/features/projects/index.tsx
index 3ae549c..8ce8783 100644
--- a/src/web/features/projects/index.tsx
+++ b/src/web/features/projects/index.tsx
@@ -1,8 +1,10 @@
-import { Space } from "antd";
-import { useState } from "react";
+import { PlusOutlined } from "@ant-design/icons";
+import { Button, Space } from "antd";
+import { useCallback, useMemo, useState } from "react";
import type { Project, ProjectStatus } from "../../../shared/api";
+import { FilterToolbar } from "../../shared/components/FilterToolbar";
import {
useArchiveProject,
useCreateProject,
@@ -11,20 +13,40 @@ import {
useRestoreProject,
useUpdateProject,
} from "../../shared/hooks/use-projects";
+import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
+import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
import { ProjectFormModal } from "./components/ProjectFormModal";
import { ProjectTable } from "./components/ProjectTable";
-import { ProjectToolbar } from "./components/ProjectToolbar";
+
+const STATUS_OPTIONS = [
+ { label: "进行中", value: "active" },
+ { label: "已归档", value: "archived" },
+];
export function ProjectsPage() {
- const [tabValue, setTabValue] = useState("active");
- const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(20);
- const [keyword, setKeyword] = useState("");
-
+ const { confirmAction } = useConfirmAction();
+ const { params, resetAll, setParams } = usePageSearchParams({
+ defaults: { page: "1", pageSize: "20" },
+ });
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProject, setEditingProject] = useState(null);
- const { data, isLoading } = useProjectList({ keyword: keyword || undefined, page, pageSize, status: tabValue });
+ const apiPage = Number(params["page"]) || 1;
+ const apiPageSize = Number(params["pageSize"]) || 20;
+ const keyword = params["keyword"] ?? "";
+ const statusFilter = params["status"] as ProjectStatus | undefined;
+ const sortBy = params["sortBy"];
+ const sortOrder = params["sortOrder"];
+
+ const { data, isFetching } = useProjectList({
+ keyword: keyword || undefined,
+ page: apiPage,
+ pageSize: apiPageSize,
+ sortBy: sortBy ?? undefined,
+ sortOrder: sortOrder,
+ status: statusFilter,
+ });
+
const createMutation = useCreateProject();
const updateMutation = useUpdateProject();
const archiveMutation = useArchiveProject();
@@ -32,54 +54,111 @@ export function ProjectsPage() {
const deleteMutation = useDeleteProject();
const isSubmitting = createMutation.isPending || updateMutation.isPending;
- const isRowActionPending = archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending;
+
+ const handleSearch = useCallback(
+ (value: string) => {
+ setParams({ keyword: value || undefined, page: "1" });
+ },
+ [setParams],
+ );
+
+ const handleReset = useCallback(() => {
+ resetAll();
+ }, [resetAll]);
+
+ const handleTableChange = useCallback(
+ (
+ pagination: { current?: number; pageSize?: number },
+ sorter: { columnKey?: string; field?: string | string[]; order?: string },
+ ) => {
+ const patch: Record = {
+ page: String(pagination.current ?? apiPage),
+ pageSize: String(pagination.pageSize ?? apiPageSize),
+ };
+ const sortField = sorter.columnKey! ?? (typeof sorter.field === "string" ? sorter.field : undefined);
+ if (sorter.order && sortField) {
+ patch["sortBy"] = sortField;
+ patch["sortOrder"] = sorter.order === "ascend" ? "asc" : "desc";
+ } else {
+ patch["sortBy"] = undefined;
+ patch["sortOrder"] = undefined;
+ }
+ setParams(patch);
+ },
+ [setParams, apiPage, apiPageSize],
+ );
+
+ const filters = useMemo(
+ () => [
+ {
+ key: "status",
+ label: "状态",
+ onChange: (value: string | undefined) => {
+ setParams({ page: "1", status: value });
+ },
+ options: STATUS_OPTIONS,
+ placeholder: "状态",
+ value: statusFilter,
+ },
+ ],
+ [setParams, statusFilter],
+ );
+
+ const handleArchive = useCallback(
+ (id: string) => confirmAction(() => archiveMutation.mutateAsync(id), "项目已归档"),
+ [confirmAction, archiveMutation],
+ );
+
+ const handleRestore = useCallback(
+ (id: string) => confirmAction(() => restoreMutation.mutateAsync(id), "项目已恢复"),
+ [confirmAction, restoreMutation],
+ );
+
+ const handleDelete = useCallback(
+ (id: string) => confirmAction(() => deleteMutation.mutateAsync(id), "项目已永久删除"),
+ [confirmAction, deleteMutation],
+ );
return (
- {
- setKeyword(value);
- setPage(1);
- }}
- onSearchClear={() => {
- setKeyword("");
- setPage(1);
- }}
- onTabChange={(key) => {
- setTabValue(key as ProjectStatus);
- setPage(1);
- }}
- openCreateDialog={() => {
- setEditingProject(null);
- setDialogOpen(true);
- }}
+ }
+ onClick={() => {
+ setEditingProject(null);
+ setDialogOpen(true);
+ }}
+ type="primary"
+ >
+ 新建项目
+
+ }
+ filters={filters}
+ search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索名称或描述" }}
/>
archiveMutation.mutateAsync(id)}
- onDelete={(id) => deleteMutation.mutateAsync(id)}
+ loading={isFetching}
+ onArchive={handleArchive}
+ onChange={handleTableChange}
+ onDelete={handleDelete}
onEdit={(project) => {
setEditingProject(project);
setDialogOpen(true);
}}
- onPageChange={(p, ps) => {
- setPage(p);
- setPageSize(ps);
- }}
- onRestore={(id) => restoreMutation.mutateAsync(id)}
- page={page}
- pageSize={pageSize}
- status={tabValue}
+ onRestore={handleRestore}
+ page={apiPage}
+ pageSize={apiPageSize}
+ sortBy={sortBy}
+ sortOrder={sortOrder}
/>
setDialogOpen(false)}
- onCreate={(data) => createMutation.mutateAsync(data)}
+ onCreate={(formData) => createMutation.mutateAsync(formData)}
onOpenChange={setDialogOpen}
onUpdate={(args) => updateMutation.mutateAsync(args)}
open={dialogOpen}
diff --git a/src/web/shared/components/FilterToolbar.tsx b/src/web/shared/components/FilterToolbar.tsx
new file mode 100644
index 0000000..40d7767
--- /dev/null
+++ b/src/web/shared/components/FilterToolbar.tsx
@@ -0,0 +1,74 @@
+import type { ReactNode } from "react";
+
+import { SearchOutlined, UndoOutlined } from "@ant-design/icons";
+import { Button, Flex, Input, Select, Space } from "antd";
+import { useEffect, useRef, useState } from "react";
+
+export interface FilterConfig {
+ key: string;
+ label: string;
+ onChange: (value: string | undefined) => void;
+ options: Array<{ label: string; value: string }>;
+ placeholder: string;
+ value?: string;
+}
+
+export interface FilterToolbarProps {
+ actions?: ReactNode;
+ filters?: FilterConfig[];
+ search?: SearchConfig;
+}
+
+export interface SearchConfig {
+ keyword?: string;
+ onReset: () => void;
+ onSearch: (value: string) => void;
+ placeholder: string;
+}
+
+export function FilterToolbar({ actions, filters, search }: FilterToolbarProps) {
+ const [draftKeyword, setDraftKeyword] = useState(search?.keyword ?? "");
+ const prevKeywordRef = useRef(search?.keyword);
+
+ useEffect(() => {
+ if (search?.keyword !== prevKeywordRef.current) {
+ prevKeywordRef.current = search?.keyword;
+ setDraftKeyword(search?.keyword ?? "");
+ }
+ }, [search?.keyword]);
+
+ return (
+
+
+ {filters?.map((filter) => (
+
+ ))}
+ {search && (
+ } type="primary" />}
+ onChange={(e) => setDraftKeyword(e.target.value)}
+ onClear={() => {
+ setDraftKeyword("");
+ search.onSearch("");
+ }}
+ onSearch={(value) => search.onSearch(value)}
+ placeholder={search.placeholder}
+ style={{ width: 220 }}
+ value={draftKeyword}
+ />
+ )}
+ } onClick={search?.onReset} title="重置" />
+
+ {actions && {actions}}
+
+ );
+}
diff --git a/src/web/shared/hooks/use-models.ts b/src/web/shared/hooks/use-models.ts
index 390236c..891772e 100644
--- a/src/web/shared/hooks/use-models.ts
+++ b/src/web/shared/hooks/use-models.ts
@@ -37,16 +37,22 @@ export async function fetchModel(id: string): Promise {
}
export async function fetchModelList(params: {
+ capabilities?: string;
keyword?: string;
page?: number;
pageSize?: number;
providerId?: string;
+ sortBy?: string;
+ sortOrder?: string;
}): Promise {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.providerId) searchParams.set("providerId", params.providerId);
+ if (params.sortBy) searchParams.set("sortBy", params.sortBy);
+ if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
+ if (params.capabilities) searchParams.set("capabilities", params.capabilities);
const qs = searchParams.toString();
const url = `/api/models${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
@@ -110,7 +116,15 @@ export function useModel(id: string) {
});
}
-export function useModelList(params: { keyword?: string; page?: number; pageSize?: number; providerId?: string }) {
+export function useModelList(params: {
+ capabilities?: string;
+ keyword?: string;
+ page?: number;
+ pageSize?: number;
+ providerId?: string;
+ sortBy?: string;
+ sortOrder?: string;
+}) {
return useQuery({
queryFn: () => fetchModelList(params),
queryKey: [...MODELS_KEY, "list", params],
diff --git a/src/web/shared/hooks/use-projects.ts b/src/web/shared/hooks/use-projects.ts
index 6fd5570..09a4337 100644
--- a/src/web/shared/hooks/use-projects.ts
+++ b/src/web/shared/hooks/use-projects.ts
@@ -43,12 +43,16 @@ export async function fetchProjectList(params: {
keyword?: string;
page?: number;
pageSize?: number;
+ sortBy?: string;
+ sortOrder?: string;
status?: ProjectStatus;
}): Promise {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
+ if (params.sortBy) searchParams.set("sortBy", params.sortBy);
+ if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
if (params.status) searchParams.set("status", params.status);
const qs = searchParams.toString();
const url = `/api/projects${qs ? `?${qs}` : ""}`;
@@ -115,7 +119,14 @@ export function useProject(id: string) {
});
}
-export function useProjectList(params: { keyword?: string; page?: number; pageSize?: number; status?: ProjectStatus }) {
+export function useProjectList(params: {
+ keyword?: string;
+ page?: number;
+ pageSize?: number;
+ sortBy?: string;
+ sortOrder?: string;
+ status?: ProjectStatus;
+}) {
return useQuery({
queryFn: () => fetchProjectList(params),
queryKey: [...PROJECTS_KEY, "list", params],
diff --git a/src/web/shared/hooks/use-providers.ts b/src/web/shared/hooks/use-providers.ts
index 40cb11a..caa3635 100644
--- a/src/web/shared/hooks/use-providers.ts
+++ b/src/web/shared/hooks/use-providers.ts
@@ -41,11 +41,17 @@ export async function fetchProviderList(params: {
keyword?: string;
page?: number;
pageSize?: number;
+ sortBy?: string;
+ sortOrder?: string;
+ type?: string;
}): Promise {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
+ if (params.sortBy) searchParams.set("sortBy", params.sortBy);
+ if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
+ if (params.type) searchParams.set("type", params.type);
const qs = searchParams.toString();
const url = `/api/providers${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
@@ -119,7 +125,14 @@ export function useProvider(id: string) {
});
}
-export function useProviderList(params: { keyword?: string; page?: number; pageSize?: number }) {
+export function useProviderList(params: {
+ keyword?: string;
+ page?: number;
+ pageSize?: number;
+ sortBy?: string;
+ sortOrder?: string;
+ type?: string;
+}) {
return useQuery({
queryFn: () => fetchProviderList(params),
queryKey: [...PROVIDERS_KEY, "list", params],
diff --git a/src/web/shared/hooks/useConfirmAction.ts b/src/web/shared/hooks/useConfirmAction.ts
new file mode 100644
index 0000000..c11c1bb
--- /dev/null
+++ b/src/web/shared/hooks/useConfirmAction.ts
@@ -0,0 +1,24 @@
+import { App } from "antd";
+import { useCallback } from "react";
+
+export interface UseConfirmActionResult {
+ confirmAction: (action: () => Promise, successMessage: string) => Promise;
+}
+
+export function useConfirmAction(): UseConfirmActionResult {
+ const { message } = App.useApp();
+
+ const confirmAction = useCallback(
+ async (action: () => Promise, successMessage: string) => {
+ try {
+ await action();
+ message.success(successMessage);
+ } catch (err: unknown) {
+ message.error((err as Error).message);
+ }
+ },
+ [message],
+ );
+
+ return { confirmAction };
+}
diff --git a/src/web/shared/hooks/usePageSearchParams.ts b/src/web/shared/hooks/usePageSearchParams.ts
new file mode 100644
index 0000000..7c27345
--- /dev/null
+++ b/src/web/shared/hooks/usePageSearchParams.ts
@@ -0,0 +1,74 @@
+import { useCallback, useMemo } from "react";
+import { useSearchParams } from "react-router";
+
+export interface UsePageSearchParamsOptions {
+ defaults?: Record;
+}
+
+export interface UsePageSearchParamsResult {
+ params: Record;
+ resetAll: () => void;
+ setParam: (key: string, value: string | undefined) => void;
+ setParams: (patch: Record) => void;
+}
+
+export function usePageSearchParams(options?: UsePageSearchParamsOptions): UsePageSearchParamsResult {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const defaults = useMemo(() => options?.defaults ?? {}, [options?.defaults]);
+
+ const params = useMemo(() => {
+ const result: Record = {};
+ for (const [key, value] of searchParams.entries()) {
+ result[key] = value;
+ }
+ for (const [key, value] of Object.entries(defaults)) {
+ if (!(key in result)) result[key] = value;
+ }
+ return result;
+ }, [searchParams, defaults]);
+
+ const setParam = useCallback(
+ (key: string, value: string | undefined) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (value === undefined || value === "") {
+ next.delete(key);
+ } else {
+ next.set(key, value);
+ }
+ return next;
+ },
+ { replace: true },
+ );
+ },
+ [setSearchParams],
+ );
+
+ const setParams = useCallback(
+ (patch: Record) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === undefined || value === "") {
+ next.delete(key);
+ } else {
+ next.set(key, value);
+ }
+ }
+ return next;
+ },
+ { replace: true },
+ );
+ },
+ [setSearchParams],
+ );
+
+ const resetAll = useCallback(() => {
+ setSearchParams(new URLSearchParams(), { replace: true });
+ }, [setSearchParams]);
+
+ return { params, resetAll, setParam, setParams };
+}
diff --git a/src/web/shared/utils/format.ts b/src/web/shared/utils/format.ts
new file mode 100644
index 0000000..757502f
--- /dev/null
+++ b/src/web/shared/utils/format.ts
@@ -0,0 +1,5 @@
+export function formatDatetime(dateStr: string): string {
+ const d = new Date(dateStr);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
diff --git a/tests/server/list-params.test.ts b/tests/server/list-params.test.ts
new file mode 100644
index 0000000..150da20
--- /dev/null
+++ b/tests/server/list-params.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, test } from "bun:test";
+
+import type { RuntimeMode } from "../../src/shared/api";
+
+import { parseListParams } from "../../src/server/helpers/list-params";
+
+const mode: RuntimeMode = "test";
+
+function makeUrl(params: Record = {}): URL {
+ const sp = new URLSearchParams(params);
+ return new URL(`http://localhost/api/test?${sp.toString()}`);
+}
+
+describe("parseListParams", () => {
+ test("returns defaults when no params provided", () => {
+ const url = makeUrl();
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.page).toBe(1);
+ expect(result.pageSize).toBe(20);
+ expect(result.keyword).toBeUndefined();
+ expect(result.sortBy).toBeUndefined();
+ expect(result.sortOrder).toBeUndefined();
+ });
+
+ test("parses valid pagination params", () => {
+ const url = makeUrl({ page: "2", pageSize: "50" });
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.page).toBe(2);
+ expect(result.pageSize).toBe(50);
+ });
+
+ test("parses keyword param", () => {
+ const url = makeUrl({ keyword: "test" });
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.keyword).toBe("test");
+ });
+
+ test("keyword empty string becomes undefined", () => {
+ const url = makeUrl({ keyword: "" });
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.keyword).toBeUndefined();
+ });
+
+ test("parses valid sort params", () => {
+ const url = makeUrl({ sortBy: "name", sortOrder: "asc" });
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.sortBy).toBe("name");
+ expect(result.sortOrder).toBe("asc");
+ });
+
+ test("parses desc sortOrder", () => {
+ const url = makeUrl({ sortBy: "createdAt", sortOrder: "desc" });
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.sortOrder).toBe("desc");
+ });
+
+ test("sortBy without sortOrder returns undefined sortOrder", () => {
+ const url = makeUrl({ sortBy: "name" });
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.sortBy).toBe("name");
+ expect(result.sortOrder).toBeUndefined();
+ });
+
+ test("rejects invalid page", () => {
+ const url = makeUrl({ page: "0" });
+ const result = parseListParams(url, mode);
+ expect(result).toBeInstanceOf(Response);
+ expect((result as Response).status).toBe(400);
+ });
+
+ test("rejects negative page", () => {
+ const url = makeUrl({ page: "-1" });
+ const result = parseListParams(url, mode);
+ expect(result).toBeInstanceOf(Response);
+ });
+
+ test("rejects non-integer page", () => {
+ const url = makeUrl({ page: "1.5" });
+ const result = parseListParams(url, mode);
+ expect(result).toBeInstanceOf(Response);
+ });
+
+ test("rejects pageSize over 200", () => {
+ const url = makeUrl({ pageSize: "201" });
+ const result = parseListParams(url, mode);
+ expect(result).toBeInstanceOf(Response);
+ });
+
+ test("rejects invalid sortOrder", () => {
+ const url = makeUrl({ sortBy: "name", sortOrder: "invalid" });
+ const result = parseListParams(url, mode);
+ expect(result).toBeInstanceOf(Response);
+ });
+
+ test("rejects sortBy not in whitelist", () => {
+ const url = makeUrl({ sortBy: "evil" });
+ const result = parseListParams(url, mode, { allowedSortBy: ["name", "createdAt"] });
+ expect(result).toBeInstanceOf(Response);
+ });
+
+ test("allows sortBy in whitelist", () => {
+ const url = makeUrl({ sortBy: "name" });
+ const result = parseListParams(url, mode, { allowedSortBy: ["name", "createdAt"] });
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.sortBy).toBe("name");
+ });
+
+ test("allows sortBy when no whitelist provided", () => {
+ const url = makeUrl({ sortBy: "anything" });
+ const result = parseListParams(url, mode);
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result.sortBy).toBe("anything");
+ });
+
+ test("parses all params together", () => {
+ const url = makeUrl({ keyword: "hello", page: "3", pageSize: "10", sortBy: "createdAt", sortOrder: "desc" });
+ const result = parseListParams(url, mode, { allowedSortBy: ["createdAt"] });
+ if (result instanceof Response) throw new Error("Should not return Response");
+ expect(result).toEqual({
+ keyword: "hello",
+ page: 3,
+ pageSize: 10,
+ sortBy: "createdAt",
+ sortOrder: "desc",
+ });
+ });
+});
diff --git a/tests/server/routes/models.test.ts b/tests/server/routes/models.test.ts
index e22f877..eddefde 100644
--- a/tests/server/routes/models.test.ts
+++ b/tests/server/routes/models.test.ts
@@ -130,6 +130,48 @@ describe("models API routes", () => {
});
});
+ test("GET /api/models sortBy + sortOrder", async () => {
+ await withRouteDb(async (db) => {
+ const p = seedProvider(db, "SortP");
+ createTestModel(db, "Beta", p);
+ createTestModel(db, "Alpha", p);
+
+ const req = new Request("http://localhost/api/models?page=1&pageSize=20&sortBy=name&sortOrder=asc");
+ const res = await listModelsViaHandler(req, db);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { items: Model[] };
+ expect(body.items[0]!.name).toBe("Alpha");
+ expect(body.items[1]!.name).toBe("Beta");
+ });
+ });
+
+ test("GET /api/models filter by capabilities", async () => {
+ await withRouteDb(async (db) => {
+ const p = seedProvider(db, "CapP");
+ createModel(db, { capabilities: ["text"], modelId: "text-1", name: "TextModel", providerId: p }, LOG);
+ createModel(
+ db,
+ { capabilities: ["reasoning"], modelId: "reasoning-1", name: "ReasoningModel", providerId: p },
+ LOG,
+ );
+
+ const req = new Request("http://localhost/api/models?page=1&pageSize=20&capabilities=text");
+ const res = await listModelsViaHandler(req, db);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { items: Model[]; total: number };
+ expect(body.total).toBe(1);
+ expect(body.items[0]!.name).toBe("TextModel");
+ });
+ });
+
+ test("GET /api/models rejects invalid sortBy", async () => {
+ await withRouteDb(async (db) => {
+ const req = new Request("http://localhost/api/models?page=1&pageSize=20&sortBy=evil");
+ const res = await listModelsViaHandler(req, db);
+ expect(res.status).toBe(400);
+ });
+ });
+
test("GET /api/models filter by providerId", async () => {
await withRouteDb(async (db) => {
const p1 = seedProvider(db, "P1");
diff --git a/tests/server/routes/providers.test.ts b/tests/server/routes/providers.test.ts
index 703eb59..8ea6c28 100644
--- a/tests/server/routes/providers.test.ts
+++ b/tests/server/routes/providers.test.ts
@@ -131,6 +131,55 @@ describe("供应商 API 路由", () => {
});
});
+ test("GET /api/providers sortBy + sortOrder", async () => {
+ await withRouteDb(async (db) => {
+ createTestProvider(db, "Beta");
+ createTestProvider(db, "Alpha");
+
+ const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=name&sortOrder=asc");
+ const res = await listProvidersViaHandler(req, db);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { items: Provider[] };
+ expect(body.items[0]!.name).toBe("Alpha");
+ expect(body.items[1]!.name).toBe("Beta");
+ });
+ });
+
+ test("GET /api/providers filter by type", async () => {
+ await withRouteDb(async (db) => {
+ createTestProvider(db, "OpenAI Provider");
+ const compatResult = createProvider(
+ db,
+ { apiKey: "sk-test", baseUrl: "https://compat.test.com", name: "Compat", type: "openai-compatible" },
+ LOG,
+ );
+ if ("error" in compatResult) throw new Error(compatResult.error);
+
+ const req = new Request("http://localhost/api/providers?page=1&pageSize=20&type=openai");
+ const res = await listProvidersViaHandler(req, db);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { items: Provider[]; total: number };
+ expect(body.total).toBe(1);
+ expect(body.items[0]!.name).toBe("OpenAI Provider");
+ });
+ });
+
+ test("GET /api/providers rejects invalid sortBy", async () => {
+ await withRouteDb(async (db) => {
+ const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=evil");
+ const res = await listProvidersViaHandler(req, db);
+ expect(res.status).toBe(400);
+ });
+ });
+
+ test("GET /api/providers rejects invalid sortOrder", async () => {
+ await withRouteDb(async (db) => {
+ const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=name&sortOrder=invalid");
+ const res = await listProvidersViaHandler(req, db);
+ expect(res.status).toBe(400);
+ });
+ });
+
test("GET /api/providers/options 返回最小字段", async () => {
await withRouteDb(async (db) => {
createTestProvider(db, "选项供应商");
diff --git a/tests/web/FilterToolbar.test.tsx b/tests/web/FilterToolbar.test.tsx
new file mode 100644
index 0000000..411404d
--- /dev/null
+++ b/tests/web/FilterToolbar.test.tsx
@@ -0,0 +1,106 @@
+import { fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "bun:test";
+import { createElement } from "react";
+
+import { FilterToolbar } from "../../src/web/shared/components/FilterToolbar";
+import { renderWithProviders } from "./test-utils";
+
+describe("FilterToolbar", () => {
+ it("renders filter Select with placeholder", () => {
+ const { getByText } = renderWithProviders(
+ createElement(FilterToolbar, {
+ filters: [
+ {
+ key: "status",
+ label: "状态",
+ onChange: () => {},
+ options: [
+ { label: "进行中", value: "active" },
+ { label: "已归档", value: "archived" },
+ ],
+ placeholder: "选择状态",
+ },
+ ],
+ }),
+ );
+ expect(getByText("选择状态")).toBeTruthy();
+ });
+
+ it("renders search input with placeholder", () => {
+ const { getByPlaceholderText } = renderWithProviders(
+ createElement(FilterToolbar, {
+ search: {
+ onReset: () => {},
+ onSearch: () => {},
+ placeholder: "搜索名称",
+ },
+ }),
+ );
+ expect(getByPlaceholderText("搜索名称")).toBeTruthy();
+ });
+
+ it("renders reset button", () => {
+ const { container } = renderWithProviders(
+ createElement(FilterToolbar, {
+ search: {
+ onReset: () => {},
+ onSearch: () => {},
+ placeholder: "搜索",
+ },
+ }),
+ );
+ const resetBtn = container.querySelector('button[title="重置"]');
+ expect(resetBtn).toBeTruthy();
+ });
+
+ it("renders action buttons", () => {
+ const { container } = renderWithProviders(
+ createElement(FilterToolbar, {
+ actions: createElement("button", null, "新建项目"),
+ search: {
+ onReset: () => {},
+ onSearch: () => {},
+ placeholder: "搜索",
+ },
+ }),
+ );
+ const buttons = container.querySelectorAll("button");
+ const hasAction = Array.from(buttons).some((btn) => btn.textContent?.includes("新建项目"));
+ expect(hasAction).toBe(true);
+ });
+
+ it("calls onSearch when search entered after typing", () => {
+ const onSearch = vi.fn();
+ const onReset = vi.fn();
+ const { getByPlaceholderText } = renderWithProviders(
+ createElement(FilterToolbar, {
+ search: {
+ keyword: "",
+ onReset,
+ onSearch,
+ placeholder: "搜索名称",
+ },
+ }),
+ );
+ const input = getByPlaceholderText("搜索名称");
+ fireEvent.change(input, { target: { value: "test" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onSearch).toHaveBeenCalledWith("test");
+ });
+
+ it("calls onReset when reset button clicked", () => {
+ const onReset = vi.fn();
+ const { container } = renderWithProviders(
+ createElement(FilterToolbar, {
+ search: {
+ onReset,
+ onSearch: () => {},
+ placeholder: "搜索",
+ },
+ }),
+ );
+ const resetBtn = container.querySelector('button[title="重置"]');
+ resetBtn?.click();
+ expect(onReset).toHaveBeenCalled();
+ });
+});
diff --git a/tests/web/components/ResourceTable.test.tsx b/tests/web/components/ResourceTable.test.tsx
index 193dfdb..a959855 100644
--- a/tests/web/components/ResourceTable.test.tsx
+++ b/tests/web/components/ResourceTable.test.tsx
@@ -74,9 +74,9 @@ function renderModelTable(overrides?: Record) {
createElement(ModelTable, {
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
loading: false,
+ onChange: () => undefined,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
- onPageChange: () => undefined,
page: 1,
pageSize: 20,
providers: [OPENAI_PROVIDER_OPTION, DEEPSEEK_PROVIDER_OPTION],
@@ -90,9 +90,9 @@ function renderProviderTable(overrides?: Record) {
createElement(ProviderTable, {
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
loading: false,
+ onChange: () => undefined,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
- onPageChange: () => undefined,
page: 1,
pageSize: 20,
...overrides,
diff --git a/tests/web/format.test.ts b/tests/web/format.test.ts
new file mode 100644
index 0000000..f75bc36
--- /dev/null
+++ b/tests/web/format.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "bun:test";
+
+import { formatDatetime } from "../../src/web/shared/utils/format";
+
+describe("formatDatetime", () => {
+ it("formats ISO date string to YYYY-MM-DD HH:mm:ss", () => {
+ expect(formatDatetime("2024-06-15T14:30:45.123Z")).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
+ });
+
+ it("pads single-digit month, day, hour, minute, second", () => {
+ const result = formatDatetime("2024-01-05T09:08:07.000Z");
+ expect(result).toMatch(/2024-0[1-9]-0[1-9] 0[0-9]:0[0-9]:0[0-9]/);
+ });
+
+ it("handles end-of-year date", () => {
+ const result = formatDatetime("2024-12-31T23:59:59.000Z");
+ expect(result).toContain("2024");
+ expect(result).toContain("59:59");
+ });
+
+ it("produces consistent output format", () => {
+ const result = formatDatetime("2024-06-15T14:30:45.123Z");
+ expect(result.length).toBe(19);
+ });
+});
diff --git a/tests/web/routes/models.test.tsx b/tests/web/routes/models.test.tsx
index 6724652..0049367 100644
--- a/tests/web/routes/models.test.tsx
+++ b/tests/web/routes/models.test.tsx
@@ -310,8 +310,9 @@ describe("ModelListPage", () => {
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
- fireEvent.change(screen.getByPlaceholderText("搜索模型名称或 ID"), { target: { value: "gpt" } });
- fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
+ const input = screen.getByPlaceholderText("搜索模型名称或 ID");
+ fireEvent.change(input, { target: { value: "gpt" } });
+ fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
}, 15000);
diff --git a/tests/web/routes/projects.test.tsx b/tests/web/routes/projects.test.tsx
index 02c6f16..96049e2 100644
--- a/tests/web/routes/projects.test.tsx
+++ b/tests/web/routes/projects.test.tsx
@@ -114,7 +114,7 @@ function LocationProbe() {
}
describe("ProjectsPage", () => {
- test("渲染项目管理入口并按状态请求项目列表", async () => {
+ test("渲染项目管理入口并展示项目列表", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
@@ -123,27 +123,34 @@ describe("ProjectsPage", () => {
expect(screen.getByText("活跃项目")).not.toBeNull();
});
- expect(screen.getByText("已归档")).not.toBeNull();
+ expect(screen.getByText("归档项目")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
expect(screen.getByPlaceholderText("搜索名称或描述")).not.toBeNull();
- expect(calls.some((call) => call.url.includes("status=active"))).toBe(true);
+ expect(calls.filter((call) => !call.url.includes("/api/meta")).length).toBeGreaterThan(0);
});
- test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => {
+ test("搜索和状态筛选会更新请求参数与用户可见结果", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
- fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
- fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
+ const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
+ fireEvent.change(searchInput1, { target: { value: "归档" } });
+ fireEvent.keyDown(searchInput1, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
- fireEvent.click(screen.getByText("已归档"));
+
+ const statusLabels = screen.getAllByText("状态");
+ const selectLabel = statusLabels.find((el) => el.closest(".ant-select"));
+ if (selectLabel) fireEvent.mouseDown(selectLabel);
+ await waitFor(() => {
+ const archivedOptions = screen.getAllByText("已归档");
+ const dropdownOption = archivedOptions.find((el) => el.closest(".ant-select-item"));
+ if (dropdownOption) fireEvent.click(dropdownOption);
+ });
+ await waitFor(() => expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true));
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
-
- expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true);
- expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true);
});
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
@@ -152,12 +159,19 @@ describe("ProjectsPage", () => {
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
- fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
- fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
+ const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
+ fireEvent.change(searchInput2, { target: { value: "归档" } });
+ fireEvent.keyDown(searchInput2, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
- fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "" } });
- fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
+ const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
+ const clearButton = searchInput3.closest(".ant-input-search")?.querySelector(".ant-input-clear-icon");
+ if (clearButton) fireEvent.click(clearButton);
+
+ await waitFor(() => {
+ const lastProjectCall = [...calls].reverse().find((call) => call.url.includes("/api/projects"));
+ expect(lastProjectCall && !lastProjectCall.url.includes("keyword=")).toBe(true);
+ });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
});
@@ -249,13 +263,12 @@ describe("ProjectsPage", () => {
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
loading: false,
onArchive,
+ onChange: () => undefined,
onDelete,
onEdit: () => undefined,
- onPageChange: () => undefined,
onRestore,
page: 1,
pageSize: 20,
- status: "active",
}),
),
);
diff --git a/tests/web/routes/providers.test.tsx b/tests/web/routes/providers.test.tsx
index 7850b8c..10cc9b6 100644
--- a/tests/web/routes/providers.test.tsx
+++ b/tests/web/routes/providers.test.tsx
@@ -205,8 +205,9 @@ describe("ProviderListPage", () => {
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
- fireEvent.change(screen.getByPlaceholderText("搜索供应商名称"), { target: { value: "Open" } });
- fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
+ const input = screen.getByPlaceholderText("搜索供应商名称");
+ fireEvent.change(input, { target: { value: "Open" } });
+ fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
}, 15000);
diff --git a/tests/web/use-confirm-action.test.ts b/tests/web/use-confirm-action.test.ts
new file mode 100644
index 0000000..033c381
--- /dev/null
+++ b/tests/web/use-confirm-action.test.ts
@@ -0,0 +1,53 @@
+import { XProvider } from "@ant-design/x";
+import { act, renderHook } from "@testing-library/react";
+import { App } from "antd";
+import { describe, expect, it } from "bun:test";
+import { createElement } from "react";
+
+import { useConfirmAction } from "../../src/web/shared/hooks/useConfirmAction";
+
+function createWrapper() {
+ return ({ children }: { children: React.ReactNode }) =>
+ createElement(XProvider, null, createElement(App, null, children));
+}
+
+describe("useConfirmAction", () => {
+ it("calls action successfully and resolves", async () => {
+ let resolved = false;
+ const action = () => {
+ resolved = true;
+ };
+
+ const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
+
+ await act(() => result.current.confirmAction(action, "成功"));
+ expect(resolved).toBe(true);
+ });
+
+ it("handles action error without throwing", async () => {
+ const action = () => {
+ throw new Error("失败");
+ };
+ const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
+
+ await act(() => result.current.confirmAction(action, "成功"));
+ expect(resolved).toBe(true);
+ });
+
+ it("handles action error without throwing", async () => {
+ const action = () => {
+ throw new Error("失败");
+ };
+ const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
+
+ let threw = false;
+ try {
+ await act(async () => {
+ await result.current.confirmAction(action, "成功");
+ });
+ } catch {
+ threw = true;
+ }
+ expect(threw).toBe(false);
+ });
+});
diff --git a/tests/web/use-page-search-params.test.ts b/tests/web/use-page-search-params.test.ts
new file mode 100644
index 0000000..1909eba
--- /dev/null
+++ b/tests/web/use-page-search-params.test.ts
@@ -0,0 +1,86 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "bun:test";
+import { createElement } from "react";
+import { MemoryRouter, Route, Routes } from "react-router";
+
+import { usePageSearchParams } from "../../src/web/shared/hooks/usePageSearchParams";
+
+function renderWithRouter(initialPath: string) {
+ return renderHook(() => usePageSearchParams(), {
+ wrapper: ({ children }) =>
+ createElement(
+ MemoryRouter,
+ { initialEntries: [initialPath] },
+ createElement(Routes, null, createElement(Route, { element: children, path: "*" })),
+ ),
+ });
+}
+
+function renderWithRouterAndDefaults(initialPath: string, defaults: Record) {
+ return renderHook(() => usePageSearchParams({ defaults }), {
+ wrapper: ({ children }) =>
+ createElement(
+ MemoryRouter,
+ { initialEntries: [initialPath] },
+ createElement(Routes, null, createElement(Route, { element: children, path: "*" })),
+ ),
+ });
+}
+
+describe("usePageSearchParams", () => {
+ it("returns empty params when URL has no search params", () => {
+ const { result } = renderWithRouter("/test");
+ expect(result.current.params).toEqual({});
+ });
+
+ it("parses existing URL search params", () => {
+ const { result } = renderWithRouter("/test?page=2&keyword=abc");
+ expect(result.current.params).toEqual({ keyword: "abc", page: "2" });
+ });
+
+ it("merges defaults for missing keys", () => {
+ const { result } = renderWithRouterAndDefaults("/test", { page: "1", pageSize: "20" });
+ expect(result.current.params).toEqual({ page: "1", pageSize: "20" });
+ });
+
+ it("URL value takes precedence over default", () => {
+ const { result } = renderWithRouterAndDefaults("/test?page=3", { page: "1" });
+ expect(result.current.params).toEqual({ page: "3" });
+ });
+
+ it("setParam updates a single param", () => {
+ const { result } = renderWithRouter("/test");
+ act(() => result.current.setParam("keyword", "hello"));
+ expect(result.current.params["keyword"]).toBe("hello");
+ });
+
+ it("setParam with undefined removes the param", () => {
+ const { result } = renderWithRouter("/test?keyword=hello");
+ act(() => result.current.setParam("keyword", undefined));
+ expect(result.current.params["keyword"]).toBeUndefined();
+ });
+
+ it("setParam with empty string removes the param", () => {
+ const { result } = renderWithRouter("/test?keyword=hello");
+ act(() => result.current.setParam("keyword", ""));
+ expect(result.current.params["keyword"]).toBeUndefined();
+ });
+
+ it("setParams updates multiple params at once", () => {
+ const { result } = renderWithRouter("/test");
+ act(() => result.current.setParams({ keyword: "test", page: "2" }));
+ expect(result.current.params).toEqual({ keyword: "test", page: "2" });
+ });
+
+ it("setParams preserves existing params", () => {
+ const { result } = renderWithRouter("/test?existing=yes");
+ act(() => result.current.setParams({ page: "3" }));
+ expect(result.current.params).toEqual({ existing: "yes", page: "3" });
+ });
+
+ it("resetAll clears all params", () => {
+ const { result } = renderWithRouter("/test?page=5&keyword=abc");
+ act(() => result.current.resetAll());
+ expect(result.current.params).toEqual({});
+ });
+});