@@ -233,15 +154,13 @@
-
-
- {{ expandedMainIds.includes(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
-
- {{ item.name }}
-
+
@@ -269,24 +188,24 @@
-
+
-
+
{{ 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 @@
暂无服务器数据
-
+
{{ group.label }}
@@ -446,6 +366,65 @@
+
+
@@ -505,6 +484,15 @@
@save="saveServerForm"
/>
+
+
+
@@ -518,70 +506,11 @@
-
-
-
- 快速添加服务器(仅 IP)
-
-
- 将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录,首个成功即加入服务器列表。
-
-
-
-
-
-
-
- 取消
- 开始轮询
-
-
-
-
-
-
-
- SSH 连接失败
-
- 已尝试全部凭据预设,均未登录成功。该服务器已加入「连接失败列表」,可稍后重试。
-
-
-
- [{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}] {{ err.preset_name }} ({{ err.username }})
-
- {{ err.error }}
-
-
- {{ pollFailureMessage }}
-
-
-
- 知道了
-
-
-
+
@@ -605,8 +534,8 @@
将对选中的 {{ scopedSelectedCount }} 台服务器执行 SSH 检测。
- 在远程 /www/wwwroot 下搜索 workerman.bat,找到后将所在目录写入各服务器的
- target_path(找到第一个即停止)。
+ 在 /www/wwwroot 下搜索 workerman.bat,取其所在目录写入
+ target_path。
@@ -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([{ 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 {
+/** 主列表 / 未设路径 / 分类视图共用的分类、在线、搜索(不含路径分区与排序) */
+function serverScopeFilters(): Record {
+ 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 {
+ const q = String(search.value ?? '').trim()
+ return {
+ ...serverScopeFilters(),
+ ...toServerApiSort(unsetSortBy.value),
+ ...(!q ? { target_path_unset: true } : {}),
+ }
+}
+
+/** 分类视图:展示当前筛选范围内全部机器(不按路径分区) */
+function groupListQueryParams(): Record {
+ 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(
+ '/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 = {}
- 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(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 | null>(null)
const pendingServers = ref([])
const pendingLoading = ref(false)
const retryingId = ref(null)
-const showPollFailure = ref(false)
-const pollFailureErrors = ref([])
-const pollFailureMessage = ref('')
const showDeletePending = ref(false)
const deletingPending = ref(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 = {
- domain,
- port: quickAddForm.value.port || 22,
- }
- const name = quickAddForm.value.name.trim()
- if (name) body.name = name
-
- const res = await http.post('/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(null)
const editingPathValue = ref('')
const savingPathId = ref(null)
+const editingRemarkId = ref(null)
+const editingRemarkValue = ref('')
+const savingRemarkId = ref(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) => {
+
\ No newline at end of file
diff --git a/frontend/src/pages/TerminalPage.vue b/frontend/src/pages/TerminalPage.vue
index 6580f35f..38cfb075 100644
--- a/frontend/src/pages/TerminalPage.vue
+++ b/frontend/src/pages/TerminalPage.vue
@@ -25,6 +25,7 @@
@switch="switchTab"
@close="closeTab"
@new="newSession()"
+ @quick-add="openTerminalQuickAdd"
@toggle-rail="toggleServerRail"
/>
@@ -94,6 +95,11 @@
/>
+
+
选择服务器
@@ -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 | null>(null)
const cmdBarRef = ref | null>(null)
+const quickAddDialogsRef = ref | 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,
diff --git a/frontend/src/utils/openServerTerminal.ts b/frontend/src/utils/openServerTerminal.ts
new file mode 100644
index 00000000..5eb2e488
--- /dev/null
+++ b/frontend/src/utils/openServerTerminal.ts
@@ -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) },
+ })
+}
diff --git a/server/api/terminal.py b/server/api/terminal.py
index 1bd818af..c237a131 100644
--- a/server/api/terminal.py
+++ b/server/api/terminal.py
@@ -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,
diff --git a/tests/test_terminal_quick_commands.py b/tests/test_terminal_quick_commands.py
index a8f57dd9..4d52bda8 100644
--- a/tests/test_terminal_quick_commands.py
+++ b/tests/test_terminal_quick_commands.py
@@ -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