From d2c940aa2c92ece7f132f3accd315a2089b68bb5 Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Fri, 3 Apr 2026 01:46:12 +0800 Subject: [PATCH] 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 * docs: add JSDoc warning for orderBy parameter in handleListQuery Co-Authored-By: Claude Opus 4.6 * 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 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + frontend/src/views/admin/Account.vue | 58 ++++++++++++++++++++++------ worker/src/admin_api/index.ts | 18 +++++++-- worker/src/common.ts | 7 +++- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11979929..57c52bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918) - feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912) - feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容 - feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767) diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 40e94de8..054fa3a1 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,6 +10,7 @@ ### 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: |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) diff --git a/frontend/src/views/admin/Account.vue b/frontend/src/views/admin/Account.vue index f997cde8..9047faa8 100644 --- a/frontend/src/views/admin/Account.vue +++ b/frontend/src/views/admin/Account.vue @@ -114,6 +114,8 @@ const selectedCount = computed(() => checkedRowKeys.value.length); const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0); const addressQuery = ref("") +const sortBy = ref("") +const sortOrder = ref("") const data = ref([]) const count = ref(0) @@ -290,10 +292,12 @@ const fetchData = async () => { + `?limit=${pageSize.value}` + `&offset=${(page.value - 1) * pageSize.value}` + (addressQuery.value ? `&query=${addressQuery.value}` : "") + + (sortBy.value ? `&sort_by=${sortBy.value}` : "") + + (sortOrder.value ? `&sort_order=${sortOrder.value}` : "") ); data.value = results; - if (addressCount > 0) { - count.value = addressCount; + if (page.value === 1 || addressCount > 0) { + count.value = addressCount ?? 0; } } catch (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' }, { title: "ID", - key: "id" + key: "id", + sorter: true, + sortOrder: sortBy.value === 'id' ? sortOrder.value : false }, { title: t('name'), - key: "name" + key: "name", + sorter: true, + sortOrder: sortBy.value === 'name' ? sortOrder.value : false }, { title: t('created_at'), - key: "created_at" + key: "created_at", + sorter: true, + sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false }, { title: t('updated_at'), - key: "updated_at" + key: "updated_at", + sorter: true, + sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false }, { title: t('source_meta'), key: "source_meta", + sorter: true, + sortOrder: sortBy.value === 'source_meta' ? sortOrder.value : false, render(row) { const val = row.source_meta; if (!val) return ''; @@ -342,6 +374,8 @@ const columns = [ { title: t('mail_count'), key: "mail_count", + sorter: true, + sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false, render(row) { return h(NButton, { @@ -368,6 +402,8 @@ const columns = [ { title: t('send_count'), key: "send_count", + sorter: true, + sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false, render(row) { return h(NButton, { @@ -497,7 +533,7 @@ const columns = [ ]) } } -] +]) watch([page, pageSize], async () => { await fetchData() @@ -560,8 +596,8 @@ onMounted(async () => { - + @keydown.enter="searchData" /> + {{ t('query') }} @@ -605,7 +641,7 @@ onMounted(async () => { + :row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded /> diff --git a/worker/src/admin_api/index.ts b/worker/src/admin_api/index.ts index 7fcacf8f..9ee22f6d 100644 --- a/worker/src/admin_api/index.ts +++ b/worker/src/admin_api/index.ts @@ -22,7 +22,19 @@ import e2e_test_api from './e2e_test_api' export const api = new Hono() 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 = { + '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) { return await handleListQuery(c, `SELECT a.*,` @@ -31,7 +43,7 @@ api.get('/admin/address', async (c) => { + ` FROM address a` + ` where name like ?`, `SELECT count(*) as count FROM address where name like ?`, - [`%${query}%`], limit, offset + [`%${query}%`], limit, offset, orderBy ); } 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` + ` FROM address a`, `SELECT count(*) as count FROM address`, - [], limit, offset + [], limit, offset, orderBy ); }) diff --git a/worker/src/common.ts b/worker/src/common.ts index f398665f..a95740ef 100644 --- a/worker/src/common.ts +++ b/worker/src/common.ts @@ -483,7 +483,9 @@ export const handleListQuery = async ( c: Context, query: string, countQuery: string, params: string[], 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 => { const msgs = i18n.getMessagesbyContext(c); if (typeof limit === "string") { @@ -498,7 +500,8 @@ export const handleListQuery = async ( if (offset == null || offset == undefined || offset < 0) { 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( ...params, limit, offset ).all();