feat: add status filter

This commit is contained in:
2025-12-17 16:11:30 +08:00
parent a7eb2364d3
commit fe9601baf4
6 changed files with 173 additions and 21 deletions

View File

@@ -13,8 +13,8 @@ type TenantFilter struct {
requests.Pagination
requests.SortQueryFilter
Name *string `json:"name,omitempty" query:"name"`
Status *string `json:"status,omitempty" query:"status"`
Name *string `json:"name,omitempty" query:"name"`
Status *consts.TenantStatus `json:"status,omitempty" query:"status"`
}
type TenantItem struct {

View File

@@ -82,6 +82,10 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests
conds = append(conds, tbl.Name.Like(database.WrapLike(*filter.Name)))
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
filter.Pagination.Format()
mm, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {

138
backend/docs/dev/api.md Normal file
View File

@@ -0,0 +1,138 @@
# Backend 新增 HTTP 接口流程Go + Fiber + atomctl
本文档描述在本仓库 `backend/` 中新增一个 HTTP 接口(例如 `/super/v1/...`的标准流程包含路由生成、Swagger 文档生成、参数绑定与测试验证。
## 相关目录
- 路由与 Controller`backend/app/http/**`
- Super 端示例:`backend/app/http/super/*.go`
- 生成路由:`backend/app/http/super/routes.gen.go`(自动生成,勿手改)
- Swagger 文档:
- `backend/docs/swagger.yaml`
- `backend/docs/swagger.json`
- `backend/docs/docs.go`(自动生成,勿手改)
- 本地接口调试示例:`backend/super.http`
- 命令:`backend/Makefile``make init`/`make run`/`make test` 等)
## 增加一个新接口(推荐步骤)
### 1) 选择模块与 BasePath
- **Super 管理端**:一般挂在 `/super/v1/...`,代码放在 `backend/app/http/super/`
- **租户端**:项目 `main.go` 注解里有 `@BasePath /t/{tenant_code}/v1`,通常租户端接口会以该前缀为基础(具体以现有路由模块为准)。
先决定:
- `METHOD`GET/POST/PATCH/DELETE…
- `PATH`:例如 `/super/v1/users/{id}``/super/v1/users/:id`
- 鉴权:是否需要 token/权限(跟随模块现有中间件)
- 请求path/query/body
- 响应:结构体 / 分页 / KV 列表等
### 2) 定义请求 DTOFilter/Form与响应 DTO
Super 模块常见模式:
- 列表分页:`dto.*Filter` / `dto.*PageFilter` + 返回 `requests.Pager`
- 更新类接口:`dto.*UpdateForm`body
- KV 枚举列表:返回 `[]requests.KV`
可参考:
- `backend/app/http/super/dto/*`
- `backend/app/http/super/user.go``backend/app/http/super/tenant.go`
### 3) 编写 Controller 方法(带 Swagger 注解 + Bind
在对应模块的 `*.go` 中新增方法,保持和现有风格一致:
- Controller struct 上保留 `// @provider`
- 方法上补齐 swagger 注解:`@Summary/@Tags/@Param/@Success/@Router`
- 使用 `@Bind` 约定参数来源path/query/body
示例(参考 `backend/app/http/super/user.go`
```go
// @Summary 用户状态列表
// @Tags Super
// @Accept json
// @Produce json
// @Success 200 {array} requests.KV
// @Router /super/v1/users/statuses [get]
func (*user) statusList(ctx fiber.Ctx) ([]requests.KV, error) { ... }
```
注意:
- `@Router` 的路径写法通常与 Fiber 路由一致(例如 `:userID`)。
- 参数绑定会驱动路由代码生成(见下一步)。
### 4) 连接业务层Service
Controller 内尽量只做:
- 参数解析/校验
- 调用 `services.*` 完成业务
- 返回结果或 error
业务逻辑集中放在 `backend/app/services`(结合现有实现),涉及 DB 的部分走现有模型/仓储层(依项目既有组织)。
### 5) 生成路由代码routes.gen.go
本项目路由是由 `atomctl` 自动生成的。
常用命令(在 `backend/` 下执行):
- 仅生成路由:`atomctl gen route`
- 全量初始化/更新(含 swagger/enum/route/service 等):`make init`
生成完成后检查:
- `backend/app/http/<module>/routes.gen.go` 是否出现新路由
- 路由 METHOD/PATH、参数绑定是否正确
### 6) 生成 Swagger 文档swagger.yaml/json/docs.go
Swagger 也是由工具生成并落盘到 `backend/docs/`
- `atomctl swag init`
- 或直接 `make init`(会包含该步骤)
生成完成后检查:
- `backend/docs/swagger.yaml``backend/docs/swagger.json` 是否包含新接口
- `backend/docs/docs.go` 是否同步更新
### 7) 本地验证
启动:
- `make run`(会先 `make build`
验证方式:
- 使用 `backend/super.http` 增加/执行请求
- 或用 curl/Swagger UI若项目已暴露 swagger 页面)
### 8) 增加测试(建议)
优先参考现有 e2e 测试:
- `backend/tests/e2e/*`
覆盖至少:
- 正常请求返回
- 参数缺失/非法
- 权限不足/未登录(如该接口需要鉴权)
运行:
- `make test`
## 常见注意事项
- 不要手改 `*.gen.go``backend/docs/docs.go`:它们由 `atomctl` 生成。
- 确认查询参数命名与 swagger 一致(例如 `page/limit/asc/desc/status`),前端会按 swagger 拼 query。
- 路由路径参数请在 `@Router` 与函数签名/`@Bind` 里保持一致(例如 `tenantID``userID`)。

View File

@@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sakai Vue</title>
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
<script type="module" crossorigin src="./assets/index-C3wBcLrK.js"></script>
<script type="module" crossorigin src="./assets/index-DH-rBOaE.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Ba8sjR1v.css">
</head>

View File

@@ -7,8 +7,8 @@ function normalizeItems(items) {
}
export const TenantService = {
async listTenants({ page, limit, name, code, sortField, sortOrder } = {}) {
const query = { page, limit, name, code };
async listTenants({ page, limit, name, code, status, sortField, sortOrder } = {}) {
const query = { page, limit, name, code, status };
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;

View File

@@ -15,6 +15,7 @@ const page = ref(1);
const rows = ref(10);
const keyword = ref('');
const status = ref('');
const sortField = ref('id');
const sortOrder = ref(-1);
@@ -65,6 +66,7 @@ async function loadTenants() {
limit: rows.value,
name: keyword.value,
code: keyword.value,
status: status.value,
sortField: sortField.value,
sortOrder: sortOrder.value
});
@@ -89,6 +91,7 @@ function onSearch() {
function onReset() {
keyword.value = '';
status.value = '';
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
@@ -127,20 +130,26 @@ function openRenewDialog(item) {
}
const tenantStatusDialogVisible = ref(false);
const tenantStatusLoading = ref(false);
const tenantStatusOptionsLoading = ref(false);
const tenantStatusUpdating = ref(false);
const tenantStatusOptions = ref([]);
const tenantStatusTenant = ref(null);
const tenantStatusValue = ref(null);
async function ensureTenantStatusOptionsLoaded() {
if (tenantStatusOptions.value.length > 0) return;
const list = await TenantService.getTenantStatuses();
tenantStatusOptions.value = (list || [])
.map((kv) => ({
label: kv?.value ?? kv?.key ?? '-',
value: kv?.key ?? ''
}))
.filter((item) => item.value);
tenantStatusOptionsLoading.value = true;
try {
const list = await TenantService.getTenantStatuses();
tenantStatusOptions.value = (list || [])
.map((kv) => ({
label: kv?.value ?? kv?.key ?? '-',
value: kv?.key ?? ''
}))
.filter((item) => item.value);
} finally {
tenantStatusOptionsLoading.value = false;
}
}
async function openTenantStatusDialog(tenant) {
@@ -148,13 +157,10 @@ async function openTenantStatusDialog(tenant) {
tenantStatusValue.value = tenant?.status ?? null;
tenantStatusDialogVisible.value = true;
tenantStatusLoading.value = true;
try {
await ensureTenantStatusOptionsLoaded();
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户状态列表', life: 4000 });
} finally {
tenantStatusLoading.value = false;
}
}
@@ -162,7 +168,7 @@ async function confirmUpdateTenantStatus() {
const tenantID = tenantStatusTenant.value?.id;
if (!tenantID || !tenantStatusValue.value) return;
tenantStatusLoading.value = true;
tenantStatusUpdating.value = true;
try {
await TenantService.updateTenantStatus({ tenantID, status: tenantStatusValue.value });
toast.add({ severity: 'success', summary: '更新成功', detail: `TenantID: ${tenantID}`, life: 3000 });
@@ -171,7 +177,7 @@ async function confirmUpdateTenantStatus() {
} catch (error) {
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新租户状态', life: 4000 });
} finally {
tenantStatusLoading.value = false;
tenantStatusUpdating.value = false;
}
}
@@ -194,6 +200,7 @@ async function confirmRenew() {
onMounted(() => {
loadTenants();
ensureTenantStatusOptionsLoaded().catch(() => {});
});
</script>
@@ -213,6 +220,9 @@ onMounted(() => {
<InputText v-model="keyword" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="tenantStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="tenantStatusOptionsLoading" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
@@ -300,12 +310,12 @@ onMounted(() => {
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">租户状态</label>
<Select v-model="tenantStatusValue" :options="tenantStatusOptions" optionLabel="label" optionValue="value" placeholder="选择状态" :disabled="tenantStatusLoading" fluid />
<Select v-model="tenantStatusValue" :options="tenantStatusOptions" optionLabel="label" optionValue="value" placeholder="选择状态" :disabled="tenantStatusUpdating" fluid />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="tenantStatusDialogVisible = false" :disabled="tenantStatusLoading" />
<Button label="确认" icon="pi pi-check" @click="confirmUpdateTenantStatus" :loading="tenantStatusLoading" :disabled="!tenantStatusValue" />
<Button label="取消" icon="pi pi-times" text @click="tenantStatusDialogVisible = false" :disabled="tenantStatusUpdating" />
<Button label="确认" icon="pi pi-check" @click="confirmUpdateTenantStatus" :loading="tenantStatusUpdating" :disabled="!tenantStatusValue" />
</template>
</Dialog>
</div>