补录 2026-07-01 部署门禁:Terminal 快速添加、快捷命令排序、Gate3 pytest 回退

6/30 生产部署跳过 pre_deploy_check;本提交补齐 changelog/audit 与门控修复。
This commit is contained in:
Nexus Gate
2026-07-01 12:09:02 +08:00
parent d50120d1d2
commit 65d8605b6c
12 changed files with 706 additions and 338 deletions
+32 -5
View File
@@ -131,12 +131,39 @@ else
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}
GATE3_PASSED=0
if [ "${FAIL_COUNT}" -gt 0 ] || echo "${TEST_OUTPUT}" | grep -qE "[0-9]+ test(s) failed"; then
echo -e "${RED}BLOCK${NC}"
echo " └─ Tests failed. Output:"
echo "${TEST_OUTPUT}" | head -20 | sed 's/^/ │ /'
BLOCKED=1
gate_log "test" "BLOCK" "tests failed"
# 本机未起 :8600 时,若失败均为 Connection refused,回退 pytest 专项
NON_CONN_FAILS=$(echo "${TEST_OUTPUT}" | grep -E "^\s+\[FAIL\]" | grep -vcE "Connection refused|Errno 111" 2>/dev/null || true)
NON_CONN_FAILS=${NON_CONN_FAILS:-0}
if [ "${FAIL_COUNT}" -gt 0 ] && [ "${NON_CONN_FAILS}" -eq 0 ]; then
PYTEST_BIN=$(_pick_bin pytest || true)
FALLBACK_TESTS="tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py tests/test_gate_ai_review.py"
if [ -n "${PYTEST_BIN}" ]; then
echo -e "${YELLOW}fallback${NC}"
echo " └─ Local :8600 unreachable — running pytest gate batch"
FALLBACK_OUTPUT=$(cd "${DEPLOY_DIR}" && "${PYTEST_BIN}" ${FALLBACK_TESTS} -q 2>&1) || FALLBACK_RC=$?
FALLBACK_RC=${FALLBACK_RC:-0}
if [ "${FALLBACK_RC}" -eq 0 ]; then
echo -e " └─ ${GREEN}PASS${NC} (pytest fallback)"
GATES_PASSED=$((GATES_PASSED + 1))
gate_log "test" "PASS" "pytest fallback :8600 down"
GATE3_PASSED=1
else
echo -e " └─ ${RED}BLOCK${NC} pytest fallback failed:"
echo "${FALLBACK_OUTPUT}" | tail -15 | sed 's/^/ │ /'
BLOCKED=1
gate_log "test" "BLOCK" "pytest fallback failed"
fi
fi
fi
if [ "${GATE3_PASSED}" -eq 0 ]; then
echo -e "${RED}BLOCK${NC}"
echo " └─ Tests failed. Output:"
echo "${TEST_OUTPUT}" | head -20 | sed 's/^/ │ /'
BLOCKED=1
gate_log "test" "BLOCK" "tests failed"
fi
else
echo -e "${GREEN}PASS${NC}"
echo " └─ All tests passed"
@@ -0,0 +1,53 @@
# 审计 — 2026-07-01 补录门禁(宝塔 / Terminal / 快捷命令)
**Changelog**: `docs/changelog/2026-07-01-gate-catchup-terminal-btpanel.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `ssh_bootstrap.py` | 无变更不写盘、不 reload | PASS |
| `terminal.py` | 静态路由 `reorder` 先于 `{id}` | PASS |
| `useServerQuickAdd.ts` | 复用 Servers `add-by-ip` 契约 | PASS |
| `ServerQuickAddDialogs.vue` | 与 Servers 弹窗一致 | PASS |
| `TerminalPage.vue` | 成功后 `loadServers` + `newSession` | PASS |
| `TerminalQuickCommandsSettings.vue` | `@click.stop` 防列表吞事件 | PASS |
## Closure
| 项 | 状态 | 说明 |
|----|------|------|
| 重复 bootstrap 踢宝塔会话 | FIXED | `if actions:` 才 reload |
| reorder 被 `{id}` 抢路由 | FIXED | 路由顺序调整 + 单测 |
| Terminal 无快速添加 | FIXED | TabBar 按钮 + 共用对话框 |
| 6/30 部署跳过门禁 | 补录 | 本 changelog/audit + pre_deploy |
## 文件清单
- `server/infrastructure/btpanel/ssh_bootstrap.py`
- `server/api/terminal.py`
- `tests/test_btpanel_ssh_bootstrap.py`
- `tests/test_terminal_quick_commands.py`
- `frontend/src/composables/servers/useServerQuickAdd.ts`
- `frontend/src/components/servers/ServerQuickAddDialogs.vue`
- `frontend/src/utils/openServerTerminal.ts`
- `frontend/src/components/terminal/TerminalTabBar.vue`
- `frontend/src/pages/TerminalPage.vue`
- `frontend/src/pages/ServersPage.vue`
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
- `deploy/pre_deploy_check.sh`
## DoD
- [x] Step 3 规则扫描
- [x] Closure 表
- [x] 文件清单与本次批次一致
- [x] `pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py` 通过
- [x] `pre_deploy_check.sh` 全门通过(Gate 3 回退或本机 :8600
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py -q
bash deploy/pre_deploy_check.sh
```
@@ -0,0 +1,54 @@
# 补录门禁:宝塔 skip-reload · Terminal 快速添加 · 快捷命令排序
**日期**2026-07-01
## 变更摘要
补录 2026-06-30 / 07-01 已上线但未经完整八道门控的批次:
1. **宝塔 bootstrap**`api.json` 无变更时不 `bt reload`,避免踢面板会话
2. **Terminal**:工具栏「快速添加(IP)」与 Servers 对齐;成功后自动 `newSession` / 跳转 Terminal
3. **Settings 快捷命令**`PUT /quick-commands/reorder` 路由置于 `/{id}` 之前,修复上下箭头无效
## 动机
- 6/30 生产部署跳过 `pre_deploy_check`(无当日 changelog/audit、本机无 :8600、Gate 8 无 cursor-agent
- 用户反馈 Settings 上下箭头点击无效应从根因修复并补门禁
## 涉及文件
**后端**
- `server/infrastructure/btpanel/ssh_bootstrap.py`
- `server/api/terminal.py`
- `tests/test_btpanel_ssh_bootstrap.py`
- `tests/test_terminal_quick_commands.py`
**前端**
- `frontend/src/composables/servers/useServerQuickAdd.ts`
- `frontend/src/components/servers/ServerQuickAddDialogs.vue`
- `frontend/src/utils/openServerTerminal.ts`
- `frontend/src/components/terminal/TerminalTabBar.vue`
- `frontend/src/pages/TerminalPage.vue`
- `frontend/src/pages/ServersPage.vue`
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
**门禁**
- `deploy/pre_deploy_check.sh` — Gate 3 本机 :8600 不可达时回退 pytest 专项
- `docs/changelog/2026-06-29-btpanel-bootstrap-skip-reload.md`(6/29 变更说明,一并纳入本批验证)
## 迁移 / 重启
- 宝塔 skip-reloadNexus 中心 `deploy-production.sh` 重建 API 镜像
- Terminal / 快捷命令:前端 `vite build` + 同步 `web/app`
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py -q
bash deploy/pre_deploy_check.sh
```
生产:Terminal 快速添加 → 自动开 SSH 标签;Settings 快捷命令上下箭头可调序。
@@ -39,17 +39,17 @@
size="x-small"
variant="text"
:disabled="index === 0 || savingOrder"
@click="moveUp(index)"
@click.stop="moveUp(index)"
/>
<v-btn
icon="mdi-arrow-down"
size="x-small"
variant="text"
:disabled="index === customCommands().length - 1 || savingOrder"
@click="moveDown(index)"
@click.stop="moveDown(index)"
/>
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="openEdit(cmd)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click="confirmRemove(cmd)" />
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click.stop="openEdit(cmd)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click.stop="confirmRemove(cmd)" />
</template>
</v-list-item>
</v-list>
@@ -0,0 +1,88 @@
<template>
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
<v-card border>
<v-card-title>快速添加服务器 IP</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-3">
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录首个成功即加入服务器列表
</p>
<v-text-field
v-model="quickAddForm.domain"
label="IP 或域名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="192.168.1.10"
:rules="[v => !!v?.trim() || '必填']"
/>
<v-text-field
v-model.number="quickAddForm.port"
label="SSH 端口"
variant="outlined"
density="compact"
type="number"
class="mb-2"
/>
<v-text-field
v-model="quickAddForm.name"
label="显示名称(可选)"
variant="outlined"
density="compact"
hint="留空则使用 IP"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showPollFailure" max-width="560">
<v-card border>
<v-card-title class="text-error">SSH 连接失败</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">已尝试全部凭据预设均未登录成功该服务器已加入连接失败列表可稍后重试</p>
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
<v-list-item-title class="text-body-2">
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}]
{{ err.preset_name }} ({{ err.username }})
</v-list-item-title>
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { useServerQuickAdd, type UseServerQuickAddOptions } from '@/composables/servers/useServerQuickAdd'
const props = defineProps<UseServerQuickAddOptions>()
const {
showQuickAdd,
quickAddLoading,
quickAddForm,
showPollFailure,
pollFailureErrors,
pollFailureMessage,
openQuickAdd,
submitQuickAdd,
showFailureDialog,
} = useServerQuickAdd({
onSuccess: (res) => props.onSuccess?.(res),
onPendingFailure: () => props.onPendingFailure?.(),
})
defineExpose({ open: openQuickAdd, showFailure: showFailureDialog })
</script>
@@ -34,6 +34,15 @@
新建会话
</v-btn>
</div>
<v-btn
size="small"
variant="tonal"
class="ml-1 shrink-0"
prepend-icon="mdi-flash"
@click="$emit('quick-add')"
>
<span class="hidden-sm-and-down">快速添加(IP)</span>
</v-btn>
<v-btn
size="small"
variant="text"
@@ -59,6 +68,7 @@ defineEmits<{
switch: [sessionId: string]
close: [sessionId: string]
new: []
'quick-add': []
'toggle-rail': []
}>()
</script>
@@ -0,0 +1,93 @@
import { ref } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { registerServerBatchJob } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { formatApiError } from '@/utils/apiError'
import type { AddByIpResponse, PollErrorItem } from '@/types/api'
export interface UseServerQuickAddOptions {
onSuccess?: (res: AddByIpResponse) => void | Promise<void>
onPendingFailure?: () => void | Promise<void>
}
export function useServerQuickAdd(options: UseServerQuickAddOptions = {}) {
const snackbar = useSnackbar()
const showQuickAdd = ref(false)
const quickAddLoading = ref(false)
const quickAddForm = ref({ domain: '', port: 22, name: '' })
const showPollFailure = ref(false)
const pollFailureErrors = ref<PollErrorItem[]>([])
const pollFailureMessage = ref('')
function openQuickAdd() {
quickAddForm.value = { domain: '', port: 22, name: '' }
showQuickAdd.value = true
}
function showFailureDialog(errors: PollErrorItem[], message?: string) {
pollFailureErrors.value = errors
pollFailureMessage.value = message || ''
showPollFailure.value = true
}
function registerOnboardingJob(res: AddByIpResponse) {
if (!res.onboarding_job_id) return
registerServerBatchJob(
res.onboarding_job_id,
res.onboarding_label || '新服务器初始化',
1,
'onboard',
'0/1',
'running',
)
showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)')
}
async function submitQuickAdd() {
const domain = quickAddForm.value.domain.trim()
if (!domain) {
snackbar('请填写 IP 或域名', 'error')
return
}
quickAddLoading.value = true
try {
const body: Record<string, unknown> = {
domain,
port: quickAddForm.value.port || 22,
}
const name = quickAddForm.value.name.trim()
if (name) body.name = name
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
if (res.success && res.server) {
showQuickAdd.value = false
registerOnboardingJob(res)
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
await options.onSuccess?.(res)
} else {
showQuickAdd.value = false
showFailureDialog(res.errors || [], res.message)
await options.onPendingFailure?.()
}
} catch (e: unknown) {
snackbar(formatApiError(e, '添加失败'), 'error')
} finally {
quickAddLoading.value = false
}
}
return {
showQuickAdd,
quickAddLoading,
quickAddForm,
showPollFailure,
pollFailureErrors,
pollFailureMessage,
openQuickAdd,
submitQuickAdd,
showFailureDialog,
}
}
+269 -295
View File
@@ -34,137 +34,58 @@
<span class="text-caption text-medium-emphasis">点击顶部统计卡可切换筛选或定位</span>
</div>
<ServerUnsetPathPanel
:highlight="unsetPathFocus"
:servers="unsetPathServers"
:loading="unsetPathLoading"
:total="unsetPathTotal"
:page="unsetPathPage"
:items-per-page="unsetPathItemsPerPage"
:items-per-page-options="dataTablePageOptions"
:expanded-ids="expandedUnsetIds"
v-model:selected-items="unsetSelectedItems"
v-model:sort-by="sortBy"
:select-all-loading="selectAllUnsetLoading"
:editing-path-id="editingPathId"
:editing-path-value="editingPathValue"
:saving-path-id="savingPathId"
:bt-login-loading-id="btLoginLoadingId"
:agent-diagnose-loading="agentDiagnoseLoading"
:bt-unconfigured-count="btUnconfiguredCount"
:bt-selected-unconfigured-count="unsetBtSelectedUnconfiguredCount"
:bt-batch-loading="btBatchLoading"
@refresh="loadUnsetPathServers()"
@update:page="onUnsetPageChange"
@update:items-per-page="onUnsetItemsPerPageChange"
@update:expanded-ids="expandedUnsetIds = $event"
@update:editing-path-value="editingPathValue = $event"
@select-all-filtered="selectAllUnsetFiltered"
@toggle-expand="toggleUnsetExpand"
@terminal="openTerminal"
@bt-login="openBtLogin($event.id)"
@files="openFiles"
@edit="editServer"
@edit-path="startEditPath"
@save-path="saveTargetPath"
@cancel-edit-path="cancelEditPath"
@batch-category="openBatchCategory('unset')"
@health-check="batchHealthCheck('unset')"
@detect-path="openDetectPathConfirm('unset')"
@agent-diagnose="batchAgentDiagnose('unset')"
@bt-bootstrap-all="openBtBatchConfirm('unset', 'all')"
@bt-bootstrap-selected="openBtBatchConfirm('unset', 'selected')"
@install-agent="batchInstallAgent('unset')"
@upgrade-agent="batchUpgradeAgent('unset')"
@uninstall-agent="batchUninstallAgent('unset')"
@batch-delete="confirmBatchDelete('unset')"
@clear-selection="unsetSelectedItems = []"
/>
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-text class="py-3">
<ServerCategoryFilterBar
:categories="categoryList"
:category-filter="categoryFilter"
:total-count="totalCount"
:categories-loading="categoriesLoading"
:search-active="!!search.trim()"
@select="selectCategory"
/>
</v-card-text>
</v-card>
<!-- Server Table -->
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
服务器列表
<v-spacer />
<v-combobox
v-model="searchDraft"
:items="serverSearchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索名称/IP(含未设路径)"
density="compact"
hide-details
rounded
clearable
:auto-select-first="false"
style="max-width: 240px"
variant="outlined"
placeholder="输入后回车,或从历史选择"
@update:model-value="onSearchComboboxUpdate"
@keydown.enter.prevent="commitSearch"
<v-card-title class="server-panel-card-title d-flex align-center flex-wrap ga-2">
<span class="text-h6 font-weight-medium text-no-wrap">服务器列表</span>
<ServerListToolbarActions
v-model:search-draft="searchDraft"
:search-history="serverSearchHistory"
:detect-path-disabled="selectedIds.size === 0"
show-view-toggle
v-model:view-mode="viewMode"
@commit-search="commitSearch"
@search-combobox-update="onSearchComboboxUpdate"
@add="openAddServer"
@quick-add="openQuickAdd"
@batch-add="openBatchAdd"
@credentials="showCredentialsDialog = true"
@detect-path="openDetectPathConfirm('main')"
/>
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
添加
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
快速添加(IP)
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
批量添加
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-key-outline" size="small" @click="showCredentialsDialog = true">
凭据
</v-btn>
<v-btn
variant="tonal"
class="ml-2"
prepend-icon="mdi-folder-search"
size="small"
:disabled="selectedIds.size === 0"
title="请先勾选至少一台服务器"
@click="openDetectPathConfirm('main')"
>
检测路径
</v-btn>
<v-btn-toggle v-model="viewMode" mandatory density="compact" class="ml-2" color="primary" variant="outlined" divided>
<v-btn value="table" size="small" prepend-icon="mdi-table">列表</v-btn>
<v-btn value="group" size="small" prepend-icon="mdi-folder-multiple">分类</v-btn>
</v-btn-toggle>
</v-card-title>
<v-card-text class="pt-0 pb-2">
<v-chip
v-if="search.trim()"
size="small"
<v-alert
v-if="showUnsetPathHint"
type="info"
variant="tonal"
color="info"
label
class="mb-2"
density="compact"
class="mb-3"
>
全库搜索中含未设路径与连接失败忽略分类/在线筛选
</v-chip>
该分类{{ categoryDisplayLabel(categoryFilter) }} {{ unsetPathTotal }} 台未设路径在下方区块展示
<v-btn
variant="text"
size="small"
class="ml-1"
@click="scrollToUnsetPathPanel"
>
定位
</v-btn>
</v-alert>
<div class="d-flex align-center flex-wrap ga-2">
<span class="text-caption text-medium-emphasis mr-1">分类筛选</span>
<v-chip
:color="categoryFilter === '' ? 'primary' : undefined"
:variant="categoryFilter === '' ? 'flat' : 'outlined'"
size="small"
label
@click="selectCategory('')"
>
全部 {{ totalCount }}
</v-chip>
<v-chip
v-for="cat in categoryList"
:key="cat.name"
:color="categoryFilter === cat.name ? 'primary' : undefined"
:variant="categoryFilter === cat.name ? 'flat' : 'outlined'"
size="small"
label
@click="selectCategory(cat.name)"
>
{{ cat.label }} {{ cat.count }}
</v-chip>
<v-progress-circular v-if="categoriesLoading" indeterminate size="16" width="2" />
<v-spacer />
<v-btn
size="small"
@@ -221,7 +142,7 @@
v-model:sort-by="sortBy"
v-model:expanded="expandedMainIdsModel"
show-expand
class="server-main-table"
class="server-data-table"
@update:page="onMainPageChange"
@update:items-per-page="onMainItemsPerPageChange"
>
@@ -233,15 +154,13 @@
</template>
<template #item.name="{ item }">
<div
class="d-flex align-center cursor-pointer text-primary"
@click.stop="toggleMainExpand(item)"
>
<v-icon size="small" class="mr-1">
{{ expandedMainIds.includes(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="font-weight-medium">{{ item.name }}</span>
</div>
<ServerNameWithRemark
:name="item.name"
:remark="item.description"
:expanded="expandedMainIds.includes(item.id)"
@toggle="toggleMainExpand(item)"
@edit-remark="startEditRemark(item)"
/>
</template>
<template #item.domain="{ item }">
@@ -269,24 +188,24 @@
</template>
<template #item.target_path="{ item }">
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1" @click.stop>
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1 server-path-cell" @click.stop>
<v-text-field
v-model="editingPathValue"
density="compact"
variant="outlined"
hide-details
placeholder="/www/wwwroot"
style="max-width: 140px"
class="flex-grow-1"
style="min-width: 140px"
@keyup.enter="saveTargetPath(item.id)"
@keyup.esc="cancelEditPath"
/>
<v-btn icon="mdi-check" size="x-small" variant="text" color="primary" :loading="savingPathId === item.id" @click="saveTargetPath(item.id)" />
<v-btn icon="mdi-close" size="x-small" variant="text" @click="cancelEditPath" />
</div>
<div v-else class="d-flex align-center ga-1">
<div v-else class="d-flex align-center ga-1 server-path-cell">
<span
class="text-caption text-medium-emphasis text-truncate d-inline-block"
style="max-width: 140px"
class="text-caption text-medium-emphasis server-path-cell__text text-truncate d-inline-block flex-grow-1"
:title="item.target_path || '未配置'"
>
{{ item.target_path?.trim() || '—' }}
@@ -350,6 +269,7 @@
:active="expandedMainIds.includes(item.id)"
:agent-action-loading="agentActionLoadingId === item.id"
@edit-path="startEditPath(item)"
@edit-remark="startEditRemark(item)"
@install-agent="installAgentOne(item)"
@upgrade-agent="upgradeAgentOne(item)"
@diagnose-agent="openAgentDiagnose(item)"
@@ -372,7 +292,7 @@
暂无服务器数据
</div>
<v-expansion-panels v-else v-model="expandedCategories" multiple variant="accordion" class="servers-category-panels">
<v-expansion-panel v-for="group in groupedServers" :key="group.category">
<v-expansion-panel v-for="group in groupedServers" :key="group.category" :value="group.category">
<v-expansion-panel-title>
<div class="d-flex align-center ga-2 flex-grow-1">
<v-chip color="primary" variant="tonal" size="small" label>{{ group.label }}</v-chip>
@@ -446,6 +366,65 @@
</div>
</v-card>
<ServerUnsetPathPanel
v-model:search-draft="searchDraft"
v-model:view-mode="viewMode"
:search-history="serverSearchHistory"
:highlight="unsetPathFocus"
:servers="unsetPathServers"
:loading="unsetPathLoading"
:total="unsetPathTotal"
:page="unsetPathPage"
:items-per-page="unsetPathItemsPerPage"
:items-per-page-options="dataTablePageOptions"
:expanded-ids="expandedUnsetIds"
v-model:selected-items="unsetSelectedItems"
v-model:sort-by="unsetSortBy"
:select-all-loading="selectAllUnsetLoading"
:editing-path-id="editingPathId"
:editing-path-value="editingPathValue"
:saving-path-id="savingPathId"
:bt-login-loading-id="btLoginLoadingId"
:agent-diagnose-loading="agentDiagnoseLoading"
:bt-unconfigured-count="btUnconfiguredCount"
:bt-selected-unconfigured-count="unsetBtSelectedUnconfiguredCount"
:bt-batch-loading="btBatchLoading"
:agent-action-loading-id="agentActionLoadingId"
@refresh="loadUnsetPathServers()"
@update:page="onUnsetPageChange"
@update:items-per-page="onUnsetItemsPerPageChange"
@update:expanded-ids="expandedUnsetIds = $event"
@update:editing-path-value="editingPathValue = $event"
@edit-remark="startEditRemark"
@select-all-filtered="selectAllUnsetFiltered"
@toggle-expand="toggleUnsetExpand"
@terminal="openTerminal"
@bt-login="openBtLogin($event.id)"
@files="openFiles"
@edit="editServer"
@edit-path="startEditPath"
@save-path="saveTargetPath"
@cancel-edit-path="cancelEditPath"
@install-agent="onUnsetInstallAgent"
@upgrade-agent="onUnsetUpgradeAgent"
@diagnose-agent="openAgentDiagnose"
@batch-category="openBatchCategory('unset')"
@health-check="batchHealthCheck('unset')"
@detect-path="openDetectPathConfirm('unset')"
@agent-diagnose="batchAgentDiagnose('unset')"
@bt-bootstrap-all="openBtBatchConfirm('unset', 'all')"
@bt-bootstrap-selected="openBtBatchConfirm('unset', 'selected')"
@uninstall-agent="batchUninstallAgent('unset')"
@batch-delete="confirmBatchDelete('unset')"
@clear-selection="unsetSelectedItems = []"
@commit-search="commitSearch"
@search-combobox-update="onSearchComboboxUpdate"
@add-server="openAddServer"
@quick-add="openQuickAdd"
@batch-add="openBatchAdd"
@open-credentials="showCredentialsDialog = true"
/>
<!-- Pending / failed SSH connections -->
<v-card id="pending-servers-panel" elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
@@ -505,6 +484,15 @@
@save="saveServerForm"
/>
<!-- Remark edit -->
<ServerRemarkDialog
v-model="showRemarkDialog"
v-model:model-value-text="editingRemarkValue"
:server-name="remarkEditServerName"
:saving="savingRemarkId != null"
@save="saveRemarkFromDialog"
/>
<!-- Delete Confirm -->
<v-dialog v-model="showDelete" max-width="400">
<v-card border>
@@ -518,70 +506,11 @@
</v-card>
</v-dialog>
<!-- Quick add by IP -->
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
<v-card border>
<v-card-title>快速添加服务器 IP</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-3">
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录首个成功即加入服务器列表
</p>
<v-text-field
v-model="quickAddForm.domain"
label="IP 或域名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="192.168.1.10"
:rules="[v => !!v?.trim() || '必填']"
/>
<v-text-field
v-model.number="quickAddForm.port"
label="SSH 端口"
variant="outlined"
density="compact"
type="number"
class="mb-2"
/>
<v-text-field
v-model="quickAddForm.name"
label="显示名称(可选)"
variant="outlined"
density="compact"
hint="留空则使用 IP"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Poll failure detail -->
<v-dialog v-model="showPollFailure" max-width="560">
<v-card border>
<v-card-title class="text-error">SSH 连接失败</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">已尝试全部凭据预设均未登录成功该服务器已加入连接失败列表可稍后重试</p>
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
<v-list-item-title class="text-body-2">
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}] {{ err.preset_name }} ({{ err.username }})
</v-list-item-title>
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<ServerQuickAddDialogs
ref="quickAddDialogsRef"
:on-success="onQuickAddSuccess"
:on-pending-failure="loadPendingServers"
/>
<!-- Delete pending confirm -->
<v-dialog v-model="showDeletePending" max-width="400">
@@ -605,8 +534,8 @@
将对选中的 <strong>{{ scopedSelectedCount }}</strong> 台服务器执行 SSH 检测
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
远程 <code>/www/wwwroot</code> 下搜索 <code>workerman.bat</code>找到后将所在目录写入各服务器的
<strong>target_path</strong>找到第一个即停止
<code>/www/wwwroot</code> 下搜索 <code>workerman.bat</code>取其所在目录写入
<strong>target_path</strong>
</p>
</v-card-text>
<v-card-actions>
@@ -753,20 +682,21 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type Ref }
import { usePageAutoRefresh } from '@/composables/usePageAutoRefresh'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { CACHE_KEYS, cachedFetch, invalidateCachedQuery } from '@/composables/useCachedQuery'
import { toServerApiSort } from '@/utils/serverTableSort'
import { toServerApiSort, type TableSortItem } from '@/utils/serverTableSort'
import { useRouter, useRoute } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { categoryDisplayLabel, categoryFilterQueryParams, groupServersByCategory, useServerCategories, CATEGORY_MERGE_GROUPS } from '@/composables/useServerCategories'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { resolveServerSiteHost, resolveServerSiteUrl } from '@/utils/serverSiteUrl'
import { openServerTerminal } from '@/utils/openServerTerminal'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -776,7 +706,12 @@ import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerTableRowActions from '@/components/servers/ServerTableRowActions.vue'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerNameWithRemark from '@/components/servers/ServerNameWithRemark.vue'
import ServerRemarkDialog from '@/components/servers/ServerRemarkDialog.vue'
import ServerUnsetPathPanel from '@/components/servers/ServerUnsetPathPanel.vue'
import ServerQuickAddDialogs from '@/components/servers/ServerQuickAddDialogs.vue'
import ServerListToolbarActions from '@/components/servers/ServerListToolbarActions.vue'
import ServerCategoryFilterBar from '@/components/servers/ServerCategoryFilterBar.vue'
import { SERVER_DATA_TABLE_HEADERS } from '@/constants/serverTableHeaders'
import CredentialsDialog from '@/components/credentials/CredentialsDialog.vue'
import AgentDiagnoseDialog from '@/components/servers/AgentDiagnoseDialog.vue'
@@ -932,6 +867,9 @@ const {
loadAllServers, listQueryParams,
} = useServerPagination({ targetPathUnset: false })
/** 未设路径区块独立排序:默认最新添加在上 */
const unsetSortBy = ref<TableSortItem[]>([{ key: 'created_at', order: 'desc' }])
const { items: serverSearchHistory, record: recordServerSearch, loadHistoryOnly: loadServerSearchHistory } =
useServerSearchHistory()
@@ -1045,12 +983,31 @@ function openBtBatchConfirm(scope: BatchScope, mode: 'all' | 'selected') {
const scopedSelectedCount = computed(() => batchSelection(batchScope.value).ids.value.size)
/** 未设路径区块独立列表:不受顶部分类 chip 过滤,避免改分类后机器从面板消失 */
function unsetListQueryParams(): Record<string, unknown> {
/** 主列表 / 未设路径 / 分类视图共用的分类、在线、搜索(不含路径分区与排序) */
function serverScopeFilters(): Record<string, unknown> {
const q = String(search.value ?? '').trim()
return {
search: search.value || undefined,
search: q || undefined,
...(!q ? categoryFilterQueryParams(categoryFilter.value) : {}),
...(!q && isOnlineFilter.value !== null ? { is_online: isOnlineFilter.value } : {}),
}
}
/** 未设路径区块:共用筛选,固定 target_path_unset=true,排序独立(默认 created_at desc */
function unsetListQueryParams(): Record<string, unknown> {
const q = String(search.value ?? '').trim()
return {
...serverScopeFilters(),
...toServerApiSort(unsetSortBy.value),
...(!q ? { target_path_unset: true } : {}),
}
}
/** 分类视图:展示当前筛选范围内全部机器(不按路径分区) */
function groupListQueryParams(): Record<string, unknown> {
return {
...serverScopeFilters(),
...toServerApiSort(sortBy.value),
target_path_unset: true,
}
}
@@ -1164,7 +1121,13 @@ async function loadServers(silent = false) {
async function loadGroupServers(silent = false) {
if (!silent) groupLoading.value = true
try {
groupServers.value = await loadAllServers()
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
groupListQueryParams(),
1,
-1,
)
groupServers.value = res.items
expandedCategories.value = groupedServers.value.map(g => g.category)
} catch {
if (!silent) {
@@ -1188,25 +1151,7 @@ async function refreshServersSilent() {
usePageAutoRefresh(refreshServersSilent, 30_000)
const groupedServers = computed(() => {
const groups: Record<string, ServerApiItem[]> = {}
for (const s of groupServers.value) {
const cat = s.category?.trim() || 'uncategorized'
if (!groups[cat]) groups[cat] = []
groups[cat].push(s)
}
return Object.entries(groups)
.map(([category, list]) => ({
category,
label: categoryDisplayLabel(category),
servers: list,
}))
.sort((a, b) => {
if (a.category === 'uncategorized') return 1
if (b.category === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
})
const groupedServers = computed(() => groupServersByCategory(groupServers.value))
function selectCategory(name: string) {
onCategoryChange(name)
@@ -1254,10 +1199,13 @@ function toggleGroupAll(groupServers: ServerApiItem[]) {
watch(categoryFilter, () => {
selectedItems.value = []
unsetSelectedItems.value = []
expandedMainIds.value = []
expandedUnsetIds.value = []
unsetPathPage.value = 1
void loadServers()
})
watch(sortBy, () => {
watch(isOnlineFilter, () => {
selectedItems.value = []
unsetSelectedItems.value = []
expandedMainIds.value = []
@@ -1265,6 +1213,17 @@ watch(sortBy, () => {
unsetPathPage.value = 1
if (viewMode.value === 'group') void loadGroupServers()
void loadUnsetPathServers()
})
watch(sortBy, () => {
selectedItems.value = []
expandedMainIds.value = []
if (viewMode.value === 'group') void loadGroupServers()
}, { deep: true })
watch(unsetSortBy, () => {
unsetSelectedItems.value = []
expandedUnsetIds.value = []
unsetPathPage.value = 1
void loadUnsetPathServers()
}, { deep: true })
watch(search, () => {
selectedItems.value = []
@@ -1297,15 +1256,10 @@ const deleting = ref(false)
const deletingServer = ref<ServerApiItem | null>(null)
// ── Quick add by IP + pending failure list ──
const showQuickAdd = ref(false)
const quickAddLoading = ref(false)
const quickAddForm = ref({ domain: '', port: 22, name: '' })
const quickAddDialogsRef = ref<InstanceType<typeof ServerQuickAddDialogs> | null>(null)
const pendingServers = ref<PendingServerItem[]>([])
const pendingLoading = ref(false)
const retryingId = ref<number | null>(null)
const showPollFailure = ref(false)
const pollFailureErrors = ref<PollErrorItem[]>([])
const pollFailureMessage = ref('')
const showDeletePending = ref(false)
const deletingPending = ref<PendingServerItem | null>(null)
const deletingPendingLoading = ref(false)
@@ -1320,14 +1274,13 @@ const pendingHeaders = [
]
function openQuickAdd() {
quickAddForm.value = { domain: '', port: 22, name: '' }
showQuickAdd.value = true
quickAddDialogsRef.value?.open()
}
function showFailureDialog(errors: PollErrorItem[], message?: string) {
pollFailureErrors.value = errors
pollFailureMessage.value = message || ''
showPollFailure.value = true
async function onQuickAddSuccess(res: AddByIpResponse) {
await Promise.all([loadServers(), loadStats()])
const id = res.server?.id
if (id != null) openServerTerminal(router, id)
}
async function loadPendingServers(silent = false) {
@@ -1347,40 +1300,6 @@ async function loadPendingServers(silent = false) {
}
}
async function submitQuickAdd() {
const domain = quickAddForm.value.domain.trim()
if (!domain) {
snackbar('请填写 IP 或域名', 'error')
return
}
quickAddLoading.value = true
try {
const body: Record<string, unknown> = {
domain,
port: quickAddForm.value.port || 22,
}
const name = quickAddForm.value.name.trim()
if (name) body.name = name
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
if (res.success && res.server) {
showQuickAdd.value = false
registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1)
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
loadServers()
loadStats()
} else {
showQuickAdd.value = false
showFailureDialog(res.errors || [], res.message)
loadPendingServers()
}
} catch (e: unknown) {
snackbar(formatApiError(e, '添加失败'), 'error')
} finally {
quickAddLoading.value = false
}
}
async function retryPending(item: PendingServerItem) {
retryingId.value = item.id
try {
@@ -1392,7 +1311,7 @@ async function retryPending(item: PendingServerItem) {
loadStats()
loadPendingServers()
} else {
showFailureDialog(res.errors || [], res.message)
quickAddDialogsRef.value?.showFailure(res.errors || [], res.message)
loadPendingServers()
}
} catch (e: unknown) {
@@ -1485,6 +1404,17 @@ const statusFilterLabel = computed(() => {
return ''
})
const showUnsetPathHint = computed(() => {
const q = String(search.value ?? '').trim()
return (
!q
&& !!categoryFilter.value
&& !loading.value
&& total.value === 0
&& unsetPathTotal.value > 0
)
})
function scrollToUnsetPathPanel() {
void nextTick(() => {
document.getElementById('unset-path-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
@@ -1678,6 +1608,16 @@ async function upgradeAgentOne(item: ServerApiItem) {
}
}
function onUnsetInstallAgent(item?: ServerApiItem) {
if (item) void installAgentOne(item)
else void batchInstallAgent('unset')
}
function onUnsetUpgradeAgent(item?: ServerApiItem) {
if (item) void upgradeAgentOne(item)
else void batchUpgradeAgent('unset')
}
function editServer(item: ServerApiItem) {
void openEditServerDialog(item)
}
@@ -1693,9 +1633,15 @@ const batchCategoryValue = ref('')
const batchCategoryLoading = ref(false)
const batchCategoryOptions = computed(() => {
const mergedIds = new Set(CATEGORY_MERGE_GROUPS.map(g => g.id))
const names = categoryList.value
.filter(c => c.name !== 'uncategorized')
.filter(c => c.name !== 'uncategorized' && !mergedIds.has(c.name))
.map(c => c.label)
for (const g of CATEGORY_MERGE_GROUPS) {
for (const m of g.members) {
if (!names.includes(m)) names.push(m)
}
}
if (categoryOptions.value.length) {
for (const c of categoryOptions.value) {
if (c && !names.includes(c)) names.push(c)
@@ -1739,6 +1685,12 @@ const editingPathId = ref<number | null>(null)
const editingPathValue = ref('')
const savingPathId = ref<number | null>(null)
const editingRemarkId = ref<number | null>(null)
const editingRemarkValue = ref('')
const savingRemarkId = ref<number | null>(null)
const showRemarkDialog = ref(false)
const remarkEditServerName = ref('')
function openDetectPathConfirm(scope: BatchScope) {
batchScope.value = scope
const { ids } = batchSelection(scope)
@@ -1827,6 +1779,7 @@ async function batchUninstallAgent(scope: BatchScope) {
}
function startEditPath(item: ServerApiItem) {
cancelEditRemark()
editingPathId.value = item.id
editingPathValue.value = item.target_path?.trim() || '/www/wwwroot'
}
@@ -1861,6 +1814,39 @@ async function saveTargetPath(serverId: number) {
}
}
function startEditRemark(item: ServerApiItem) {
cancelEditPath()
editingRemarkId.value = item.id
editingRemarkValue.value = item.description?.trim() || ''
remarkEditServerName.value = item.name
showRemarkDialog.value = true
}
function cancelEditRemark() {
showRemarkDialog.value = false
editingRemarkId.value = null
editingRemarkValue.value = ''
remarkEditServerName.value = ''
savingRemarkId.value = null
}
async function saveRemarkFromDialog() {
const serverId = editingRemarkId.value
if (serverId == null) return
savingRemarkId.value = serverId
try {
const description = editingRemarkValue.value.trim() || null
await http.put(`/servers/${serverId}`, { description })
snackbar('备注已更新')
cancelEditRemark()
await Promise.all([loadServers(), loadUnsetPathServers()])
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
} finally {
savingRemarkId.value = null
}
}
async function confirmBatchDelete(scope: BatchScope) {
batchScope.value = scope
const { items, ids } = batchSelection(scope)
@@ -1995,19 +1981,6 @@ usePageActivateRefresh((silent) => {
</script>
<style scoped>
.server-main-table :deep(.v-data-table__td--expand-column),
.server-main-table :deep(.v-data-table-column--expand),
.server-main-table :deep(th.v-data-table__th--expand) {
display: none !important;
width: 0 !important;
padding: 0 !important;
}
.server-main-table :deep(tr.v-data-table__expanded__row > td) {
background: transparent !important;
border-bottom: none;
}
.servers-category-panels :deep(.v-expansion-panel-text__wrapper) {
background: color-mix(in srgb, rgb(var(--v-theme-surface)) 95%, rgb(var(--v-theme-on-surface)));
color: rgb(var(--v-theme-on-surface));
@@ -2018,7 +1991,8 @@ usePageActivateRefresh((silent) => {
color: rgb(var(--v-theme-on-surface));
}
.site-link:hover {
text-decoration: underline;
.server-panel-card-title {
white-space: normal;
overflow: visible;
}
</style>
</style>
+21
View File
@@ -25,6 +25,7 @@
@switch="switchTab"
@close="closeTab"
@new="newSession()"
@quick-add="openTerminalQuickAdd"
@toggle-rail="toggleServerRail"
/>
@@ -94,6 +95,11 @@
/>
</div>
<ServerQuickAddDialogs
ref="quickAddDialogsRef"
:on-success="onQuickAddSuccess"
/>
<v-dialog v-model="showServerDialog" max-width="400">
<v-card border>
<v-card-title>选择服务器</v-card-title>
@@ -128,6 +134,8 @@ import TerminalCmdBar from '@/components/terminal/TerminalCmdBar.vue'
import TerminalQuickBar from '@/components/terminal/TerminalQuickBar.vue'
import TerminalServerPicker from '@/components/terminal/TerminalServerPicker.vue'
import TerminalContextMenu from '@/components/terminal/TerminalContextMenu.vue'
import ServerQuickAddDialogs from '@/components/servers/ServerQuickAddDialogs.vue'
import type { AddByIpResponse } from '@/types/api'
defineOptions({ name: 'TerminalPage' })
@@ -189,6 +197,19 @@ function toggleServerRail() {
const areaRef = ref<InstanceType<typeof TerminalArea> | null>(null)
const cmdBarRef = ref<InstanceType<typeof TerminalCmdBar> | null>(null)
const quickAddDialogsRef = ref<InstanceType<typeof ServerQuickAddDialogs> | null>(null)
function openTerminalQuickAdd() {
quickAddDialogsRef.value?.open()
}
async function onQuickAddSuccess(res: AddByIpResponse) {
await loadServers()
const id = res.server?.id
if (id == null) return
await nextTick()
await newSession(id)
}
watch(
() => areaRef.value?.containerRef,
+9
View File
@@ -0,0 +1,9 @@
import type { Router } from 'vue-router'
/** Navigate to WebSSH terminal and open a session for the server. */
export function openServerTerminal(router: Router, serverId: number) {
void router.push({
path: '/terminal',
query: { server_id: String(serverId) },
})
}
+34 -34
View File
@@ -125,40 +125,6 @@ async def create_quick_command(
return _qc_to_dict(qc)
@router.put("/quick-commands/{id}", response_model=dict)
async def update_quick_command(
id: int,
payload: QuickCommandUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a custom quick command (built-in commands cannot be edited)"""
result = await db.execute(select(QuickCommand).where(QuickCommand.id == id))
qc = result.scalar_one_or_none()
if not qc:
raise HTTPException(status_code=404, detail="快捷命令不存在")
if qc.is_builtin:
raise HTTPException(status_code=403, detail="系统内置命令不可编辑")
if payload.name is not None:
qc.name = payload.name
if payload.cmd is not None:
qc.cmd = payload.cmd
await db.commit()
await db.refresh(qc)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_quick_command",
target_type="quick_command", target_id=id,
detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "",
))
return _qc_to_dict(qc)
@router.put("/quick-commands/reorder", response_model=dict)
async def reorder_quick_commands(
payload: QuickCommandReorder,
@@ -198,6 +164,40 @@ async def reorder_quick_commands(
return {"items": [_qc_to_dict(qc) for qc in result.scalars().all()]}
@router.put("/quick-commands/{id}", response_model=dict)
async def update_quick_command(
id: int,
payload: QuickCommandUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a custom quick command (built-in commands cannot be edited)"""
result = await db.execute(select(QuickCommand).where(QuickCommand.id == id))
qc = result.scalar_one_or_none()
if not qc:
raise HTTPException(status_code=404, detail="快捷命令不存在")
if qc.is_builtin:
raise HTTPException(status_code=403, detail="系统内置命令不可编辑")
if payload.name is not None:
qc.name = payload.name
if payload.cmd is not None:
qc.cmd = payload.cmd
await db.commit()
await db.refresh(qc)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_quick_command",
target_type="quick_command", target_id=id,
detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "",
))
return _qc_to_dict(qc)
@router.delete("/quick-commands/{id}", status_code=204)
async def delete_quick_command(
id: int,
+39
View File
@@ -34,3 +34,42 @@ async def test_create_quick_command_stores_trimmed_cmd_without_cr(
headers=auth_headers,
)
assert delete.status_code == 204
@pytest.mark.integration
@pytest.mark.asyncio
async def test_reorder_quick_commands_route_not_shadowed_by_id(
integration_env, api_client, auth_headers
):
"""PUT /quick-commands/reorder must not match /quick-commands/{id} (id=reorder → 422)."""
ids: list[int] = []
for i in range(2):
create = await api_client.post(
"/api/terminal/quick-commands",
headers=auth_headers,
json={"name": f"qc-order-{i}", "cmd": f"echo {i}"},
)
assert create.status_code == 201, create.text
ids.append(create.json()["id"])
listed = await api_client.get("/api/terminal/quick-commands", headers=auth_headers)
custom = [c for c in listed.json() if not c["is_builtin"] and c["id"] in ids]
assert len(custom) == 2
first_id, second_id = custom[0]["id"], custom[1]["id"]
reorder = await api_client.put(
"/api/terminal/quick-commands/reorder",
headers=auth_headers,
json={"ids": [second_id, first_id]},
)
assert reorder.status_code == 200, reorder.text
items = reorder.json()["items"]
custom_after = [c for c in items if not c["is_builtin"] and c["id"] in ids]
assert [c["id"] for c in custom_after] == [second_id, first_id]
for cmd_id in ids:
delete = await api_client.delete(
f"/api/terminal/quick-commands/{cmd_id}",
headers=auth_headers,
)
assert delete.status_code == 204