From 6f547560d135f510590d7938e9db6f6c11a241ba Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 4 Jun 2026 17:25:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80=20=E2=80=94=20Fil?= =?UTF-8?q?terToolbar=20+=20usePageSearchParams=20+=20parseListParams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 40 +-- docs/development/backend.md | 4 +- docs/development/crud.md | 167 ++++++++++++ docs/development/frontend.md | 62 +++-- src/server/db/models.ts | 33 ++- src/server/db/projects.ts | 28 +- src/server/db/providers.ts | 31 ++- src/server/helpers/index.ts | 2 + src/server/helpers/list-params.ts | 59 +++++ src/server/routes/models/list.ts | 22 +- src/server/routes/projects/list.ts | 22 +- src/server/routes/providers/list.ts | 22 +- src/shared/api.ts | 7 + src/web/features/models/ModelListPage.tsx | 160 +++++++++--- src/web/features/models/ProviderListPage.tsx | 137 +++++++--- .../features/models/components/ModelTable.tsx | 174 ++++++------ .../models/components/ModelToolbar.tsx | 37 --- .../models/components/ProviderTable.tsx | 151 ++++++----- .../models/components/ProviderToolbar.tsx | 37 --- .../projects/components/ProjectTable.tsx | 247 ++++++++---------- .../projects/components/ProjectToolbar.tsx | 55 ---- src/web/features/projects/index.tsx | 161 +++++++++--- src/web/shared/components/FilterToolbar.tsx | 74 ++++++ src/web/shared/hooks/use-models.ts | 16 +- src/web/shared/hooks/use-projects.ts | 13 +- src/web/shared/hooks/use-providers.ts | 15 +- src/web/shared/hooks/useConfirmAction.ts | 24 ++ src/web/shared/hooks/usePageSearchParams.ts | 74 ++++++ src/web/shared/utils/format.ts | 5 + tests/server/list-params.test.ts | 134 ++++++++++ tests/server/routes/models.test.ts | 42 +++ tests/server/routes/providers.test.ts | 49 ++++ tests/web/FilterToolbar.test.tsx | 106 ++++++++ tests/web/components/ResourceTable.test.tsx | 4 +- tests/web/format.test.ts | 25 ++ tests/web/routes/models.test.tsx | 5 +- tests/web/routes/projects.test.tsx | 45 ++-- tests/web/routes/providers.test.tsx | 5 +- tests/web/use-confirm-action.test.ts | 53 ++++ tests/web/use-page-search-params.test.ts | 86 ++++++ 40 files changed, 1805 insertions(+), 628 deletions(-) create mode 100644 docs/development/crud.md create mode 100644 src/server/helpers/list-params.ts delete mode 100644 src/web/features/models/components/ModelToolbar.tsx delete mode 100644 src/web/features/models/components/ProviderToolbar.tsx delete mode 100644 src/web/features/projects/components/ProjectToolbar.tsx create mode 100644 src/web/shared/components/FilterToolbar.tsx create mode 100644 src/web/shared/hooks/useConfirmAction.ts create mode 100644 src/web/shared/hooks/usePageSearchParams.ts create mode 100644 src/web/shared/utils/format.ts create mode 100644 tests/server/list-params.test.ts create mode 100644 tests/web/FilterToolbar.test.tsx create mode 100644 tests/web/format.test.ts create mode 100644 tests/web/use-confirm-action.test.ts create mode 100644 tests/web/use-page-search-params.test.ts 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) => ( - - - void handleDelete(record.id)} - title="确认删除此模型?" - > - - - - ), - title: "操作", - width: 180, - }), - [onEdit, handleDelete], + void onDelete(record.id)} + title="确认删除此模型?" + > + + + + ), + 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} - /> - - - - ); -} 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) => ( - - - void handleDelete(record.id)} - title="确认删除此供应商?" - > - - - - ), - title: "操作", - width: 180, - }), - [onEdit, handleDelete], + void onDelete(record.id)} + title="确认删除此供应商?" + > + + + + ), + 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} - /> - - - - ); -} 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 ( + + + + void onArchive(record.id)} + title="确认归档此项目?" + > + + + + ); + } return ( - - + void onRestore(record.id)} title="确认恢复此项目?"> + + void handleArchive(record.id)} - title="确认归档此项目?" + description="此操作不可恢复。" + onConfirm={() => void onDelete(record.id)} + title="确认永久删除此项目?" > - ); - } - return ( - - void handleRestore(record.id)} title="确认恢复此项目?"> - - - void handleDelete(record.id)} - title="确认永久删除此项目?" - > - - - - ); + }, + 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" && ( - - )} - - - ); -} 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) => ( +