feat: 重构桌面管理模块 - 拆分系统添加和系统管理菜单,支持批量配置Windows账号和删除设备

This commit is contained in:
lvfengfree 2026-01-21 21:24:01 +08:00
parent ca7231ecb9
commit 8acd7b0ab6
10 changed files with 667 additions and 330 deletions

View File

@ -341,7 +341,7 @@ export const osDeviceApi = {
startScan(networkSegment: string, subnetMask: string) {
return request.post({
url: '/api/os-devices/scan/start',
params: { networkSegment, subnetMask }
data: { networkSegment, subnetMask }
})
},
@ -352,6 +352,21 @@ export const osDeviceApi = {
})
},
// 获取扫描结果(未保存的设备列表)
getScanResults(taskId: string) {
return request.get<any[]>({
url: `/api/os-devices/scan/results/${taskId}`
})
},
// 保存选中的设备
saveSelectedDevices(taskId: string, selectedIps: string[]) {
return request.post({
url: '/api/os-devices/scan/save',
data: { taskId, selectedIps }
})
},
// 取消扫描
cancelScan(taskId: string) {
return request.post({
@ -392,6 +407,14 @@ export const osDeviceApi = {
})
},
// 设置 Windows 登录凭据
setCredentials(id: number, credentials: { username: string; password: string }) {
return request.put({
url: `/api/os-devices/${id}/credentials`,
data: credentials
})
},
// 删除设备
delete(id: number) {
return request.del({

View File

@ -3,7 +3,7 @@
<ElCard shadow="never">
<template #header>
<div class="card-header">
<span>远程桌面</span>
<span>系统管理</span>
<div class="header-actions">
<ElTag v-if="isCheckingStatus" type="info" size="small" style="margin-right: 10px">
<el-icon class="is-loading"><Refresh /></el-icon>
@ -11,7 +11,7 @@
</ElTag>
<ElInput
v-model="searchKeyword"
placeholder="搜索 IP 地址"
placeholder="搜索 IP / 主机名"
style="width: 200px; margin-right: 10px"
clearable
@clear="handleSearch"
@ -25,40 +25,80 @@
</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>
配置Windows账号
</ElButton>
<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="filteredDevices"
v-loading="loading"
stripe
style="width: 100%"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="50" />
<ElTableColumn prop="ipAddress" label="IP 地址" width="140" />
<ElTableColumn prop="hostname" label="主机名" width="150" />
<ElTableColumn label="AMT状态" width="90">
<ElTableColumn prop="hostname" label="主机名" width="150">
<template #default="{ row }">
<ElTag :type="row.amtOnline ? 'success' : 'info'" size="small">
{{ row.amtOnline ? '在线' : '离线' }}
{{ row.hostname || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作系统" width="120">
<template #default="{ row }">
<ElTag :type="getOsTagType(row.osType)" size="small">{{ row.osType }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="90">
<template #default="{ row }">
<ElTag :type="row.isOnline ? 'success' : 'danger'" size="small">
{{ row.isOnline ? '在线' : '离线' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="系统状态" width="90">
<ElTableColumn label="AMT 绑定" width="130">
<template #default="{ row }">
<ElTag :type="row.osOnline ? 'success' : 'danger'" size="small">
{{ row.osOnline ? '运行中' : '已关机' }}
</ElTag>
<ElTag v-if="row.amtDeviceId" type="success" size="small">{{ row.amtDeviceIp }}</ElTag>
<ElTag v-else type="info" size="small">未绑定</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="Windows账号" width="120">
<ElTableColumn label="账号" width="120">
<template #default="{ row }">
<ElTag v-if="row.windowsUsername" type="success" size="small">{{ row.windowsUsername }}</ElTag>
<span v-if="row.windowsUsername">{{ row.windowsUsername }}</span>
<ElTag v-else type="warning" size="small">未配置</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="description" label="备注" min-width="150" />
<ElTableColumn label="操作" width="380" fixed="right">
<ElTableColumn label="密码" width="120">
<template #default="{ row }">
<ElButton type="success" size="small" @click="handleRemoteDesktop(row)" :disabled="!row.osOnline || !row.windowsUsername">
<span v-if="row.windowsPassword">{{ row.windowsPassword }}</span>
<ElTag v-else type="warning" size="small">未配置</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="360" fixed="right">
<template #default="{ row }">
<ElButton type="success" size="small" @click="handleRemoteDesktop(row)" :disabled="!row.isOnline">
远程桌面
</ElButton>
<ElButton type="info" size="small" @click="handleSetCredentials(row)">
配置账号
<ElButton type="info" size="small" @click="handleFetchInfo(row)">
获取信息
</ElButton>
<ElDropdown trigger="click" @command="(cmd: string) => handlePowerCommand(cmd, row)" style="margin-left: 8px">
<ElDropdown v-if="row.amtDeviceId" 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>
@ -75,16 +115,43 @@
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-if="!loading && devices.length === 0" description="暂无设备,请先在系统添加中扫描并添加设备" />
</ElCard>
<!-- 远程桌面弹窗 -->
<RemoteDesktopModal v-model="showRemoteDesktopModal" :device="selectedDevice" />
<!-- 获取信息对话框 -->
<ElDialog v-model="showFetchDialog" title="获取系统信息" width="400px">
<ElAlert type="info" :closable="false" style="margin-bottom: 15px">
需要提供 Windows 管理员凭据通过 WMI 获取系统信息
</ElAlert>
<ElForm :model="fetchForm" label-width="80px">
<ElFormItem label="用户名">
<ElInput v-model="fetchForm.username" placeholder="administrator" />
</ElFormItem>
<ElFormItem label="密码">
<ElInput v-model="fetchForm.password" type="password" show-password />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="showFetchDialog = false">取消</ElButton>
<ElButton type="primary" @click="doFetchInfo" :loading="fetching">获取</ElButton>
</template>
</ElDialog>
<!-- 配置 Windows 账号弹窗 -->
<ElDialog v-model="showCredentialsDialog" title="配置 Windows 登录账号" width="450px">
<ElForm :model="credentialsForm" label-width="100px">
<ElFormItem label="设备 IP">
<ElInput :model-value="selectedDevice?.ipAddress" disabled />
<ElDialog v-model="showCredentialsDialog" title="配置 Windows 登录账号" width="500px">
<div v-if="credentialsTargetDevices.length > 1" class="target-devices-info">
<ElAlert type="info" :closable="false" show-icon>
将为以下 {{ credentialsTargetDevices.length }} 台设备配置相同的 Windows 账号
<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 登录用户名" />
@ -102,26 +169,53 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown, VideoPlay, VideoPause, RefreshRight, CircleClose } from '@element-plus/icons-vue'
import { deviceApi, powerApi } from '@/api/amt'
import { Search, Refresh, ArrowDown, VideoPlay, VideoPause, RefreshRight, CircleClose, Delete, Key } from '@element-plus/icons-vue'
import { osDeviceApi, powerApi } from '@/api/amt'
import RemoteDesktopModal from '@/views/amt/modules/remote-desktop-modal.vue'
defineOptions({ name: 'DesktopManageDevices' })
const devices = ref<any[]>([])
const selectedDevices = ref<any[]>([])
const loading = ref(false)
const isCheckingStatus = ref(false)
const searchKeyword = ref('')
const showRemoteDesktopModal = ref(false)
const showCredentialsDialog = ref(false)
const selectedDevice = ref<any>(null)
//
const showFetchDialog = ref(false)
const fetching = ref(false)
const fetchForm = ref({ username: '', password: '' })
let currentFetchDevice: any = null
//
const showCredentialsDialog = ref(false)
const credentialsTargetDevices = ref<any[]>([])
const credentialsForm = ref({ username: '', password: '' })
const savingCredentials = ref(false)
let statusCheckInterval: number | null = null
const filteredDevices = computed(() => {
if (!searchKeyword.value) return devices.value
const kw = searchKeyword.value.toLowerCase()
return devices.value.filter(d =>
d.ipAddress?.toLowerCase().includes(kw) ||
d.hostname?.toLowerCase().includes(kw)
)
})
const getOsTagType = (osType: string) => {
switch (osType) {
case 'Windows': return 'primary'
case 'Linux': return 'success'
default: return 'info'
}
}
onMounted(() => {
fetchDevices()
startStatusCheck()
@ -134,7 +228,7 @@ onUnmounted(() => {
const fetchDevices = async () => {
loading.value = true
try {
devices.value = await deviceApi.getAllDevices()
devices.value = await osDeviceApi.getAll()
} catch (error) {
console.error('获取设备列表失败:', error)
} finally {
@ -142,78 +236,106 @@ const fetchDevices = async () => {
}
}
const handleSearch = async () => {
if (searchKeyword.value) {
loading.value = true
try {
devices.value = await deviceApi.searchDevices(searchKeyword.value)
} catch (error) {
console.error('搜索设备失败:', error)
} finally {
loading.value = false
}
} else {
fetchDevices()
}
const handleSelectionChange = (selection: any[]) => {
selectedDevices.value = selection
}
const handleSearch = () => {
//
}
const handleRefresh = async () => {
await fetchDevices()
await checkAllDevicesStatus()
ElMessage.success('刷新成功')
}
const checkAllDevicesStatus = async () => {
if (isCheckingStatus.value || devices.value.length === 0) return
isCheckingStatus.value = true
try {
const statusList = await deviceApi.checkAllDevicesStatus()
const statusMap = new Map(statusList.map((s: any) => [s.id, { amtOnline: s.amtOnline, osOnline: s.osOnline }]))
devices.value.forEach(device => {
if (statusMap.has(device.id)) {
const status = statusMap.get(device.id)
device.amtOnline = status.amtOnline
device.osOnline = status.osOnline
}
})
} catch (error) {
console.error('检测设备状态失败:', error)
} finally {
isCheckingStatus.value = false
}
}
const startStatusCheck = () => {
checkAllDevicesStatus()
statusCheckInterval = window.setInterval(() => checkAllDevicesStatus(), 30000)
statusCheckInterval = window.setInterval(() => {
//
}, 30000)
}
const stopStatusCheck = () => {
if (statusCheckInterval) { clearInterval(statusCheckInterval); statusCheckInterval = null }
if (statusCheckInterval) {
clearInterval(statusCheckInterval)
statusCheckInterval = null
}
}
const handleRemoteDesktop = (device: any) => {
if (!device.osOnline) { ElMessage.warning('设备操作系统未运行,无法连接远程桌面'); return }
if (!device.windowsUsername) { ElMessage.warning('请先配置该设备的 Windows 登录账号'); return }
selectedDevice.value = device
if (!device.isOnline) {
ElMessage.warning('设备离线,无法连接远程桌面')
return
}
selectedDevice.value = {
id: device.amtDeviceId || device.id,
ipAddress: device.ipAddress,
hostname: device.hostname,
windowsUsername: device.loggedInUser
}
showRemoteDesktopModal.value = true
}
const handleSetCredentials = async (device: any) => {
selectedDevice.value = device
credentialsForm.value = { username: device.windowsUsername || '', password: '' }
const handleFetchInfo = (row: any) => {
currentFetchDevice = row
fetchForm.value = { username: '', password: '' }
showFetchDialog.value = true
}
const doFetchInfo = async () => {
if (!fetchForm.value.username || !fetchForm.value.password) {
ElMessage.warning('请输入凭据')
return
}
fetching.value = true
try {
await osDeviceApi.fetchInfo(currentFetchDevice.id, fetchForm.value)
showFetchDialog.value = false
fetchDevices()
ElMessage.success('系统信息已更新')
} catch (error: any) {
ElMessage.error(error.message || '获取信息失败')
} finally {
fetching.value = false
}
}
// Windows
const handleBatchSetCredentials = () => {
if (selectedDevices.value.length === 0) return
credentialsTargetDevices.value = [...selectedDevices.value]
credentialsForm.value = { username: 'administrator', password: '' }
showCredentialsDialog.value = true
}
const saveCredentials = async () => {
if (!credentialsForm.value.username) { ElMessage.warning('请输入用户名'); return }
if (!credentialsForm.value.username) {
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 osDeviceApi.setCredentials(device.id, credentialsForm.value)
successCount++
} catch (e) {
console.error('配置Windows账号失败:', device.ipAddress, e)
}
}
showCredentialsDialog.value = false
ElMessage.success('账号配置成功')
if (successCount === credentialsTargetDevices.value.length) {
ElMessage.success('Windows账号配置成功')
} else {
ElMessage.warning(`成功配置 ${successCount} 台,失败 ${credentialsTargetDevices.value.length - successCount}`)
}
} catch (error) {
ElMessage.error('保存失败')
} finally {
@ -221,7 +343,42 @@ const saveCredentials = async () => {
}
}
//
const handleBatchDelete = async () => {
if (selectedDevices.value.length === 0) return
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedDevices.value.length} 台设备吗?`,
'删除确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
let successCount = 0
for (const device of selectedDevices.value) {
try {
await osDeviceApi.delete(device.id)
successCount++
} catch (e) {
console.error('删除设备失败:', device.id, e)
}
}
ElMessage.success(`成功删除 ${successCount} 台设备`)
fetchDevices()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handlePowerCommand = async (command: string, device: any) => {
if (!device.amtDeviceId) {
ElMessage.warning('该设备未绑定 AMT无法进行电源管理')
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: '确定要关机吗?' },
@ -231,15 +388,15 @@ const handlePowerCommand = async (command: string, device: any) => {
}
const action = actionMap[command]
if (!action) return
try {
await ElMessageBox.confirm(`设备: ${device.ipAddress}\n${action.confirmMsg}`, `确认${action.name}`, {
confirmButtonText: '确定', cancelButtonText: '取消', type: command.includes('force') ? 'warning' : 'info'
})
ElMessage.info(`正在执行${action.name}...`)
const response = await action.api(device.id)
const response = await action.api(device.amtDeviceId)
if (response.success) {
ElMessage.success(response.message || `${action.name}命令已发送`)
setTimeout(() => checkAllDevicesStatus(), 3000)
} else {
ElMessage.error(response.error || `${action.name}失败`)
}
@ -253,4 +410,10 @@ const handlePowerCommand = async (command: string, device: any) => {
.devices-page { padding: 0; }
.card-header { display: flex; justify-content: space-between; align-items: center; font-size: 16px; font-weight: 500; }
.header-actions { 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; }
.target-devices-info { margin-bottom: 16px; }
.device-list { margin-top: 8px; font-family: monospace; font-size: 12px; word-break: break-all; }
</style>

View File

@ -1,174 +1,121 @@
<template>
<div class="os-devices-page">
<!-- 工具栏 -->
<div class="toolbar">
<div class="left">
<ElButton type="primary" @click="showScanDialog = true">
<ElIcon><Search /></ElIcon>
扫描操作系统
</ElButton>
<ElButton @click="handleAutoBind" :loading="autoBinding">
<ElIcon><Link /></ElIcon>
自动绑定 AMT
</ElButton>
<ElButton @click="loadDevices" :loading="loading">
<ElIcon><Refresh /></ElIcon>
刷新
</ElButton>
</div>
<div class="right">
<ElInput v-model="searchKeyword" placeholder="搜索 IP/主机名" clearable style="width: 200px" />
</div>
</div>
<ElCard shadow="never">
<template #header>
<div class="card-header">
<span>系统添加</span>
<div class="header-actions">
<ElButton type="primary" @click="showScanDialog = true">
<ElIcon><Search /></ElIcon>
扫描网络
</ElButton>
</div>
</div>
</template>
<!-- 设备列表 -->
<ElTable :data="filteredDevices" v-loading="loading" stripe>
<ElTableColumn prop="ipAddress" label="IP 地址" width="140" />
<ElTableColumn prop="hostname" label="主机名" width="150" />
<ElTableColumn label="操作系统" width="200">
<template #default="{ row }">
<ElTag :type="getOsTagType(row.osType)" size="small">{{ row.osType }}</ElTag>
<span v-if="row.osVersion" style="margin-left: 5px; font-size: 12px; color: #999">
{{ row.osVersion?.substring(0, 30) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn prop="systemUuid" label="UUID" width="280">
<template #default="{ row }">
<span v-if="row.systemUuid" style="font-family: monospace; font-size: 11px">{{ row.systemUuid }}</span>
<ElTag v-else type="warning" size="small">未获取</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="AMT 绑定" width="150">
<template #default="{ row }">
<ElTag v-if="row.amtDeviceId" type="success" size="small">
{{ row.amtDeviceIp }}
</ElTag>
<ElTag v-else type="info" size="small">未绑定</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="80">
<template #default="{ row }">
<ElTag :type="row.isOnline ? 'success' : 'danger'" size="small">
{{ row.isOnline ? '在线' : '离线' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="280" fixed="right">
<template #default="{ row }">
<ElButton size="small" @click="handleFetchInfo(row)">获取信息</ElButton>
<ElButton size="small" @click="handleBindAmt(row)" v-if="!row.amtDeviceId">绑定 AMT</ElButton>
<ElButton size="small" type="warning" @click="handleUnbindAmt(row)" v-else>解绑</ElButton>
<ElButton size="small" type="danger" @click="handleDelete(row)">删除</ElButton>
</template>
</ElTableColumn>
</ElTable>
<!-- 扫描结果列表 -->
<div v-if="scanResults.length > 0" class="scan-results">
<div class="results-header">
<span>发现 {{ scanResults.length }} 台设备请选择要添加的设备</span>
<div>
<ElButton size="small" @click="selectAll">全选</ElButton>
<ElButton size="small" @click="deselectAll">取消全选</ElButton>
</div>
</div>
<ElTable :data="scanResults" @selection-change="handleSelectionChange" ref="tableRef">
<ElTableColumn type="selection" width="55" />
<ElTableColumn prop="ipAddress" label="IP 地址" width="150" />
<ElTableColumn label="操作系统" width="120">
<template #default="{ row }">
<ElTag :type="getOsTagType(row.osType)" size="small">{{ row.osType }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="hostname" label="主机名" width="150">
<template #default="{ row }">
{{ row.hostname || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100">
<template #default="{ row }">
<ElTag :type="row.isOnline ? 'success' : 'danger'" size="small">
{{ row.isOnline ? '在线' : '离线' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="discoveredAt" label="发现时间">
<template #default="{ row }">
{{ formatTime(row.discoveredAt) }}
</template>
</ElTableColumn>
</ElTable>
<div class="results-footer">
<span>已选择 {{ selectedDevices.length }} 台设备</span>
<div>
<ElButton @click="clearResults">取消</ElButton>
<ElButton type="primary" @click="saveSelectedDevices" :loading="saving" :disabled="selectedDevices.length === 0">
添加选中设备
</ElButton>
</div>
</div>
</div>
<!-- 空状态 -->
<ElEmpty v-else description="点击扫描网络发现局域网内的设备" />
</ElCard>
<!-- 扫描对话框 -->
<ElDialog v-model="showScanDialog" title="扫描操作系统" width="500px">
<ElDialog v-model="showScanDialog" title="扫描网络" width="500px" :close-on-click-modal="false">
<ElForm :model="scanForm" label-width="100px">
<ElFormItem label="网段">
<ElInput v-model="scanForm.networkSegment" placeholder="例如: 192.168.1.0" />
</ElFormItem>
<ElFormItem label="子网掩码">
<ElSelect v-model="scanForm.subnetMask" style="width: 100%">
<ElOption label="/24 (255.255.255.0)" value="/24" />
<ElOption label="/16 (255.255.0.0)" value="/16" />
<ElOption label="/8 (255.0.0.0)" value="/8" />
<ElOption label="/24 (255.255.255.0) - 254 台主机" value="/24" />
<ElOption label="/23 (255.255.254.0) - 510 台主机" value="/23" />
<ElOption label="/22 (255.255.252.0) - 1022 台主机" value="/22" />
</ElSelect>
</ElFormItem>
</ElForm>
<div v-if="scanning" class="scan-progress">
<ElProgress :percentage="scanProgress.progressPercentage" :format="() => `${scanProgress.scannedCount}/${scanProgress.totalCount}`" />
<p>当前扫描: {{ scanProgress.currentIp }}</p>
<ElProgress :percentage="scanProgress.progressPercentage" :format="formatProgress" />
<p>当前扫描: {{ scanProgress.currentIp || '-' }}</p>
<p>已发现: {{ scanProgress.foundDevices }} 台设备</p>
</div>
<template #footer>
<ElButton @click="showScanDialog = false" :disabled="scanning">取消</ElButton>
<ElButton type="primary" @click="startScan" :loading="scanning">
<ElButton @click="cancelScan" v-if="scanning">取消扫描</ElButton>
<ElButton @click="showScanDialog = false" v-else>关闭</ElButton>
<ElButton type="primary" @click="startScan" :loading="scanning" :disabled="scanning">
{{ scanning ? '扫描中...' : '开始扫描' }}
</ElButton>
</template>
</ElDialog>
<!-- 获取信息对话框 -->
<ElDialog v-model="showFetchDialog" title="获取系统信息" width="400px">
<ElAlert type="info" :closable="false" style="margin-bottom: 15px">
需要提供 Windows 管理员凭据通过 WMI 获取系统信息
</ElAlert>
<ElForm :model="fetchForm" label-width="80px">
<ElFormItem label="用户名">
<ElInput v-model="fetchForm.username" placeholder="administrator" />
</ElFormItem>
<ElFormItem label="密码">
<ElInput v-model="fetchForm.password" type="password" show-password />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="showFetchDialog = false">取消</ElButton>
<ElButton type="primary" @click="doFetchInfo" :loading="fetching">获取</ElButton>
</template>
</ElDialog>
<!-- 绑定 AMT 对话框 -->
<ElDialog v-model="showBindDialog" title="绑定 AMT 设备" width="500px">
<ElTable :data="amtDevices" v-loading="loadingAmt" @row-click="selectAmtDevice" highlight-current-row>
<ElTableColumn prop="ipAddress" label="IP 地址" width="140" />
<ElTableColumn prop="hostname" label="主机名" />
<ElTableColumn prop="systemUuid" label="UUID" width="280">
<template #default="{ row }">
<span v-if="row.systemUuid" style="font-family: monospace; font-size: 11px">{{ row.systemUuid }}</span>
<ElTag v-else type="warning" size="small">未获取</ElTag>
</template>
</ElTableColumn>
</ElTable>
<template #footer>
<ElButton @click="showBindDialog = false">取消</ElButton>
<ElButton type="primary" @click="doBind" :disabled="!selectedAmtDevice">绑定</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Link } from '@element-plus/icons-vue'
import { osDeviceApi, deviceApi } from '@/api/amt'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { osDeviceApi } from '@/api/amt'
const loading = ref(false)
const devices = ref<any[]>([])
const searchKeyword = ref('')
const autoBinding = ref(false)
defineOptions({ name: 'OsDevices' })
const tableRef = ref()
const scanResults = ref<any[]>([])
const selectedDevices = ref<any[]>([])
const saving = ref(false)
//
const showScanDialog = ref(false)
const scanning = ref(false)
const scanForm = ref({ networkSegment: '192.168.1.0', subnetMask: '/24' })
const scanProgress = ref({ scannedCount: 0, totalCount: 0, foundDevices: 0, progressPercentage: 0, currentIp: '' })
let scanTaskId = ''
//
const showFetchDialog = ref(false)
const fetching = ref(false)
const fetchForm = ref({ username: '', password: '' })
let currentFetchDevice: any = null
//
const showBindDialog = ref(false)
const loadingAmt = ref(false)
const amtDevices = ref<any[]>([])
const selectedAmtDevice = ref<any>(null)
let currentBindDevice: any = null
const filteredDevices = computed(() => {
if (!searchKeyword.value) return devices.value
const kw = searchKeyword.value.toLowerCase()
return devices.value.filter(d =>
d.ipAddress?.toLowerCase().includes(kw) ||
d.hostname?.toLowerCase().includes(kw)
)
})
let currentTaskId = ''
const getOsTagType = (osType: string) => {
switch (osType) {
@ -178,57 +125,66 @@ const getOsTagType = (osType: string) => {
}
}
const loadDevices = async () => {
loading.value = true
try {
devices.value = await osDeviceApi.getAll()
} catch (error) {
console.error('加载设备失败', error)
} finally {
loading.value = false
}
const formatTime = (time: string) => {
if (!time) return '-'
return new Date(time).toLocaleString()
}
const formatProgress = () => {
return scanProgress.value.scannedCount + '/' + scanProgress.value.totalCount
}
const handleSelectionChange = (selection: any[]) => {
selectedDevices.value = selection
}
const selectAll = () => {
tableRef.value?.toggleAllSelection()
}
const deselectAll = () => {
tableRef.value?.clearSelection()
}
const startScan = async () => {
scanning.value = true
scanProgress.value = { scannedCount: 0, totalCount: 0, foundDevices: 0, progressPercentage: 0, currentIp: '' }
scanResults.value = []
selectedDevices.value = []
try {
const result = await osDeviceApi.startScan(scanForm.value.networkSegment, scanForm.value.subnetMask)
scanTaskId = result.taskId
let retryCount = 0
const maxRetries = 3
currentTaskId = result.taskId
//
const pollProgress = async () => {
if (!scanning.value) return
try {
const progress = await osDeviceApi.getScanStatus(scanTaskId)
retryCount = 0 //
const progress = await osDeviceApi.getScanStatus(currentTaskId)
// -1
if (progress.progressPercentage === -1) {
scanning.value = false
ElMessage.warning('扫描任务已失效')
return
}
scanProgress.value = progress
if (progress.progressPercentage < 100) {
setTimeout(pollProgress, 500)
} else {
//
scanning.value = false
showScanDialog.value = false
ElMessage.success(`扫描完成,发现 ${progress.foundDevices} 台设备`)
loadDevices()
await loadScanResults()
}
} catch (error) {
retryCount++
if (retryCount < maxRetries) {
//
setTimeout(pollProgress, 1000)
} else {
scanning.value = false
ElMessage.error('获取扫描进度失败')
}
scanning.value = false
ElMessage.error('获取扫描进度失败')
}
}
// 500ms
setTimeout(pollProgress, 500)
} catch (error) {
scanning.value = false
@ -236,95 +192,63 @@ const startScan = async () => {
}
}
const handleAutoBind = async () => {
autoBinding.value = true
const cancelScan = async () => {
try {
await osDeviceApi.autoBind()
loadDevices()
} finally {
autoBinding.value = false
await osDeviceApi.cancelScan(currentTaskId)
scanning.value = false
ElMessage.info('扫描已取消')
} catch (error) {
ElMessage.error('取消扫描失败')
}
}
const handleFetchInfo = (row: any) => {
currentFetchDevice = row
fetchForm.value = { username: '', password: '' }
showFetchDialog.value = true
const loadScanResults = async () => {
try {
const results = await osDeviceApi.getScanResults(currentTaskId)
scanResults.value = results
if (results.length === 0) {
ElMessage.info('未发现任何设备')
} else {
ElMessage.success(`发现 ${results.length} 台设备,请选择要添加的设备`)
}
} catch (error) {
ElMessage.error('获取扫描结果失败')
}
}
const doFetchInfo = async () => {
if (!fetchForm.value.username || !fetchForm.value.password) {
ElMessage.warning('请输入凭据')
const saveSelectedDevices = async () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请选择要添加的设备')
return
}
fetching.value = true
saving.value = true
try {
await osDeviceApi.fetchInfo(currentFetchDevice.id, fetchForm.value)
showFetchDialog.value = false
loadDevices()
} catch (error: any) {
ElMessage.error(error.message || '获取信息失败')
const selectedIps = selectedDevices.value.map(d => d.ipAddress)
const result = await osDeviceApi.saveSelectedDevices(currentTaskId, selectedIps)
ElMessage.success(result.message || `成功添加 ${result.savedCount} 台设备`)
clearResults()
} catch (error) {
ElMessage.error('添加设备失败')
} finally {
fetching.value = false
saving.value = false
}
}
const handleBindAmt = async (row: any) => {
currentBindDevice = row
selectedAmtDevice.value = null
showBindDialog.value = true
loadingAmt.value = true
try {
amtDevices.value = await deviceApi.getAllDevices()
} finally {
loadingAmt.value = false
}
const clearResults = () => {
scanResults.value = []
selectedDevices.value = []
currentTaskId = ''
}
const selectAmtDevice = (row: any) => {
selectedAmtDevice.value = row
}
const doBind = async () => {
if (!selectedAmtDevice.value) return
try {
await osDeviceApi.bindAmt(currentBindDevice.id, selectedAmtDevice.value.id)
showBindDialog.value = false
loadDevices()
} catch (error) {
ElMessage.error('绑定失败')
}
}
const handleUnbindAmt = async (row: any) => {
try {
await ElMessageBox.confirm('确定要解除 AMT 绑定吗?', '确认')
await osDeviceApi.unbindAmt(row.id)
loadDevices()
} catch (error) {
if (error !== 'cancel') ElMessage.error('解绑失败')
}
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除此设备吗?', '确认删除', { type: 'warning' })
await osDeviceApi.delete(row.id)
loadDevices()
} catch (error) {
if (error !== 'cancel') ElMessage.error('删除失败')
}
}
onMounted(() => {
loadDevices()
})
</script>
<style scoped>
.os-devices-page { padding: 20px; }
.toolbar { display: flex; justify-content: space-between; margin-bottom: 20px; }
.toolbar .left { display: flex; gap: 10px; }
.os-devices-page { padding: 0; }
.card-header { display: flex; justify-content: space-between; align-items: center; font-size: 16px; font-weight: 500; }
.header-actions { display: flex; gap: 10px; }
.scan-results { }
.results-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.results-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 15px; border-top: 1px solid #eee; }
.scan-progress { margin-top: 20px; text-align: center; }
.scan-progress p { margin: 10px 0; color: #666; }
</style>

View File

@ -52,6 +52,8 @@ public class OsDevicesController : ControllerBase
DiscoveredAt = o.DiscoveredAt,
LastUpdatedAt = o.LastUpdatedAt,
Description = o.Description,
WindowsUsername = o.WindowsUsername,
WindowsPassword = o.WindowsPassword,
AmtDeviceId = o.AmtDeviceId,
AmtDeviceIp = o.AmtDevice != null ? o.AmtDevice.IpAddress : null
})
@ -104,16 +106,28 @@ public class OsDevicesController : ControllerBase
{
var taskId = Guid.NewGuid().ToString("N");
var progress = new Progress<OsScanProgress>(p =>
// 初始化进度为 0%
_scanProgress[taskId] = new OsScanProgress
{
TaskId = taskId,
ScannedCount = 0,
TotalCount = 1, // 避免除以0
FoundDevices = 0,
ProgressPercentage = 0,
CurrentIp = "初始化中..."
};
// 使用 Action 回调直接更新进度
Action<OsScanProgress> progressCallback = p =>
{
_scanProgress[taskId] = p;
});
};
_ = Task.Run(async () =>
{
try
{
await _scannerService.ScanNetworkAsync(taskId, request.NetworkSegment, request.SubnetMask, progress);
await _scannerService.ScanNetworkAsync(taskId, request.NetworkSegment, request.SubnetMask, progressCallback);
}
catch (Exception ex)
{
@ -138,7 +152,16 @@ public class OsDevicesController : ControllerBase
{
return Ok(ApiResponse<OsScanProgress>.Success(progress));
}
return Ok(ApiResponse<OsScanProgress>.Fail(404, "扫描任务不存在"));
// 任务不存在时返回 -1 表示任务不存在,前端应该停止轮询
return Ok(ApiResponse<OsScanProgress>.Success(new OsScanProgress
{
TaskId = taskId,
ScannedCount = 0,
TotalCount = 0,
FoundDevices = 0,
ProgressPercentage = -1, // -1 表示任务不存在
CurrentIp = null
}));
}
/// <summary>
@ -151,6 +174,52 @@ public class OsDevicesController : ControllerBase
return Ok(ApiResponse<object>.Success(null, "扫描已取消"));
}
/// <summary>
/// 获取扫描发现的设备列表(未保存到数据库)
/// </summary>
[HttpGet("scan/results/{taskId}")]
public ActionResult<ApiResponse<List<ScanResultDto>>> GetScanResults(string taskId)
{
_logger.LogInformation("Getting scan results for task: {TaskId}", taskId);
var devices = _scannerService.GetScanResults(taskId);
_logger.LogInformation("Found {Count} devices in scan results for task: {TaskId}", devices.Count, taskId);
var results = devices.Select(d => new ScanResultDto
{
IpAddress = d.IpAddress,
OsType = d.OsType.ToString(),
Hostname = d.Hostname,
IsOnline = d.IsOnline,
DiscoveredAt = d.DiscoveredAt
}).ToList();
return Ok(ApiResponse<List<ScanResultDto>>.Success(results));
}
/// <summary>
/// 保存选中的设备到数据库
/// </summary>
[HttpPost("scan/save")]
public async Task<ActionResult<ApiResponse<SaveDevicesResponse>>> SaveSelectedDevices(
[FromBody] SaveDevicesRequest request)
{
if (string.IsNullOrEmpty(request.TaskId) || request.SelectedIps == null || request.SelectedIps.Count == 0)
{
return Ok(ApiResponse<SaveDevicesResponse>.Fail(400, "请选择要添加的设备"));
}
var savedCount = await _scannerService.SaveSelectedDevicesAsync(request.TaskId, request.SelectedIps);
// 清除扫描结果
_scannerService.ClearScanResults(request.TaskId);
return Ok(ApiResponse<SaveDevicesResponse>.Success(new SaveDevicesResponse
{
SavedCount = savedCount,
Message = $"成功添加 {savedCount} 台设备"
}));
}
/// <summary>
/// 获取设备详细信息(通过 WMI
/// </summary>
@ -266,6 +335,24 @@ public class OsDevicesController : ControllerBase
return Ok(ApiResponse<object>.Success(null, "自动绑定完成"));
}
/// <summary>
/// 设置 Windows 登录凭据
/// </summary>
[HttpPut("{id}/credentials")]
public async Task<ActionResult<ApiResponse<object>>> SetCredentials(long id, [FromBody] WindowsCredentialsRequest request)
{
var device = await _context.OsDevices.FindAsync(id);
if (device == null)
return Ok(ApiResponse<object>.Fail(404, "设备不存在"));
device.WindowsUsername = request.Username;
device.WindowsPassword = request.Password; // TODO: 加密存储
device.LastUpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return Ok(ApiResponse<object>.Success(null, "凭据已保存"));
}
/// <summary>
/// 删除设备
/// </summary>
@ -300,6 +387,8 @@ public class OsDeviceDto
public DateTime DiscoveredAt { get; set; }
public DateTime LastUpdatedAt { get; set; }
public string? Description { get; set; }
public string? WindowsUsername { get; set; }
public string? WindowsPassword { get; set; }
public long? AmtDeviceId { get; set; }
public string? AmtDeviceIp { get; set; }
}
@ -321,3 +410,30 @@ public class OsScanRequest
public string NetworkSegment { get; set; } = string.Empty;
public string SubnetMask { get; set; } = string.Empty;
}
public class ScanResultDto
{
public string IpAddress { get; set; } = string.Empty;
public string OsType { get; set; } = string.Empty;
public string? Hostname { get; set; }
public bool IsOnline { get; set; }
public DateTime DiscoveredAt { get; set; }
}
public class SaveDevicesRequest
{
public string TaskId { get; set; } = string.Empty;
public List<string> SelectedIps { get; set; } = new();
}
public class SaveDevicesResponse
{
public int SavedCount { get; set; }
public string Message { get; set; } = string.Empty;
}
public class WindowsCredentialsRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View File

@ -115,7 +115,8 @@ public static class DbSeeder
// 桌面管理菜单(系统内置)
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 },
new() { Id = 21, ParentId = 20, Name = "OsDevices", Path = "os-devices", Component = "/desktop-manage/os-devices", Title = "系统添加", KeepAlive = true, Sort = 1, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
new() { Id = 22, ParentId = 20, Name = "DesktopDevices", Path = "devices", Component = "/desktop-manage/devices", Title = "系统管理", KeepAlive = true, Sort = 2, Roles = "R_SUPER,R_ADMIN", IsSystem = true },
// 系统管理菜单(系统内置)
new() { Id = 10, Name = "System", Path = "/system", Component = "/index/index", Title = "menus.system.title", Icon = "ri:user-3-line", Sort = 99, Roles = "R_SUPER,R_ADMIN", IsSystem = true },

View File

@ -82,6 +82,16 @@ public class OsDevice
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Windows 登录用户名
/// </summary>
public string? WindowsUsername { get; set; }
/// <summary>
/// Windows 登录密码(加密存储)
/// </summary>
public string? WindowsPassword { get; set; }
/// <summary>
/// 关联的 AMT 设备 ID
/// </summary>

View File

@ -125,8 +125,13 @@ public class AmtPowerService : IAmtPowerService
var returnValue = outputObject.GetProperty("ReturnValue");
var returnCode = Convert.ToUInt32(returnValue.ToString());
_logger.LogInformation("电源操作返回码: {ReturnCode} for {Ip}", returnCode, ipAddress);
if (returnCode == 0)
// AMT 返回码说明:
// 0 = 成功完成
// 4096 = 作业已启动(异步操作,也是成功)
// 2 = 操作不允许
if (returnCode == 0 || returnCode == 4096)
{
result.Success = true;
result.Message = GetActionSuccessMessage(action);

View File

@ -11,11 +11,14 @@ namespace AmtScanner.Api.Services;
public interface IWindowsScannerService
{
Task<List<OsDevice>> ScanNetworkAsync(string taskId, string networkSegment, string subnetMask,
IProgress<OsScanProgress> progress, CancellationToken cancellationToken = default);
Action<OsScanProgress> progressCallback, CancellationToken cancellationToken = default);
Task<OsDevice?> GetOsInfoAsync(string ipAddress, string username, string password);
Task<string?> GetSystemUuidAsync(string ipAddress, string username, string password);
Task BindAmtDevicesAsync();
void CancelScan(string taskId);
List<OsDevice> GetScanResults(string taskId);
void ClearScanResults(string taskId);
Task<int> SaveSelectedDevicesAsync(string taskId, List<string> selectedIps);
}
public class WindowsScannerService : IWindowsScannerService
@ -24,6 +27,8 @@ public class WindowsScannerService : IWindowsScannerService
private readonly ILogger<WindowsScannerService> _logger;
private readonly IConfiguration _configuration;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationTokens = new();
// 存储扫描发现的设备(未保存到数据库)
private static readonly ConcurrentDictionary<string, List<OsDevice>> _scanResults = new();
public WindowsScannerService(
IServiceScopeFactory scopeFactory,
@ -39,17 +44,21 @@ public class WindowsScannerService : IWindowsScannerService
string taskId,
string networkSegment,
string subnetMask,
IProgress<OsScanProgress> progress,
Action<OsScanProgress> progressCallback,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting OS scan for task: {TaskId}", taskId);
_logger.LogInformation("Starting OS scan for task: {TaskId}, network: {Network}{Mask}",
taskId, networkSegment, subnetMask);
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_cancellationTokens[taskId] = cts;
_scanResults[taskId] = new List<OsDevice>();
try
{
var ipList = CalculateIpRange(networkSegment, subnetMask);
_logger.LogInformation("Calculated {Count} IPs to scan", ipList.Count);
var foundDevices = new ConcurrentBag<OsDevice>();
int scannedCount = 0;
int foundCount = 0;
@ -72,9 +81,18 @@ public class WindowsScannerService : IWindowsScannerService
{
foundDevices.Add(device);
var found = Interlocked.Increment(ref foundCount);
await SaveOsDeviceAsync(device);
_logger.LogInformation("Found device: {Ip}, OS: {OsType}", ip, device.OsType);
// 存储到内存中等待用户选择
lock (_scanResults)
{
if (_scanResults.TryGetValue(taskId, out var list))
{
list.Add(device);
}
}
progress.Report(new OsScanProgress
progressCallback(new OsScanProgress
{
TaskId = taskId,
ScannedCount = scanned,
@ -87,7 +105,7 @@ public class WindowsScannerService : IWindowsScannerService
}
else
{
progress.Report(new OsScanProgress
progressCallback(new OsScanProgress
{
TaskId = taskId,
ScannedCount = scanned,
@ -104,9 +122,7 @@ public class WindowsScannerService : IWindowsScannerService
}
});
// 扫描完成后尝试绑定 AMT 设备
await BindAmtDevicesAsync();
_logger.LogInformation("OS scan completed. Found {Count} devices", foundDevices.Count);
return foundDevices.ToList();
}
finally
@ -116,6 +132,56 @@ public class WindowsScannerService : IWindowsScannerService
}
}
/// <summary>
/// 获取扫描结果(未保存的设备列表)
/// </summary>
public List<OsDevice> GetScanResults(string taskId)
{
_logger.LogInformation("GetScanResults called for task: {TaskId}, available tasks: {Tasks}",
taskId, string.Join(", ", _scanResults.Keys));
if (_scanResults.TryGetValue(taskId, out var devices))
{
_logger.LogInformation("Found {Count} devices for task: {TaskId}", devices.Count, taskId);
return devices.ToList();
}
_logger.LogWarning("No scan results found for task: {TaskId}", taskId);
return new List<OsDevice>();
}
/// <summary>
/// 清除扫描结果
/// </summary>
public void ClearScanResults(string taskId)
{
_scanResults.TryRemove(taskId, out _);
}
/// <summary>
/// 保存选中的设备到数据库
/// </summary>
public async Task<int> SaveSelectedDevicesAsync(string taskId, List<string> selectedIps)
{
if (!_scanResults.TryGetValue(taskId, out var devices))
{
return 0;
}
var selectedDevices = devices.Where(d => selectedIps.Contains(d.IpAddress)).ToList();
int savedCount = 0;
foreach (var device in selectedDevices)
{
await SaveOsDeviceAsync(device);
savedCount++;
}
// 保存后尝试绑定 AMT 设备
await BindAmtDevicesAsync();
return savedCount;
}
public void CancelScan(string taskId)
{
if (_cancellationTokens.TryGetValue(taskId, out var cts))

View File

@ -0,0 +1,4 @@
-- 为 OsDevices 表添加 Windows 凭据字段
ALTER TABLE `OsDevices`
ADD COLUMN `WindowsUsername` VARCHAR(100) NULL AFTER `Description`,
ADD COLUMN `WindowsPassword` VARCHAR(500) NULL AFTER `WindowsUsername`;

View File

@ -0,0 +1,25 @@
-- 更新桌面管理菜单结构
-- 将原来的"远程桌面"改为"系统添加"和"系统管理"两个子菜单
-- 1. 更新原有的菜单项ID=21为"系统添加"
UPDATE Menus SET
Name = 'OsDevices',
Path = 'os-devices',
Component = '/desktop-manage/os-devices',
Title = '系统添加',
Sort = 1
WHERE Id = 21;
-- 2. 添加新的"系统管理"菜单项ID=22
INSERT INTO Menus (Id, ParentId, Name, Path, Component, Title, Icon, Sort, Roles, IsHide, KeepAlive, IsSystem, IsIframe, IsHideTab, CreatedAt)
VALUES (22, 20, 'DesktopDevices', 'devices', '/desktop-manage/devices', '系统管理', NULL, 2, 'R_SUPER,R_ADMIN', 0, 1, 1, 0, 0, NOW())
ON DUPLICATE KEY UPDATE
Name = 'DesktopDevices',
Path = 'devices',
Component = '/desktop-manage/devices',
Title = '系统管理',
Sort = 2;
-- 3. 为超级管理员和管理员角色添加新菜单权限
INSERT IGNORE INTO RoleMenus (RoleId, MenuId)
SELECT r.Id, 22 FROM Roles r WHERE r.RoleCode IN ('R_SUPER', 'R_ADMIN');