feat: 完善 AWS 管理与云服务器操作日志
Build and Deploy Vue3 / build (push) Failing after 20s
Build and Deploy Vue3 / deploy (push) Skipped

This commit is contained in:
shiran
2026-08-29 17:26:27 +08:00
parent edc1118a52
commit d0171659bd
14 changed files with 688 additions and 72 deletions
+39 -2
View File
@@ -1,6 +1,13 @@
import { http2 } from '@/utils/request.js'
const base = '/api/v1/admin/server/aws_service'
const runtimeBase = `${base}/instances/runtime`
export const createAwsIdempotencyKey = (prefix = 'aws') => {
const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `${prefix}-${random}`.slice(0, 128)
}
const withKey = (key) => ({ headers: { 'Idempotency-Key': key || createAwsIdempotencyKey() } })
export const getAwsServiceList = (params) => http2.get(`${base}/list`, { params })
export const getAwsServiceDetail = (id) => http2.get(`${base}/detail`, { params: { id } })
@@ -15,5 +22,35 @@ export const createAwsGoods = (data) => http2.post(`${base}/goods/create`, data)
export const updateAwsGoods = (data) => http2.post(`${base}/goods/update`, data)
export const deleteAwsGoods = (id) => http2.delete(`${base}/goods/delete`, { params: { id } })
export const getAwsInstanceList = (params) => http2.get(`${base}/instances/list`, { params })
export const retryAwsInstance = (instanceId) => http2.post(`${base}/instances/retry`, null, { params: { instance_id: instanceId } })
export const getAwsInstanceDetail = (identifier) => http2.get(`${base}/instances/detail`, {
params: typeof identifier === 'object' ? identifier : { instance_id: identifier }
})
export const retryAwsInstance = (instanceId, note = '') => http2.post(`${base}/instances/retry`, { instance_id: instanceId, note })
export const resyncAwsInstance = (instanceId, note = '') => http2.post(`${base}/instances/resync`, { instance_id: instanceId, note })
export const resolveAwsInstance = (instanceId, resolution, note = '') => http2.post(`${base}/instances/resolve`, { instance_id: instanceId, resolution, note })
export const getAdminAwsRuntimeDetails = instanceId => http2.get(`${runtimeBase}/details`, { params: { instance_id: instanceId } })
export const refreshAdminAwsRuntimeDetails = (instanceId, key) => http2.post(`${runtimeBase}/details/refresh`, { instance_id: instanceId }, withKey(key))
export const getAdminAwsLoginInfo = instanceId => http2.post(`${runtimeBase}/login_info`, { instance_id: instanceId })
export const resetAdminAwsPassword = (instanceId, key) => http2.post(`${runtimeBase}/password/reset`, { instance_id: instanceId }, withKey(key))
export const getAdminAwsMonitoring = instanceId => http2.get(`${runtimeBase}/monitoring`, { params: { instance_id: instanceId } })
export const getAdminAwsSecurityGroup = instanceId => http2.get(`${runtimeBase}/security_group`, { params: { instance_id: instanceId } })
export const addAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2.post(`${runtimeBase}/security_group/rules/add`, { instance_id: instanceId, rule }, withKey(key))
export const deleteAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2.post(`${runtimeBase}/security_group/rules/delete`, { instance_id: instanceId, rule }, withKey(key))
export const getAdminAwsVolumes = instanceId => http2.get(`${runtimeBase}/volumes`, { params: { instance_id: instanceId } })
export const createAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/create`, data, withKey(key))
export const modifyAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/modify`, data, withKey(key))
export const attachAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/attach`, data, withKey(key))
export const detachAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/detach`, data, withKey(key))
export const deleteAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/delete`, data, withKey(key))
export const getAdminAwsEip = instanceId => http2.get(`${runtimeBase}/eip`, { params: { instance_id: instanceId } })
export const replaceAdminAwsEip = (data, key) => http2.post(`${runtimeBase}/eip/replace`, data, withKey(key))
export const disassociateAdminAwsEip = (instanceId, key) => http2.post(`${runtimeBase}/eip/disassociate`, { instance_id: instanceId }, withKey(key))
export const releaseAdminAwsEip = (instanceId, key) => http2.post(`${runtimeBase}/eip/release`, { instance_id: instanceId }, withKey(key))
export const updateAdminAwsEipReverseDns = (data, key) => http2.post(`${runtimeBase}/eip/reverse_dns`, data, withKey(key))
export const getAdminAwsOperations = (instanceId, params = {}) => http2.get(`${runtimeBase}/operations`, { params: { instance_id: instanceId, ...params } })
export const getAdminAwsOperationDetail = operationId => http2.get(`${runtimeBase}/operations/detail`, { params: { operation_id: operationId } })
export const retryAdminAwsOperation = (operationId, note = '', key) => http2.post(`${runtimeBase}/operations/retry`, { operation_id: operationId, note }, withKey(key))
export const resyncAdminAwsOperation = operationId => http2.post(`${runtimeBase}/operations/resync`, null, { params: { operation_id: operationId } })
export const resolveAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/resolve`, { operation_id: operationId, note })
export const rejectAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/reject`, { operation_id: operationId, note })
+10
View File
@@ -114,6 +114,16 @@ export const renewUserGoods = (data) => http2.post(`${GOODS_BASE}/renew`, fd(dat
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params })
export const getUserVmTrafficHourly = (params) => http2.get(`${BASE}/traffic_hourly`, { params })
// ========== 虚拟机专用操作日志 ==========
const OPERATION_RECORD_BASE = '/api/v1/admin/host_service/point/vm/operation_record'
export const getVmOperationRecordList = (params) => http2.get(`${OPERATION_RECORD_BASE}/list`, { params })
export const getVmOperationRecordDetail = (id) => http2.get(`${OPERATION_RECORD_BASE}/detail`, { params: { id } })
export const refreshVmOperationRecord = (id) => http2.post(
`${OPERATION_RECORD_BASE}/refresh`,
fd({ id }),
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
// ========== 流量策略 ==========
// 测试未通过(接口新增,待联调)
export const getUserVmTrafficPolicy = (params) => http2.get(`${BASE}/traffic_policy`, { params })
@@ -0,0 +1,99 @@
<template>
<el-dialog v-model="visible" title="选择商品或商品组" width="720px" append-to-body destroy-on-close @close="close">
<el-alert type="info" :closable="false" show-icon title="选择商品将绑定已有商品;选择商品组将自动创建商品并绑定。"/>
<div class="tree-panel">
<el-tree
ref="treeRef"
node-key="key"
lazy
:load="loadNode"
:props="treeProps"
highlight-current
:expand-on-click-node="false"
@node-click="selectNode"
>
<template #default="{ data }">
<div class="tree-node">
<el-icon class="node-icon"><FolderOpened v-if="data.type==='group'"/><Box v-else/></el-icon>
<span class="node-name">{{ data.name }}</span>
<el-tag size="small" :type="data.type==='group'?'primary':'success'">{{ data.type==='group'?'商品组':'商品' }}</el-tag>
<span class="node-id">#{{ data.id }}</span>
</div>
</template>
</el-tree>
</div>
<div class="selected-result">
<span class="result-label">当前选择</span>
<template v-if="selected">
<el-tag size="small" :type="selected.type==='group'?'primary':'success'">{{ selected.type==='group'?'商品组':'商品' }}</el-tag>
<strong>{{ selected.name }}</strong>
<span class="node-id">#{{ selected.id }}</span>
</template>
<span v-else class="empty-text">尚未选择</span>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="confirm">确定选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Box, FolderOpened } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getProductGroupList, getProductList } from '@/api/admin/product.js'
const props=defineProps({
modelValue:{type:Boolean,default:false},
currentType:{type:String,default:''},
currentId:{type:[String,Number],default:''},
currentName:{type:String,default:''}
})
const emit=defineEmits(['update:modelValue','confirm'])
const visible=ref(false), selected=ref(null), treeRef=ref()
const treeProps={label:'name',children:'children',isLeaf:'isLeaf'}
const list=data=>data?.data?.data||data?.data?.list||[]
const groupNode=group=>({key:`group_${group.id}`,id:group.id,type:'group',name:group.name||`商品组 ${group.id}`,level:Number(group.level)||1,isLeaf:false})
const productNode=product=>({key:`product_${product.id}`,id:product.id,type:'product',name:product.name||`商品 ${product.id}`,isLeaf:true})
watch(()=>props.modelValue,value=>{
visible.value=value
if(value)selected.value=props.currentType&&props.currentId?{type:props.currentType,id:props.currentId,name:props.currentName||`${props.currentType==='group'?'商品组':'商品'} ${props.currentId}`} : null
})
watch(visible,value=>emit('update:modelValue',value))
async function loadNode(node,resolve){
try{
if(node.level===0){
const response=await getProductGroupList({page:1,count:100,level:1})
if(response.data?.code!==200)throw new Error(response.data?.message||response.data?.msg)
return resolve(list(response.data).filter(item=>item.delete!==true).map(groupNode))
}
if(node.data?.type==='group'){
const groupId=node.data.id
const childLevel=(node.data.level||1)+1
const [groupsResponse,productsResponse]=await Promise.all([
getProductGroupList({page:1,count:100,parent_id:groupId,level:childLevel}),
getProductList({page:1,count:100,good_group_id:groupId,delete:false})
])
const groups=groupsResponse.data?.code===200?list(groupsResponse.data).filter(item=>item.delete!==true).map(groupNode):[]
const products=productsResponse.data?.code===200?list(productsResponse.data).filter(item=>item.delete!==true).map(productNode):[]
return resolve([...groups,...products])
}
resolve([])
}catch(error){
console.error('加载商品层级失败:',error)
ElMessage.error('加载商品层级失败')
resolve([])
}
}
function selectNode(data){selected.value={type:data.type,id:data.id,name:data.name};treeRef.value?.setCurrentKey(data.key)}
function close(){visible.value=false}
function confirm(){if(!selected.value)return;emit('confirm',{...selected.value});close()}
</script>
<style scoped>
.tree-panel{height:420px;margin-top:16px;padding:8px;border:1px solid var(--el-border-color);border-radius:8px;overflow:auto}.tree-node{display:flex;align-items:center;gap:8px;width:100%;padding-right:12px}.node-icon{color:var(--el-color-primary)}.node-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-id,.empty-text{color:var(--el-text-color-secondary)}.selected-result{display:flex;align-items:center;gap:8px;min-height:32px;margin-top:14px}.result-label{margin-right:4px;color:var(--el-text-color-secondary)}
</style>
+1 -2
View File
@@ -186,8 +186,7 @@ export const menus = [
icon: 'Cloudy',
children: [
{ path: '/aws/services', title: '服务配置' },
{ path: '/aws/goods', title: '商品绑定' },
{ path: '/aws/instances', title: '履约实例' }
{ path: '/aws/goods', title: '商品绑定' }
]
},
{
-6
View File
@@ -689,12 +689,6 @@ const routes = [
name: 'AwsGoods',
component: () => import('../views/aws/AwsGoods.vue'),
meta: { title: '商品绑定' }
},
{
path: 'instances',
name: 'AwsInstances',
component: () => import('../views/aws/AwsInstances.vue'),
meta: { title: '履约实例' }
}
]
},
+77 -16
View File
@@ -10,52 +10,113 @@
<el-table-column prop="exchangeRate" label="汇率" width="90"/>
<el-table-column prop="gracePeriodHours" label="宽限期(h)" width="100"/>
<el-table-column label="强制策略" min-width="210"><template #default="{row}"><el-tag v-if="row.forceAssociateEip" size="small">EIP</el-tag><el-tag v-if="row.forceEncryptedRootVolume" size="small" type="success">加密系统盘</el-tag><el-tag v-if="row.forceInstallAgent" size="small" type="warning">Agent</el-tag></template></el-table-column>
<el-table-column label="用户能力" min-width="260"><template #default="{row}"><el-tag v-for="cap in enabledCapabilities(row)" :key="cap" size="small" class="tag" type="info">{{cap}}</el-tag><span v-if="!enabledCapabilities(row).length">全部关闭</span></template></el-table-column>
<el-table-column label="状态" width="80"><template #default="{row}"><el-tag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</el-tag></template></el-table-column>
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-popconfirm title="确认删除无历史记录的绑定?" @confirm="remove(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm></template></el-table-column>
</el-table>
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
<el-dialog v-model="dialog" :title="editing?'编辑 AWS 商品绑定':'新增 AWS 商品绑定'" width="820px" top="4vh" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="145px">
<el-row :gutter="18"><el-col :span="12"><el-form-item label="商品 ID" prop="good_id"><el-input-number v-model="form.good_id" :min="1" :disabled="editing" style="width:100%"/></el-form-item></el-col><el-col :span="12"><el-form-item label="AWS 服务" prop="service_id"><el-select v-model="form.service_id" style="width:100%"><el-option v-for="s in services" :key="s.id" :label="`${s.name} (#${s.id})`" :value="s.id"/></el-select></el-form-item></el-col></el-row>
<el-form-item label="允许区域" prop="allowed_regions"><el-select v-model="form.allowed_regions" multiple filterable allow-create default-first-option placeholder="输入 Region 后回车" style="width:100%"/></el-form-item>
<el-form-item label="镜像分类白名单"><el-select v-model="form.allowed_image_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="镜像白名单"><el-select v-model="form.allowed_images" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="规格分类白名单"><el-select v-model="form.allowed_type_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="规格白名单"><el-select v-model="form.allowed_types" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-alert v-if="!editing && form.goods_target_type==='group'" class="create-tip" type="info" :closable="false" show-icon title="保存后将根据所选商品组自动创建 AWS 商品,并完成原子绑定。"/>
<el-row :gutter="18">
<el-col :span="12">
<el-form-item v-if="editing" label="商品 ID" prop="good_id">
<el-input-number v-model="form.good_id" :min="1" :disabled="editing" style="width:100%"/>
</el-form-item>
<el-form-item v-else label="商品 / 商品组" prop="goods_target_id">
<div class="selector-field">
<el-input :model-value="selectedGoodsTargetLabel" readonly placeholder="请选择商品或商品组"/>
<el-button type="primary" plain @click="goodsSelectorVisible=true">选择</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="AWS 服务" prop="service_id"><el-select v-model="form.service_id" style="width:100%" @change="changeService"><el-option v-for="s in services" :key="s.id" :label="`${s.name} (#${s.id})`" :value="s.id"/></el-select></el-form-item></el-col>
</el-row>
<el-form-item label="允许区域" prop="allowed_regions">
<el-select
v-model="form.allowed_regions"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:loading="regionsLoading"
:disabled="!form.service_id || regionsLoading"
:placeholder="regionPlaceholder"
style="width:100%"
>
<el-option v-for="region in regionOptions" :key="region.id" :label="region.label" :value="region.id"/>
</el-select>
</el-form-item>
<el-form-item label="镜像分类白名单"><el-select v-model="form.allowed_image_categories" multiple filterable allow-create default-first-option placeholder="未选择表示全部允许" style="width:100%"/></el-form-item>
<el-form-item label="镜像白名单"><el-select v-model="form.allowed_images" multiple filterable allow-create default-first-option placeholder="未选择表示全部允许" style="width:100%"/></el-form-item>
<el-form-item label="规格分类白名单"><el-select v-model="form.allowed_type_categories" multiple filterable allow-create default-first-option placeholder="未选择表示全部允许" style="width:100%"/></el-form-item>
<el-form-item label="规格白名单"><el-select v-model="form.allowed_types" multiple filterable allow-create default-first-option placeholder="未选择表示全部允许" style="width:100%"/></el-form-item>
<el-row :gutter="18"><el-col :span="12"><el-form-item label="人民币汇率" prop="exchange_rate"><el-input-number v-model="form.exchange_rate" :min="0.000001" :precision="6" style="width:100%"/></el-form-item></el-col><el-col :span="12"><el-form-item label="宽限期(小时)"><el-input-number v-model="form.grace_period_hours" :min="0" style="width:100%"/></el-form-item></el-col></el-row>
<el-form-item label="强制策略"><el-checkbox v-model="form.force_associate_eip">绑定 EIP</el-checkbox><el-checkbox v-model="form.force_encrypted_root_volume">加密系统盘</el-checkbox><el-checkbox v-model="form.force_install_agent">安装 Agent</el-checkbox><el-checkbox v-model="form.enabled">启用绑定</el-checkbox></el-form-item>
<el-form-item label="用户运行期能力"><el-checkbox v-model="form.allow_user_login_info">登录凭据</el-checkbox><el-checkbox v-model="form.allow_user_password_reset">密码重置</el-checkbox><el-checkbox v-model="form.allow_user_monitoring">监控</el-checkbox><el-checkbox v-model="form.allow_user_security_group">安全组</el-checkbox><el-checkbox v-model="form.allow_user_volume_read">数据卷读取</el-checkbox></el-form-item>
<el-form-item label="默认创建参数" prop="defaults_text"><el-input v-model="form.defaults_text" type="textarea" :rows="7" placeholder="{}" class="json"/></el-form-item>
<el-form-item label="商品约束" prop="constraints_text"><el-input v-model="form.constraints_text" type="textarea" :rows="6" placeholder="{}" class="json"/></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存</el-button></template>
</el-dialog>
<ProductGoodsTreeSelector v-model="goodsSelectorVisible" :current-type="form.goods_target_type" :current-id="form.goods_target_id" :current-name="form.goods_target_name" @confirm="selectGoodsTarget"/>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { createAwsGoods, deleteAwsGoods, getAwsGoodsDetail, getAwsGoodsList, getAwsServiceList, updateAwsGoods } from '@/api/admin/awsService.js'
const route=useRoute(), loading=ref(false), submitting=ref(false), rows=ref([]), services=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref()
import { createAwsGoods, deleteAwsGoods, getAwsGoodsDetail, getAwsGoodsList, getAwsServiceList, testAwsService, updateAwsGoods } from '@/api/admin/awsService.js'
import ProductGoodsTreeSelector from '@/components/admin/ProductGoodsTreeSelector.vue'
const route=useRoute(), loading=ref(false), submitting=ref(false), regionsLoading=ref(false), rows=ref([]), services=ref([]), regionOptions=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref(), goodsSelectorVisible=ref(false)
let regionRequestId=0
const query=reactive({page:1,count:20,service_id:route.query.service_id?Number(route.query.service_id):undefined})
const empty=()=>({id:null,good_id:null,service_id:null,allowed_regions:[],allowed_image_categories:[],allowed_images:[],allowed_type_categories:[],allowed_types:[],defaults_text:'{}',constraints_text:'{}',force_associate_eip:false,force_encrypted_root_volume:true,force_install_agent:true,grace_period_hours:168,exchange_rate:1,enabled:true})
const empty=()=>({id:null,good_id:null,goods_target_type:'',goods_target_id:null,goods_target_name:'',service_id:null,allowed_regions:[],allowed_image_categories:[],allowed_images:[],allowed_type_categories:[],allowed_types:[],defaults_text:'{}',constraints_text:'{}',force_associate_eip:false,force_encrypted_root_volume:true,force_install_agent:true,allow_user_login_info:true,allow_user_password_reset:true,allow_user_monitoring:true,allow_user_security_group:true,allow_user_volume_read:true,grace_period_hours:168,exchange_rate:1,enabled:true})
const form=reactive(empty())
const selectedGoodsTargetLabel=computed(()=>form.goods_target_id?`${form.goods_target_type==='group'?'商品组':'商品'}${form.goods_target_name||''} (#${form.goods_target_id})`:'')
const regionPlaceholder=computed(()=>!form.service_id?'请先选择 AWS 服务':regionsLoading.value?'正在读取可用区域':regionOptions.value.length?'请选择允许销售的区域':'暂无可用区域')
const jsonRule=(_,value,cb)=>{try{const v=JSON.parse(value||'{}');if(!v||Array.isArray(v)||typeof v!=='object')throw new Error();cb()}catch{cb(new Error('请输入有效的 JSON 对象'))}}
const rules={good_id:[{required:true,message:'请输入商品 ID'}],service_id:[{required:true,message:'请选择服务'}],allowed_regions:[{type:'array',required:true,min:1,message:'至少填写一个区域'}],exchange_rate:[{required:true,message:'请输入大于 0 的汇率'}],defaults_text:[{validator:jsonRule}],constraints_text:[{validator:jsonRule}]}
const rules={good_id:[{required:true,message:'请输入商品 ID'}],goods_target_id:[{required:true,message:'请选择商品或商品组'}],service_id:[{required:true,message:'请选择服务'}],allowed_regions:[{type:'array',required:true,min:1,message:'至少填写一个区域'}],exchange_rate:[{required:true,message:'请输入大于 0 的汇率'}],defaults_text:[{validator:jsonRule}],constraints_text:[{validator:jsonRule}]}
const body=r=>r?.data||{}
async function loadServices(){const b=body(await getAwsServiceList({page:1,count:100}));if(b.code===200)services.value=b.data?.data||b.data?.list||[]}
async function load(){loading.value=true;try{const params={...query};if(!params.service_id)delete params.service_id;const b=body(await getAwsGoodsList(params));if(b.code===200){rows.value=b.data?.data||b.data?.list||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
const serviceName=id=>services.value.find(v=>v.id===id)?.name||'服务'
function search(){query.page=1;load()} function reset(){query.service_id=undefined;search()}
function openCreate(){editing.value=false;Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true}
async function openEdit(row){editing.value=true;let data=row;try{const b=body(await getAwsGoodsDetail(row.id));if(b.code===200)data=b.data}catch{}Object.assign(form,{id:data.id,good_id:data.goodId,service_id:data.serviceId,allowed_regions:[...(data.allowedRegions||[])],allowed_image_categories:[...(data.allowedImageCategories||[])],allowed_images:[...(data.allowedImages||[])],allowed_type_categories:[...(data.allowedTypeCategories||[])],allowed_types:[...(data.allowedTypes||[])],defaults_text:JSON.stringify(data.defaults||{},null,2),constraints_text:JSON.stringify(data.constraints||{},null,2),force_associate_eip:!!data.forceAssociateEip,force_encrypted_root_volume:!!data.forceEncryptedRootVolume,force_install_agent:!!data.forceInstallAgent,grace_period_hours:data.gracePeriodHours??168,exchange_rate:data.exchangeRate??1,enabled:!!data.enabled});dialog.value=true}
function payload(){return{service_id:form.service_id,allowed_regions:form.allowed_regions,allowed_image_categories:form.allowed_image_categories,allowed_images:form.allowed_images,allowed_type_categories:form.allowed_type_categories,allowed_types:form.allowed_types,defaults:JSON.parse(form.defaults_text||'{}'),constraints:JSON.parse(form.constraints_text||'{}'),force_associate_eip:form.force_associate_eip,force_encrypted_root_volume:form.force_encrypted_root_volume,force_install_agent:form.force_install_agent,grace_period_hours:form.grace_period_hours,exchange_rate:form.exchange_rate,enabled:form.enabled}}
async function submit(){await formRef.value?.validate();submitting.value=true;try{const data=payload();if(editing.value)data.id=form.id;else data.good_id=form.good_id;const b=body(await(editing.value?updateAwsGoods(data):createAwsGoods(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}}
async function loadAvailableRegions(serviceId){
const requestId=++regionRequestId
regionOptions.value=[]
if(!serviceId){regionsLoading.value=false;return}
regionsLoading.value=true
try{
const b=body(await testAwsService(serviceId))
if(requestId!==regionRequestId)return
if(b.code!==200){ElMessage.error(b.message||'读取可用区域失败');return}
const regions=Array.isArray(b.data?.regions)?b.data.regions:[]
regionOptions.value=regions.filter(region=>typeof region==='string'||region?.ready!==false).map(region=>{
if(typeof region==='string')return{id:region,label:region}
const id=region.id||region.region||region.code
const name=region.name_zh||region.nameZh||region.name_en||region.nameEn||''
return{id,label:name?`${name} (${id})`:id}
}).filter(region=>region.id)
if(!regionOptions.value.length)ElMessage.warning('当前 AWS 服务暂无已就绪的可用区域')
}catch{
if(requestId===regionRequestId)ElMessage.error('读取可用区域失败')
}finally{
if(requestId===regionRequestId)regionsLoading.value=false
}
}
function changeService(serviceId){form.allowed_regions=[];formRef.value?.clearValidate('allowed_regions');loadAvailableRegions(serviceId)}
function openCreate(){editing.value=false;Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true;loadAvailableRegions(form.service_id)}
async function openEdit(row){editing.value=true;let data=row;try{const b=body(await getAwsGoodsDetail(row.id));if(b.code===200)data=b.data}catch{}Object.assign(form,{id:data.id,good_id:data.goodId,service_id:data.serviceId,allowed_regions:[...(data.allowedRegions||[])],allowed_image_categories:[...(data.allowedImageCategories||[])],allowed_images:[...(data.allowedImages||[])],allowed_type_categories:[...(data.allowedTypeCategories||[])],allowed_types:[...(data.allowedTypes||[])],defaults_text:JSON.stringify(data.defaults||{},null,2),constraints_text:JSON.stringify(data.constraints||{},null,2),force_associate_eip:!!data.forceAssociateEip,force_encrypted_root_volume:!!data.forceEncryptedRootVolume,force_install_agent:!!data.forceInstallAgent,allow_user_login_info:data.allowUserLoginInfo===true,allow_user_password_reset:data.allowUserPasswordReset===true,allow_user_monitoring:data.allowUserMonitoring===true,allow_user_security_group:data.allowUserSecurityGroup===true,allow_user_volume_read:data.allowUserVolumeRead===true,grace_period_hours:data.gracePeriodHours??168,exchange_rate:data.exchangeRate??1,enabled:!!data.enabled});dialog.value=true;loadAvailableRegions(form.service_id)}
function selectGoodsTarget(target){form.goods_target_type=target.type;form.goods_target_id=target.id;form.goods_target_name=target.name||'';formRef.value?.clearValidate('goods_target_id')}
function payload(){return{service_id:form.service_id,allowed_regions:form.allowed_regions,allowed_image_categories:form.allowed_image_categories,allowed_images:form.allowed_images,allowed_type_categories:form.allowed_type_categories,allowed_types:form.allowed_types,defaults:JSON.parse(form.defaults_text||'{}'),constraints:JSON.parse(form.constraints_text||'{}'),force_associate_eip:form.force_associate_eip,force_encrypted_root_volume:form.force_encrypted_root_volume,force_install_agent:form.force_install_agent,allow_user_login_info:form.allow_user_login_info,allow_user_password_reset:form.allow_user_password_reset,allow_user_monitoring:form.allow_user_monitoring,allow_user_security_group:form.allow_user_security_group,allow_user_volume_read:form.allow_user_volume_read,grace_period_hours:form.grace_period_hours,exchange_rate:form.exchange_rate,enabled:form.enabled}}
function enabledCapabilities(row){return[[row.allowUserLoginInfo,'凭据'],[row.allowUserPasswordReset,'重置密码'],[row.allowUserMonitoring,'监控'],[row.allowUserSecurityGroup,'安全组'],[row.allowUserVolumeRead,'数据卷']].filter(v=>v[0]===true).map(v=>v[1])}
async function submit(){await formRef.value?.validate();submitting.value=true;try{const data=payload();if(editing.value)data.id=form.id;else if(form.goods_target_type==='group')data.good_group_id=form.goods_target_id;else data.good_id=form.goods_target_id;const b=body(await(editing.value?updateAwsGoods(data):createAwsGoods(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}}
async function remove(row){const b=body(await deleteAwsGoods(row.id));if(b.code===200){ElMessage.success('删除成功');load()}else ElMessage.error(b.message||'删除失败')}
onMounted(()=>{loadServices();load()})
</script>
<style scoped>
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:260px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.tag{margin:2px}.json :deep(textarea){font-family:Consolas,Monaco,monospace}
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:260px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.tag{margin:2px}.json :deep(textarea){font-family:Consolas,Monaco,monospace}.create-tip{margin-bottom:18px}.selector-field{display:flex;width:100%;gap:8px}.selector-field .el-input{flex:1}
</style>
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="page" :class="{ embedded }" v-loading="loading">
<div v-if="!embedded" class="header"><div class="header-title"><el-button link @click="$router.back()">← 返回</el-button><div><h2>AWS 实例运维 #{{ instanceId }}</h2><p>履约审核、远端资源和运行期操作统一管理</p></div></div><el-button @click="loadBase">刷新</el-button></div>
<el-alert v-if="pageError" :title="pageError" type="error" show-icon :closable="false" class="alert"/>
<div v-if="instance" class="summary">
<el-descriptions :column="4" border>
<el-descriptions-item label="用户商品"><el-link type="primary" @click="$router.push(`/user-goods/detail/${instance.userGoodsId}`)">#{{instance.userGoodsId}}</el-link></el-descriptions-item>
<el-descriptions-item label="订单"><el-link type="primary" @click="$router.push({path:'/order/list',query:{order_id:instance.orderId}})">#{{instance.orderId}}</el-link></el-descriptions-item>
<el-descriptions-item label="服务"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:instance.serviceId}})">#{{instance.serviceId}}</el-link></el-descriptions-item>
<el-descriptions-item label="履约状态"><el-tag :type="statusTag(instance.provisionStatus)">{{statusText(instance.provisionStatus)}}</el-tag></el-descriptions-item>
<el-descriptions-item label="Region">{{instance.region||'-'}}</el-descriptions-item><el-descriptions-item label="可用区">{{instance.placementId||'-'}}</el-descriptions-item><el-descriptions-item label="远端实例">{{instance.remoteAwsInstanceId||instance.remoteInstanceId||'-'}}</el-descriptions-item><el-descriptions-item label="待执行">{{instance.pendingAction||'-'}}</el-descriptions-item>
</el-descriptions>
</div>
<el-tabs v-if="instance" v-model="tab" class="tabs" @tab-change="tabChanged">
<el-tab-pane label="履约审核" name="fulfillment">
<div class="toolbar"><span>履约记录与失败审核</span><div><el-button @click="fulfillmentAction('resync')">重新同步</el-button><el-button v-if="instance.provisionStatus==='failed'" type="warning" @click="fulfillmentAction('retry')">审核重试</el-button><el-button v-if="instance.provisionStatus==='failed'" type="success" @click="fulfillmentAction('resolved')">确认解决</el-button><el-button v-if="instance.provisionStatus==='failed'" type="danger" plain @click="fulfillmentAction('reject')">驳回</el-button></div></div>
<el-descriptions :column="2" border><el-descriptions-item label="审核状态">{{instance.reviewStatus||'-'}}</el-descriptions-item><el-descriptions-item label="上次状态">{{statusText(instance.previousProvisionStatus)}}</el-descriptions-item><el-descriptions-item label="远端任务">{{instance.remoteTaskId||'-'}}</el-descriptions-item><el-descriptions-item label="重试次数">{{instance.retryCount||0}}</el-descriptions-item><el-descriptions-item label="错误代码">{{instance.lastErrorCode||'-'}}</el-descriptions-item><el-descriptions-item label="失败阶段">{{instance.failureStage||'-'}}</el-descriptions-item><el-descriptions-item label="错误信息" :span="2">{{instance.lastError||'-'}}</el-descriptions-item><el-descriptions-item label="审核备注" :span="2">{{instance.reviewNote||'-'}}</el-descriptions-item></el-descriptions>
<div class="split"><section><h4>创建请求</h4><pre>{{pretty(baseDetail?.request)}}</pre></section><section><h4>资源快照</h4><pre>{{snapshotText}}</pre></section></div>
</el-tab-pane>
<el-tab-pane label="运行概览" name="runtime">
<div class="toolbar"><span>实时详情与访问控制</span><div><el-button @click="loadRuntime">刷新详情</el-button><el-button :loading="submitting" @click="refreshRuntime">主动刷新</el-button><el-button :disabled="loginRetryAfter>0" @click="readLogin">{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'读取登录凭据'}}</el-button><el-button type="warning" @click="confirmPasswordReset">重置密码</el-button><el-button @click="loadMonitoring">读取监控</el-button></div></div>
<AwsRuntimeVisualization :details="runtimeDetails" :monitoring="monitoring" />
<el-collapse v-if="runtimeDetails||monitoring" class="raw-collapse"><el-collapse-item title="查看原始运行期响应" name="raw"><div class="split"><section><h4>实时详情</h4><pre>{{pretty(runtimeDetails)}}</pre></section><section><h4>监控聚合</h4><pre>{{pretty(monitoring)}}</pre></section></div></el-collapse-item></el-collapse>
</el-tab-pane>
<el-tab-pane label="网络安全" name="network">
<div class="toolbar"><span>安全组</span><div><el-button @click="loadNetwork">刷新</el-button><el-button type="primary" @click="ruleDialog=true">添加规则</el-button></div></div>
<el-table :data="securityRules" border stripe><el-table-column prop="direction" label="方向" width="90"/><el-table-column prop="protocol" label="协议" width="90"/><el-table-column label="端口" width="120"><template #default="{row}">{{portText(row)}}</template></el-table-column><el-table-column prop="cidr" label="CIDR" min-width="150"/><el-table-column prop="description" label="说明"/><el-table-column label="操作" width="80"><template #default="{row}"><el-button link type="danger" @click="deleteRule(row)">删除</el-button></template></el-table-column></el-table>
<div class="toolbar sub"><span>EIP</span><div><el-button @click="ensureEip">确保/更换</el-button><el-button @click="setPtr">更新 PTR</el-button><el-button type="danger" plain @click="dangerEip('disassociate')">解绑</el-button><el-button type="danger" @click="dangerEip('release')">释放</el-button></div></div><pre>{{pretty(eip)}}</pre>
</el-tab-pane>
<el-tab-pane label="存储" name="storage">
<div class="toolbar"><span>数据卷</span><div><el-button @click="loadVolumes">刷新</el-button><el-button type="primary" @click="openVolume('create')">创建并挂载</el-button></div></div>
<el-table :data="volumes" border stripe><el-table-column prop="volume_id" label="卷 ID" min-width="170"/><el-table-column prop="name" label="名称"/><el-table-column prop="size_gib" label="容量 GiB" width="95"/><el-table-column prop="volume_type" label="类型" width="85"/><el-table-column prop="state" label="状态" width="100"/><el-table-column prop="device_name" label="设备" width="105"/><el-table-column label="操作" width="245"><template #default="{row}"><el-button link @click="openVolume('modify',row)">扩容/修改</el-button><el-button link @click="openVolume('attach',row)">挂载</el-button><el-button link type="warning" @click="volumeDanger('detach',row)">卸载</el-button><el-button link type="danger" @click="volumeDanger('delete',row)">删除</el-button></template></el-table-column></el-table>
</el-tab-pane>
<el-tab-pane label="操作中心" name="operations">
<div class="toolbar"><span>运行期异步操作</span><el-button @click="loadOperations">刷新</el-button></div>
<el-table :data="operations" border stripe><el-table-column prop="id" label="ID" width="70"/><el-table-column prop="operationType" label="类型" min-width="150"/><el-table-column prop="actorType" label="操作者" width="85"/><el-table-column label="状态" width="100"><template #default="{row}"><el-tag :type="operationTag(row.status)">{{operationText(row.status)}}</el-tag></template></el-table-column><el-table-column prop="progress" label="进度" width="70"/><el-table-column prop="errorMessage" label="错误" min-width="170" show-overflow-tooltip/><el-table-column label="操作" width="265"><template #default="{row}"><el-button link @click="showOperation(row.id)">详情</el-button><el-button v-if="['queued','running'].includes(row.status)" link @click="operationReview('resync',row)">同步</el-button><el-button v-if="['failed','rejected'].includes(row.status)" link type="warning" @click="operationReview('retry',row)">重试</el-button><el-button v-if="row.status==='failed'" link type="success" @click="operationReview('resolve',row)">确认解决</el-button><el-button v-if="row.status==='failed'" link type="danger" @click="operationReview('reject',row)">驳回</el-button></template></el-table-column></el-table>
<el-pagination v-model:current-page="operationPage" :page-size="20" :total="operationTotal" layout="total, prev, pager, next" class="pager" @current-change="loadOperations"/>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="loginDialog" title="AWS 登录凭据" width="820px" destroy-on-close @closed="clearCredentials">
<el-alert title="敏感信息仅在当前弹窗临时展示,关闭后立即清除" type="warning" show-icon :closable="false" class="credential-alert"/>
<div class="credential-grid">
<div v-for="(user,ui) in loginUsers" :key="ui" class="credential-card">
<div class="credential-user"><span class="user-avatar"><el-icon><User /></el-icon></span><div><h4>{{user.username||'未知用户'}}</h4><span>{{(user.methods||[]).length}} 种登录方式</span></div></div>
<section v-for="(method,mi) in user.methods||[]" :key="mi" class="credential-method" :class="{unavailable:method.credential?.available===false}">
<div class="method-head"><div><b>{{credentialType(method)}}</b></div><el-tag :type="method.credential?.available===false?'danger':'success'">{{method.credential?.available===false?'不可用':'可用'}}</el-tag></div>
<el-alert v-if="method.credential?.error" :title="method.credential.error" type="error" show-icon :closable="false"/>
<div class="credential-meta-grid"><div><span>主机地址</span><code :title="method.host">{{method.host||'-'}}</code></div><div><span>协议</span><code>{{String(method.protocol||'ssh').toUpperCase()}}</code></div><div><span>端口</span><code>{{method.port||'-'}}</code></div><div><span>用户名</span><code>{{user.username||'-'}}</code></div></div>
<div v-if="isPassword(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>登录密码</span></div><div class="secret-display"><code>{{revealed[`${ui}-${mi}-p`]?method.credential.password:mask(method.credential.password)}}</code></div><div class="secret-actions"><el-button @click="toggleSecret(`${ui}-${mi}-p`)">{{revealed[`${ui}-${mi}-p`]?'隐藏密码':'查看密码'}}</el-button><el-button type="primary" plain @click="copy(method.credential.password)">复制密码</el-button></div></div>
<div v-if="isPrivateKey(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>SSH 私钥</span><el-tooltip :content="`密钥指纹:${method.credential?.fingerprint||'暂无'}`" placement="top"><el-button class="fingerprint-button" circle link :icon="InfoFilled" aria-label="查看密钥指纹"/></el-tooltip></div><div class="secret-display"><code>{{method.credential?.key_name||'EC2 Key Pair'}}</code></div><div class="secret-actions"><el-button @click="downloadPrivateKey(method.credential.private_key,method.credential?.key_name)">下载私钥</el-button><el-button type="primary" plain @click="copy(method.credential.private_key)">复制私钥</el-button></div></div>
</section>
</div>
</div>
<el-empty v-if="!loginUsers.length" description="暂无可用登录凭据"/>
</el-dialog>
<el-dialog v-model="ruleDialog" title="添加安全组规则" width="520px"><el-form :model="rule" label-width="90px"><el-form-item label="方向"><el-select v-model="rule.direction"><el-option label="入站" value="ingress"/><el-option label="出站" value="egress"/></el-select></el-form-item><el-form-item label="协议"><el-select v-model="rule.protocol"><el-option v-for="p in protocols" :key="p" :label="p" :value="p"/></el-select></el-form-item><el-form-item label="端口范围"><el-input-number v-model="rule.from_port" :min="-1" :max="65535"/> - <el-input-number v-model="rule.to_port" :min="-1" :max="65535"/></el-form-item><el-form-item label="CIDR"><el-input v-model.trim="rule.cidr"/></el-form-item><el-form-item label="说明"><el-input v-model="rule.description" maxlength="200"/></el-form-item></el-form><template #footer><el-button @click="ruleDialog=false">取消</el-button><el-button type="primary" @click="addRule">确认</el-button></template></el-dialog>
<el-dialog v-model="volumeDialog" :title="volumeTitles[volumeMode]" width="560px"><el-form :model="volumeForm" label-width="130px"><el-form-item v-if="volumeMode!=='create'" label="卷 ID"><el-input v-model="volumeForm.volume_id" :disabled="volumeMode!=='attach'"/></el-form-item><el-form-item v-if="volumeMode==='create'" label="名称"><el-input v-model="volumeForm.name"/></el-form-item><el-form-item v-if="['create','attach'].includes(volumeMode)" label="设备名"><el-input v-model="volumeForm.device_name" placeholder="/dev/sdf"/></el-form-item><el-form-item v-if="['create','modify'].includes(volumeMode)" label="容量 GiB"><el-input-number v-model="volumeForm.size_gib" :min="1" :max="16384"/></el-form-item><el-form-item v-if="['create','modify'].includes(volumeMode)" label="类型"><el-select v-model="volumeForm.type"><el-option v-for="v in volumeTypes" :key="v" :label="v" :value="v"/></el-select></el-form-item><el-form-item v-if="['create','modify'].includes(volumeMode)" label="IOPS"><el-input-number v-model="volumeForm.iops" :min="0"/></el-form-item><el-form-item v-if="['create','modify'].includes(volumeMode)" label="吞吐量"><el-input-number v-model="volumeForm.throughput" :min="0"/></el-form-item><el-form-item v-if="volumeMode==='create'" label="加密"><el-switch v-model="volumeForm.encrypted"/></el-form-item><el-form-item v-if="['create','attach'].includes(volumeMode)" label="实例终止时删除"><el-switch v-model="volumeForm.delete_on_termination"/></el-form-item></el-form><template #footer><el-button @click="volumeDialog=false">取消</el-button><el-button type="primary" @click="submitVolume">提交</el-button></template></el-dialog>
<el-dialog v-model="operationDialog" title="运行期操作详情" width="650px"><pre>{{pretty(currentOperation)}}</pre></el-dialog>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled, User } from '@element-plus/icons-vue'
import * as api from '@/api/admin/awsService.js'
import AwsRuntimeVisualization from './AwsRuntimeVisualization.vue'
const props=defineProps({embedded:{type:Boolean,default:false},instanceId:{type:Number,default:0}})
const route=useRoute(),embedded=computed(()=>props.embedded),instanceId=props.instanceId||Number(route.params.instanceId),loading=ref(false),submitting=ref(false),pageError=ref(''),tab=ref('fulfillment')
const baseDetail=ref(null),runtimeDetails=ref(null),monitoring=ref(null),security=ref(null),eip=ref(null),volumes=ref([]),operations=ref([]),operationPage=ref(1),operationTotal=ref(0)
const loginDialog=ref(false),loginUsers=ref([]),loginRetryAfter=ref(0),retryTimer=ref(null),revealed=reactive({}),ruleDialog=ref(false),volumeDialog=ref(false),volumeMode=ref('create'),operationDialog=ref(false),currentOperation=ref(null),pollTimer=ref(null),pollStarted=ref(0)
const protocols=['tcp','udp','icmp','icmpv6','-1'],volumeTypes=['gp3','gp2','io1','io2','st1','sc1','standard'],volumeTitles={create:'创建并挂载数据卷',modify:'扩容或修改数据卷',attach:'挂载数据卷'}
const rule=reactive({direction:'ingress',protocol:'tcp',from_port:22,to_port:22,cidr:'0.0.0.0/0',description:''})
const volumeForm=reactive({volume_id:'',name:'',device_name:'/dev/sdf',size_gib:20,type:'gp3',iops:0,throughput:0,encrypted:true,kms_key_id:'',delete_on_termination:false})
const envelope=r=>r?.data||r||{},dataOf=r=>envelope(r)?.data,message=(e,f)=>e?.response?.data?.message||e?.response?.data?.msg||envelope(e)?.message||e?.message||f
const instance=computed(()=>baseDetail.value?.instance||null)
const snapshotText=computed(()=>{const raw=instance.value?.resourceSnapshot;if(!raw)return'暂无资源快照';try{return JSON.stringify(typeof raw==='string'?JSON.parse(raw):raw,null,2)}catch{return String(raw)}})
const securityRules=computed(()=>{const d=security.value||{};if(Array.isArray(d.rules))return d.rules;return[...(d.ingress_rules||d.ingressRules||[]).map(v=>({...v,direction:v.direction||'ingress'})),...(d.egress_rules||d.egressRules||[]).map(v=>({...v,direction:v.direction||'egress'}))]})
const pretty=v=>JSON.stringify(v??{},null,2),mask=v=>v?'••••••••':'-',statusText=s=>({pending:'等待中',submitting:'提交中',provisioning:'创建中',active:'运行中',failed:'失败',stopping:'停止中',stopped:'已停止',terminating:'终止中',terminated:'已终止'})[s]||s||'-'
const isPrivateKey=method=>method?.credential?.kind==='private_key'||String(method?.type||'').includes('private_key')||Boolean(method?.credential?.private_key)
const isPassword=method=>method?.credential?.kind==='password'||String(method?.type||'').includes('password')||Boolean(method?.credential?.password)
const credentialType=method=>isPrivateKey(method)?'SSH 私钥':isPassword(method)?'SSH 密码':method?.type||'登录凭据'
const sourceText=source=>({ec2_key_pair:'EC2 密钥对',agent_managed:'Agent 托管'})[source]||source||'未知来源'
const downloadPrivateKey=(value,name)=>{if(!value){ElMessage.warning('暂无可下载的私钥');return}const safeName=String(name||'aws-private-key').replace(/[^\w.-]/g,'_');const url=URL.createObjectURL(new Blob([value],{type:'application/x-pem-file'}));const link=document.createElement('a');link.href=url;link.download=safeName.endsWith('.pem')?safeName:`${safeName}.pem`;link.click();URL.revokeObjectURL(url)}
const statusTag=s=>s==='active'?'success':s==='failed'?'danger':['pending','submitting','provisioning','stopping','terminating'].includes(s)?'warning':'info'
const operationText=s=>({queued:'排队中',running:'执行中',succeeded:'成功',failed:'失败',rejected:'已驳回'})[s]||s||'-',operationTag=s=>s==='succeeded'?'success':s==='failed'?'danger':s==='rejected'?'info':'warning'
const portText=r=>r.protocol==='-1'?'全部':`${r.from_port??r.fromPort??'-'}-${r.to_port??r.toPort??'-'}`
async function safe(call,fallback){try{const r=await call();const b=envelope(r);if(b.code!==200)throw new Error(b.message||fallback);return dataOf(r)}catch(e){ElMessage.error(message(e,fallback));return null}}
async function loadBase(){loading.value=true;pageError.value='';try{const v=await safe(()=>api.getAwsInstanceDetail(instanceId),'加载履约详情失败');if(v)baseDetail.value=v;else pageError.value='AWS 履约实例不存在或加载失败'}finally{loading.value=false}}
async function loadRuntime(){const v=await safe(()=>api.getAdminAwsRuntimeDetails(instanceId),'读取实时详情失败');if(v!==null)runtimeDetails.value=v}
async function loadMonitoring(){const v=await safe(()=>api.getAdminAwsMonitoring(instanceId),'读取监控失败');if(v!==null)monitoring.value=v}
async function loadNetwork(){const [s,p]=await Promise.all([safe(()=>api.getAdminAwsSecurityGroup(instanceId),'读取安全组失败'),safe(()=>api.getAdminAwsEip(instanceId),'读取 EIP 失败')]);if(s!==null)security.value=s;if(p!==null)eip.value=p}
async function loadVolumes(){const v=await safe(()=>api.getAdminAwsVolumes(instanceId),'读取数据卷失败');if(v!==null)volumes.value=Array.isArray(v)?v:(v.list||v.volumes||[])}
async function loadOperations(){const v=await safe(()=>api.getAdminAwsOperations(instanceId,{page:operationPage.value,count:20}),'读取操作记录失败');if(v){operations.value=v.list||[];operationTotal.value=v.all_count||0}}
function tabChanged(name){if(name==='runtime'&&!runtimeDetails.value)loadRuntime();if(name==='network'&&!security.value)loadNetwork();if(name==='storage'&&!volumes.value.length)loadVolumes();if(name==='operations')loadOperations()}
async function promptNote(title){try{return(await ElMessageBox.prompt('请输入操作备注(可留空)',title,{inputValue:''})).value}catch{return null}}
async function fulfillmentAction(type){const note=await promptNote({resync:'重新同步履约',retry:'审核重试',resolved:'确认解决',reject:'驳回履约'}[type]);if(note===null)return;let call;if(type==='resync')call=()=>api.resyncAwsInstance(instanceId,note);else if(type==='retry')call=()=>api.retryAwsInstance(instanceId,note);else call=()=>api.resolveAwsInstance(instanceId,type,note);const v=await safe(call,'履约操作失败');if(v!==null){ElMessage.success('操作成功');loadBase()}}
function stopPoll(){clearTimeout(pollTimer.value);pollTimer.value=null}
function pollOperation(id,onSuccess){stopPoll();pollStarted.value=Date.now();const tick=async()=>{if(Date.now()-pollStarted.value>=120000)return ElMessage.warning('操作仍在处理,请稍后手动刷新');try{const r=await api.getAdminAwsOperationDetail(id);const op=dataOf(r);if(['queued','running'].includes(op?.status)){pollTimer.value=setTimeout(tick,5000);return}if(op?.status==='succeeded'){ElMessage.success('操作完成');await onSuccess?.()}else ElMessage.error(op?.errorMessage||'操作失败')}catch{pollTimer.value=setTimeout(tick,5000)}};pollTimer.value=setTimeout(tick,5000)}
async function runAsync(call,onSuccess){if(submitting.value)return;submitting.value=true;try{const r=await call();const b=envelope(r);if(b.code!==200)throw new Error(b.message||'提交失败');const op=b.data;ElMessage.success('操作已接受');if(op?.operation_id)pollOperation(op.operation_id,onSuccess)}catch(e){ElMessage.error(message(e,'操作提交失败'))}finally{submitting.value=false}}
function refreshRuntime(){runAsync(()=>api.refreshAdminAwsRuntimeDetails(instanceId),loadRuntime)}
function startLoginRetry(seconds){clearInterval(retryTimer.value);loginRetryAfter.value=Number(seconds)||5;retryTimer.value=setInterval(()=>{loginRetryAfter.value--;if(loginRetryAfter.value<=0){clearInterval(retryTimer.value);retryTimer.value=null}},1000)}
async function readLogin(){if(loginRetryAfter.value)return;try{const r=await api.getAdminAwsLoginInfo(instanceId);const b=envelope(r);if(b.code!==200)throw new Error(b.message||'读取登录凭据失败');loginUsers.value=b.data?.users||[];loginDialog.value=true}catch(e){if(e?.response?.status===429)startLoginRetry(e.response.headers?.['retry-after']||5);ElMessage.error(message(e,'读取登录凭据失败'))}}
function clearCredentials(){loginUsers.value=[];Object.keys(revealed).forEach(k=>delete revealed[k])}
function toggleSecret(k){revealed[k]=!revealed[k]} async function copy(v){if(!v)return ElMessage.warning('暂无可复制内容');await navigator.clipboard.writeText(v);ElMessage.success('已复制')}
async function confirmPasswordReset(){try{await ElMessageBox.confirm('重置后原密码可能立即失效,确认继续?','重置实例密码',{type:'warning'})}catch{return}runAsync(()=>api.resetAdminAwsPassword(instanceId))}
function validRule(){if(!protocols.includes(rule.protocol)||!['ingress','egress'].includes(rule.direction))return'方向或协议无效';if(!Number.isInteger(rule.from_port)||!Number.isInteger(rule.to_port)||rule.from_port< -1||rule.to_port>65535||rule.from_port>rule.to_port)return'端口范围无效';if(!(/^(?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}$|^[0-9a-fA-F:]+\/\d{1,3}$/).test(rule.cidr))return'CIDR 无效';return''}
function addRule(){const err=validRule();if(err)return ElMessage.warning(err);runAsync(()=>api.addAdminAwsSecurityGroupRule(instanceId,{...rule}),async()=>{ruleDialog.value=false;await loadNetwork()})}
async function deleteRule(row){try{await ElMessageBox.confirm(`确认删除 ${row.protocol} ${portText(row)} ${row.cidr}`,'删除安全组规则',{type:'warning'})}catch{return}const r={direction:row.direction||'ingress',protocol:row.protocol,from_port:row.from_port??row.fromPort,to_port:row.to_port??row.toPort,cidr:row.cidr,description:row.description||''};runAsync(()=>api.deleteAdminAwsSecurityGroupRule(instanceId,r),loadNetwork)}
async function ensureEip(){const allocation=await promptNote('确保或更换 EIP(可填写 Allocation ID');if(allocation===null)return;runAsync(()=>api.replaceAdminAwsEip({instance_id:instanceId,allocation_id:allocation}),loadNetwork)}
async function setPtr(){const domain=await promptNote('更新 EIP PTR(填写域名,留空清除)');if(domain===null)return;runAsync(()=>api.updateAdminAwsEipReverseDns({instance_id:instanceId,domain_name:domain}),loadNetwork)}
async function dangerEip(type){try{await ElMessageBox.confirm(type==='release'?'释放 EIP 后可能无法恢复,确认继续?':'确认解绑当前 EIP',type==='release'?'释放 EIP':'解绑 EIP',{type:'error'})}catch{return}runAsync(()=>type==='release'?api.releaseAdminAwsEip(instanceId):api.disassociateAdminAwsEip(instanceId),loadNetwork)}
function openVolume(mode,row={}){volumeMode.value=mode;Object.assign(volumeForm,{volume_id:row.volume_id||'',name:row.name||'',device_name:row.device_name||'/dev/sdf',size_gib:Number(row.size_gib)||20,type:row.volume_type||'gp3',iops:Number(row.iops)||0,throughput:Number(row.throughput)||0,encrypted:row.encrypted!==false,kms_key_id:row.kms_key_id||'',delete_on_termination:!!row.delete_on_termination});volumeDialog.value=true}
function submitVolume(){if(['create','modify'].includes(volumeMode.value)&&(volumeForm.size_gib<1||volumeForm.size_gib>16384))return ElMessage.warning('容量范围为 1-16384 GiB');if(['create','attach'].includes(volumeMode.value)&&!volumeForm.device_name)return ElMessage.warning('请输入设备名');const data={instance_id:instanceId,...volumeForm};const call=volumeMode.value==='create'?()=>api.createAdminAwsVolume(data):volumeMode.value==='modify'?()=>api.modifyAdminAwsVolume(data):()=>api.attachAdminAwsVolume(data);runAsync(call,async()=>{volumeDialog.value=false;await loadVolumes()})}
async function volumeDanger(type,row){try{await ElMessageBox.confirm(type==='delete'?'删除数据卷不可恢复,确认继续?':'卸载可能导致实例内文件系统异常,确认继续?',type==='delete'?'删除数据卷':'卸载数据卷',{type:'error'})}catch{return}const call=type==='delete'?()=>api.deleteAdminAwsVolume({instance_id:instanceId,volume_id:row.volume_id,force:false}):()=>api.detachAdminAwsVolume({instance_id:instanceId,volume_id:row.volume_id,force:false});runAsync(call,loadVolumes)}
async function showOperation(id){const v=await safe(()=>api.getAdminAwsOperationDetail(id),'读取操作详情失败');if(v){currentOperation.value=v;operationDialog.value=true}}
async function operationReview(type,row){const note=type==='resync'?'':await promptNote({retry:'重试运行期操作',resolve:'确认操作已解决',reject:'驳回失败操作'}[type]);if(note===null)return;if(type==='resync'){const v=await safe(()=>api.resyncAdminAwsOperation(row.id),'同步操作失败');if(v!==null){ElMessage.success('同步完成');loadOperations()}return}if(type==='retry')runAsync(()=>api.retryAdminAwsOperation(row.id,note),loadOperations);else{const v=await safe(()=>type==='resolve'?api.resolveAdminAwsOperation(row.id,note):api.rejectAdminAwsOperation(row.id,note),'审核操作失败');if(v!==null){ElMessage.success('审核完成');loadOperations()}}}
defineExpose({refresh:loadBase})
onMounted(loadBase);onBeforeUnmount(()=>{stopPoll();clearInterval(retryTimer.value);clearCredentials()})
</script>
<style scoped>
.page{padding:20px;background:#f5f7fa}.page.embedded{padding:0;background:transparent}.header,.header-title,.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px}.header{padding:20px 24px;margin-bottom:16px;border:1px solid #d9ecff;border-radius:14px;background:linear-gradient(135deg,#edf5ff,#f9fbff);box-shadow:0 7px 22px rgba(53,84,136,.06)}h2{margin:0}.header p{margin:5px 0 0;color:#909399}.alert{margin-bottom:14px}.summary{margin-bottom:16px}.embedded .summary{margin-top:18px}.tabs{padding:0 20px 22px;border-radius:12px;background:#fff}.embedded .tabs{padding:0 0 8px}.toolbar{margin:18px 0;font-weight:600}.toolbar.sub{margin-top:28px}.split{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px}.split section{min-width:0}pre{max-height:430px;overflow:auto;padding:14px;border:1px solid #ebeef5;border-radius:6px;background:#f7f8fa;font:12px/1.6 Consolas,monospace;white-space:pre-wrap;word-break:break-all}.credential{margin-bottom:18px}.credential h4{margin:0 0 10px}.credential .el-descriptions{margin-bottom:10px}.secret{display:inline-block;max-width:70%;margin-right:8px;font-family:Consolas,monospace;white-space:pre-wrap;word-break:break-all;vertical-align:top}.pager{justify-content:flex-end;margin-top:16px}.raw-collapse{margin-top:16px}:deep(.el-tabs__item){transition:color .2s,transform .2s}:deep(.el-tabs__item:hover){transform:translateY(-1px)}:deep(.el-tabs__active-bar){height:3px;border-radius:4px;background:linear-gradient(90deg,#4f7cff,#7c5cff)}@media(max-width:1000px){.split{grid-template-columns:1fr}}
.credential-alert{margin-bottom:16px}.credential-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,360px));gap:16px;align-items:start}.credential-card{min-width:0;margin:0;padding:15px;border:1px solid #dfe8f5;border-radius:15px;background:#fff;box-shadow:0 7px 22px rgba(42,64,99,.06);box-sizing:border-box}.credential-user{display:flex;align-items:center;gap:11px;margin-bottom:13px;padding-bottom:13px;border-bottom:1px solid #edf1f6}.user-avatar{display:grid;width:40px;height:40px;border-radius:11px;background:#edf4ff;color:#4f7cff;font-size:19px;place-items:center}.credential-user h4{margin:0;color:#263247}.credential-user div>span{display:block;margin-top:4px;color:#98a2b3;font-size:12px}.credential-method{margin-top:10px;padding:13px;border:1px solid #e9eef5;border-radius:11px;background:#fbfcfe}.credential-method.unavailable{opacity:.7}.method-head,.method-head>div{display:flex;align-items:center}.method-head{justify-content:space-between;gap:12px}.method-head>div{min-width:0;align-items:flex-start;flex-direction:column;gap:3px}.method-head b{color:#344054}.method-head span{color:#98a2b3;font-size:11px}.credential-meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.credential-meta-grid>div{min-width:0;padding:8px 9px;border-radius:8px;background:#fff}.credential-meta-grid span,.credential-meta-grid code{display:block}.credential-meta-grid span{margin-bottom:4px;color:#98a2b3;font-size:11px}.credential-meta-grid code{overflow:hidden;color:#475467;font:12px Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.secret-block{margin-top:11px;padding:11px;border:1px solid #e6ebf3;border-radius:9px;background:#fff}.credential-secret-card{display:flex;min-height:138px;flex-direction:column;box-sizing:border-box}.secret-label{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px}.secret-label span{color:#344054;font-weight:600}.fingerprint-button{font-size:16px}.secret-display{display:flex;min-height:40px;align-items:center;flex:1}.secret-display code{display:block;width:100%;overflow:hidden;padding:10px;border-radius:7px;background:#f6f8fb;color:#344054;font:13px Consolas,monospace;text-overflow:ellipsis;white-space:nowrap;box-sizing:border-box}.secret-actions{display:flex;gap:7px;margin-top:10px}.secret-actions .el-button{flex:1;margin:0}@media(max-width:760px){.credential-grid{grid-template-columns:1fr}}
</style>
-45
View File
@@ -1,45 +0,0 @@
<template>
<div class="page">
<div class="header"><div><h2>AWS 履约实例</h2><p>查看远端实例快照并审核失败任务</p></div><el-button @click="load">刷新</el-button></div>
<div class="filters"><el-select v-model="query.status" clearable placeholder="全部履约状态" @change="search"><el-option v-for="s in statuses" :key="s" :label="statusText(s)" :value="s"/></el-select><el-button @click="reset">重置</el-button></div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="实例 ID" width="90"/>
<el-table-column label="用户商品" width="105"><template #default="{row}"><el-link type="primary" @click="$router.push(`/user-goods/detail/${row.userGoodsId}`)">{{row.userGoodsId}}</el-link></template></el-table-column>
<el-table-column label="订单" width="100"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/order/list',query:{order_id:row.orderId}})">{{row.orderId}}</el-link></template></el-table-column>
<el-table-column label="服务" width="95"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:row.serviceId}})">{{row.serviceId}}</el-link></template></el-table-column>
<el-table-column prop="region" label="Region" min-width="125"/>
<el-table-column prop="remoteAwsInstanceId" label="AWS 实例" min-width="160" show-overflow-tooltip/>
<el-table-column label="履约状态" width="110"><template #default="{row}"><el-tag :type="statusType(row.provisionStatus)">{{statusText(row.provisionStatus)}}</el-tag></template></el-table-column>
<el-table-column prop="pendingAction" label="待执行" width="90"/>
<el-table-column prop="retryCount" label="重试" width="65"/>
<el-table-column prop="lastError" label="最近错误" min-width="180" show-overflow-tooltip/>
<el-table-column label="最近同步" width="170"><template #default="{row}">{{formatTime(row.lastSyncedAt)}}</template></el-table-column>
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="showDetail(row)">详情</el-button><el-popconfirm v-if="row.provisionStatus==='failed'" title="确认审核并重试该履约?" @confirm="retry(row)"><template #reference><el-button link type="warning" :loading="retryingId===row.id">重试</el-button></template></el-popconfirm></template></el-table-column>
</el-table>
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
<el-drawer v-model="drawer" title="AWS 履约详情" size="600px">
<template v-if="current"><el-descriptions :column="2" border><el-descriptions-item label="实例 ID">{{current.id}}</el-descriptions-item><el-descriptions-item label="状态">{{statusText(current.provisionStatus)}}</el-descriptions-item><el-descriptions-item label="远端实例">{{current.remoteAwsInstanceId||'-'}}</el-descriptions-item><el-descriptions-item label="任务 ID">{{current.remoteTaskId||'-'}}</el-descriptions-item><el-descriptions-item label="错误代码">{{current.lastErrorCode||'-'}}</el-descriptions-item><el-descriptions-item label="下次重试">{{formatTime(current.nextRetryAt)}}</el-descriptions-item><el-descriptions-item label="错误信息" :span="2">{{current.lastError||'-'}}</el-descriptions-item></el-descriptions><h4>资源快照</h4><pre class="snapshot">{{snapshotText}}</pre></template>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAwsInstanceList, retryAwsInstance } from '@/api/admin/awsService.js'
const statuses=['pending','submitting','provisioning','active','failed','stopping','stopped','terminating','terminated']
const labels={pending:'等待中',submitting:'提交中',provisioning:'创建中',active:'运行中',failed:'失败',stopping:'停止中',stopped:'已停止',terminating:'终止中',terminated:'已终止'}
const loading=ref(false), retryingId=ref(null), rows=ref([]), total=ref(0), drawer=ref(false), current=ref(null)
const query=reactive({page:1,count:20,status:undefined})
const body=r=>r?.data||{}
const statusText=s=>labels[s]||s||'-'
const statusType=s=>s==='active'?'success':s==='failed'?'danger':['pending','submitting','provisioning','stopping','terminating'].includes(s)?'warning':'info'
const formatTime=t=>t?new Date(t).toLocaleString('zh-CN',{hour12:false}):'-'
const snapshotText=computed(()=>{const raw=current.value?.resourceSnapshot;if(!raw)return '暂无资源快照';try{return JSON.stringify(typeof raw==='string'?JSON.parse(raw):raw,null,2)}catch{return String(raw)}})
async function load(){loading.value=true;try{const params={...query};if(!params.status)delete params.status;const b=body(await getAwsInstanceList(params));if(b.code===200){rows.value=b.data?.list||b.data?.data||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
function search(){query.page=1;load()} function reset(){query.status=undefined;search()} function showDetail(row){current.value=row;drawer.value=true}
async function retry(row){retryingId.value=row.id;try{const b=body(await retryAwsInstance(row.id));if(b.code===200){ElMessage.success('已接受重试');load()}else ElMessage.error(b.message||'重试失败')}finally{retryingId.value=null}}
onMounted(load)
</script>
<style scoped>
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:230px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.snapshot{padding:16px;border-radius:8px;background:#f5f7fa;white-space:pre-wrap;word-break:break-all;max-height:55vh;overflow:auto}
</style>
+41
View File
@@ -0,0 +1,41 @@
<template>
<div v-if="details || monitoring" class="runtime-visual">
<div v-if="details" class="hero-grid">
<article><span class="icon blue"><el-icon><Monitor /></el-icon></span><div><small>实例状态</small><strong>{{ stateText(instance.state || instance.provision_status) }}</strong><i>{{ instance.instance_id || '-' }}</i></div></article>
<article><span class="icon green"><el-icon><Connection /></el-icon></span><div><small>公网 / 私网 IP</small><strong>{{ instance.public_ip_address || '-' }}</strong><i>{{ instance.private_ip_address || '-' }}</i></div></article>
<article><span class="icon purple"><el-icon><Cpu /></el-icon></span><div><small>规格 / 可用区</small><strong>{{ instance.instance_type || '-' }}</strong><i>{{ instance.availability_zone || '-' }}</i></div></article>
<article><span class="icon orange"><el-icon><CircleCheck /></el-icon></span><div><small>Agent</small><strong>{{ agent.status === 'online' ? '在线' : agent.status || '未连接' }}</strong><i>{{ agent.os || '-' }} · {{ agent.version || '-' }}</i></div></article>
</div>
<div v-if="details" class="resource-grid">
<section class="surface"><header><b><el-icon><Tickets /></el-icon> 实例与费用</b><small>{{ time(instance.synced_at) }}</small></header><div class="props"><div v-for="r in rows" :key="r[0]"><span>{{ r[0] }}</span><b>{{ r[1] || '-' }}</b></div></div><div class="costs"><div><span>本月</span><b>{{ money(instance.month_cost) }}</b></div><div><span>预计本月</span><b>{{ money(instance.projected_month_cost) }}</b></div><div><span>完整月</span><b>{{ money(instance.full_month_cost) }}</b></div></div></section>
<section class="surface"><header><b><el-icon><TrendCharts /></el-icon> 流量与资源</b><small>{{ limit.measurement_source === 'agent' ? 'Agent 计量' : 'AWS 计量' }}</small></header><div class="traffic"><strong>{{ bytes(limit.current_usage_bytes) }}</strong><span>/ {{ bytes(limit.limit_bytes) }}</span></div><el-progress :percentage="trafficPercent" :stroke-width="9"/><div class="counts"><span><b>{{ details.security_groups?.length || 0 }}</b> 安全组</span><span><b>{{ details.storage?.volumes?.length || 0 }}</b> 数据卷</span><span><b>{{ details.network?.elastic_ips?.length || 0 }}</b> EIP</span></div></section>
</div>
<template v-if="monitoring">
<div class="metrics"><article><small>CPU</small><strong>{{ percent(metrics.cpu_percent) }}</strong></article><article><small>内存</small><strong>{{ percent(ratio(metrics.memory_used_bytes,metrics.memory_total_bytes)) }}</strong><span>{{ bytes(metrics.memory_used_bytes) }} / {{ bytes(metrics.memory_total_bytes) }}</span></article><article><small>磁盘</small><strong>{{ percent(ratio(metrics.disk_used_bytes,metrics.disk_total_bytes)) }}</strong><span>{{ bytes(metrics.disk_used_bytes) }} / {{ bytes(metrics.disk_total_bytes) }}</span></article><article><small>运行时长</small><strong>{{ duration(metrics.uptime_seconds) }}</strong></article></div>
<div class="charts"><section class="surface"><header><b>网络吞吐趋势</b><small>{{ monitoring.traffic?.bucket_seconds || 60 }} 秒聚合</small></header><div ref="lineRef" class="chart"></div></section><section class="surface"><header><b>资源使用率</b><small>Agent 最新采样</small></header><div ref="polarRef" class="chart"></div></section></div>
</template>
</div>
<el-empty v-else description="点击上方按钮读取实时详情或监控数据" />
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { CircleCheck, Connection, Cpu, Monitor, Tickets, TrendCharts } from '@element-plus/icons-vue'
const props=defineProps({details:Object,monitoring:Object})
const lineRef=ref(),polarRef=ref();let lineChart,polarChart
const instance=computed(()=>props.details?.instance||{}),agent=computed(()=>props.details?.agent||{}),limit=computed(()=>props.details?.traffic?.limit||{}),monitorAgent=computed(()=>props.monitoring?.agent||{}),metrics=computed(()=>monitorAgent.value.metrics||{})
const rows=computed(()=>[['Region',instance.value.region],['可用区',instance.value.availability_zone],['镜像',instance.value.image_id],['密钥',instance.value.key_name],['VPC',instance.value.vpc_id],['根设备',instance.value.root_device_name]])
const ratio=(a,b)=>b?Number(a||0)/Number(b)*100:0,percent=v=>`${Number(v||0).toFixed(1)}%`,money=v=>`${instance.value.cost_currency||'USD'} ${Number(v||0).toFixed(2)}`,time=v=>v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'-'
const trafficPercent=computed(()=>Math.min(100,Math.max(0,ratio(limit.value.current_usage_bytes,limit.value.limit_bytes))))
const stateText=s=>({running:'运行中',active:'运行中',stopped:'已停止',terminated:'已终止'})[s]||s||'-'
function bytes(v){const n=Number(v||0);if(!n)return'0 B';const units=['B','KB','MB','GB','TB'],i=Math.min(Math.floor(Math.log(n)/Math.log(1024)),4);return`${(n/1024**i).toFixed(i?2:0)} ${units[i]}`}
function duration(v){let s=Number(v||0),d=Math.floor(s/86400);s%=86400;return`${d?d+'天 ':''}${Math.floor(s/3600)}小时 ${Math.floor(s%3600/60)}`}
function dispose(){lineChart?.dispose();polarChart?.dispose();lineChart=polarChart=null}
async function draw(){await nextTick();if(!props.monitoring||!lineRef.value||!polarRef.value)return;dispose();lineChart=echarts.init(lineRef.value);polarChart=echarts.init(polarRef.value);const all=props.monitoring.traffic?.series||[],rows=all.some(v=>v.interface_name==='__host__')?all.filter(v=>v.interface_name==='__host__'):all;lineChart.setOption({color:['#4f7cff','#16b8a6'],tooltip:{trigger:'axis'},legend:{top:0},grid:{left:55,right:18,top:42,bottom:28},xAxis:{type:'category',boundaryGap:false,data:rows.map(v=>new Date(v.bucket_at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}))},yAxis:{type:'value',axisLabel:{formatter:v=>bytes(v)},splitLine:{lineStyle:{color:'#edf1f7'}}},series:[{name:'入站',type:'line',smooth:true,symbol:'none',areaStyle:{opacity:.1},data:rows.map(v=>v.avg_rx_bytes_per_second||0)},{name:'出站',type:'line',smooth:true,symbol:'none',areaStyle:{opacity:.08},data:rows.map(v=>v.avg_tx_bytes_per_second||0)}]});polarChart.setOption({color:['#4f7cff','#8b5cf6','#16b8a6'],tooltip:{trigger:'item'},angleAxis:{max:100,show:false},radiusAxis:{type:'category',data:['CPU','内存','磁盘'],axisLine:{show:false},axisTick:{show:false}},polar:{radius:['20%','84%']},series:[{type:'bar',coordinateSystem:'polar',roundCap:true,barWidth:13,data:[Number(metrics.value.cpu_percent||0),ratio(metrics.value.memory_used_bytes,metrics.value.memory_total_bytes),ratio(metrics.value.disk_used_bytes,metrics.value.disk_total_bytes)].map(v=>Number(v.toFixed(1))),label:{show:true,position:'middle',formatter:'{c}%',color:'#fff'}}]})}
const resize=()=>{lineChart?.resize();polarChart?.resize()};watch(()=>props.monitoring,draw,{deep:true});window.addEventListener('resize',resize);onBeforeUnmount(()=>{dispose();window.removeEventListener('resize',resize)})
</script>
<style scoped>
.runtime-visual{margin-top:16px}.hero-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}.hero-grid article{display:flex;min-width:0;align-items:center;padding:17px;border:1px solid #e8eef7;border-radius:13px;background:#fff}.icon{display:grid;width:42px;height:42px;flex:none;margin-right:11px;border-radius:12px;font-size:20px;place-items:center}.blue{background:#edf4ff;color:#4f7cff}.green{background:#eafaf6;color:#16b8a6}.purple{background:#f3efff;color:#8b5cf6}.orange{background:#fff2e8;color:#f59e0b}.hero-grid small,.hero-grid strong,.hero-grid i{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hero-grid small,.hero-grid i{color:#8a94a6;font-size:11px;font-style:normal}.hero-grid strong{margin:4px 0;color:#263247}.resource-grid,.charts{display:grid;grid-template-columns:1.3fr 1fr;gap:14px;margin-bottom:14px}.surface{padding:18px;border:1px solid #e8eef5;border-radius:13px;background:#fff}.surface header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.surface header b{display:flex;align-items:center;gap:6px;color:#344054}.surface header .el-icon{color:#4f7cff}.surface header small{color:#98a2b3}.props{display:grid;grid-template-columns:repeat(3,1fr);gap:0 18px}.props div{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px dashed #edf0f5}.props span,.props b{overflow:hidden;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.props span{color:#98a2b3}.props b{color:#344054}.costs,.counts{display:flex;gap:24px;margin-top:16px;padding-top:13px;border-top:1px solid #edf0f5}.costs span,.costs b{display:block}.costs span{color:#98a2b3;font-size:11px}.costs b{margin-top:5px;color:#f59e0b}.traffic{margin:8px 0 14px}.traffic strong{font-size:25px;color:#344054}.traffic span{color:#98a2b3}.counts span{color:#667085;font-size:12px}.counts b{color:#4f7cff;font-size:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}.metrics article{padding:17px;border:1px solid #e8eef7;border-radius:13px;background:linear-gradient(145deg,#fff,#f8faff)}.metrics small,.metrics strong,.metrics span{display:block}.metrics small,.metrics span{color:#8a94a6;font-size:11px}.metrics strong{margin:7px 0;color:#344054;font-size:20px}.chart{height:280px}@media(max-width:1100px){.hero-grid,.metrics{grid-template-columns:repeat(2,1fr)}.resource-grid,.charts{grid-template-columns:1fr}}
</style>
+12
View File
@@ -210,6 +210,8 @@
</el-card>
<!-- 类型适配栏目区域 -->
<AwsUserGoodsDetail v-else-if="isAwsGoods" ref="awsDetailRef" :goods-id="goodsId" />
<UserVmDetail v-else-if="currentTag === '云服务器'" ref="vmDetailRef" embedded :goods-id="goodsId" />
<el-card v-else-if="detail.good" shadow="hover" class="related-card">
@@ -336,6 +338,7 @@ import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import UserVmDetail from '@/views/user-vm/UserVmDetail.vue'
import AwsUserGoodsDetail from './components/AwsUserGoodsDetail.vue'
import dayjs from 'dayjs'
const route = useRoute()
@@ -345,6 +348,7 @@ const goodsId = computed(() => parseInt(route.params.id) || 0)
const loading = ref(false)
const refreshing = ref(false)
const vmDetailRef = ref(null)
const awsDetailRef = ref(null)
const submitLoading = ref(false)
const detail = ref(null)
const detailExpanded = ref(false)
@@ -387,6 +391,10 @@ const expireStatusClass = computed(() => {
const goBack = () => router.push('/user-goods/list')
const currentTag = computed(() => (detail.value?.tag || detail.value?.good?.tag || '').toLowerCase())
const isAwsGoods = computed(() => {
const tag = currentTag.value.replace(/\s+/g, '')
return tag === 'aws' || tag.includes('aws云服务器') || tag.includes('aws服务器')
})
// deleteAt
const isDeleted = computed(() => !!(detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at))
@@ -458,6 +466,10 @@ const refreshDetail = async () => {
await nextTick()
await vmDetailRef.value?.refresh?.()
}
if (isAwsGoods.value && !isDeleted.value) {
await nextTick()
await awsDetailRef.value?.refresh?.()
}
} finally {
refreshing.value = false
}
@@ -0,0 +1,202 @@
<template>
<el-card shadow="never" class="aws-panel">
<template #header>
<div class="panel-header">
<div class="panel-heading">
<span class="heading-icon"><el-icon><Cloudy /></el-icon></span>
<div>
<strong>AWS 专属信息</strong>
<span>履约状态与远端资源快照</span>
</div>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="refreshAll">刷新</el-button>
</div>
</div>
</template>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" class="panel-alert">
<template #default><el-button link type="primary" @click="refresh">重新加载</el-button></template>
</el-alert>
<div v-loading="loading" class="panel-body">
<template v-if="instance">
<el-alert
v-if="instance.lastError || instance.last_error"
:title="instance.lastError || instance.last_error"
:description="instance.lastErrorCode || instance.last_error_code || undefined"
type="error"
show-icon
:closable="false"
class="panel-alert"
/>
<div class="instance-hero">
<div class="instance-identity">
<span class="instance-icon"><el-icon><Monitor /></el-icon></span>
<div>
<div class="instance-title-row">
<strong>{{ resource.name || 'AWS 云服务器' }}</strong>
<el-tag :type="statusType(provisionStatus)" effect="light">{{ statusText(provisionStatus) }}</el-tag>
<el-tag v-if="pendingAction" type="warning" effect="plain">正在{{ actionText(pendingAction) }}</el-tag>
</div>
<code>{{ remoteInstanceId || '等待 AWS 实例创建' }}</code>
</div>
</div>
<div class="instance-links">
<el-button v-if="instance.serviceId || instance.service_id" link type="primary" @click="openService">
服务 #{{ instance.serviceId || instance.service_id }}
</el-button>
<span>履约记录 #{{ instance.id }}</span>
</div>
</div>
<div class="resource-grid">
<article>
<span class="resource-icon blue"><el-icon><Location /></el-icon></span>
<div><small>Region / 可用区</small><strong>{{ instance.region || resource.region || '-' }}</strong><span>{{ resource.availability_zone || instance.placementId || instance.placement_id || '-' }}</span></div>
</article>
<article>
<span class="resource-icon purple"><el-icon><Cpu /></el-icon></span>
<div><small>实例规格</small><strong>{{ resource.instance_type || requestSnapshot.instance_type || '-' }}</strong><span>{{ resource.image_id || requestSnapshot.image_id || '镜像信息待同步' }}</span></div>
</article>
<article>
<span class="resource-icon green"><el-icon><Connection /></el-icon></span>
<div><small>公网 IP</small><strong class="mono">{{ resource.public_ip_address || resource.public_ip || '-' }}</strong><span>私网 {{ resource.private_ip_address || resource.private_ip || '-' }}</span></div>
</article>
<article>
<span class="resource-icon orange"><el-icon><Timer /></el-icon></span>
<div><small>最近同步</small><strong>{{ formatTime(instance.lastSyncedAt || instance.last_synced_at || resource.synced_at) }}</strong><span>重试 {{ instance.retryCount ?? instance.retry_count ?? 0 }} </span></div>
</article>
</div>
<el-descriptions :column="4" border class="fulfillment-details">
<el-descriptions-item label="履约状态"><el-tag :type="statusType(provisionStatus)" size="small">{{ statusText(provisionStatus) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="远端状态">{{ resource.state || resource.provision_status || '-' }}</el-descriptions-item>
<el-descriptions-item label="待执行操作">{{ pendingAction ? actionText(pendingAction) : '-' }}</el-descriptions-item>
<el-descriptions-item label="审核状态">{{ reviewText(instance.reviewStatus || instance.review_status) }}</el-descriptions-item>
<el-descriptions-item label="远端任务 ID" :span="2"><span class="mono breakable">{{ instance.remoteTaskId || instance.remote_task_id || resource.last_task_id || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(instance.CreatedAt || instance.createdAt || instance.created_at) }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ formatTime(instance.finishedAt || instance.finished_at) }}</el-descriptions-item>
</el-descriptions>
<div class="management-title">
<div><strong>实例管理</strong><span>履约审核运行概览网络安全存储与操作中心</span></div>
</div>
<AwsInstanceDetail :key="instance.id" ref="managementRef" embedded :instance-id="Number(instance.id)" />
</template>
<el-empty v-else-if="!loading && !errorMessage" description="该用户商品尚未生成 AWS 履约实例" :image-size="90" />
</div>
</el-card>
</template>
<script setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Cloudy, Connection, Cpu, Location, Monitor, Refresh, Timer } from '@element-plus/icons-vue'
import { getAwsInstanceDetail } from '@/api/admin/awsService.js'
import AwsInstanceDetail from '@/views/aws/AwsInstanceDetail.vue'
const props = defineProps({
goodsId: { type: Number, required: true }
})
const router = useRouter()
const loading = ref(false)
const errorMessage = ref('')
const instance = ref(null)
const managementRef = ref(null)
const envelope = response => response?.data || response || {}
const field = (object, camel, snake) => object?.[camel] ?? object?.[snake]
const provisionStatus = computed(() => field(instance.value, 'provisionStatus', 'provision_status') || '')
const pendingAction = computed(() => field(instance.value, 'pendingAction', 'pending_action') || '')
const remoteInstanceId = computed(() => field(instance.value, 'remoteAwsInstanceId', 'remote_aws_instance_id') || field(instance.value, 'remoteInstanceId', 'remote_instance_id') || resource.value.instance_id || '')
const requestSnapshot = computed(() => parseObject(instance.value?.request || instance.value?.requestSnapshot || instance.value?.request_snapshot))
const snapshot = computed(() => parseObject(instance.value?.resourceSnapshot || instance.value?.resource_snapshot))
const resource = computed(() => snapshot.value?.resource?.instance || snapshot.value?.instance || snapshot.value?.resource || snapshot.value || {})
function parseObject(value) {
if (!value) return {}
if (typeof value === 'object') return value
try { return JSON.parse(value) } catch { return {} }
}
const statusLabels = { pending: '等待中', submitting: '提交中', provisioning: '创建中', active: '运行中', failed: '失败', stopping: '停止中', stopped: '已停止', terminating: '终止中', terminated: '已终止' }
const statusText = status => statusLabels[status] || status || '-'
const statusType = status => status === 'active' ? 'success' : status === 'failed' ? 'danger' : ['pending', 'submitting', 'provisioning', 'stopping', 'terminating'].includes(status) ? 'warning' : 'info'
const actionText = action => ({ create: '创建', start: '启动', stop: '停止', reboot: '重启', terminate: '终止' })[action] || action || '-'
const reviewText = value => ({ none: '无需审核', pending: '待审核', retrying: '重试中', resolved: '已解决', rejected: '已驳回' })[value] || value || '-'
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
async function refresh() {
if (!props.goodsId || loading.value) return
loading.value = true
errorMessage.value = ''
instance.value = null
try {
const response = await getAwsInstanceDetail({ user_goods_id: props.goodsId })
const body = envelope(response)
if (body.code !== 200) throw new Error(body.message || body.msg || 'AWS 履约信息加载失败')
instance.value = body.data?.instance || body.data || null
} catch (error) {
errorMessage.value = error?.response?.data?.message || error?.message || 'AWS 履约信息加载失败'
} finally {
loading.value = false
}
}
function openService() {
const id = instance.value?.serviceId || instance.value?.service_id
if (id) router.push({ path: '/aws/services', query: { id } })
}
async function refreshAll() {
await refresh()
await nextTick()
await managementRef.value?.refresh?.()
}
defineExpose({ refresh: refreshAll })
refresh()
</script>
<style scoped>
.aws-panel { margin-top: 16px; border: 1px solid #dce8f8; border-radius: 12px; }
.panel-header, .panel-heading, .header-actions, .instance-hero, .instance-identity, .instance-title-row, .instance-links, .failure-actions { display: flex; align-items: center; }
.panel-header, .instance-hero, .failure-actions { justify-content: space-between; gap: 18px; }
.panel-heading { gap: 12px; }
.heading-icon, .instance-icon, .resource-icon { display: grid; flex: 0 0 auto; place-items: center; }
.heading-icon { width: 38px; height: 38px; border-radius: 11px; color: #fff; background: linear-gradient(135deg, #409eff, #6b63f6); font-size: 20px; }
.panel-heading strong, .panel-heading span { display: block; }
.panel-heading strong { color: #263247; font-size: 16px; }
.panel-heading span { margin-top: 4px; color: #98a2b3; font-size: 12px; }
.header-actions { gap: 8px; }
.header-actions .el-button { margin: 0; }
.panel-body { min-height: 110px; }
.panel-alert { margin-bottom: 16px; }
.instance-hero { margin-bottom: 16px; padding: 18px 20px; border: 1px solid #dbe9fb; border-radius: 12px; background: linear-gradient(135deg, #f2f7ff, #fbfdff); }
.instance-identity { min-width: 0; gap: 13px; }
.instance-icon { width: 44px; height: 44px; border-radius: 13px; color: #4f7cff; background: #e6efff; font-size: 22px; }
.instance-title-row { flex-wrap: wrap; gap: 8px; margin-bottom: 6px; }
.instance-title-row strong { color: #1f2d3d; font-size: 17px; }
.instance-identity code { color: #667085; font: 12px Consolas, monospace; }
.instance-links { flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #98a2b3; font-size: 12px; }
.resource-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.resource-grid article { display: flex; min-width: 0; align-items: center; gap: 12px; padding: 15px; border: 1px solid #e7edf5; border-radius: 11px; background: #fff; box-shadow: 0 5px 16px rgba(42, 61, 94, .04); }
.resource-icon { width: 38px; height: 38px; border-radius: 11px; font-size: 19px; }
.resource-icon.blue { color: #337ecc; background: #ecf5ff; }.resource-icon.purple { color: #6b57d2; background: #f2efff; }.resource-icon.green { color: #20a36a; background: #ebf8f2; }.resource-icon.orange { color: #e58a18; background: #fff4e6; }
.resource-grid article div { min-width: 0; }
.resource-grid small, .resource-grid strong, .resource-grid span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.resource-grid small { margin-bottom: 5px; color: #98a2b3; font-size: 11px; }.resource-grid strong { color: #344054; font-size: 13px; }.resource-grid span { margin-top: 4px; color: #7f8b9c; font-size: 11px; }
.mono { font-family: Consolas, monospace; }.breakable { word-break: break-all; }
.fulfillment-details { margin-top: 4px; }
.management-title { margin: 24px 0 4px; padding-top: 20px; border-top: 1px solid #e7edf5; }
.management-title strong, .management-title span { display: block; }
.management-title strong { color: #263247; font-size: 16px; }
.management-title span { margin-top: 5px; color: #98a2b3; font-size: 12px; }
.failure-actions { margin-top: 16px; padding: 13px 16px; border-radius: 9px; color: #b54708; background: #fffaeb; font-size: 13px; }
.failure-actions span { display: flex; align-items: center; gap: 7px; }
@media (max-width: 1200px) { .resource-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .panel-header, .instance-hero, .failure-actions { align-items: flex-start; flex-direction: column; }.resource-grid { grid-template-columns: 1fr; }.fulfillment-details :deep(.el-descriptions__body) { overflow-x: auto; }.instance-links { justify-content: flex-start; } }
</style>
+4
View File
@@ -564,6 +564,9 @@
</div>
</el-tab-pane>
<el-tab-pane v-if="isVmGoods" label="操作日志" name="operations">
<VmOperationRecords v-if="activeTab === 'operations'" :user-goods-id="userGoodsId" :vm-id="Number(vm?.id || 0)" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
@@ -1215,6 +1218,7 @@ import UserSelector from '@/components/UserSelector/index.vue'
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
import UserVmVolumeSelector from '@/components/admin/UserVmVolumeSelector.vue'
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
import VmOperationRecords from './components/VmOperationRecords.vue'
import dayjs from 'dayjs'
import * as echarts from 'echarts'
@@ -0,0 +1,70 @@
<template>
<div class="operation-records">
<div class="filters">
<el-input v-model.trim="filters.keyword" clearable placeholder="搜索操作、任务或错误" style="width:220px" @keyup.enter="search" />
<el-select v-model="filters.status" clearable placeholder="全部状态" style="width:130px" @change="search">
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="filters.operator_type" clearable placeholder="全部操作者" style="width:130px" @change="search">
<el-option label="管理员" value="admin" /><el-option label="用户" value="user" /><el-option label="系统" value="system" />
</el-select>
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" style="width:350px" @change="search" />
<el-button type="primary" @click="search">查询</el-button><el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe border empty-text="暂无操作日志">
<el-table-column prop="id" label="ID" width="76" />
<el-table-column label="操作" min-width="160"><template #default="{row}"><strong>{{ operationLabel(row.operation_type) }}</strong><div class="muted">{{ row.note || row.operation_type || '-' }}</div></template></el-table-column>
<el-table-column label="操作者" width="120"><template #default="{row}">{{ operatorLabel(row.operator_type) }}<div class="muted">#{{ row.operator_id || '-' }}</div></template></el-table-column>
<el-table-column label="状态" width="105"><template #default="{row}"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="task_id" label="任务 ID" min-width="170" show-overflow-tooltip />
<el-table-column label="开始时间" width="168"><template #default="{row}">{{ formatTime(row.started_at) }}</template></el-table-column>
<el-table-column label="耗时" width="100"><template #default="{row}">{{ duration(row) }}</template></el-table-column>
<el-table-column prop="error_message" label="错误信息" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right"><template #default="{row}"><el-button link type="primary" @click="showDetail(row.id)">详情</el-button><el-button v-if="canRefresh(row)" link type="warning" :loading="refreshingId===row.id" @click="refreshRecord(row)">核对状态</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page" v-model:page-size="count" :page-sizes="[10,20,50,100]" :total="total" layout="total,sizes,prev,pager,next" class="pagination" @current-change="load" @size-change="sizeChanged" />
<el-dialog v-model="detailVisible" title="虚拟机操作日志详情" width="760px" destroy-on-close>
<el-descriptions v-if="detail" :column="2" border>
<el-descriptions-item label="日志 ID">#{{ detail.id }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="操作类型">{{ operationLabel(detail.operation_type) }}</el-descriptions-item><el-descriptions-item label="操作者">{{ operatorLabel(detail.operator_type) }} #{{ detail.operator_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户商品">#{{ detail.user_good_id || '-' }}</el-descriptions-item><el-descriptions-item label="虚拟机">#{{ detail.vm_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="服务 / 主机">#{{ detail.service_id || '-' }} / #{{ detail.host_id || '-' }}</el-descriptions-item><el-descriptions-item label="任务 ID">{{ detail.task_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="请求方式 / 路由" :span="2">{{ detail.method || '-' }} {{ detail.route || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.note || '-' }}</el-descriptions-item><el-descriptions-item label="错误" :span="2">{{ detail.error_message || '-' }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ formatTime(detail.started_at) }}</el-descriptions-item><el-descriptions-item label="完成时间">{{ formatTime(detail.finished_at) }}</el-descriptions-item>
</el-descriptions>
<div v-if="detail?.request_snapshot || detail?.result_snapshot" class="snapshots"><section v-if="detail.request_snapshot"><h4>请求快照</h4><pre>{{ pretty(detail.request_snapshot) }}</pre></section><section v-if="detail.result_snapshot"><h4>结果快照</h4><pre>{{ pretty(detail.result_snapshot) }}</pre></section></div>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getVmOperationRecordDetail, getVmOperationRecordList, refreshVmOperationRecord } from '@/api/admin/userVm'
const props = defineProps({ userGoodsId: { type: Number, required: true }, vmId: { type: Number, default: 0 } })
const loading=ref(false),rows=ref([]),page=ref(1),count=ref(10),total=ref(0),dateRange=ref([]),detailVisible=ref(false),detail=ref(null),refreshingId=ref(0)
const filters=reactive({keyword:'',status:'',operator_type:''})
const statuses=[['pending','等待中'],['running','执行中'],['success','成功'],['failed','失败'],['aborted','已中止'],['unknown','未知']].map(([value,label])=>({value,label}))
const bodyOf=r=>r?.data||r||{},errorText=(e,f)=>e?.response?.data?.message||e?.message||f
const statusLabel=s=>Object.fromEntries(statuses.map(v=>[v.value,v.label]))[s]||s||'-'
const statusType=s=>s==='success'?'success':s==='failed'?'danger':s==='aborted'?'info':['pending','running'].includes(s)?'warning':''
const operatorLabel=s=>({admin:'管理员',user:'用户',system:'系统'})[s]||s||'-'
const operationLabel=s=>({start:'启动',stop:'关机',reboot:'重启',rebuild:'重装系统',migrate:'迁移',change_password:'重置密码',reset_mac:'重置 MAC',rescue:'救援模式',exit_rescue:'退出救援',create_snapshot:'创建快照',restore_snapshot:'恢复快照',delete_snapshot:'删除快照',create_backup:'创建备份',restore_backup:'恢复备份',delete_backup:'删除备份'})[s]||s||'-'
const formatTime=v=>v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'-'
const duration=row=>{if(!row.started_at)return'-';const end=row.finished_at?new Date(row.finished_at).getTime():Date.now(),seconds=Math.max(0,Math.round((end-new Date(row.started_at).getTime())/1000));return seconds<60?`${seconds} `:`${Math.floor(seconds/60)}${seconds%60}`}
const pretty=v=>JSON.stringify(v,null,2)
const canRefresh=row=>['unknown','running'].includes(row.status)&&Boolean(row.task_id||row.migration_record_id)
async function load(){loading.value=true;try{const params={page:page.value,count:count.value,user_good_id:props.userGoodsId};if(props.vmId)params.vm_id=props.vmId;if(filters.keyword)params.keyword=filters.keyword;if(filters.status)params.status=filters.status;if(filters.operator_type)params.operator_type=filters.operator_type;if(dateRange.value?.length)[params.started_at_start,params.started_at_end]=dateRange.value;const b=bodyOf(await getVmOperationRecordList(params));if(b.code!==200)throw new Error(b.message||'读取操作日志失败');rows.value=b.data?.data||[];total.value=Number(b.data?.all_count||0)}catch(e){ElMessage.error(errorText(e,'读取操作日志失败'))}finally{loading.value=false}}
function search(){page.value=1;load()}function sizeChanged(){page.value=1;load()}
async function showDetail(id){try{const b=bodyOf(await getVmOperationRecordDetail(id));if(b.code!==200)throw new Error(b.message||'读取详情失败');detail.value=b.data?.record||null;detailVisible.value=true}catch(e){ElMessage.error(errorText(e,'读取详情失败'))}}
async function refreshRecord(row){refreshingId.value=row.id;try{const b=bodyOf(await refreshVmOperationRecord(row.id));if(b.code!==200)throw new Error(b.message||'状态核对失败');ElMessage.success('状态核对完成');await load()}catch(e){ElMessage.error(errorText(e,'状态核对失败'))}finally{refreshingId.value=0}}
onMounted(load)
</script>
<style scoped>
.filters{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0}.muted{margin-top:3px;color:#909399;font-size:12px}.pagination{justify-content:flex-end;margin-top:16px}.snapshots{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px}.snapshots section{min-width:0}.snapshots h4{margin:0 0 8px}.snapshots pre{max-height:300px;overflow:auto;margin:0;padding:12px;border:1px solid #e4e7ed;border-radius:6px;background:#f7f8fa;font:12px/1.6 Consolas,monospace;white-space:pre-wrap;word-break:break-all}@media(max-width:900px){.snapshots{grid-template-columns:1fr}}
</style>
+1 -1
View File
@@ -24029,7 +24029,7 @@
},
"/api/v1/admin/good/user_vm/migrate": {
"post": {
"summary": "迁移虚拟机(更换宿主机)",
"summary": "迁移虚拟机(更换宿主机,不保留数据",
"deprecated": false,
"description": "",
"tags": [