mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-07 00:17:12 +08:00
feat(admin): add column sorting and reset pagination on search (#927)
* feat(admin): add column sorting and reset pagination on search (#918) - Add server-side column sorting for admin address list (ID, name, created_at, updated_at, mail_count, send_count) - Reset pagination to page 1 when searching or changing sort order - Add optional orderBy parameter to handleListQuery with whitelist validation Closes #918 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add JSDoc warning for orderBy parameter in handleListQuery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review findings - Fix count not resetting to 0 when search returns empty results - Add source_meta column sorting support - Use Object.hasOwn to prevent prototype pollution in sort column lookup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
db93828a81
commit
d2c940aa2c
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918)
|
||||||
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912)
|
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912)
|
||||||
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
||||||
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |Admin| Admin account list now supports column sorting (ID, name, created at, updated at, mail count, send count), search automatically resets pagination to page 1 (#918)
|
||||||
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
|
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
|
||||||
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
|
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
|
||||||
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
|
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ const selectedCount = computed(() => checkedRowKeys.value.length);
|
|||||||
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
|
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
|
||||||
|
|
||||||
const addressQuery = ref("")
|
const addressQuery = ref("")
|
||||||
|
const sortBy = ref("")
|
||||||
|
const sortOrder = ref("")
|
||||||
|
|
||||||
const data = ref([])
|
const data = ref([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
@@ -290,10 +292,12 @@ const fetchData = async () => {
|
|||||||
+ `?limit=${pageSize.value}`
|
+ `?limit=${pageSize.value}`
|
||||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||||
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
||||||
|
+ (sortBy.value ? `&sort_by=${sortBy.value}` : "")
|
||||||
|
+ (sortOrder.value ? `&sort_order=${sortOrder.value}` : "")
|
||||||
);
|
);
|
||||||
data.value = results;
|
data.value = results;
|
||||||
if (addressCount > 0) {
|
if (page.value === 1 || addressCount > 0) {
|
||||||
count.value = addressCount;
|
count.value = addressCount ?? 0;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -301,29 +305,57 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const searchData = () => {
|
||||||
|
if (page.value === 1) {
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSorterChange = (sorter) => {
|
||||||
|
sortBy.value = sorter.columnKey || "";
|
||||||
|
sortOrder.value = sorter.order || "";
|
||||||
|
if (page.value === 1) {
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
{
|
{
|
||||||
type: 'selection'
|
type: 'selection'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "ID",
|
title: "ID",
|
||||||
key: "id"
|
key: "id",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('name'),
|
title: t('name'),
|
||||||
key: "name"
|
key: "name",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('created_at'),
|
title: t('created_at'),
|
||||||
key: "created_at"
|
key: "created_at",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('updated_at'),
|
title: t('updated_at'),
|
||||||
key: "updated_at"
|
key: "updated_at",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('source_meta'),
|
title: t('source_meta'),
|
||||||
key: "source_meta",
|
key: "source_meta",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortBy.value === 'source_meta' ? sortOrder.value : false,
|
||||||
render(row) {
|
render(row) {
|
||||||
const val = row.source_meta;
|
const val = row.source_meta;
|
||||||
if (!val) return '';
|
if (!val) return '';
|
||||||
@@ -342,6 +374,8 @@ const columns = [
|
|||||||
{
|
{
|
||||||
title: t('mail_count'),
|
title: t('mail_count'),
|
||||||
key: "mail_count",
|
key: "mail_count",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false,
|
||||||
render(row) {
|
render(row) {
|
||||||
return h(NButton,
|
return h(NButton,
|
||||||
{
|
{
|
||||||
@@ -368,6 +402,8 @@ const columns = [
|
|||||||
{
|
{
|
||||||
title: t('send_count'),
|
title: t('send_count'),
|
||||||
key: "send_count",
|
key: "send_count",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false,
|
||||||
render(row) {
|
render(row) {
|
||||||
return h(NButton,
|
return h(NButton,
|
||||||
{
|
{
|
||||||
@@ -497,7 +533,7 @@ const columns = [
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
])
|
||||||
|
|
||||||
watch([page, pageSize], async () => {
|
watch([page, pageSize], async () => {
|
||||||
await fetchData()
|
await fetchData()
|
||||||
@@ -560,8 +596,8 @@ onMounted(async () => {
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
<n-input-group style="margin-bottom: 10px;">
|
<n-input-group style="margin-bottom: 10px;">
|
||||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
|
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
|
||||||
@keydown.enter="fetchData" />
|
@keydown.enter="searchData" />
|
||||||
<n-button @click="fetchData" type="primary" tertiary>
|
<n-button @click="searchData" type="primary" tertiary>
|
||||||
{{ t('query') }}
|
{{ t('query') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-input-group>
|
</n-input-group>
|
||||||
@@ -605,7 +641,7 @@ onMounted(async () => {
|
|||||||
</n-pagination>
|
</n-pagination>
|
||||||
</div>
|
</div>
|
||||||
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
|
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
|
||||||
:row-key="row => row.id" embedded />
|
:row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Multi-action progress modal -->
|
<!-- Multi-action progress modal -->
|
||||||
|
|||||||
@@ -22,7 +22,19 @@ import e2e_test_api from './e2e_test_api'
|
|||||||
export const api = new Hono<HonoCustomType>()
|
export const api = new Hono<HonoCustomType>()
|
||||||
|
|
||||||
api.get('/admin/address', async (c) => {
|
api.get('/admin/address', async (c) => {
|
||||||
const { limit, offset, query } = c.req.query();
|
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||||
|
const allowedSortColumns: Record<string, string> = {
|
||||||
|
'id': 'a.id',
|
||||||
|
'name': 'a.name',
|
||||||
|
'created_at': 'a.created_at',
|
||||||
|
'updated_at': 'a.updated_at',
|
||||||
|
'source_meta': 'a.source_meta',
|
||||||
|
'mail_count': 'mail_count',
|
||||||
|
'send_count': 'send_count',
|
||||||
|
};
|
||||||
|
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
|
||||||
|
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
|
||||||
|
const orderBy = `${sortColumn} ${sortDirection}`;
|
||||||
if (query) {
|
if (query) {
|
||||||
return await handleListQuery(c,
|
return await handleListQuery(c,
|
||||||
`SELECT a.*,`
|
`SELECT a.*,`
|
||||||
@@ -31,7 +43,7 @@ api.get('/admin/address', async (c) => {
|
|||||||
+ ` FROM address a`
|
+ ` FROM address a`
|
||||||
+ ` where name like ?`,
|
+ ` where name like ?`,
|
||||||
`SELECT count(*) as count FROM address where name like ?`,
|
`SELECT count(*) as count FROM address where name like ?`,
|
||||||
[`%${query}%`], limit, offset
|
[`%${query}%`], limit, offset, orderBy
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return await handleListQuery(c,
|
return await handleListQuery(c,
|
||||||
@@ -40,7 +52,7 @@ api.get('/admin/address', async (c) => {
|
|||||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||||
+ ` FROM address a`,
|
+ ` FROM address a`,
|
||||||
`SELECT count(*) as count FROM address`,
|
`SELECT count(*) as count FROM address`,
|
||||||
[], limit, offset
|
[], limit, offset, orderBy
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -483,7 +483,9 @@ export const handleListQuery = async (
|
|||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
query: string, countQuery: string, params: string[],
|
query: string, countQuery: string, params: string[],
|
||||||
limit: string | number | undefined | null,
|
limit: string | number | undefined | null,
|
||||||
offset: string | number | undefined | null
|
offset: string | number | undefined | null,
|
||||||
|
/** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */
|
||||||
|
orderBy?: string
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const msgs = i18n.getMessagesbyContext(c);
|
const msgs = i18n.getMessagesbyContext(c);
|
||||||
if (typeof limit === "string") {
|
if (typeof limit === "string") {
|
||||||
@@ -498,7 +500,8 @@ export const handleListQuery = async (
|
|||||||
if (offset == null || offset == undefined || offset < 0) {
|
if (offset == null || offset == undefined || offset < 0) {
|
||||||
return c.text(msgs.InvalidOffsetMsg, 400)
|
return c.text(msgs.InvalidOffsetMsg, 400)
|
||||||
}
|
}
|
||||||
const resultsQuery = `${query} order by id desc limit ? offset ?`;
|
const orderClause = orderBy || 'id desc';
|
||||||
|
const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`;
|
||||||
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
||||||
...params, limit, offset
|
...params, limit, offset
|
||||||
).all();
|
).all();
|
||||||
|
|||||||
Reference in New Issue
Block a user