From c4354135633933d2e501409cc3de6afba5e20b39 Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Mon, 8 Jun 2026 04:30:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(servers):=20=E5=88=86=E7=B1=BB=E7=AD=9B?= =?UTF-8?q?=E9=80=89/=E6=89=B9=E9=87=8F=E6=94=B9=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E3=80=81=E5=88=86=E9=A1=B5=E4=B8=AD=E6=96=87=E4=B8=8E=E5=88=97?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=EF=BC=8C=E8=84=9A=E6=9C=AC=E5=BA=93=E6=8C=89?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E9=80=89=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 300+ 台服务器按分类浏览与批量管理,修复分页「全部」与排序;门控 Gate3 强制本地 API。 Co-authored-by: Cursor --- deploy/pre_deploy_check.sh | 3 +- ...-06-08-servers-category-pagination-sort.md | 120 +++++++ ...026-06-07-servers-category-filter-batch.md | 52 +++ ...servers-category-pagination-sort-deploy.md | 43 +++ .../servers/ServerCategoryPicker.vue | 178 +++++++++++ frontend/src/composables/files/constants.ts | 10 +- .../src/composables/useServerCategories.ts | 48 +++ frontend/src/composables/useServerList.ts | 27 +- .../src/composables/useServerPagination.ts | 60 +++- frontend/src/constants/dataTable.ts | 29 ++ frontend/src/pages/AlertsPage.vue | 5 +- frontend/src/pages/AuditPage.vue | 5 +- frontend/src/pages/CommandsPage.vue | 63 ++-- frontend/src/pages/CredentialsPage.vue | 41 ++- frontend/src/pages/DashboardPage.vue | 11 +- frontend/src/pages/RetriesPage.vue | 7 +- frontend/src/pages/SchedulesPage.vue | 10 +- frontend/src/pages/ScriptsPage.vue | 75 ++--- frontend/src/pages/ServersPage.vue | 296 +++++++++++++++++- frontend/src/plugins/vuetify.ts | 6 + frontend/src/types/api.ts | 9 + frontend/src/utils/paginatedFetch.ts | 62 ++++ frontend/src/utils/serverTableSort.ts | 23 ++ scripts/import_servers_from_xls.py | 210 +++++++++++++ server/api/schemas.py | 15 +- server/api/servers.py | 51 ++- .../application/services/credential_poller.py | 60 +++- server/infrastructure/database/server_repo.py | 25 +- tests/test_credential_poller.py | 15 + web/app/index.html | 2 +- 30 files changed, 1419 insertions(+), 142 deletions(-) create mode 100644 docs/audit/2026-06-08-servers-category-pagination-sort.md create mode 100644 docs/changelog/2026-06-07-servers-category-filter-batch.md create mode 100644 docs/changelog/2026-06-08-servers-category-pagination-sort-deploy.md create mode 100644 frontend/src/components/servers/ServerCategoryPicker.vue create mode 100644 frontend/src/composables/useServerCategories.ts create mode 100644 frontend/src/constants/dataTable.ts create mode 100644 frontend/src/utils/paginatedFetch.ts create mode 100644 frontend/src/utils/serverTableSort.ts create mode 100644 scripts/import_servers_from_xls.py diff --git a/deploy/pre_deploy_check.sh b/deploy/pre_deploy_check.sh index f3934667..9f4e11cc 100644 --- a/deploy/pre_deploy_check.sh +++ b/deploy/pre_deploy_check.sh @@ -126,7 +126,8 @@ if [ ! -f "${TEST_SCRIPT}" ]; then BLOCKED=1 gate_log "test" "BLOCK" "script not found" else - TEST_OUTPUT=$(cd "${DEPLOY_DIR}" && python3 "${TEST_SCRIPT}" 2>&1) || true + # Always hit local API for gate 3 (ignore shell NEXUS_TEST_BASE used by import/e2e scripts) + TEST_OUTPUT=$(cd "${DEPLOY_DIR}" && env -u NEXUS_TEST_BASE NEXUS_TEST_BASE=http://127.0.0.1:8600 python3 "${TEST_SCRIPT}" 2>&1) || true FAIL_COUNT=$(echo "${TEST_OUTPUT}" | grep -cE "^\s+\[FAIL\]" 2>/dev/null || true) FAIL_COUNT=${FAIL_COUNT:-0} if [ "${FAIL_COUNT}" -gt 0 ] || echo "${TEST_OUTPUT}" | grep -qE "[0-9]+ test(s) failed"; then diff --git a/docs/audit/2026-06-08-servers-category-pagination-sort.md b/docs/audit/2026-06-08-servers-category-pagination-sort.md new file mode 100644 index 00000000..5b16a926 --- /dev/null +++ b/docs/audit/2026-06-08-servers-category-pagination-sort.md @@ -0,0 +1,120 @@ +# 审计 — 2026-06-08 服务器分类 / 分页 / 排序 / 脚本库选机 + +## 审计信息 + +- **日期**: 2026-06-08 +- **审计人**: Cursor Agent +- **触发原因**: 新功能(分类筛选、批量改分类、脚本库选机、分页与排序修复)生产部署 + +## 审计范围 + +本批次含 2026-06-07 已提交对齐项(c8b0663)与 2026-06-08 分类/分页/排序增量。 + +| 文件 | 状态 | +|------|------| +| `Dockerfile.prod` | ☑ 已审(CSV 模板 COPY) | +| `deploy/pre_deploy_check.sh` | ☑ 已审(Gate3 强制本地 BASE) | +| `deploy/gate_log.jsonl` | ☑ 自动生成 | +| `server/api/servers.py` | ☑ 已审 | +| `server/api/schemas.py` | ☑ 已审 | +| `server/infrastructure/database/server_repo.py` | ☑ 已审 | +| `server/infrastructure/database/migrations.py` | ☑ 已审 | +| `server/domain/models/__init__.py` | ☑ 已审 | +| `server/application/services/credential_poller.py` | ☑ 已审 | +| `server/application/services/script_service.py` | ☑ 已审 | +| `server/application/services/sync_engine_v2.py` | ☑ 已审 | +| `frontend/public/servers_import_template.csv` | ☑ 已审 | +| `frontend/src/pages/ServersPage.vue` | ☑ 已审 | +| `frontend/src/pages/ScriptsPage.vue` | ☑ 已审 | +| `frontend/src/pages/CommandsPage.vue` | ☑ 已审 | +| `frontend/src/pages/DashboardPage.vue` | ☑ 已审 | +| `frontend/src/pages/AlertsPage.vue` | ☑ 已审 | +| `frontend/src/pages/AuditPage.vue` | ☑ 已审 | +| `frontend/src/pages/CredentialsPage.vue` | ☑ 已审 | +| `frontend/src/pages/RetriesPage.vue` | ☑ 已审 | +| `frontend/src/pages/SchedulesPage.vue` | ☑ 已审 | +| `frontend/src/components/push/PushHistoryTable.vue` | ☑ 已审 | +| `frontend/src/components/servers/ServerCategoryPicker.vue` | ☑ 已审 | +| `frontend/src/composables/useServerPagination.ts` | ☑ 已审 | +| `frontend/src/composables/useServerList.ts` | ☑ 已审 | +| `frontend/src/composables/useServerCategories.ts` | ☑ 已审 | +| `frontend/src/composables/push/usePushLogs.ts` | ☑ 已审 | +| `frontend/src/composables/push/labels.ts` | ☑ 已审 | +| `frontend/src/composables/files/constants.ts` | ☑ 已审 | +| `frontend/src/constants/dataTable.ts` | ☑ 已审 | +| `frontend/src/utils/paginatedFetch.ts` | ☑ 已审 | +| `frontend/src/utils/serverTableSort.ts` | ☑ 已审 | +| `frontend/src/plugins/vuetify.ts` | ☑ 已审 | +| `frontend/src/types/api.ts` | ☑ 已审 | +| `scripts/import_servers_from_xls.py` | ☑ 已审 | +| `tests/test_credential_poller.py` | ☑ 已审 | +| `tests/test_rsync_push_permissions.py` | ☑ 已审 | +| `tests/test_script_dispatch.py` | ☑ 已审 | +| `web/app/index.html` | ☑ 构建产物入口 | + +## Step 3 规则扫描 + +| H | 规则 | 结论 | +|---|------|------| +| H1 | 批量改分类须鉴权 + 审计 | PASS `Depends(get_current_admin)` + `audit_log batch_update_category` | +| H2 | 单次批量上限防滥用 | PASS schema `max_length=200`;repo 分块 | +| H3 | 分类筛选 SQL 注入 | PASS 参数化 `category = :cat` / `IS NULL` | +| H4 | 前端「全部」不得单页截断 | PASS `paginatedFetch` 多页 200 上限循环 | +| H5 | 排序字段白名单 | PASS `server_repo` 仅允许已知列 | +| H6 | 导入脚本无明文密钥入仓 | PASS 读环境变量;凭据不入 git | +| H7 | CUD 无静默吞错 | PASS API 显式 HTTPException;poller 记录失败 | + +## Closure + +| H | 判定 | 依据 | +|---|------|------| +| H1 | PASS | `servers.py` `batch_update_category` + audit | +| H2 | PASS | `BatchCategoryUpdate.server_ids` Field(max_length=200) | +| H3 | PASS | `server_repo.list_servers` 绑定参数 | +| H4 | PASS | `fetchPagePerPage` while 循环至 `total` | +| H5 | PASS | `_SORT_COLUMNS` 映射 | +| H6 | PASS | `import_servers_from_xls.py` os.environ | +| H7 | PASS | 无 bare `except: pass` | + +## Step 5 入口表 + +| 入口 | 鉴权 | 说明 | +|------|------|------| +| `GET /api/servers` | JWT | `category`/`sort_by`/`sort_order` 查询 | +| `POST /api/servers/batch/category` | JWT + admin | 批量改分类 | +| `POST /api/servers/batch` | JWT + admin | 名称+IP 批量添加 | +| `#/servers` UI | 会话 | 筛选/排序/批量 | +| `#/scripts` UI | 会话 | 分类选机 | +| `scripts/import_servers_from_xls.py` | CLI + JWT | 运维导入 | + +## Step 6 输入→Sink + +- `category` 查询 → ORM `WHERE category = :cat`(参数化) +- `server_ids[]` → `UPDATE servers SET category`(IN 绑定) +- Excel IP/名称 → `add-by-ip` API(后端校验 IP 格式) +- 排序列 key → 白名单映射 → ORDER BY + +## Step 7 归类 + +``` +servers.py + schemas + server_repo 3文件 7H 0FINDING +frontend 页面/composables/utils 8文件 7H 0FINDING +import_servers_from_xls.py 1文件 2H 0FINDING +──────────────────────────────────────────────────── +总计 7H 0FINDING +``` + +## Step 8 DoD + +- ☑ 功能与 changelog 一致 +- ☑ `pytest tests/test_credential_poller.py` 通过 +- ☑ 门控 lint/import/security 通过 +- ☑ 0 FINDING + +## FINDING 列表 + +无 + +## 结论 + +☑ 审计通过,0 FINDING,可部署 diff --git a/docs/changelog/2026-06-07-servers-category-filter-batch.md b/docs/changelog/2026-06-07-servers-category-filter-batch.md new file mode 100644 index 00000000..a8e2d828 --- /dev/null +++ b/docs/changelog/2026-06-07-servers-category-filter-batch.md @@ -0,0 +1,52 @@ +# 2026-06-07 — 服务器分类筛选 / 批量改分类 / 脚本库按分类选机 + +## 日期 +2026-06-07 + +## 变更摘要 +服务器列表新增按分类筛选、分类分组视图、批量改分类;脚本库执行/临时命令的服务器选择改为按分类勾选(与推送页一致)。 + +## 动机 +导入 300+ 台服务器后需按业务分类浏览与管理;原列表仅有分类列,无法筛选或批量调整。 + +## 涉及文件 +| 文件 | 说明 | +|------|------| +| `server/infrastructure/database/server_repo.py` | `uncategorized` 筛选;`update_category_batch()` | +| `server/api/servers.py` | `POST /api/servers/batch/category` | +| `server/api/schemas.py` | `BatchCategoryUpdate` / `BatchCategoryResult` | +| `frontend/src/composables/useServerCategories.ts` | 分类列表与计数 | +| `frontend/src/composables/useServerPagination.ts` | `categoryFilter`、`loadAllServers()` | +| `frontend/src/pages/ServersPage.vue` | 分类 Chip、列表/分类视图、批量改分类对话框 | +| `frontend/src/types/api.ts` | `BatchCategoryResult` 类型 | +| `frontend/src/components/servers/ServerCategoryPicker.vue` | 可复用按分类服务器选择器 | +| `frontend/src/composables/useServerList.ts` | `category` 字段;`loadServers({ all: true })` 分页拉全量 | +| `frontend/src/pages/ScriptsPage.vue` | 执行对话框与临时命令改用 `ServerCategoryPicker` | + +## 功能说明 +- **分类筛选**:Chip 展示各分类数量,点击过滤列表(`uncategorized` → 未分类)。 +- **分类视图**:切换「分类」后以折叠面板按分类分组展示,支持勾选与批量操作。 +- **批量改分类**:勾选后点「修改分类」,支持选择已有分类或输入新名称;留空清除分类。单次 API 最多 200 台,前端自动分块。 +- **脚本库选机**:`#/scripts` 执行脚本对话框与「临时命令执行」支持按分类全选/单选、搜索过滤,展示在线状态与已选台数。 + +## 是否需迁移 +否 + +## 是否需重启 +是(后端 API 变更);前端需 `vite build` 后部署 + +## 2026-06-07 补充:服务器列表列排序 +- `useServerPagination` 接入 `v-model:sort-by` → API `sort_by` / `sort_order` +- 表头 key 与后端对齐(`domain`、`agent_version`、`last_heartbeat`);`操作` 列 `sortable: false` +- 后端新增 `target_path` 可排序字段 +- 未实现排序的其它 `v-data-table-server` 默认关闭排序箭头(`headersWithoutSort`) + +## 2026-06-07 补充:表格分页中文化与「全部」修复 +- Vuetify 全局 `zhHans` 语言包(「每页数目」「全部」「上一页」等) +- `fetchPagePerPage` / `fetchLimitOffset`:选「全部」(-1) 时自动多页拉取(API 单次上限 200) +- 修复调度列表 `slice(0,-1)` 误删最后一行;命令日志页补 `items-per-page` 变更回调 + +## 验证方式 +1. `pytest tests/test_credential_poller.py`(既有用例) +2. 本地 `#/servers`:分类 Chip 筛选、分类视图、批量改分类 +3. `POST /api/servers/batch/category` 审计日志 `batch_update_category` diff --git a/docs/changelog/2026-06-08-servers-category-pagination-sort-deploy.md b/docs/changelog/2026-06-08-servers-category-pagination-sort-deploy.md new file mode 100644 index 00000000..caf3f0f8 --- /dev/null +++ b/docs/changelog/2026-06-08-servers-category-pagination-sort-deploy.md @@ -0,0 +1,43 @@ +# 2026-06-08 — 服务器分类/分页/排序 + 脚本库选机(生产部署) + +## 日期 +2026-06-08 + +## 变更摘要 +服务器列表:按分类筛选与分组视图、批量改分类、列排序修复、Vuetify 分页中文化与「全部」多页拉取;脚本库执行/临时命令改为按分类选机;新增 Excel 批量导入脚本。 + +## 动机 +生产已导入 300+ 台服务器,需按业务分类浏览、批量调整分类,并在脚本库按分类勾选目标机;原分页「全部」无效、排序箭头无响应、分页文案为英文。 + +## 涉及文件 +| 文件 | 说明 | +|------|------| +| `server/infrastructure/database/server_repo.py` | `uncategorized` 筛选;`update_category_batch()`;`target_path` 可排序 | +| `server/api/servers.py` | `POST /api/servers/batch/category` | +| `server/api/schemas.py` | `BatchCategoryUpdate` / `BatchCategoryResult` | +| `server/application/services/credential_poller.py` | 批量添加支持 `名称IP` | +| `frontend/src/pages/ServersPage.vue` | 分类 Chip、列表/分类视图、批量改分类、排序 | +| `frontend/src/pages/ScriptsPage.vue` | `ServerCategoryPicker` 选机 | +| `frontend/src/composables/useServerCategories.ts` | 分类列表与计数 | +| `frontend/src/composables/useServerPagination.ts` | 分类筛选、`sort_by`/`sort_order` | +| `frontend/src/composables/useServerList.ts` | `loadServers({ all: true })` | +| `frontend/src/components/servers/ServerCategoryPicker.vue` | 可复用按分类选择器 | +| `frontend/src/constants/dataTable.ts` | 分页选项含「全部」(-1) | +| `frontend/src/utils/paginatedFetch.ts` | `-1` 时多页拉取 | +| `frontend/src/utils/serverTableSort.ts` | 列 key → API 字段映射 | +| `frontend/src/plugins/vuetify.ts` | 全局 `zhHans` | +| `scripts/import_servers_from_xls.py` | Excel 批量导入(add-by-ip) | +| `tests/test_credential_poller.py` | 批量名称解析用例 | + +## 是否需迁移 +否 + +## 是否需重启 +是(后端 API);前端需 `vite build` 后随 Docker 镜像部署 + +## 验证方式 +1. `pytest tests/test_credential_poller.py` +2. `bash deploy/pre_deploy_check.sh`(Gate 1–7) +3. 生产 `#/servers`:分类筛选、分组、批量改分类、列排序、分页「全部」 +4. 生产 `#/scripts`:按分类选机执行脚本 +5. `curl https://api.synaglobal.vip/health` → `ok`;`/app/` → 200 diff --git a/frontend/src/components/servers/ServerCategoryPicker.vue b/frontend/src/components/servers/ServerCategoryPicker.vue new file mode 100644 index 00000000..949cc224 --- /dev/null +++ b/frontend/src/components/servers/ServerCategoryPicker.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/frontend/src/composables/files/constants.ts b/frontend/src/composables/files/constants.ts index f7b25298..aa8d1718 100644 --- a/frontend/src/composables/files/constants.ts +++ b/frontend/src/composables/files/constants.ts @@ -1,9 +1,17 @@ export const FILES_LAST_SERVER_KEY = 'nexus:files:last_server_id' export const FILES_HINT_DISMISSED_KEY = 'nexus:files-hint-dismissed' +import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable' + /** v-data-table 默认每页条数(Vuetify 默认 10 过少) */ export const FILES_TABLE_ITEMS_PER_PAGE = 50 -export const FILES_TABLE_ITEMS_PER_PAGE_OPTIONS = [25, 50, 100, 250, -1] as const +export const FILES_TABLE_ITEMS_PER_PAGE_OPTIONS = [ + { value: 25, title: '25' }, + { value: 50, title: '50' }, + { value: 100, title: '100' }, + { value: 250, title: '250' }, + ...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS.filter(o => o.value === -1), +] as const export const FILES_CACHE_TTL_MS = 5000 export const FILES_CACHE_MAX_ENTRIES = 50 diff --git a/frontend/src/composables/useServerCategories.ts b/frontend/src/composables/useServerCategories.ts new file mode 100644 index 00000000..15f4ab13 --- /dev/null +++ b/frontend/src/composables/useServerCategories.ts @@ -0,0 +1,48 @@ +import { ref } from 'vue' +import { http } from '@/api' + +export interface ServerCategoryOption { + /** API value: `` for all, ``uncategorized`` for empty category */ + name: string + label: string + count: number +} + +export function categoryDisplayLabel(name: string): string { + return name === 'uncategorized' ? '未分类' : name +} + +export function useServerCategories() { + const categories = ref([]) + const loading = ref(false) + const totalCount = ref(0) + + async function loadCategories() { + loading.value = true + try { + const res = await http.get<{ + categories: { name: string; count: number }[] + }>('/servers/categories') + const items = res.categories || [] + totalCount.value = items.reduce((sum, c) => sum + (c.count || 0), 0) + categories.value = items + .map(c => ({ + name: c.name, + label: categoryDisplayLabel(c.name), + count: c.count, + })) + .sort((a, b) => { + if (a.name === 'uncategorized') return 1 + if (b.name === 'uncategorized') return -1 + return a.label.localeCompare(b.label, 'zh') + }) + } catch { + categories.value = [] + totalCount.value = 0 + } finally { + loading.value = false + } + } + + return { categories, loading, totalCount, loadCategories } +} diff --git a/frontend/src/composables/useServerList.ts b/frontend/src/composables/useServerList.ts index 57d36e08..7c2cd9a2 100644 --- a/frontend/src/composables/useServerList.ts +++ b/frontend/src/composables/useServerList.ts @@ -8,12 +8,14 @@ import { ref } from 'vue' import { http } from '@/api' import { useSnackbar } from '@/composables/useSnackbar' import { formatApiError } from '@/utils/apiError' +import type { PaginatedResponse } from '@/types/api' export interface ServerBrief { id: number name: string domain?: string target_path?: string + category?: string | null is_online?: boolean status?: string } @@ -23,13 +25,28 @@ export function useServerList() { const loading = ref(false) const snackbar = useSnackbar() - async function loadServers(opts?: { perPage?: number }) { + async function loadServers(opts?: { perPage?: number; all?: boolean }) { loading.value = true try { - const res = await http.getList('/servers/', { - per_page: opts?.perPage ?? 200, - }) - servers.value = res.items + const perPage = opts?.perPage ?? 200 + if (!opts?.all) { + const res = await http.getList('/servers/', { per_page: perPage }) + servers.value = res.items + return + } + const all: ServerBrief[] = [] + let page = 1 + let pages = 1 + do { + const res = await http.get>('/servers/', { + page, + per_page: perPage, + }) + all.push(...(res.items || [])) + pages = res.pages ?? 1 + page += 1 + } while (page <= pages) + servers.value = all } catch (e: unknown) { servers.value = [] snackbar(formatApiError(e, '加载服务器列表失败'), 'error') diff --git a/frontend/src/composables/useServerPagination.ts b/frontend/src/composables/useServerPagination.ts index e656cd9e..84a03185 100644 --- a/frontend/src/composables/useServerPagination.ts +++ b/frontend/src/composables/useServerPagination.ts @@ -5,9 +5,9 @@ * Eliminates the identical pattern in ServersPage and DashboardPage. */ import { ref, onUnmounted } from 'vue' -import { http } from '@/api' import type { ServerApiItem } from '@/types/api' -import type { PaginatedResponse } from '@/types/api' +import { fetchPagePerPage } from '@/utils/paginatedFetch' +import { toServerApiSort, type TableSortItem } from '@/utils/serverTableSort' export function useServerPagination() { const servers = ref([]) @@ -16,17 +16,28 @@ export function useServerPagination() { const itemsPerPage = ref(20) const total = ref(0) const search = ref('') + /** API category filter; empty string = all */ + const categoryFilter = ref('') + const sortBy = ref([]) async function loadServers() { loading.value = true try { - const res = await http.get>('/servers/', { - page: page.value, - per_page: itemsPerPage.value, - search: search.value || undefined, - }) - servers.value = res.items || [] - total.value = res.total || 0 + const res = await fetchPagePerPage( + '/servers/', + { + search: search.value || undefined, + category: categoryFilter.value || undefined, + ...toServerApiSort(sortBy.value), + }, + page.value, + itemsPerPage.value, + ) + servers.value = res.items + total.value = res.total + if (itemsPerPage.value === -1) { + page.value = 1 + } } catch (e) { servers.value = [] total.value = 0 @@ -54,8 +65,35 @@ export function useServerPagination() { loadServers() } + function onCategoryChange(category: string) { + categoryFilter.value = category + page.value = 1 + loadServers() + } + + function onSortByChange(next: TableSortItem[]) { + sortBy.value = next + page.value = 1 + loadServers() + } + + async function loadAllServers(): Promise { + const res = await fetchPagePerPage( + '/servers/', + { + search: search.value || undefined, + category: categoryFilter.value || undefined, + ...toServerApiSort(sortBy.value), + }, + 1, + -1, + ) + return res.items + } + return { - servers, loading, page, itemsPerPage, total, search, - loadServers, onSearch, onPageChange, onItemsPerPageChange, + servers, loading, page, itemsPerPage, total, search, categoryFilter, sortBy, + loadServers, onSearch, onPageChange, onItemsPerPageChange, onCategoryChange, onSortByChange, + loadAllServers, } } diff --git a/frontend/src/constants/dataTable.ts b/frontend/src/constants/dataTable.ts new file mode 100644 index 00000000..65dcd2b8 --- /dev/null +++ b/frontend/src/constants/dataTable.ts @@ -0,0 +1,29 @@ +/** Vuetify v-data-table 每页条数(-1 = 全部,title 已中文化) */ +export const DATA_TABLE_ITEMS_PER_PAGE_OPTIONS = [ + { value: 10, title: '10' }, + { value: 20, title: '20' }, + { value: 50, title: '50' }, + { value: 100, title: '100' }, + { value: 200, title: '200' }, + { value: -1, title: '全部' }, +] as const + +export type DataTableHeader = { + title: string + key: string + width?: number + align?: 'start' | 'center' | 'end' + sortable?: boolean +} + +/** 未接服务端排序的表格:默认关闭列头排序箭头,避免点击无效果 */ +export function headersWithoutSort(headers: DataTableHeader[]): DataTableHeader[] { + return headers.map(h => ({ ...h, sortable: h.sortable ?? false })) +} + +/** 客户端分页切片(-1 表示不分页) */ +export function sliceClientPage(items: T[], page: number, itemsPerPage: number): T[] { + if (itemsPerPage === -1) return items + const start = (page - 1) * itemsPerPage + return items.slice(start, start + itemsPerPage) +} diff --git a/frontend/src/pages/AlertsPage.vue b/frontend/src/pages/AlertsPage.vue index 69dccaf3..22ab06e6 100644 --- a/frontend/src/pages/AlertsPage.vue +++ b/frontend/src/pages/AlertsPage.vue @@ -98,6 +98,7 @@ import { ref, onMounted } from 'vue' import { http } from '@/api' import { useSnackbar } from '@/composables/useSnackbar' import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api' +import { headersWithoutSort } from '@/constants/dataTable' const snackbar = useSnackbar() @@ -128,13 +129,13 @@ const statusOptions = [ { label: '已恢复', value: 'recovered' }, ] -const headers = [ +const headers = headersWithoutSort([ { title: '类型', key: 'alert_type', width: 100 }, { title: '服务器', key: 'server_name' }, { title: '详情', key: 'value' }, { title: '状态', key: 'is_recovery', width: 100 }, { title: '时间', key: 'created_at', width: 160 }, -] +]) // ── Data loading ── async function loadStats() { diff --git a/frontend/src/pages/AuditPage.vue b/frontend/src/pages/AuditPage.vue index adc599c5..d1a01883 100644 --- a/frontend/src/pages/AuditPage.vue +++ b/frontend/src/pages/AuditPage.vue @@ -99,6 +99,7 @@ import { useSnackbar } from '@/composables/useSnackbar' import type { PaginatedResponse, AuditItem } from '@/types/api' import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels' import { normalizeAuditList } from '@/utils/auditNormalize' +import { headersWithoutSort } from '@/constants/dataTable' const snackbar = useSnackbar() @@ -113,14 +114,14 @@ const dateFilter = ref('') const actionOptions = auditActionFilterOptions -const headers = [ +const headers = headersWithoutSort([ { title: '操作', key: 'action', width: 100 }, { title: '用户', key: 'admin_username', width: 100 }, { title: '目标', key: 'target', width: 140 }, { title: '详情', key: 'detail' }, { title: 'IP', key: 'ip_address', width: 120 }, { title: '时间', key: 'created_at', width: 160 }, -] +]) // ── Data loading ── async function loadAudit() { diff --git a/frontend/src/pages/CommandsPage.vue b/frontend/src/pages/CommandsPage.vue index e6cf76ce..4741090b 100644 --- a/frontend/src/pages/CommandsPage.vue +++ b/frontend/src/pages/CommandsPage.vue @@ -83,9 +83,11 @@ :loading="loading" :page="page" :items-per-page="itemsPerPage" + :items-per-page-options="dataTablePageOptions" hover density="compact" @update:page="onPageChange" + @update:items-per-page="onItemsPerPageChange" >