fix: 修复网络扫描进度显示问题,优化AMT设备列表UI

This commit is contained in:
lvfengfree 2026-01-21 19:53:40 +08:00
parent bd64e889fd
commit ca7231ecb9
20 changed files with 1298 additions and 477 deletions

View File

@ -6,7 +6,7 @@ export const scanApi = {
startScan(networkSegment: string, subnetMask: string) {
return request.post({
url: '/api/scan/start',
params: { networkSegment, subnetMask }
data: { networkSegment, subnetMask }
})
},
@ -96,6 +96,15 @@ export const deviceApi = {
return request.get({
url: `/api/devices/${id}/credentials`
})
},
// 设置设备 AMT 凭据
setAmtCredentials(id: number, data: { username: string; password: string }) {
return request.put({
url: `/api/devices/${id}/amt-credentials`,
data: data,
showSuccessMessage: true
})
}
}

View File

@ -1,174 +0,0 @@
<template>
<div class="credentials-page">
<ElCard shadow="never">
<template #header>
<div class="card-header">
<span>AMT 凭据管理</span>
<ElButton type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>
添加凭据
</ElButton>
</div>
</template>
<ElTable :data="credentials" v-loading="loading" style="width: 100%">
<ElTableColumn prop="name" label="名称" width="180" />
<ElTableColumn prop="username" label="用户名" width="150" />
<ElTableColumn label="密码" width="120">
<template #default="{ row }">
<ElTag v-if="row.hasPassword" type="success" size="small">已设置</ElTag>
<ElTag v-else type="danger" size="small">未设置</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="默认" width="100">
<template #default="{ row }">
<ElTag v-if="row.isDefault" type="primary" size="small">默认</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="description" label="描述" />
<ElTableColumn label="操作" width="180">
<template #default="{ row }">
<ElButton size="small" @click="showEditDialog(row)">编辑</ElButton>
<ElButton size="small" type="danger" @click="handleDelete(row)">删除</ElButton>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
<!-- 添加/编辑对话框 -->
<ElDialog v-model="dialogVisible" :title="isEdit ? '编辑凭据' : '添加凭据'" width="500px">
<ElForm :model="form" label-width="100px">
<ElFormItem label="名称" required>
<ElInput v-model="form.name" placeholder="例如:默认凭据" />
</ElFormItem>
<ElFormItem label="用户名" required>
<ElInput v-model="form.username" placeholder="例如admin" />
</ElFormItem>
<ElFormItem label="密码" required>
<ElInput v-model="form.password" type="password" placeholder="请输入AMT密码" show-password />
<div v-if="isEdit" style="color: #909399; font-size: 12px; margin-top: 5px;">
留空则不修改密码
</div>
</ElFormItem>
<ElFormItem label="设为默认">
<ElSwitch v-model="form.isDefault" />
<div style="color: #909399; font-size: 12px; margin-top: 5px;">
默认凭据将用于扫描时的认证
</div>
</ElFormItem>
<ElFormItem label="描述">
<ElInput v-model="form.description" type="textarea" :rows="3" placeholder="可选,描述此凭据的用途" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">确定</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { credentialApi } from '@/api/amt'
defineOptions({ name: 'AmtCredentials' })
const credentials = ref<any[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const form = reactive({
id: null as number | null,
name: '',
username: '',
password: '',
isDefault: false,
description: ''
})
const loadCredentials = async () => {
loading.value = true
try {
credentials.value = await credentialApi.getAllCredentials()
} catch (error) {
ElMessage.error('加载凭据列表失败')
console.error(error)
} finally {
loading.value = false
}
}
const showAddDialog = () => {
isEdit.value = false
Object.assign(form, { id: null, name: '', username: '', password: '', isDefault: false, description: '' })
dialogVisible.value = true
}
const showEditDialog = (row: any) => {
isEdit.value = true
Object.assign(form, { id: row.id, name: row.name, username: row.username, password: '', isDefault: row.isDefault, description: row.description })
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!form.name || !form.username) {
ElMessage.warning('请填写名称和用户名')
return
}
if (!isEdit.value && !form.password) {
ElMessage.warning('请填写密码')
return
}
try {
if (isEdit.value && form.id) {
await credentialApi.updateCredential(form.id, form)
} else {
await credentialApi.createCredential(form)
}
dialogVisible.value = false
loadCredentials()
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '添加失败')
console.error(error)
}
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要删除凭据 "${row.name}" 吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await credentialApi.deleteCredential(row.id)
loadCredentials()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
console.error(error)
}
}
}
onMounted(() => {
loadCredentials()
})
</script>
<style scoped>
.credentials-page {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -25,10 +25,52 @@
</div>
</div>
</template>
<!-- 操作工具栏 -->
<div class="batch-toolbar">
<div class="selection-info">
<ElTag v-if="selectedDevices.length > 0" type="primary">
已选择 {{ selectedDevices.length }} 台设备
</ElTag>
<span v-else class="hint-text">请勾选设备进行操作</span>
</div>
<div class="batch-actions">
<ElButton type="info" :disabled="selectedDevices.length === 0" @click="handleBatchSetCredentials">
<el-icon><Key /></el-icon>
配置AMT账号
</ElButton>
<ElDropdown trigger="click" @command="handleBatchPowerCommand" :disabled="selectedDevices.length === 0">
<ElButton type="warning" :disabled="selectedDevices.length === 0">
<el-icon><Lightning /></el-icon>
电源管理 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="power-on" :icon="VideoPlay">开机</ElDropdownItem>
<ElDropdownItem command="power-off" :icon="VideoPause">关机</ElDropdownItem>
<ElDropdownItem command="restart" :icon="RefreshRight">重启</ElDropdownItem>
<ElDropdownItem divided command="force-off" :icon="CircleClose">强制关机</ElDropdownItem>
<ElDropdownItem command="force-restart" :icon="Refresh">强制重启</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton type="danger" :disabled="selectedDevices.length === 0" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
删除
</ElButton>
</div>
</div>
<ElTable :data="devices" v-loading="loading" stripe style="width: 100%">
<ElTable
:data="devices"
v-loading="loading"
stripe
style="width: 100%"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="50" />
<ElTableColumn prop="ipAddress" label="IP 地址" width="140" />
<ElTableColumn prop="systemUuid" label="系统 UUID" width="360">
<ElTableColumn prop="systemUuid" label="系统 UUID" width="320">
<template #default="{ row }">
<div style="display: flex; align-items: center; gap: 8px;">
<span v-if="row.systemUuid" style="font-family: monospace; font-size: 12px;">{{ row.systemUuid }}</span>
@ -64,16 +106,15 @@
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="系统状态" width="90">
<ElTableColumn label="AMT账号" width="120">
<template #default="{ row }">
<ElTag :type="row.osOnline ? 'success' : 'danger'" size="small">
{{ row.osOnline ? '运行中' : '已关机' }}
</ElTag>
<span v-if="row.amtUsername">{{ row.amtUsername }}</span>
<ElTag v-else type="warning" size="small">未配置</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="Windows账号" width="120">
<ElTableColumn label="AMT密码" width="120">
<template #default="{ row }">
<ElTag v-if="row.windowsUsername" type="success" size="small">{{ row.windowsUsername }}</ElTag>
<span v-if="row.amtPasswordDecrypted">{{ row.amtPasswordDecrypted }}</span>
<ElTag v-else type="warning" size="small">未配置</ElTag>
</template>
</ElTableColumn>
@ -83,34 +124,11 @@
{{ formatDateTime(row.discoveredAt) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="420" fixed="right">
<ElTableColumn label="操作" width="120" fixed="right">
<template #default="{ row }">
<ElButton type="success" size="small" @click="handleRemoteDesktop(row)" :disabled="!row.osOnline || !row.windowsUsername">
远程桌面
</ElButton>
<ElButton type="info" size="small" @click="handleSetCredentials(row)">
配置账号
</ElButton>
<ElDropdown trigger="click" @command="(cmd: string) => handlePowerCommand(cmd, row)" style="margin-left: 8px">
<ElButton type="warning" size="small">
电源管理 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="power-on" :icon="VideoPlay">开机</ElDropdownItem>
<ElDropdownItem command="power-off" :icon="VideoPause">关机</ElDropdownItem>
<ElDropdownItem command="restart" :icon="RefreshRight">重启</ElDropdownItem>
<ElDropdownItem divided command="force-off" :icon="CircleClose">强制关机</ElDropdownItem>
<ElDropdownItem command="force-restart" :icon="Refresh">强制重启</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton type="primary" size="small" @click="handleViewHardware(row)" style="margin-left: 8px">
<ElButton type="primary" size="small" @click="handleViewHardware(row)">
硬件配置
</ElButton>
<ElButton type="danger" size="small" @click="handleDelete(row)">
删除
</ElButton>
</template>
</ElTableColumn>
</ElTable>
@ -122,17 +140,23 @@
<!-- 远程桌面弹窗 -->
<RemoteDesktopModal v-model="showRemoteDesktopModal" :device="selectedDevice" />
<!-- 配置 Windows 账号弹窗 -->
<ElDialog v-model="showCredentialsDialog" title="配置 Windows 登录账号" width="450px">
<ElForm :model="credentialsForm" label-width="100px">
<ElFormItem label="设备 IP">
<ElInput :model-value="selectedDevice?.ipAddress" disabled />
<!-- 配置 AMT 账号弹窗 -->
<ElDialog v-model="showCredentialsDialog" title="配置 AMT 登录账号" width="500px">
<div v-if="credentialsTargetDevices.length > 1" class="target-devices-info">
<ElAlert type="info" :closable="false" show-icon>
将为以下 {{ credentialsTargetDevices.length }} 台设备配置相同的 AMT 账号
<div class="device-list">{{ credentialsTargetDevices.map(d => d.ipAddress).join(', ') }}</div>
</ElAlert>
</div>
<ElForm :model="credentialsForm" label-width="100px" style="margin-top: 16px">
<ElFormItem v-if="credentialsTargetDevices.length === 1" label="设备 IP">
<ElInput :model-value="credentialsTargetDevices[0]?.ipAddress" disabled />
</ElFormItem>
<ElFormItem label="用户名">
<ElInput v-model="credentialsForm.username" placeholder="Windows 登录用户名" />
<ElInput v-model="credentialsForm.username" placeholder="AMT 登录用户名(通常为 admin" />
</ElFormItem>
<ElFormItem label="密码">
<ElInput v-model="credentialsForm.password" type="password" placeholder="Windows 登录密码" show-password />
<ElInput v-model="credentialsForm.password" type="password" placeholder="AMT 登录密码" show-password />
</ElFormItem>
</ElForm>
<template #footer>
@ -143,10 +167,11 @@
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown, VideoPlay, VideoPause, RefreshRight, CircleClose } from '@element-plus/icons-vue'
import { Search, Refresh, ArrowDown, VideoPlay, VideoPause, RefreshRight, CircleClose, Delete, Lightning, Key } from '@element-plus/icons-vue'
import { deviceApi, powerApi, hardwareApi } from '@/api/amt'
import HardwareInfoModal from './modules/hardware-info-modal.vue'
import RemoteDesktopModal from './modules/remote-desktop-modal.vue'
@ -154,6 +179,7 @@ import RemoteDesktopModal from './modules/remote-desktop-modal.vue'
defineOptions({ name: 'AmtDevices' })
const devices = ref<any[]>([])
const selectedDevices = ref<any[]>([])
const loading = ref(false)
const isCheckingStatus = ref(false)
const searchKeyword = ref('')
@ -162,6 +188,7 @@ const showRemoteDesktopModal = ref(false)
const showCredentialsDialog = ref(false)
const selectedDeviceId = ref(0)
const selectedDevice = ref<any>(null)
const credentialsTargetDevices = ref<any[]>([])
const credentialsForm = ref({ username: '', password: '' })
const savingCredentials = ref(false)
@ -187,6 +214,10 @@ const fetchDevices = async () => {
}
}
const handleSelectionChange = (selection: any[]) => {
selectedDevices.value = selection
}
const handleSearch = async () => {
if (searchKeyword.value) {
loading.value = true
@ -244,15 +275,28 @@ const stopStatusCheck = () => {
}
}
const handleDelete = async (device: any) => {
const handleBatchDelete = async () => {
if (selectedDevices.value.length === 0) return
try {
await ElMessageBox.confirm(`确定要删除设备 ${device.ipAddress} 吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedDevices.value.length} 台设备吗?`,
'删除确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
await deviceApi.deleteDevice(device.id)
const ids = selectedDevices.value.map(d => d.id)
let successCount = 0
for (const id of ids) {
try {
await deviceApi.deleteDevice(id)
successCount++
} catch (e) {
console.error('删除设备失败:', id, e)
}
}
ElMessage.success(`成功删除 ${successCount} 台设备`)
fetchDevices()
} catch (error: any) {
if (error !== 'cancel') {
@ -280,8 +324,15 @@ const handleRemoteDesktop = (device: any) => {
}
const handleSetCredentials = async (device: any) => {
selectedDevice.value = device
credentialsForm.value = { username: device.windowsUsername || '', password: '' }
credentialsTargetDevices.value = [device]
credentialsForm.value = { username: device.amtUsername || 'admin', password: '' }
showCredentialsDialog.value = true
}
const handleBatchSetCredentials = () => {
if (selectedDevices.value.length === 0) return
credentialsTargetDevices.value = [...selectedDevices.value]
credentialsForm.value = { username: 'admin', password: '' }
showCredentialsDialog.value = true
}
@ -290,17 +341,34 @@ const saveCredentials = async () => {
ElMessage.warning('请输入用户名')
return
}
if (!credentialsForm.value.password) {
ElMessage.warning('请输入密码')
return
}
savingCredentials.value = true
try {
await deviceApi.setDeviceCredentials(selectedDevice.value.id, credentialsForm.value)
//
const device = devices.value.find(d => d.id === selectedDevice.value.id)
if (device) {
device.windowsUsername = credentialsForm.value.username
let successCount = 0
for (const device of credentialsTargetDevices.value) {
try {
await deviceApi.setAmtCredentials(device.id, credentialsForm.value)
//
const d = devices.value.find(item => item.id === device.id)
if (d) {
d.amtUsername = credentialsForm.value.username
}
successCount++
} catch (e) {
console.error('配置AMT账号失败:', device.ipAddress, e)
}
}
showCredentialsDialog.value = false
ElMessage.success('账号配置成功')
if (successCount === credentialsTargetDevices.value.length) {
ElMessage.success('AMT账号配置成功')
} else {
ElMessage.warning(`成功配置 ${successCount} 台,失败 ${credentialsTargetDevices.value.length - successCount}`)
}
} catch (error) {
ElMessage.error('保存失败')
} finally {
@ -311,7 +379,6 @@ const saveCredentials = async () => {
const handleFetchUuid = async (device: any) => {
device.fetchingUuid = true
try {
// UUID
const hardwareInfo = await hardwareApi.getHardwareInfo(device.id, true)
if (hardwareInfo.systemInfo?.uuid) {
device.systemUuid = hardwareInfo.systemInfo.uuid
@ -326,7 +393,11 @@ const handleFetchUuid = async (device: any) => {
}
}
const handlePowerCommand = async (command: string, device: any) => {
//
const handleBatchPowerCommand = async (command: string) => {
if (selectedDevices.value.length === 0) return
const actionMap: Record<string, { api: Function; name: string; confirmMsg: string }> = {
'power-on': { api: powerApi.powerOn, name: '开机', confirmMsg: '确定要开机吗?' },
'power-off': { api: powerApi.powerOff, name: '关机', confirmMsg: '确定要关机吗?这将优雅地关闭操作系统。' },
@ -338,23 +409,44 @@ const handlePowerCommand = async (command: string, device: any) => {
const action = actionMap[command]
if (!action) return
const deviceIps = selectedDevices.value.map(d => d.ipAddress).join(', ')
try {
await ElMessageBox.confirm(`设备: ${device.ipAddress}\n${action.confirmMsg}`, `确认${action.name}`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: command.includes('force') ? 'warning' : 'info'
})
await ElMessageBox.confirm(
`选中设备: ${deviceIps}\n\n${action.confirmMsg}`,
`${action.name}确认`,
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: command.includes('force') ? 'warning' : 'info'
}
)
ElMessage.info(`正在执行${action.name}...`)
const response = await action.api(device.id)
let successCount = 0
let failCount = 0
if (response.success) {
ElMessage.success(response.message || `${action.name}命令已发送`)
setTimeout(() => checkAllDevicesStatus(), 3000)
} else {
ElMessage.error(response.error || `${action.name}失败`)
for (const device of selectedDevices.value) {
try {
const response = await action.api(device.id)
if (response.success) {
successCount++
} else {
failCount++
}
} catch (e) {
failCount++
}
}
if (successCount > 0) {
ElMessage.success(`${action.name}命令已发送: 成功 ${successCount}${failCount > 0 ? `, 失败 ${failCount}` : ''}`)
} else {
ElMessage.error(`${action.name}失败`)
}
setTimeout(() => checkAllDevicesStatus(), 3000)
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(`${action.name}失败`)
@ -405,4 +497,29 @@ const formatDateTime = (dateTime: string) => {
display: flex;
align-items: center;
}
.batch-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
margin-bottom: 16px;
background: #f5f7fa;
border-radius: 4px;
}
.selection-info {
display: flex;
align-items: center;
}
.hint-text {
color: #909399;
font-size: 14px;
}
.batch-actions {
display: flex;
gap: 10px;
}
</style>

View File

@ -204,6 +204,7 @@ const handleStartScan = async () => {
try {
const result = await scanApi.startScan(scanForm.networkSegment, scanForm.subnetMask)
console.log('Start scan result:', result)
scanProgress.taskId = result.taskId
ElMessage.success('扫描任务已启动')
startPolling()
@ -220,6 +221,7 @@ const startPolling = () => {
pollTimer = window.setInterval(async () => {
try {
const status = await scanApi.getScanStatus(scanProgress.taskId)
console.log('Scan status response:', status)
scanProgress.scannedCount = status.scannedCount || 0
scanProgress.totalCount = status.totalCount || 0
scanProgress.foundDevices = status.foundDevices || 0
@ -227,12 +229,14 @@ const startPolling = () => {
? Math.round((status.scannedCount / status.totalCount) * 100)
: 0
if (status.status === 'completed' || status.scannedCount >= status.totalCount) {
// completed foundDevices
if (status.status === 'completed') {
stopPolling()
scanning.value = false
scanProgress.status = 'completed'
scanProgress.progressPercentage = 100
ElMessage.success(`扫描完成,发现 ${scanProgress.foundDevices} 个 AMT 设备`)
// 使 foundDevices
ElMessage.success(`扫描完成,发现 ${status.foundDevices || 0} 个 AMT 设备`)
}
} catch (error) {
console.error('获取扫描状态失败:', error)

View File

@ -1,201 +0,0 @@
<template>
<div class="windows-credentials-page">
<ElCard shadow="never">
<template #header>
<div class="card-header">
<span>Windows 凭据管理</span>
<ElButton type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>
添加凭据
</ElButton>
</div>
</template>
<ElTable :data="credentials" v-loading="loading" style="width: 100%">
<ElTableColumn prop="name" label="名称" width="180">
<template #default="{ row }">
{{ row.name }}
<ElTag v-if="row.isDefault" type="success" size="small" style="margin-left: 8px">默认</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="username" label="用户名" width="150" />
<ElTableColumn prop="domain" label="域名" width="120">
<template #default="{ row }">
{{ row.domain || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="note" label="备注">
<template #default="{ row }">
{{ row.note || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="240">
<template #default="{ row }">
<ElButton size="small" @click="showEditDialog(row)">编辑</ElButton>
<ElButton v-if="!row.isDefault" size="small" type="success" @click="handleSetDefault(row)">设为默认</ElButton>
<ElButton size="small" type="danger" @click="handleDelete(row)">删除</ElButton>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
<!-- 添加/编辑对话框 -->
<ElDialog v-model="dialogVisible" :title="isEdit ? '编辑凭据' : '添加凭据'" width="500px">
<ElForm :model="form" label-width="100px">
<ElFormItem label="名称" required>
<ElInput v-model="form.name" placeholder="例如:服务器管理员" />
</ElFormItem>
<ElFormItem label="用户名" required>
<ElInput v-model="form.username" placeholder="Windows 用户名" />
</ElFormItem>
<ElFormItem label="密码" :required="!isEdit">
<ElInput v-model="form.password" type="password" :placeholder="isEdit ? '留空则不修改' : '密码'" show-password />
</ElFormItem>
<ElFormItem label="域名">
<ElInput v-model="form.domain" placeholder="可选WORKGROUP" />
</ElFormItem>
<ElFormItem label="设为默认">
<ElSwitch v-model="form.isDefault" />
<div style="color: #909399; font-size: 12px; margin-top: 5px;">
默认凭据将用于远程桌面连接
</div>
</ElFormItem>
<ElFormItem label="备注">
<ElInput v-model="form.note" type="textarea" :rows="2" placeholder="可选备注" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="saving">确定</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { windowsCredentialsApi } from '@/api/amt'
defineOptions({ name: 'WindowsCredentials' })
const credentials = ref<any[]>([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const form = reactive({
id: null as number | null,
name: '',
username: '',
password: '',
domain: '',
isDefault: false,
note: ''
})
const loadCredentials = async () => {
loading.value = true
try {
credentials.value = await windowsCredentialsApi.getAll()
} catch (error) {
ElMessage.error('加载凭据列表失败')
console.error(error)
} finally {
loading.value = false
}
}
const showAddDialog = () => {
isEdit.value = false
Object.assign(form, { id: null, name: '', username: '', password: '', domain: '', isDefault: false, note: '' })
dialogVisible.value = true
}
const showEditDialog = (row: any) => {
isEdit.value = true
Object.assign(form, {
id: row.id,
name: row.name,
username: row.username,
password: '',
domain: row.domain || '',
isDefault: row.isDefault,
note: row.note || ''
})
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!form.name || !form.username) {
ElMessage.warning('请填写名称和用户名')
return
}
if (!isEdit.value && !form.password) {
ElMessage.warning('请填写密码')
return
}
saving.value = true
try {
if (isEdit.value && form.id) {
await windowsCredentialsApi.update(form.id, form)
} else {
await windowsCredentialsApi.create(form)
}
dialogVisible.value = false
loadCredentials()
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '添加失败')
console.error(error)
} finally {
saving.value = false
}
}
const handleSetDefault = async (row: any) => {
try {
await windowsCredentialsApi.setDefault(row.id)
loadCredentials()
} catch (error) {
ElMessage.error('设置失败')
console.error(error)
}
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要删除凭据 "${row.name}" 吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await windowsCredentialsApi.delete(row.id)
loadCredentials()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
console.error(error)
}
}
}
onMounted(() => {
loadCredentials()
})
</script>
<style scoped>
.windows-credentials-page {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -29,10 +29,32 @@ public class DevicesController : ControllerBase
}
[HttpGet]
public async Task<ActionResult<ApiResponse<List<AmtDevice>>>> GetAllDevices()
public async Task<ActionResult<ApiResponse<List<object>>>> GetAllDevices()
{
var devices = await _context.AmtDevices.ToListAsync();
return Ok(ApiResponse<List<AmtDevice>>.Success(devices));
// 解密 AMT 密码返回给前端
var result = devices.Select(d => new {
d.Id,
d.IpAddress,
d.Hostname,
d.SystemUuid,
d.MajorVersion,
d.MinorVersion,
d.ProvisioningState,
d.Description,
d.AmtOnline,
d.OsOnline,
d.WindowsUsername,
d.WindowsPassword,
d.AmtUsername,
AmtPasswordDecrypted = string.IsNullOrEmpty(d.AmtPassword) ? null :
System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(d.AmtPassword)),
d.DiscoveredAt,
d.LastSeenAt
}).ToList();
return Ok(ApiResponse<List<object>>.Success(result.Cast<object>().ToList()));
}
[HttpGet("{id}")]
@ -184,6 +206,35 @@ public class DevicesController : ControllerBase
}));
}
/// <summary>
/// 设置设备的 AMT 登录凭据
/// </summary>
[HttpPut("{id}/amt-credentials")]
public async Task<ActionResult<ApiResponse<object>>> SetAmtCredentials(long id, [FromBody] SetAmtCredentialsRequest request)
{
var device = await _context.AmtDevices.FindAsync(id);
if (device == null)
{
return Ok(ApiResponse<object>.Fail(404, "设备不存在"));
}
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return Ok(ApiResponse<object>.Fail(400, "用户名和密码不能为空"));
}
device.AmtUsername = request.Username;
// 简单加密存储密码(生产环境应使用更安全的加密方式)
device.AmtPassword = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(request.Password));
await _context.SaveChangesAsync();
_logger.LogInformation("Updated AMT credentials for device {Id} ({Ip})", id, device.IpAddress);
return Ok(ApiResponse<object>.Success(null, "AMT凭据设置成功"));
}
/// <summary>
/// 检测所有设备的在线状态
/// </summary>
@ -407,3 +458,12 @@ public class AddDeviceRequest
public string? WindowsUsername { get; set; }
public string? WindowsPassword { get; set; }
}
/// <summary>
/// 设置设备 AMT 凭据请求
/// </summary>
public class SetAmtCredentialsRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View File

@ -44,40 +44,46 @@ public class ScanController : ControllerBase
FoundDevices = 0
};
// 创建进度回调 - 直接使用 Action 而不是 Progress<T>
Action<ScanProgress> progressCallback = p =>
{
// 更新状态存储
if (_scanStatuses.TryGetValue(taskId, out var status))
{
status.ScannedCount = p.ScannedCount;
status.TotalCount = p.TotalCount;
status.FoundDevices = p.FoundDevices;
status.CurrentIp = p.CurrentIp;
_logger.LogInformation("Progress update: scanned={Scanned}, total={Total}, found={Found}, ip={Ip}",
p.ScannedCount, p.TotalCount, p.FoundDevices, p.CurrentIp);
}
// 异步发送 SignalR 通知(不等待)
_ = _hubContext.Clients.All.SendAsync("ReceiveScanProgress", p);
};
// Start scan in background
_ = Task.Run(async () =>
{
var progress = new Progress<ScanProgress>(async p =>
{
// 更新状态存储
if (_scanStatuses.TryGetValue(taskId, out var status))
{
status.ScannedCount = p.ScannedCount;
status.TotalCount = p.TotalCount;
status.FoundDevices = p.FoundDevices;
status.CurrentIp = p.CurrentIp;
}
await _hubContext.Clients.All.SendAsync("ReceiveScanProgress", p);
});
try
{
await _scannerService.ScanNetworkAsync(
var foundDevicesList = await _scannerService.ScanNetworkAsync(
taskId,
request.NetworkSegment,
request.SubnetMask,
progress
progressCallback
);
// 更新状态为完成
// 更新状态为完成,并确保 foundDevices 是正确的
if (_scanStatuses.TryGetValue(taskId, out var status))
{
status.Status = "completed";
status.FoundDevices = foundDevicesList.Count;
_logger.LogInformation("Scan task {TaskId} completed with {Count} devices found", taskId, foundDevicesList.Count);
}
// Send completion notification
_logger.LogInformation("Scan task {TaskId} completed", taskId);
await _hubContext.Clients.All.SendAsync("ScanCompleted", new { taskId });
}
catch (Exception ex)
@ -102,6 +108,8 @@ public class ScanController : ControllerBase
{
if (_scanStatuses.TryGetValue(taskId, out var status))
{
_logger.LogDebug("GetScanStatus: taskId={TaskId}, status={Status}, scanned={Scanned}, total={Total}, found={Found}",
taskId, status.Status, status.ScannedCount, status.TotalCount, status.FoundDevices);
return Ok(ApiResponse<ScanStatusInfo>.Success(status));
}

View File

@ -192,5 +192,6 @@ public class AppDbContext : DbContext
modelBuilder.Entity<AmtDevice>()
.HasIndex(d => d.SystemUuid);
}
}

View File

@ -97,7 +97,10 @@ public static class DbSeeder
private static async Task SeedMenusAsync(AppDbContext context)
{
if (await context.Menus.AnyAsync()) return;
if (await context.Menus.AnyAsync())
{
return;
}
var menus = new List<Menu>
{
@ -106,13 +109,12 @@ public static class DbSeeder
new() { Id = 2, ParentId = 1, Name = "Console", Path = "console", Component = "/dashboard/console", Title = "menus.dashboard.console", KeepAlive = false, Sort = 1, Roles = "R_SUPER,R_ADMIN,R_USER", IsSystem = true },
// AMT 设备管理菜单(系统内置)
new() { Id = 5, Name = "AmtManage", Path = "/amt", Component = "/index/index", Title = "AMT设备管理", Icon = "ri:computer-line", Sort = 2, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
new() { Id = 5, Name = "AmtManage", Path = "/amt", Component = "/index/index", Title = "AMT设备管理", Icon = "ri:computer-line", Sort = 3, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
new() { Id = 6, ParentId = 5, Name = "AmtScan", Path = "scan", Component = "/amt/scan", Title = "设备扫描", KeepAlive = true, Sort = 1, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
new() { Id = 7, ParentId = 5, Name = "AmtDevices", Path = "devices", Component = "/amt/devices", Title = "设备管理", KeepAlive = true, Sort = 2, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
new() { Id = 8, ParentId = 5, Name = "AmtCredentials", Path = "credentials", Component = "/amt/credentials", Title = "AMT凭据", KeepAlive = true, Sort = 3, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
// 桌面管理菜单(系统内置)
new() { Id = 20, Name = "DesktopManage", Path = "/desktop-manage", Component = "/index/index", Title = "桌面管理", Icon = "ri:remote-control-line", Sort = 3, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
new() { Id = 20, Name = "DesktopManage", Path = "/desktop-manage", Component = "/index/index", Title = "桌面管理", Icon = "ri:remote-control-line", Sort = 4, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
new() { Id = 21, ParentId = 20, Name = "DesktopDevices", Path = "devices", Component = "/desktop-manage/devices", Title = "远程桌面", KeepAlive = true, Sort = 1, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
// 系统管理菜单(系统内置)

View File

@ -0,0 +1,801 @@
// <auto-generated />
using System;
using AmtScanner.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AmtScanner.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260121095352_AddDeviceReservation")]
partial class AddDeviceReservation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("AmtScanner.Api.Models.AmtCredential", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<bool>("IsDefault")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("AmtCredentials");
});
modelBuilder.Entity("AmtScanner.Api.Models.AmtDevice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<bool>("AmtOnline")
.HasColumnType("tinyint(1)");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("datetime(6)");
b.Property<string>("Hostname")
.HasColumnType("longtext");
b.Property<string>("IpAddress")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime(6)");
b.Property<int>("MajorVersion")
.HasColumnType("int");
b.Property<int>("MinorVersion")
.HasColumnType("int");
b.Property<bool>("OsOnline")
.HasColumnType("tinyint(1)");
b.Property<int>("ProvisioningState")
.HasColumnType("int");
b.Property<string>("SystemUuid")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("WindowsPassword")
.HasColumnType("longtext");
b.Property<string>("WindowsUsername")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("IpAddress")
.IsUnique();
b.HasIndex("SystemUuid");
b.ToTable("AmtDevices");
});
modelBuilder.Entity("AmtScanner.Api.Models.DeviceReservation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("AccessToken")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("DeviceId")
.HasColumnType("bigint");
b.Property<DateTime>("EndTime")
.HasColumnType("datetime(6)");
b.Property<string>("Note")
.HasColumnType("longtext");
b.Property<DateTime>("StartTime")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "Status", "EndTime");
b.ToTable("DeviceReservations");
});
modelBuilder.Entity("AmtScanner.Api.Models.HardwareInfo", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("DeviceId")
.HasColumnType("bigint");
b.Property<DateTime>("LastUpdated")
.HasColumnType("datetime(6)");
b.Property<int?>("ProcessorCores")
.HasColumnType("int");
b.Property<int?>("ProcessorCurrentClockSpeed")
.HasColumnType("int");
b.Property<int?>("ProcessorMaxClockSpeed")
.HasColumnType("int");
b.Property<string>("ProcessorModel")
.HasColumnType("longtext");
b.Property<int?>("ProcessorThreads")
.HasColumnType("int");
b.Property<string>("SystemManufacturer")
.HasColumnType("longtext");
b.Property<string>("SystemModel")
.HasColumnType("longtext");
b.Property<string>("SystemSerialNumber")
.HasColumnType("longtext");
b.Property<string>("SystemUuid")
.HasColumnType("longtext");
b.Property<long?>("TotalMemoryBytes")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("LastUpdated");
b.ToTable("HardwareInfos");
});
modelBuilder.Entity("AmtScanner.Api.Models.MemoryModule", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<long?>("CapacityBytes")
.HasColumnType("bigint");
b.Property<long>("HardwareInfoId")
.HasColumnType("bigint");
b.Property<string>("Manufacturer")
.HasColumnType("longtext");
b.Property<string>("MemoryType")
.HasColumnType("longtext");
b.Property<string>("PartNumber")
.HasColumnType("longtext");
b.Property<string>("SerialNumber")
.HasColumnType("longtext");
b.Property<string>("SlotLocation")
.HasColumnType("longtext");
b.Property<int?>("SpeedMHz")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("HardwareInfoId");
b.ToTable("MemoryModules");
});
modelBuilder.Entity("AmtScanner.Api.Models.Menu", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("AuthList")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("Component")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Icon")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<bool>("IsHide")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsHideTab")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsIframe")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSystem")
.HasColumnType("tinyint(1)");
b.Property<bool>("KeepAlive")
.HasColumnType("tinyint(1)");
b.Property<string>("Link")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int?>("ParentId")
.HasColumnType("int");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Roles")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("Sort")
.HasColumnType("int");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.HasKey("Id");
b.HasIndex("Name");
b.HasIndex("ParentId");
b.ToTable("Menus");
});
modelBuilder.Entity("AmtScanner.Api.Models.OsDevice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<long?>("AmtDeviceId")
.HasColumnType("bigint");
b.Property<string>("Architecture")
.HasColumnType("longtext");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("datetime(6)");
b.Property<string>("Hostname")
.HasColumnType("longtext");
b.Property<string>("IpAddress")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<bool>("IsOnline")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastBootTime")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastOnlineAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("LoggedInUser")
.HasColumnType("longtext");
b.Property<string>("MacAddress")
.HasColumnType("longtext");
b.Property<int>("OsType")
.HasColumnType("int");
b.Property<string>("OsVersion")
.HasColumnType("longtext");
b.Property<string>("SystemUuid")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.HasKey("Id");
b.HasIndex("AmtDeviceId");
b.HasIndex("IpAddress")
.IsUnique();
b.HasIndex("SystemUuid");
b.ToTable("OsDevices");
});
modelBuilder.Entity("AmtScanner.Api.Models.RemoteAccessToken", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("DeviceId")
.HasColumnType("bigint");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsUsed")
.HasColumnType("tinyint(1)");
b.Property<int>("MaxUseCount")
.HasColumnType("int");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<int>("UseCount")
.HasColumnType("int");
b.Property<DateTime?>("UsedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("Token")
.IsUnique();
b.ToTable("RemoteAccessTokens");
});
modelBuilder.Entity("AmtScanner.Api.Models.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<bool>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<string>("RoleCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("RoleCode")
.IsUnique();
b.ToTable("Roles");
});
modelBuilder.Entity("AmtScanner.Api.Models.RoleMenu", b =>
{
b.Property<int>("RoleId")
.HasColumnType("int");
b.Property<int>("MenuId")
.HasColumnType("int");
b.HasKey("RoleId", "MenuId");
b.HasIndex("MenuId");
b.ToTable("RoleMenus");
});
modelBuilder.Entity("AmtScanner.Api.Models.StorageDevice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<long?>("CapacityBytes")
.HasColumnType("bigint");
b.Property<string>("DeviceId")
.HasColumnType("longtext");
b.Property<long>("HardwareInfoId")
.HasColumnType("bigint");
b.Property<string>("InterfaceType")
.HasColumnType("longtext");
b.Property<string>("Model")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("HardwareInfoId");
b.ToTable("StorageDevices");
});
modelBuilder.Entity("AmtScanner.Api.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Avatar")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(1)
.HasColumnType("varchar(1)");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<string>("NickName")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Phone")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("RefreshToken")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("RefreshTokenExpiryTime")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(1)
.HasColumnType("varchar(1)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("UpdatedBy")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("AmtScanner.Api.Models.UserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("RoleId")
.HasColumnType("int");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles");
});
modelBuilder.Entity("AmtScanner.Api.Models.WindowsCredential", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Domain")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsDefault")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("WindowsCredentials");
});
modelBuilder.Entity("AmtScanner.Api.Models.DeviceReservation", b =>
{
b.HasOne("AmtScanner.Api.Models.AmtDevice", "Device")
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AmtScanner.Api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Device");
b.Navigation("User");
});
modelBuilder.Entity("AmtScanner.Api.Models.HardwareInfo", b =>
{
b.HasOne("AmtScanner.Api.Models.AmtDevice", "Device")
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Device");
});
modelBuilder.Entity("AmtScanner.Api.Models.MemoryModule", b =>
{
b.HasOne("AmtScanner.Api.Models.HardwareInfo", "HardwareInfo")
.WithMany("MemoryModules")
.HasForeignKey("HardwareInfoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("HardwareInfo");
});
modelBuilder.Entity("AmtScanner.Api.Models.Menu", b =>
{
b.HasOne("AmtScanner.Api.Models.Menu", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("AmtScanner.Api.Models.OsDevice", b =>
{
b.HasOne("AmtScanner.Api.Models.AmtDevice", "AmtDevice")
.WithMany()
.HasForeignKey("AmtDeviceId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("AmtDevice");
});
modelBuilder.Entity("AmtScanner.Api.Models.RemoteAccessToken", b =>
{
b.HasOne("AmtScanner.Api.Models.AmtDevice", "Device")
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Device");
});
modelBuilder.Entity("AmtScanner.Api.Models.RoleMenu", b =>
{
b.HasOne("AmtScanner.Api.Models.Menu", "Menu")
.WithMany("RoleMenus")
.HasForeignKey("MenuId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AmtScanner.Api.Models.Role", "Role")
.WithMany("RoleMenus")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Menu");
b.Navigation("Role");
});
modelBuilder.Entity("AmtScanner.Api.Models.StorageDevice", b =>
{
b.HasOne("AmtScanner.Api.Models.HardwareInfo", "HardwareInfo")
.WithMany("StorageDevices")
.HasForeignKey("HardwareInfoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("HardwareInfo");
});
modelBuilder.Entity("AmtScanner.Api.Models.UserRole", b =>
{
b.HasOne("AmtScanner.Api.Models.Role", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AmtScanner.Api.Models.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("AmtScanner.Api.Models.HardwareInfo", b =>
{
b.Navigation("MemoryModules");
b.Navigation("StorageDevices");
});
modelBuilder.Entity("AmtScanner.Api.Models.Menu", b =>
{
b.Navigation("Children");
b.Navigation("RoleMenus");
});
modelBuilder.Entity("AmtScanner.Api.Models.Role", b =>
{
b.Navigation("RoleMenus");
b.Navigation("UserRoles");
});
modelBuilder.Entity("AmtScanner.Api.Models.User", b =>
{
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AmtScanner.Api.Migrations
{
/// <inheritdoc />
public partial class AddDeviceReservation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DeviceReservations",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DeviceId = table.Column<long>(type: "bigint", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false),
StartTime = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndTime = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
AccessToken = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Note = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeviceReservations", x => x.Id);
table.ForeignKey(
name: "FK_DeviceReservations_AmtDevices_DeviceId",
column: x => x.DeviceId,
principalTable: "AmtDevices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DeviceReservations_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_DeviceReservations_DeviceId",
table: "DeviceReservations",
column: "DeviceId");
migrationBuilder.CreateIndex(
name: "IX_DeviceReservations_DeviceId_Status_EndTime",
table: "DeviceReservations",
columns: new[] { "DeviceId", "Status", "EndTime" });
migrationBuilder.CreateIndex(
name: "IX_DeviceReservations_UserId",
table: "DeviceReservations",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DeviceReservations");
}
}
}

View File

@ -115,6 +115,47 @@ namespace AmtScanner.Api.Migrations
b.ToTable("AmtDevices");
});
modelBuilder.Entity("AmtScanner.Api.Models.DeviceReservation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("AccessToken")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<long>("DeviceId")
.HasColumnType("bigint");
b.Property<DateTime>("EndTime")
.HasColumnType("datetime(6)");
b.Property<string>("Note")
.HasColumnType("longtext");
b.Property<DateTime>("StartTime")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "Status", "EndTime");
b.ToTable("DeviceReservations");
});
modelBuilder.Entity("AmtScanner.Api.Models.HardwareInfo", b =>
{
b.Property<long>("Id")
@ -605,6 +646,25 @@ namespace AmtScanner.Api.Migrations
b.ToTable("WindowsCredentials");
});
modelBuilder.Entity("AmtScanner.Api.Models.DeviceReservation", b =>
{
b.HasOne("AmtScanner.Api.Models.AmtDevice", "Device")
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AmtScanner.Api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Device");
b.Navigation("User");
});
modelBuilder.Entity("AmtScanner.Api.Models.HardwareInfo", b =>
{
b.HasOne("AmtScanner.Api.Models.AmtDevice", "Device")

View File

@ -45,6 +45,16 @@ public class AmtDevice
/// </summary>
public string? WindowsPassword { get; set; }
/// <summary>
/// AMT 登录用户名
/// </summary>
public string? AmtUsername { get; set; }
/// <summary>
/// AMT 登录密码(加密存储)
/// </summary>
public string? AmtPassword { get; set; }
public DateTime DiscoveredAt { get; set; }
public DateTime LastSeenAt { get; set; }

View File

@ -32,7 +32,7 @@ public class AmtScannerService : IAmtScannerService
string taskId,
string networkSegment,
string subnetMask,
IProgress<ScanProgress> progress,
Action<ScanProgress> progressCallback,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting network scan for task: {TaskId}", taskId);
@ -69,10 +69,13 @@ public class AmtScannerService : IAmtScannerService
foundDevices.Add(device);
var found = Interlocked.Increment(ref foundCount);
_logger.LogInformation("Found AMT device at {Ip}, total found: {Found}", ip, found);
// Save to database
await SaveDeviceAsync(device);
progress.Report(new ScanProgress
// 直接调用回调,不使用 Progress<T>
progressCallback(new ScanProgress
{
TaskId = taskId,
ScannedCount = scanned,
@ -85,7 +88,8 @@ public class AmtScannerService : IAmtScannerService
}
else
{
progress.Report(new ScanProgress
// 直接调用回调,不使用 Progress<T>
progressCallback(new ScanProgress
{
TaskId = taskId,
ScannedCount = scanned,

View File

@ -104,7 +104,12 @@ public class GuacamoleService : IGuacamoleService
["enable-menu-animations"] = "true",
["disable-bitmap-caching"] = "false",
["disable-offscreen-caching"] = "false",
["disable-glyph-caching"] = "false"
["disable-glyph-caching"] = "false",
// 启用驱动器重定向(文件传输)
["enable-drive"] = "true",
["drive-name"] = "共享文件夹",
["drive-path"] = "/drive",
["create-drive-path"] = "true"
},
["attributes"] = new Dictionary<string, string>()
};
@ -204,7 +209,12 @@ public class GuacamoleService : IGuacamoleService
["enable-font-smoothing"] = "true",
["enable-full-window-drag"] = "true",
["enable-desktop-composition"] = "true",
["enable-menu-animations"] = "true"
["enable-menu-animations"] = "true",
// 启用驱动器重定向(文件传输)
["enable-drive"] = "true",
["drive-name"] = "共享文件夹",
["drive-path"] = "/drive",
["create-drive-path"] = "true"
},
["attributes"] = new Dictionary<string, string>()
};

View File

@ -4,7 +4,7 @@ namespace AmtScanner.Api.Services;
public interface IAmtScannerService
{
Task<List<AmtDevice>> ScanNetworkAsync(string taskId, string networkSegment, string subnetMask, IProgress<ScanProgress> progress, CancellationToken cancellationToken = default);
Task<List<AmtDevice>> ScanNetworkAsync(string taskId, string networkSegment, string subnetMask, Action<ScanProgress> progressCallback, CancellationToken cancellationToken = default);
void CancelScan(string taskId);
}

View File

@ -0,0 +1,11 @@
-- 为 AmtDevices 表添加 AMT 登录凭据字段
-- 执行前请确保已备份数据库
-- 添加 AmtUsername 字段
ALTER TABLE `AmtDevices` ADD COLUMN `AmtUsername` VARCHAR(100) NULL AFTER `WindowsPassword`;
-- 添加 AmtPassword 字段(加密存储)
ALTER TABLE `AmtDevices` ADD COLUMN `AmtPassword` VARCHAR(500) NULL AFTER `AmtUsername`;
-- 验证字段是否添加成功
-- SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'AmtDevices' AND COLUMN_NAME IN ('AmtUsername', 'AmtPassword');

View File

@ -0,0 +1,5 @@
-- 删除"电脑使用"菜单
DELETE FROM Menus WHERE Path = '/computer-use';
-- 删除预约表(如果存在)
DROP TABLE IF EXISTS DeviceReservations;

View File

@ -210,3 +210,21 @@ ports:
2. 修改数据库默认密码
3. 生产环境使用 HTTPS配置 Nginx 反向代理)
4. 限制访问 IP 范围
## 文件传输功能
系统已启用 Guacamole 的驱动器重定向功能,可以在远程桌面中传输文件。
### 使用方法
1. 连接远程桌面后,打开 **文件资源管理器**
2. 在左侧导航栏找到 **网络位置** 或 **此电脑**
3. 会看到一个名为 **共享文件夹** 的网络驱动器
4. 将文件拖放到该驱动器即可上传,从该驱动器复制文件即可下载
### 注意事项
- 共享文件夹存储在 Docker 卷 `guacd-drive`
- 所有连接共享同一个文件夹,请注意文件管理
- 大文件传输可能较慢,取决于网络带宽
- 如需为每个用户/连接分配独立文件夹,需要进一步配置

View File

@ -6,6 +6,8 @@ services:
image: guacamole/guacd:latest
container_name: guacd
restart: always
volumes:
- guacd-drive:/drive
networks:
- guacamole-net
@ -50,3 +52,4 @@ networks:
volumes:
guacamole-db-data:
guacd-drive: