refactor: 统一管理页面布局 — FilterToolbar + usePageSearchParams + parseListParams
This commit is contained in:
@@ -5,14 +5,15 @@
|
|||||||
## 目录索引
|
## 目录索引
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/
|
docs/
|
||||||
README.md
|
|
||||||
development/
|
|
||||||
README.md
|
README.md
|
||||||
architecture.md
|
development/
|
||||||
backend.md
|
README.md
|
||||||
frontend.md
|
architecture.md
|
||||||
release.md
|
backend.md
|
||||||
|
crud.md
|
||||||
|
frontend.md
|
||||||
|
release.md
|
||||||
user/
|
user/
|
||||||
README.md
|
README.md
|
||||||
usage.md
|
usage.md
|
||||||
@@ -39,18 +40,18 @@ docs/
|
|||||||
|
|
||||||
## 按任务阅读路径
|
## 按任务阅读路径
|
||||||
|
|
||||||
| 任务 | 必读文档 |
|
| 任务 | 必读文档 |
|
||||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
| -------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
|
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
|
||||||
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||||
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
|
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
|
||||||
| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
|
| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
|
||||||
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
|
| 修改前端 CRUD 管理页面 | [开发文档](development/README.md)、[前端开发](development/frontend.md)、[CRUD 模式](development/crud.md) |
|
||||||
| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
|
| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
|
||||||
| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
|
| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
|
||||||
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||||
| 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) |
|
| 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) |
|
||||||
| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
|
| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
|
||||||
|
|
||||||
## 文档归属矩阵
|
## 文档归属矩阵
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ docs/
|
|||||||
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
|
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
|
||||||
| 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` |
|
| 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` |
|
||||||
| 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` |
|
| 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` |
|
||||||
|
| 管理页面 CRUD 模式(筛选工具条、URL 同步、分页排序约定) | `docs/development/crud.md` |
|
||||||
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
|
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
|
||||||
| 快速开始、安装配置 | `docs/user/usage.md` |
|
| 快速开始、安装配置 | `docs/user/usage.md` |
|
||||||
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |
|
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
- `response.ts`:`createApiError(error, status)`、`createHeaders(mode, init)`、`createMetaResponse(version)`、`formatDuration(ms)`、`jsonResponse(body, options)`
|
- `response.ts`:`createApiError(error, status)`、`createHeaders(mode, init)`、`createMetaResponse(version)`、`formatDuration(ms)`、`jsonResponse(body, options)`
|
||||||
- `url.ts`:`parseIdFromUrl(url)`
|
- `url.ts`:`parseIdFromUrl(url)`
|
||||||
|
- `list-params.ts`:`parseListParams(url, mode, options?)` — 统一校验分页/排序参数,替代 validatePagination
|
||||||
|
- `pagination.ts`:`paginateQuery()` — Drizzle 分页查询封装
|
||||||
|
|
||||||
`src/server/middleware/`:
|
`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)。
|
||||||
|
|||||||
167
docs/development/crud.md
Normal file
167
docs/development/crud.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# CRUD 管理页面模式
|
||||||
|
|
||||||
|
管理类页面(项目管理、模型管理、供应商管理)遵循统一的 CRUD 模式。本文档描述该模式的后端和前端约定。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
三个管理页面原本各自实现了工具条、筛选、分页、搜索等逻辑,代码重复且交互不一致。统一后所有管理页面使用相同的基础设施。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
每个管理页面的组件结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<Space className="app-page-flex" orientation="vertical" size="large">
|
||||||
|
<FilterToolbar /> ← 筛选 + 搜索 + 操作按钮
|
||||||
|
<EntityTable /> ← 数据表格(分页 + 排序 + 行操作)
|
||||||
|
<EntityFormModal /> ← 创建/编辑弹窗
|
||||||
|
</Space>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端基础设施
|
||||||
|
|
||||||
|
### 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<string, string>`,读取当前 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 通知,刷新列表 |
|
||||||
@@ -18,21 +18,21 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
|||||||
| 功能模块 | 路径 | 说明 |
|
| 功能模块 | 路径 | 说明 |
|
||||||
| -------- | --------------------- | --------------------------- |
|
| -------- | --------------------- | --------------------------- |
|
||||||
| 仪表盘 | `features/dashboard/` | 总览页面 |
|
| 仪表盘 | `features/dashboard/` | 总览页面 |
|
||||||
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索 |
|
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索、排序 |
|
||||||
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
|
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
|
||||||
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
|
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
|
||||||
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
|
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
|
||||||
|
|
||||||
## 页面
|
## 页面
|
||||||
|
|
||||||
| 页面 | 路径 | 入口 |
|
| 页面 | 路径 | 入口 |
|
||||||
| -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 总览 | `/` | `features/dashboard/index.tsx` |
|
| 总览 | `/` | `features/dashboard/index.tsx` |
|
||||||
| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
|
| 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar(状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
|
||||||
| 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx` + `ProviderListPage.tsx`。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
|
| 模型管理 | `/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` | `features/chat/index.tsx` |
|
||||||
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
|
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
|
||||||
| 404 | `*` | `features/not-found/index.tsx` |
|
| 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) |
|
| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳(Provider + Layout) |
|
||||||
| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
|
| FilterToolbar | `shared/components/FilterToolbar.tsx` | 统一筛选工具条(Select 筛选 + 搜索 + 重置 + 操作按钮) |
|
||||||
| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
|
| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
|
||||||
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
|
| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
|
||||||
|
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
|
||||||
|
|
||||||
### 共享 Hooks
|
### 共享 Hooks
|
||||||
|
|
||||||
| Hook | 路径 | 说明 |
|
| Hook | 路径 | 说明 |
|
||||||
| ----------------------- | --------------------------------------- | ---------------------------------------------------------- |
|
| ------------------------ | --------------------------------------- | --------------------------------------------------------------------- |
|
||||||
| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`(30s 轮询,5s staleTime) |
|
| `use-page-search-params` | `shared/hooks/usePageSearchParams.ts` | URL 查询参数同步(筛选/分页/排序),批量更新 `setParams` 避免闭包覆盖 |
|
||||||
| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
|
| `use-confirm-action` | `shared/hooks/useConfirmAction.ts` | 包装异步操作 + toast 成功/失败通知 |
|
||||||
| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
|
| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`(30s 轮询,5s staleTime) |
|
||||||
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
|
| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
|
||||||
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
|
| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
|
||||||
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
|
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
|
||||||
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
|
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
|
||||||
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
|
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
|
||||||
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
|
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
|
||||||
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
|
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
|
||||||
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
|
| `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/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/time.ts` | `formatCountdown`、`formatDurationUnit`、`formatRelativeTime`、`isOlderThan`、`subtractHours` |
|
||||||
| `utils/date-group.ts` | `getDateGroup`、`groupByDate`、`GROUP_LABELS`、`GROUP_ORDER`、`DateGroup`、`DateGroupData` |
|
| `utils/date-group.ts` | `getDateGroup`、`groupByDate`、`GROUP_LABELS`、`GROUP_ORDER`、`DateGroup`、`DateGroupData` |
|
||||||
|
|
||||||
## 更新触发条件
|
## 更新触发条件
|
||||||
|
|
||||||
修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引、hooks/工具清单、目录结构或功能模块归属时,必须更新本文档。
|
修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引、hooks/工具清单、目录结构或功能模块归属时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。
|
||||||
|
|
||||||
## 日志模块
|
## 日志模块
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { paginateQuery, wrap } from "./connection";
|
||||||
@@ -124,7 +124,15 @@ export function getModelWithProvider(
|
|||||||
|
|
||||||
export function listModels(
|
export function listModels(
|
||||||
raw: Database,
|
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 } {
|
): { items: Model[]; page: number; pageSize: number; total: number } {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
@@ -137,10 +145,16 @@ export function listModels(
|
|||||||
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
|
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, {
|
return paginateQuery(raw, models, {
|
||||||
conditions,
|
conditions,
|
||||||
mapRow: toModel,
|
mapRow: toModel,
|
||||||
orderBy: () => desc(models.createdAt),
|
orderBy: orderByFn,
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
});
|
});
|
||||||
@@ -212,6 +226,17 @@ export function updateModel(
|
|||||||
return { model: toModel(updated!) };
|
return { model: toModel(updated!) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildModelOrderBy(
|
||||||
|
sortBy: string | undefined,
|
||||||
|
sortOrder: SortOrder | undefined,
|
||||||
|
): ((table: typeof models) => ReturnType<typeof desc>) | undefined {
|
||||||
|
if (!sortBy) return (t) => desc(t.createdAt);
|
||||||
|
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
|
||||||
|
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
function toModel(row: typeof models.$inferSelect): Model {
|
function toModel(row: typeof models.$inferSelect): Model {
|
||||||
return {
|
return {
|
||||||
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
|
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { paginateQuery, wrap } from "./connection";
|
||||||
@@ -88,7 +88,14 @@ export function getProject(raw: Database, id: string): { error: string; status:
|
|||||||
|
|
||||||
export function listProjects(
|
export function listProjects(
|
||||||
raw: Database,
|
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 } {
|
): { items: Project[]; page: number; pageSize: number; total: number } {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
@@ -101,10 +108,12 @@ export function listProjects(
|
|||||||
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
|
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderByFn = buildProjectOrderBy(options.sortBy, options.sortOrder);
|
||||||
|
|
||||||
return paginateQuery(raw, projects, {
|
return paginateQuery(raw, projects, {
|
||||||
conditions,
|
conditions,
|
||||||
mapRow: toProject,
|
mapRow: toProject,
|
||||||
orderBy: () => desc(projects.createdAt),
|
orderBy: orderByFn,
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
});
|
});
|
||||||
@@ -174,6 +183,17 @@ export function updateProject(
|
|||||||
return { project: toProject(updated!) };
|
return { project: toProject(updated!) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProjectOrderBy(
|
||||||
|
sortBy: string | undefined,
|
||||||
|
sortOrder: SortOrder | undefined,
|
||||||
|
): ((table: typeof projects) => ReturnType<typeof desc>) | undefined {
|
||||||
|
if (!sortBy) return (t) => desc(t.createdAt);
|
||||||
|
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
|
||||||
|
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
function toProject(row: typeof projects.$inferSelect): Project {
|
function toProject(row: typeof projects.$inferSelect): Project {
|
||||||
return {
|
return {
|
||||||
archivedAt: row.archivedAt,
|
archivedAt: row.archivedAt,
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { paginateQuery, wrap } from "./connection";
|
||||||
@@ -85,7 +91,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
|
|||||||
|
|
||||||
export function listProviders(
|
export function listProviders(
|
||||||
raw: Database,
|
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 } {
|
): { items: Provider[]; page: number; pageSize: number; total: number } {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
@@ -94,10 +100,16 @@ export function listProviders(
|
|||||||
conditions.push(like(providers.name, pattern));
|
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, {
|
return paginateQuery(raw, providers, {
|
||||||
conditions,
|
conditions,
|
||||||
mapRow: toProvider,
|
mapRow: toProvider,
|
||||||
orderBy: () => desc(providers.createdAt),
|
orderBy: orderByFn,
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
});
|
});
|
||||||
@@ -158,6 +170,17 @@ export function updateProvider(
|
|||||||
return { provider: toProvider(updated!) };
|
return { provider: toProvider(updated!) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProviderOrderBy(
|
||||||
|
sortBy: string | undefined,
|
||||||
|
sortOrder: SortOrder | undefined,
|
||||||
|
): ((table: typeof providers) => ReturnType<typeof desc>) | undefined {
|
||||||
|
if (!sortBy) return (t) => desc(t.createdAt);
|
||||||
|
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
|
||||||
|
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
function toProvider(row: typeof providers.$inferSelect): Provider {
|
function toProvider(row: typeof providers.$inferSelect): Provider {
|
||||||
return {
|
return {
|
||||||
apiKey: row.apiKey,
|
apiKey: row.apiKey,
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
export type { ParsedListParams } from "./list-params";
|
||||||
|
export { parseListParams } from "./list-params";
|
||||||
export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response";
|
export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response";
|
||||||
export { parseIdFromUrl } from "./url";
|
export { parseIdFromUrl } from "./url";
|
||||||
|
|||||||
59
src/server/helpers/list-params.ts
Normal file
59
src/server/helpers/list-params.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -4,24 +4,26 @@ import type { RuntimeMode } from "../../../shared/api";
|
|||||||
import type { Logger } from "../../logger";
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
import { listModels } from "../../db/models";
|
import { listModels } from "../../db/models";
|
||||||
import { jsonResponse } from "../../helpers";
|
import { jsonResponse, parseListParams } from "../../helpers";
|
||||||
import { validatePagination } from "../../middleware";
|
|
||||||
|
const ALLOWED_SORT_BY = ["createdAt", "name"];
|
||||||
|
|
||||||
export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||||
const url = new URL(req.url);
|
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 providerId = url.searchParams.get("providerId");
|
||||||
|
const capabilities = url.searchParams.get("capabilities");
|
||||||
|
|
||||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
|
||||||
if (pagination instanceof Response) return pagination;
|
if (parsed instanceof Response) return parsed;
|
||||||
|
|
||||||
const result = listModels(db, {
|
const result = listModels(db, {
|
||||||
keyword: keyword ?? undefined,
|
capabilities: capabilities ?? undefined,
|
||||||
page: pagination.page,
|
keyword: parsed.keyword,
|
||||||
pageSize: pagination.pageSize,
|
page: parsed.page,
|
||||||
|
pageSize: parsed.pageSize,
|
||||||
providerId: providerId ?? undefined,
|
providerId: providerId ?? undefined,
|
||||||
|
sortBy: parsed.sortBy,
|
||||||
|
sortOrder: parsed.sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(result, { mode });
|
return jsonResponse(result, { mode });
|
||||||
|
|||||||
@@ -4,27 +4,27 @@ import type { RuntimeMode } from "../../../shared/api";
|
|||||||
import type { Logger } from "../../logger";
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
import { listProjects } from "../../db/projects";
|
import { listProjects } from "../../db/projects";
|
||||||
import { createApiError, jsonResponse } from "../../helpers";
|
import { createApiError, jsonResponse, parseListParams } from "../../helpers";
|
||||||
import { validatePagination } from "../../middleware";
|
|
||||||
|
const ALLOWED_SORT_BY = ["createdAt", "name", "updatedAt"];
|
||||||
|
|
||||||
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||||
const url = new URL(req.url);
|
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 statusParam = url.searchParams.get("status");
|
||||||
|
|
||||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
|
||||||
if (pagination instanceof Response) return pagination;
|
|
||||||
|
|
||||||
if (statusParam && statusParam !== "active" && statusParam !== "archived") {
|
if (statusParam && statusParam !== "active" && statusParam !== "archived") {
|
||||||
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
|
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, {
|
const result = listProjects(db, {
|
||||||
keyword: keyword ?? undefined,
|
keyword: parsed.keyword,
|
||||||
page: pagination.page,
|
page: parsed.page,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: parsed.pageSize,
|
||||||
|
sortBy: parsed.sortBy,
|
||||||
|
sortOrder: parsed.sortOrder,
|
||||||
status: (statusParam as "active" | "archived") ?? undefined,
|
status: (statusParam as "active" | "archived") ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,24 @@ import type { RuntimeMode } from "../../../shared/api";
|
|||||||
import type { Logger } from "../../logger";
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
import { listProviders } from "../../db/providers";
|
import { listProviders } from "../../db/providers";
|
||||||
import { jsonResponse } from "../../helpers";
|
import { jsonResponse, parseListParams } from "../../helpers";
|
||||||
import { validatePagination } from "../../middleware";
|
|
||||||
|
const ALLOWED_SORT_BY = ["createdAt", "name"];
|
||||||
|
|
||||||
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const pageParam = url.searchParams.get("page");
|
const typeParam = url.searchParams.get("type");
|
||||||
const pageSizeParam = url.searchParams.get("pageSize");
|
|
||||||
const keyword = url.searchParams.get("keyword");
|
|
||||||
|
|
||||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
|
||||||
if (pagination instanceof Response) return pagination;
|
if (parsed instanceof Response) return parsed;
|
||||||
|
|
||||||
const result = listProviders(db, {
|
const result = listProviders(db, {
|
||||||
keyword: keyword ?? undefined,
|
keyword: parsed.keyword,
|
||||||
page: pagination.page,
|
page: parsed.page,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: parsed.pageSize,
|
||||||
|
sortBy: parsed.sortBy,
|
||||||
|
sortOrder: parsed.sortOrder,
|
||||||
|
type: typeParam ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(result, { mode });
|
return jsonResponse(result, { mode });
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export interface CreateProviderRequest {
|
|||||||
// 前后端共享的类型都放在这个文件中
|
// 前后端共享的类型都放在这个文件中
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
|
export interface ListSortParams {
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Material {
|
export interface Material {
|
||||||
associatedDate: string;
|
associatedDate: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -127,6 +132,8 @@ export type ModelCapability =
|
|||||||
| "video-generation"
|
| "video-generation"
|
||||||
| "video-recognition";
|
| "video-recognition";
|
||||||
|
|
||||||
|
export type SortOrder = "asc" | "desc";
|
||||||
|
|
||||||
export interface UpdateConversationRequest {
|
export interface UpdateConversationRequest {
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Space } from "antd";
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
import { useState } from "react";
|
import { Button, Space } from "antd";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { Model, TestModelRequest } from "../../../shared/api";
|
import type { Model, TestModelRequest } from "../../../shared/api";
|
||||||
|
|
||||||
|
import { FilterToolbar } from "../../shared/components/FilterToolbar";
|
||||||
import {
|
import {
|
||||||
useCreateModel,
|
useCreateModel,
|
||||||
useDeleteModel,
|
useDeleteModel,
|
||||||
@@ -11,21 +13,46 @@ import {
|
|||||||
useUpdateModel,
|
useUpdateModel,
|
||||||
} from "../../shared/hooks/use-models";
|
} from "../../shared/hooks/use-models";
|
||||||
import { useProviderOptions } from "../../shared/hooks/use-providers";
|
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 { ModelFormModal } from "./components/ModelFormModal";
|
||||||
import { ModelTable } from "./components/ModelTable";
|
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() {
|
export function ModelListPage() {
|
||||||
const [page, setPage] = useState(1);
|
const { confirmAction } = useConfirmAction();
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const { params, resetAll, setParams } = usePageSearchParams({
|
||||||
const [keyword, setKeyword] = useState("");
|
defaults: { page: "1", pageSize: "20" },
|
||||||
|
});
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingModel, setEditingModel] = useState<Model | null>(null);
|
const [editingModel, setEditingModel] = useState<Model | null>(null);
|
||||||
|
|
||||||
const { data: modelData, isLoading: modelLoading } = useModelList({
|
const apiPage = Number(params["page"]) || 1;
|
||||||
keyword: keyword || undefined,
|
const apiPageSize = Number(params["pageSize"]) || 20;
|
||||||
page,
|
const keyword = params["keyword"] ?? "";
|
||||||
pageSize,
|
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 {
|
const {
|
||||||
@@ -41,42 +68,107 @@ export function ModelListPage() {
|
|||||||
const testModelMutation = useTestModelConnection();
|
const testModelMutation = useTestModelConnection();
|
||||||
|
|
||||||
const isSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
|
const isSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
|
||||||
const isActionPending = deleteModelMutation.isPending;
|
const modelProviders = useMemo(() => providerOptionsData?.items ?? [], [providerOptionsData?.items]);
|
||||||
const modelProviders = 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<string, string | undefined> = {
|
||||||
|
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 (
|
return (
|
||||||
<Space className="app-page-flex" orientation="vertical" size="large">
|
<Space className="app-page-flex" orientation="vertical" size="large">
|
||||||
<ModelToolbar
|
<FilterToolbar
|
||||||
keyword={keyword}
|
actions={
|
||||||
onSearch={(value) => {
|
<Button
|
||||||
setKeyword(value);
|
icon={<PlusOutlined />}
|
||||||
setPage(1);
|
onClick={() => {
|
||||||
}}
|
setEditingModel(null);
|
||||||
onSearchClear={() => {
|
setDialogOpen(true);
|
||||||
setKeyword("");
|
}}
|
||||||
setPage(1);
|
type="primary"
|
||||||
}}
|
>
|
||||||
openCreateDialog={() => {
|
新建模型
|
||||||
setEditingModel(null);
|
</Button>
|
||||||
setDialogOpen(true);
|
}
|
||||||
}}
|
filters={filters}
|
||||||
|
search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索模型名称或 ID" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelTable
|
<ModelTable
|
||||||
data={modelData}
|
data={modelData}
|
||||||
loading={modelLoading || providerOptionsLoading || isActionPending}
|
loading={modelLoading || providerOptionsLoading}
|
||||||
onDelete={(id) => deleteModelMutation.mutateAsync(id)}
|
onChange={handleTableChange}
|
||||||
|
onDelete={handleDelete}
|
||||||
onEdit={(model) => {
|
onEdit={(model) => {
|
||||||
setEditingModel(model);
|
setEditingModel(model);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
onPageChange={(p, ps) => {
|
page={apiPage}
|
||||||
setPage(p);
|
pageSize={apiPageSize}
|
||||||
setPageSize(ps);
|
|
||||||
}}
|
|
||||||
page={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
providers={modelProviders}
|
providers={modelProviders}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelFormModal
|
<ModelFormModal
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Space } from "antd";
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
import { useState } from "react";
|
import { Button, Space } from "antd";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { Provider } from "../../../shared/api";
|
import type { Provider } from "../../../shared/api";
|
||||||
|
|
||||||
|
import { FilterToolbar } from "../../shared/components/FilterToolbar";
|
||||||
import {
|
import {
|
||||||
useCreateProvider,
|
useCreateProvider,
|
||||||
useDeleteProvider,
|
useDeleteProvider,
|
||||||
@@ -10,21 +12,39 @@ import {
|
|||||||
useTestProviderConfig,
|
useTestProviderConfig,
|
||||||
useUpdateProvider,
|
useUpdateProvider,
|
||||||
} from "../../shared/hooks/use-providers";
|
} from "../../shared/hooks/use-providers";
|
||||||
|
import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
|
||||||
|
import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
|
||||||
import { ProviderFormModal } from "./components/ProviderFormModal";
|
import { ProviderFormModal } from "./components/ProviderFormModal";
|
||||||
import { ProviderTable } from "./components/ProviderTable";
|
import { ProviderTable } from "./components/ProviderTable";
|
||||||
import { ProviderToolbar } from "./components/ProviderToolbar";
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ label: "OpenAI", value: "openai" },
|
||||||
|
{ label: "OpenAI 兼容", value: "openai-compatible" },
|
||||||
|
{ label: "Anthropic", value: "anthropic" },
|
||||||
|
];
|
||||||
|
|
||||||
export function ProviderListPage() {
|
export function ProviderListPage() {
|
||||||
const [page, setPage] = useState(1);
|
const { confirmAction } = useConfirmAction();
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const { params, resetAll, setParams } = usePageSearchParams({
|
||||||
const [keyword, setKeyword] = useState("");
|
defaults: { page: "1", pageSize: "20" },
|
||||||
|
});
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingProvider, setEditingProvider] = useState<null | Provider>(null);
|
const [editingProvider, setEditingProvider] = useState<null | Provider>(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,
|
keyword: keyword || undefined,
|
||||||
page,
|
page: apiPage,
|
||||||
pageSize,
|
pageSize: apiPageSize,
|
||||||
|
sortBy: sortBy ?? undefined,
|
||||||
|
sortOrder: sortOrder,
|
||||||
|
type: typeFilter ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createProviderMutation = useCreateProvider();
|
const createProviderMutation = useCreateProvider();
|
||||||
@@ -33,40 +53,93 @@ export function ProviderListPage() {
|
|||||||
const testProviderConfigMutation = useTestProviderConfig();
|
const testProviderConfigMutation = useTestProviderConfig();
|
||||||
|
|
||||||
const isSubmitting = createProviderMutation.isPending || updateProviderMutation.isPending;
|
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<string, string | undefined> = {
|
||||||
|
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 (
|
return (
|
||||||
<Space className="app-page-flex" orientation="vertical" size="large">
|
<Space className="app-page-flex" orientation="vertical" size="large">
|
||||||
<ProviderToolbar
|
<FilterToolbar
|
||||||
keyword={keyword}
|
actions={
|
||||||
onSearch={(value) => {
|
<Button
|
||||||
setKeyword(value);
|
icon={<PlusOutlined />}
|
||||||
setPage(1);
|
onClick={() => {
|
||||||
}}
|
setEditingProvider(null);
|
||||||
onSearchClear={() => {
|
setDialogOpen(true);
|
||||||
setKeyword("");
|
}}
|
||||||
setPage(1);
|
type="primary"
|
||||||
}}
|
>
|
||||||
openCreateDialog={() => {
|
新建供应商
|
||||||
setEditingProvider(null);
|
</Button>
|
||||||
setDialogOpen(true);
|
}
|
||||||
}}
|
filters={filters}
|
||||||
|
search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索供应商名称" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProviderTable
|
<ProviderTable
|
||||||
data={providerData}
|
data={providerData}
|
||||||
loading={providerLoading || isActionPending}
|
loading={providerLoading}
|
||||||
onDelete={(id) => deleteProviderMutation.mutateAsync(id)}
|
onChange={handleTableChange}
|
||||||
|
onDelete={handleDelete}
|
||||||
onEdit={(provider) => {
|
onEdit={(provider) => {
|
||||||
setEditingProvider(provider);
|
setEditingProvider(provider);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
onPageChange={(p, ps) => {
|
page={apiPage}
|
||||||
setPage(p);
|
pageSize={apiPageSize}
|
||||||
setPageSize(ps);
|
sortBy={sortBy}
|
||||||
}}
|
sortOrder={sortOrder}
|
||||||
page={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProviderFormModal
|
<ProviderFormModal
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import type { TableColumnsType } from "antd";
|
import type { TableColumnsType, TableProps } from "antd";
|
||||||
|
|
||||||
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
||||||
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
|
import { Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api";
|
import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api";
|
||||||
|
|
||||||
|
import { formatDatetime } from "../../../shared/utils/format";
|
||||||
|
|
||||||
interface ModelTableProps {
|
interface ModelTableProps {
|
||||||
data: ModelListResponse | undefined;
|
data: ModelListResponse | undefined;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onDelete: (id: string) => Promise<unknown>;
|
onChange: (
|
||||||
|
pagination: { current?: number; pageSize?: number },
|
||||||
|
sorter: { columnKey?: string; field?: string | string[]; order?: string },
|
||||||
|
) => void;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
onEdit: (model: Model) => void;
|
onEdit: (model: Model) => void;
|
||||||
onPageChange: (page: number, pageSize: number) => void;
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
providers: ProviderOption[];
|
providers: ProviderOption[];
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CAPABILITY_LABELS: Record<string, string> = {
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
@@ -28,110 +35,111 @@ const CAPABILITY_LABELS: Record<string, string> = {
|
|||||||
"video-recognition": "视频识别",
|
"video-recognition": "视频识别",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getProviderName(providerId: string, providers: ProviderOption[]): string {
|
|
||||||
return providers.find((p) => p.id === providerId)?.name ?? providerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMNS: TableColumnsType<Model> = [
|
|
||||||
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
|
|
||||||
{
|
|
||||||
dataIndex: "providerId",
|
|
||||||
ellipsis: true,
|
|
||||||
title: "供应商",
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataIndex: "capabilities",
|
|
||||||
render: (value: string[]) =>
|
|
||||||
value.length > 0 ? (
|
|
||||||
<Space size={[4, 4]} wrap>
|
|
||||||
{value.map((c) => (
|
|
||||||
<Tag key={c}>{CAPABILITY_LABELS[c] ?? c}</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
) : null,
|
|
||||||
title: "能力",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ModelTable({
|
export function ModelTable({
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onPageChange,
|
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
providers,
|
providers,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
}: ModelTableProps) {
|
}: ModelTableProps) {
|
||||||
const { message } = AntApp.useApp();
|
const columns = useMemo<TableColumnsType<Model>>(
|
||||||
|
() => [
|
||||||
const handleDelete = useCallback(
|
{
|
||||||
async (id: string) => {
|
dataIndex: "name",
|
||||||
try {
|
ellipsis: true,
|
||||||
await onDelete(id);
|
sorter: true,
|
||||||
message.success("模型已删除");
|
sortOrder:
|
||||||
} catch (err: unknown) {
|
sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||||
message.error((err as Error).message);
|
title: "名称",
|
||||||
}
|
width: 180,
|
||||||
},
|
},
|
||||||
[onDelete, message],
|
{
|
||||||
);
|
dataIndex: "providerId",
|
||||||
|
ellipsis: true,
|
||||||
const columnsWithProvider = useMemo<TableColumnsType<Model>>(
|
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
|
||||||
() =>
|
title: "供应商",
|
||||||
COLUMNS.map((col) =>
|
width: 120,
|
||||||
"dataIndex" in col && col.dataIndex === "providerId"
|
},
|
||||||
? {
|
{
|
||||||
...col,
|
dataIndex: "capabilities",
|
||||||
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
|
render: (value: string[]) =>
|
||||||
}
|
value.length > 0 ? (
|
||||||
: col,
|
<Space size={[4, 4]} wrap>
|
||||||
),
|
{value.map((c) => (
|
||||||
[providers],
|
<Tag key={c}>{CAPABILITY_LABELS[c] ?? c}</Tag>
|
||||||
);
|
))}
|
||||||
|
</Space>
|
||||||
const operationColumn = useMemo<TableColumnsType<Model>[number]>(
|
) : null,
|
||||||
() => ({
|
title: "能力",
|
||||||
dataIndex: "op",
|
},
|
||||||
render: (_value: unknown, record: Model) => (
|
{
|
||||||
<Space size="small">
|
align: "center",
|
||||||
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
dataIndex: "createdAt",
|
||||||
编辑
|
render: (_value: unknown, record: Model) => formatDatetime(record.createdAt),
|
||||||
</Button>
|
sorter: true,
|
||||||
<Popconfirm
|
sortOrder:
|
||||||
description="此操作不可恢复。"
|
sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||||
onConfirm={() => void handleDelete(record.id)}
|
title: "创建时间",
|
||||||
title="确认删除此模型?"
|
width: 180,
|
||||||
>
|
},
|
||||||
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
{
|
||||||
删除
|
dataIndex: "op",
|
||||||
|
render: (_value: unknown, record: Model) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
||||||
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
<Popconfirm
|
||||||
</Space>
|
description="此操作不可恢复。"
|
||||||
),
|
onConfirm={() => void onDelete(record.id)}
|
||||||
title: "操作",
|
title="确认删除此模型?"
|
||||||
width: 180,
|
>
|
||||||
}),
|
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
||||||
[onEdit, handleDelete],
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
title: "操作",
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onEdit, onDelete, providers, sortBy, sortOrder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(() => [...columnsWithProvider, operationColumn], [columnsWithProvider, operationColumn]);
|
const handleTableChange: TableProps<Model>["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 (
|
return (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
locale={{ emptyText: "暂无模型数据" }}
|
||||||
|
onChange={handleTableChange}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
hideOnSinglePage: false,
|
hideOnSinglePage: false,
|
||||||
onChange: onPageChange,
|
|
||||||
pageSize,
|
pageSize,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
}}
|
}}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProviderName(providerId: string, providers: ProviderOption[]): string {
|
||||||
|
return providers.find((p) => p.id === providerId)?.name ?? providerId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
|
|
||||||
<div />
|
|
||||||
<Space size="small">
|
|
||||||
<Input.Search
|
|
||||||
allowClear
|
|
||||||
enterButton="搜索"
|
|
||||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
|
||||||
onClear={() => {
|
|
||||||
setDraftKeyword("");
|
|
||||||
onSearchClear();
|
|
||||||
}}
|
|
||||||
onSearch={(value) => onSearch(value)}
|
|
||||||
placeholder="搜索模型名称或 ID"
|
|
||||||
value={draftKeyword}
|
|
||||||
/>
|
|
||||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
|
||||||
新建模型
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +1,120 @@
|
|||||||
import type { TableColumnsType } from "antd";
|
import type { TableColumnsType, TableProps } from "antd";
|
||||||
|
|
||||||
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
||||||
import { App as AntApp, Button, Popconfirm, Space, Table } from "antd";
|
import { Button, Popconfirm, Space, Table } from "antd";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import type { Provider, ProviderListResponse } from "../../../../shared/api";
|
import type { Provider, ProviderListResponse } from "../../../../shared/api";
|
||||||
|
|
||||||
|
import { formatDatetime } from "../../../shared/utils/format";
|
||||||
|
|
||||||
interface ProviderTableProps {
|
interface ProviderTableProps {
|
||||||
data: ProviderListResponse | undefined;
|
data: ProviderListResponse | undefined;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onDelete: (id: string) => Promise<unknown>;
|
onChange: (
|
||||||
|
pagination: { current?: number; pageSize?: number },
|
||||||
|
sorter: { columnKey?: string; field?: string | string[]; order?: string },
|
||||||
|
) => void;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onPageChange: (page: number, pageSize: number) => void;
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<Provider["type"], string> = {
|
export function ProviderTable({
|
||||||
anthropic: "Anthropic",
|
data,
|
||||||
openai: "OpenAI",
|
loading,
|
||||||
"openai-compatible": "OpenAI 兼容",
|
onChange,
|
||||||
};
|
onDelete,
|
||||||
|
onEdit,
|
||||||
const COLUMNS: TableColumnsType<Provider> = [
|
page,
|
||||||
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
|
pageSize,
|
||||||
{
|
sortBy,
|
||||||
dataIndex: "type",
|
sortOrder,
|
||||||
render: (value: Provider["type"]) => TYPE_LABELS[value] ?? value,
|
}: ProviderTableProps) {
|
||||||
title: "类型",
|
const columns = useMemo<TableColumnsType<Provider>>(
|
||||||
width: 140,
|
() => [
|
||||||
},
|
{
|
||||||
{ dataIndex: "baseUrl", ellipsis: true, title: "Base URL" },
|
dataIndex: "name",
|
||||||
];
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) {
|
sortOrder:
|
||||||
const { message } = AntApp.useApp();
|
sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||||
|
title: "名称",
|
||||||
const handleDelete = useCallback(
|
width: 180,
|
||||||
async (id: string) => {
|
},
|
||||||
try {
|
{
|
||||||
await onDelete(id);
|
dataIndex: "type",
|
||||||
message.success("供应商已删除");
|
render: (_value: unknown, record: Provider) => {
|
||||||
} catch (err: unknown) {
|
const labels: Record<string, string> = {
|
||||||
message.error((err as Error).message);
|
anthropic: "Anthropic",
|
||||||
}
|
openai: "OpenAI",
|
||||||
},
|
"openai-compatible": "OpenAI 兼容",
|
||||||
[onDelete, message],
|
};
|
||||||
);
|
return labels[record.type] ?? record.type;
|
||||||
|
},
|
||||||
const operationColumn = useMemo<TableColumnsType<Provider>[number]>(
|
title: "类型",
|
||||||
() => ({
|
width: 140,
|
||||||
dataIndex: "op",
|
},
|
||||||
render: (_value: unknown, record: Provider) => (
|
{ dataIndex: "baseUrl", ellipsis: true, title: "Base URL" },
|
||||||
<Space size="small">
|
{
|
||||||
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
align: "center",
|
||||||
编辑
|
dataIndex: "createdAt",
|
||||||
</Button>
|
render: (_value: unknown, record: Provider) => formatDatetime(record.createdAt),
|
||||||
<Popconfirm
|
sorter: true,
|
||||||
description="该供应商下存在模型时无法删除,请先删除或迁移相关模型。"
|
sortOrder:
|
||||||
onConfirm={() => void handleDelete(record.id)}
|
sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||||
title="确认删除此供应商?"
|
title: "创建时间",
|
||||||
>
|
width: 180,
|
||||||
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
},
|
||||||
删除
|
{
|
||||||
|
dataIndex: "op",
|
||||||
|
render: (_value: unknown, record: Provider) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
||||||
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
<Popconfirm
|
||||||
</Space>
|
description="删除后关联的模型将无法使用。"
|
||||||
),
|
onConfirm={() => void onDelete(record.id)}
|
||||||
title: "操作",
|
title="确认删除此供应商?"
|
||||||
width: 180,
|
>
|
||||||
}),
|
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
||||||
[onEdit, handleDelete],
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
title: "操作",
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onEdit, onDelete, sortBy, sortOrder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
|
const handleTableChange: TableProps<Provider>["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 (
|
return (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
locale={{ emptyText: "暂无供应商数据" }}
|
||||||
|
onChange={handleTableChange}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
hideOnSinglePage: false,
|
hideOnSinglePage: false,
|
||||||
onChange: onPageChange,
|
|
||||||
pageSize,
|
pageSize,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
}}
|
}}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
|
|
||||||
<div />
|
|
||||||
<Space size="small">
|
|
||||||
<Input.Search
|
|
||||||
allowClear
|
|
||||||
enterButton="搜索"
|
|
||||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
|
||||||
onClear={() => {
|
|
||||||
setDraftKeyword("");
|
|
||||||
onSearchClear();
|
|
||||||
}}
|
|
||||||
onSearch={(value) => onSearch(value)}
|
|
||||||
placeholder="搜索供应商名称"
|
|
||||||
value={draftKeyword}
|
|
||||||
/>
|
|
||||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
|
||||||
新建供应商
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
|
||||||
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
|
import { Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useNavigate } from "react-router";
|
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 {
|
interface ProjectTableProps {
|
||||||
data: ProjectListResponse | undefined;
|
data: ProjectListResponse | undefined;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onArchive: (id: string) => Promise<unknown>;
|
onArchive: (id: string) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<unknown>;
|
onChange: (
|
||||||
|
pagination: { current?: number; pageSize?: number },
|
||||||
|
sorter: { columnKey?: string; field?: string | string[]; order?: string },
|
||||||
|
) => void;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
onEdit: (project: Project) => void;
|
onEdit: (project: Project) => void;
|
||||||
onPageChange: (page: number, pageSize: number) => void;
|
onRestore: (id: string) => Promise<void>;
|
||||||
onRestore: (id: string) => Promise<unknown>;
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
status: ProjectStatus;
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMNS: TableColumnsType<Project> = [
|
|
||||||
{ 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 <Tag>已归档</Tag>;
|
|
||||||
}
|
|
||||||
return <Tag color="blue">进行中</Tag>;
|
|
||||||
},
|
|
||||||
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({
|
export function ProjectTable({
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
onArchive,
|
onArchive,
|
||||||
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onPageChange,
|
|
||||||
onRestore,
|
onRestore,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
status,
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
}: ProjectTableProps) {
|
}: ProjectTableProps) {
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleArchive = useCallback(
|
const columns = useMemo<TableColumnsType<Project>>(
|
||||||
async (id: string) => {
|
() => [
|
||||||
try {
|
{
|
||||||
await onArchive(id);
|
dataIndex: "name",
|
||||||
message.success("项目已归档");
|
ellipsis: true,
|
||||||
} catch (err: unknown) {
|
sorter: true,
|
||||||
message.error((err as Error).message);
|
sortOrder:
|
||||||
}
|
sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||||
},
|
title: "名称",
|
||||||
[onArchive, message],
|
width: 140,
|
||||||
);
|
},
|
||||||
|
{ dataIndex: "description", ellipsis: true, title: "描述" },
|
||||||
const handleRestore = useCallback(
|
{
|
||||||
async (id: string) => {
|
align: "center",
|
||||||
try {
|
dataIndex: "status",
|
||||||
await onRestore(id);
|
render: (_value: unknown, record: Project) => {
|
||||||
message.success("项目已恢复");
|
if (record.status === "archived") {
|
||||||
} catch (err: unknown) {
|
return <Tag>已归档</Tag>;
|
||||||
message.error((err as Error).message);
|
}
|
||||||
}
|
return <Tag color="blue">进行中</Tag>;
|
||||||
},
|
},
|
||||||
[onRestore, message],
|
title: "状态",
|
||||||
);
|
width: 90,
|
||||||
|
},
|
||||||
const handleDelete = useCallback(
|
{
|
||||||
async (id: string) => {
|
align: "center",
|
||||||
try {
|
dataIndex: "createdAt",
|
||||||
await onDelete(id);
|
render: (_value: unknown, record: Project) => formatDatetime(record.createdAt),
|
||||||
message.success("项目已永久删除");
|
sorter: true,
|
||||||
} catch (err: unknown) {
|
sortOrder:
|
||||||
message.error((err as Error).message);
|
sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||||
}
|
title: "创建时间",
|
||||||
},
|
width: 180,
|
||||||
[onDelete, message],
|
},
|
||||||
);
|
{
|
||||||
|
align: "center",
|
||||||
const operationColumn = useMemo<TableColumnsType<Project>[number]>(
|
dataIndex: "updatedAt",
|
||||||
() => ({
|
render: (_value: unknown, record: Project) => formatDatetime(record.updatedAt),
|
||||||
dataIndex: "op",
|
sorter: true,
|
||||||
render: (_value, record: Project) => {
|
sortOrder:
|
||||||
if (record.status === "active") {
|
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 (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
icon={<LoginOutlined />}
|
||||||
|
onClick={() => void navigate(`/workbench/${record.id}`)}
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
>
|
||||||
|
工作台
|
||||||
|
</Button>
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
description="归档后项目将变为只读。"
|
||||||
|
onConfirm={() => void onArchive(record.id)}
|
||||||
|
title="确认归档此项目?"
|
||||||
|
>
|
||||||
|
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link">
|
||||||
|
归档
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Button
|
<Popconfirm onConfirm={() => void onRestore(record.id)} title="确认恢复此项目?">
|
||||||
icon={<LoginOutlined />}
|
<Button icon={<RedoOutlined />} size="small" type="link">
|
||||||
onClick={() => void navigate(`/workbench/${record.id}`)}
|
恢复
|
||||||
size="small"
|
</Button>
|
||||||
type="link"
|
</Popconfirm>
|
||||||
>
|
|
||||||
工作台
|
|
||||||
</Button>
|
|
||||||
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
description="归档后项目将变为只读。"
|
description="此操作不可恢复。"
|
||||||
onConfirm={() => void handleArchive(record.id)}
|
onConfirm={() => void onDelete(record.id)}
|
||||||
title="确认归档此项目?"
|
title="确认永久删除此项目?"
|
||||||
>
|
>
|
||||||
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link">
|
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
||||||
归档
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return (
|
title: "操作",
|
||||||
<Space size="small">
|
width: 260,
|
||||||
<Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?">
|
|
||||||
<Button icon={<RedoOutlined />} size="small" type="link">
|
|
||||||
恢复
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
<Popconfirm
|
|
||||||
description="此操作不可恢复。"
|
|
||||||
onConfirm={() => void handleDelete(record.id)}
|
|
||||||
title="确认永久删除此项目?"
|
|
||||||
>
|
|
||||||
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
title: "操作",
|
],
|
||||||
width: status === "active" ? 260 : 160,
|
[navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder],
|
||||||
}),
|
|
||||||
[navigate, onEdit, handleArchive, handleRestore, handleDelete, status],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
|
const handleTableChange: TableProps<Project>["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 (
|
return (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
locale={{ emptyText: "暂无项目数据" }}
|
||||||
|
onChange={handleTableChange}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
hideOnSinglePage: false,
|
hideOnSinglePage: false,
|
||||||
onChange: onPageChange,
|
|
||||||
pageSize,
|
pageSize,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
}}
|
}}
|
||||||
rowKey="id"
|
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())}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
|
|
||||||
<Tabs activeKey={activeTab} items={STATUS_TAB_ITEMS} onChange={onTabChange} />
|
|
||||||
<Space size="small">
|
|
||||||
<Input.Search
|
|
||||||
allowClear
|
|
||||||
enterButton="搜索"
|
|
||||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
|
||||||
onClear={() => {
|
|
||||||
setDraftKeyword("");
|
|
||||||
onSearchClear();
|
|
||||||
}}
|
|
||||||
onSearch={(value) => onSearch(value)}
|
|
||||||
placeholder="搜索名称或描述"
|
|
||||||
value={draftKeyword}
|
|
||||||
/>
|
|
||||||
{activeTab === "active" && (
|
|
||||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
|
||||||
新建项目
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Space } from "antd";
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
import { useState } from "react";
|
import { Button, Space } from "antd";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { Project, ProjectStatus } from "../../../shared/api";
|
import type { Project, ProjectStatus } from "../../../shared/api";
|
||||||
|
|
||||||
|
import { FilterToolbar } from "../../shared/components/FilterToolbar";
|
||||||
import {
|
import {
|
||||||
useArchiveProject,
|
useArchiveProject,
|
||||||
useCreateProject,
|
useCreateProject,
|
||||||
@@ -11,20 +13,40 @@ import {
|
|||||||
useRestoreProject,
|
useRestoreProject,
|
||||||
useUpdateProject,
|
useUpdateProject,
|
||||||
} from "../../shared/hooks/use-projects";
|
} from "../../shared/hooks/use-projects";
|
||||||
|
import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
|
||||||
|
import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
|
||||||
import { ProjectFormModal } from "./components/ProjectFormModal";
|
import { ProjectFormModal } from "./components/ProjectFormModal";
|
||||||
import { ProjectTable } from "./components/ProjectTable";
|
import { ProjectTable } from "./components/ProjectTable";
|
||||||
import { ProjectToolbar } from "./components/ProjectToolbar";
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ label: "进行中", value: "active" },
|
||||||
|
{ label: "已归档", value: "archived" },
|
||||||
|
];
|
||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
const [tabValue, setTabValue] = useState<ProjectStatus>("active");
|
const { confirmAction } = useConfirmAction();
|
||||||
const [page, setPage] = useState(1);
|
const { params, resetAll, setParams } = usePageSearchParams({
|
||||||
const [pageSize, setPageSize] = useState(20);
|
defaults: { page: "1", pageSize: "20" },
|
||||||
const [keyword, setKeyword] = useState("");
|
});
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingProject, setEditingProject] = useState<null | Project>(null);
|
const [editingProject, setEditingProject] = useState<null | Project>(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 createMutation = useCreateProject();
|
||||||
const updateMutation = useUpdateProject();
|
const updateMutation = useUpdateProject();
|
||||||
const archiveMutation = useArchiveProject();
|
const archiveMutation = useArchiveProject();
|
||||||
@@ -32,54 +54,111 @@ export function ProjectsPage() {
|
|||||||
const deleteMutation = useDeleteProject();
|
const deleteMutation = useDeleteProject();
|
||||||
|
|
||||||
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
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<string, string | undefined> = {
|
||||||
|
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 (
|
return (
|
||||||
<Space className="app-page-flex" orientation="vertical" size="large">
|
<Space className="app-page-flex" orientation="vertical" size="large">
|
||||||
<ProjectToolbar
|
<FilterToolbar
|
||||||
activeTab={tabValue}
|
actions={
|
||||||
keyword={keyword}
|
<Button
|
||||||
onSearch={(value) => {
|
icon={<PlusOutlined />}
|
||||||
setKeyword(value);
|
onClick={() => {
|
||||||
setPage(1);
|
setEditingProject(null);
|
||||||
}}
|
setDialogOpen(true);
|
||||||
onSearchClear={() => {
|
}}
|
||||||
setKeyword("");
|
type="primary"
|
||||||
setPage(1);
|
>
|
||||||
}}
|
新建项目
|
||||||
onTabChange={(key) => {
|
</Button>
|
||||||
setTabValue(key as ProjectStatus);
|
}
|
||||||
setPage(1);
|
filters={filters}
|
||||||
}}
|
search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索名称或描述" }}
|
||||||
openCreateDialog={() => {
|
|
||||||
setEditingProject(null);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProjectTable
|
<ProjectTable
|
||||||
data={data}
|
data={data}
|
||||||
loading={isLoading || isRowActionPending}
|
loading={isFetching}
|
||||||
onArchive={(id) => archiveMutation.mutateAsync(id)}
|
onArchive={handleArchive}
|
||||||
onDelete={(id) => deleteMutation.mutateAsync(id)}
|
onChange={handleTableChange}
|
||||||
|
onDelete={handleDelete}
|
||||||
onEdit={(project) => {
|
onEdit={(project) => {
|
||||||
setEditingProject(project);
|
setEditingProject(project);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
onPageChange={(p, ps) => {
|
onRestore={handleRestore}
|
||||||
setPage(p);
|
page={apiPage}
|
||||||
setPageSize(ps);
|
pageSize={apiPageSize}
|
||||||
}}
|
sortBy={sortBy}
|
||||||
onRestore={(id) => restoreMutation.mutateAsync(id)}
|
sortOrder={sortOrder}
|
||||||
page={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
status={tabValue}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProjectFormModal
|
<ProjectFormModal
|
||||||
editingProject={editingProject}
|
editingProject={editingProject}
|
||||||
onCancel={() => setDialogOpen(false)}
|
onCancel={() => setDialogOpen(false)}
|
||||||
onCreate={(data) => createMutation.mutateAsync(data)}
|
onCreate={(formData) => createMutation.mutateAsync(formData)}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
onUpdate={(args) => updateMutation.mutateAsync(args)}
|
onUpdate={(args) => updateMutation.mutateAsync(args)}
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
74
src/web/shared/components/FilterToolbar.tsx
Normal file
74
src/web/shared/components/FilterToolbar.tsx
Normal file
@@ -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 (
|
||||||
|
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
|
||||||
|
<Space size="small" wrap>
|
||||||
|
{filters?.map((filter) => (
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
key={filter.key}
|
||||||
|
onChange={filter.onChange}
|
||||||
|
options={filter.options}
|
||||||
|
placeholder={filter.placeholder}
|
||||||
|
style={{ minWidth: 120 }}
|
||||||
|
value={filter.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{search && (
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
enterButton={<Button icon={<SearchOutlined />} 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button icon={<UndoOutlined />} onClick={search?.onReset} title="重置" />
|
||||||
|
</Space>
|
||||||
|
{actions && <Space size="small">{actions}</Space>}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,16 +37,22 @@ export async function fetchModel(id: string): Promise<Model> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchModelList(params: {
|
export async function fetchModelList(params: {
|
||||||
|
capabilities?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
}): Promise<ModelListResponse> {
|
}): Promise<ModelListResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.page) searchParams.set("page", String(params.page));
|
if (params.page) searchParams.set("page", String(params.page));
|
||||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
if (params.keyword) searchParams.set("keyword", params.keyword);
|
||||||
if (params.providerId) searchParams.set("providerId", params.providerId);
|
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 qs = searchParams.toString();
|
||||||
const url = `/api/models${qs ? `?${qs}` : ""}`;
|
const url = `/api/models${qs ? `?${qs}` : ""}`;
|
||||||
const response = await fetch(url);
|
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({
|
return useQuery({
|
||||||
queryFn: () => fetchModelList(params),
|
queryFn: () => fetchModelList(params),
|
||||||
queryKey: [...MODELS_KEY, "list", params],
|
queryKey: [...MODELS_KEY, "list", params],
|
||||||
|
|||||||
@@ -43,12 +43,16 @@ export async function fetchProjectList(params: {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
status?: ProjectStatus;
|
status?: ProjectStatus;
|
||||||
}): Promise<ProjectListResponse> {
|
}): Promise<ProjectListResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.page) searchParams.set("page", String(params.page));
|
if (params.page) searchParams.set("page", String(params.page));
|
||||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
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);
|
if (params.status) searchParams.set("status", params.status);
|
||||||
const qs = searchParams.toString();
|
const qs = searchParams.toString();
|
||||||
const url = `/api/projects${qs ? `?${qs}` : ""}`;
|
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({
|
return useQuery({
|
||||||
queryFn: () => fetchProjectList(params),
|
queryFn: () => fetchProjectList(params),
|
||||||
queryKey: [...PROJECTS_KEY, "list", params],
|
queryKey: [...PROJECTS_KEY, "list", params],
|
||||||
|
|||||||
@@ -41,11 +41,17 @@ export async function fetchProviderList(params: {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
type?: string;
|
||||||
}): Promise<ProviderListResponse> {
|
}): Promise<ProviderListResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.page) searchParams.set("page", String(params.page));
|
if (params.page) searchParams.set("page", String(params.page));
|
||||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
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 qs = searchParams.toString();
|
||||||
const url = `/api/providers${qs ? `?${qs}` : ""}`;
|
const url = `/api/providers${qs ? `?${qs}` : ""}`;
|
||||||
const response = await fetch(url);
|
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({
|
return useQuery({
|
||||||
queryFn: () => fetchProviderList(params),
|
queryFn: () => fetchProviderList(params),
|
||||||
queryKey: [...PROVIDERS_KEY, "list", params],
|
queryKey: [...PROVIDERS_KEY, "list", params],
|
||||||
|
|||||||
24
src/web/shared/hooks/useConfirmAction.ts
Normal file
24
src/web/shared/hooks/useConfirmAction.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { App } from "antd";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export interface UseConfirmActionResult {
|
||||||
|
confirmAction: (action: () => Promise<unknown>, successMessage: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfirmAction(): UseConfirmActionResult {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
const confirmAction = useCallback(
|
||||||
|
async (action: () => Promise<unknown>, successMessage: string) => {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
message.success(successMessage);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
message.error((err as Error).message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[message],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { confirmAction };
|
||||||
|
}
|
||||||
74
src/web/shared/hooks/usePageSearchParams.ts
Normal file
74
src/web/shared/hooks/usePageSearchParams.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
|
export interface UsePageSearchParamsOptions {
|
||||||
|
defaults?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePageSearchParamsResult {
|
||||||
|
params: Record<string, string>;
|
||||||
|
resetAll: () => void;
|
||||||
|
setParam: (key: string, value: string | undefined) => void;
|
||||||
|
setParams: (patch: Record<string, string | undefined>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageSearchParams(options?: UsePageSearchParamsOptions): UsePageSearchParamsResult {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const defaults = useMemo(() => options?.defaults ?? {}, [options?.defaults]);
|
||||||
|
|
||||||
|
const params = useMemo(() => {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
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<string, string | undefined>) => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
5
src/web/shared/utils/format.ts
Normal file
5
src/web/shared/utils/format.ts
Normal file
@@ -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())}`;
|
||||||
|
}
|
||||||
134
tests/server/list-params.test.ts
Normal file
134
tests/server/list-params.test.ts
Normal file
@@ -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<string, string> = {}): 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
test("GET /api/models filter by providerId", async () => {
|
||||||
await withRouteDb(async (db) => {
|
await withRouteDb(async (db) => {
|
||||||
const p1 = seedProvider(db, "P1");
|
const p1 = seedProvider(db, "P1");
|
||||||
|
|||||||
@@ -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 () => {
|
test("GET /api/providers/options 返回最小字段", async () => {
|
||||||
await withRouteDb(async (db) => {
|
await withRouteDb(async (db) => {
|
||||||
createTestProvider(db, "选项供应商");
|
createTestProvider(db, "选项供应商");
|
||||||
|
|||||||
106
tests/web/FilterToolbar.test.tsx
Normal file
106
tests/web/FilterToolbar.test.tsx
Normal file
@@ -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<HTMLButtonElement>('button[title="重置"]');
|
||||||
|
resetBtn?.click();
|
||||||
|
expect(onReset).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,9 +74,9 @@ function renderModelTable(overrides?: Record<string, unknown>) {
|
|||||||
createElement(ModelTable, {
|
createElement(ModelTable, {
|
||||||
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
|
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
|
||||||
loading: false,
|
loading: false,
|
||||||
|
onChange: () => undefined,
|
||||||
onDelete: () => Promise.resolve(),
|
onDelete: () => Promise.resolve(),
|
||||||
onEdit: () => undefined,
|
onEdit: () => undefined,
|
||||||
onPageChange: () => undefined,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
providers: [OPENAI_PROVIDER_OPTION, DEEPSEEK_PROVIDER_OPTION],
|
providers: [OPENAI_PROVIDER_OPTION, DEEPSEEK_PROVIDER_OPTION],
|
||||||
@@ -90,9 +90,9 @@ function renderProviderTable(overrides?: Record<string, unknown>) {
|
|||||||
createElement(ProviderTable, {
|
createElement(ProviderTable, {
|
||||||
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
|
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
|
||||||
loading: false,
|
loading: false,
|
||||||
|
onChange: () => undefined,
|
||||||
onDelete: () => Promise.resolve(),
|
onDelete: () => Promise.resolve(),
|
||||||
onEdit: () => undefined,
|
onEdit: () => undefined,
|
||||||
onPageChange: () => undefined,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
25
tests/web/format.test.ts
Normal file
25
tests/web/format.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -310,8 +310,9 @@ describe("ModelListPage", () => {
|
|||||||
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
||||||
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
|
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("搜索模型名称或 ID"), { target: { value: "gpt" } });
|
const input = screen.getByPlaceholderText("搜索模型名称或 ID");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
fireEvent.change(input, { target: { value: "gpt" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
|
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function LocationProbe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectsPage", () => {
|
describe("ProjectsPage", () => {
|
||||||
test("渲染项目管理入口并按状态请求项目列表", async () => {
|
test("渲染项目管理入口并展示项目列表", async () => {
|
||||||
const calls = createProjectFetchMock();
|
const calls = createProjectFetchMock();
|
||||||
|
|
||||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
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.getByText("归档项目")).not.toBeNull();
|
||||||
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
|
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
|
||||||
expect(screen.getByPlaceholderText("搜索名称或描述")).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();
|
const calls = createProjectFetchMock();
|
||||||
|
|
||||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
|
const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
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));
|
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());
|
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 () => {
|
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
|
||||||
@@ -152,12 +159,19 @@ describe("ProjectsPage", () => {
|
|||||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
|
const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
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));
|
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "" } });
|
const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
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());
|
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 },
|
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
|
||||||
loading: false,
|
loading: false,
|
||||||
onArchive,
|
onArchive,
|
||||||
|
onChange: () => undefined,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit: () => undefined,
|
onEdit: () => undefined,
|
||||||
onPageChange: () => undefined,
|
|
||||||
onRestore,
|
onRestore,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
status: "active",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -205,8 +205,9 @@ describe("ProviderListPage", () => {
|
|||||||
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
|
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
|
||||||
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
|
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("搜索供应商名称"), { target: { value: "Open" } });
|
const input = screen.getByPlaceholderText("搜索供应商名称");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
fireEvent.change(input, { target: { value: "Open" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
|
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
|
|||||||
53
tests/web/use-confirm-action.test.ts
Normal file
53
tests/web/use-confirm-action.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
tests/web/use-page-search-params.test.ts
Normal file
86
tests/web/use-page-search-params.test.ts
Normal file
@@ -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<string, string>) {
|
||||||
|
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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user