refactor: 统一管理页面布局 — FilterToolbar + usePageSearchParams + parseListParams

This commit is contained in:
2026-06-04 17:25:36 +08:00
parent 61b479e2be
commit 6f547560d1
40 changed files with 1805 additions and 628 deletions

View File

@@ -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` |

View File

@@ -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
View 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 页 |
| 分页 | 点击分页器 | 更新 URLpage+pageSize |
| 排序 | 点击列头排序 | 重置到第 1 页,更新 URL |
| 删除 | Popconfirm 确认后 | toast 通知,刷新列表 |

View File

@@ -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` | 素材 CRUDcreate/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` | 素材 CRUDcreate/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)。
## 日志模块 ## 日志模块

View File

@@ -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[],

View File

@@ -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,

View File

@@ -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,

View File

@@ -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";

View 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 };
}

View File

@@ -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 });

View File

@@ -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,
}); });

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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())}`;
}

View File

@@ -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>
);
}

View File

@@ -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}

View 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>
);
}

View File

@@ -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],

View File

@@ -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],

View File

@@ -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],

View 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 };
}

View 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 };
}

View 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())}`;
}

View 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",
});
});
});

View File

@@ -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");

View File

@@ -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, "选项供应商");

View 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();
});
});

View File

@@ -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
View 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);
});
});

View File

@@ -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);

View File

@@ -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",
}), }),
), ),
); );

View File

@@ -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);

View 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);
});
});

View 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({});
});
});