From 543bbb0b6994408076b2ff5da6ec7f4d939b569f Mon Sep 17 00:00:00 2001 From: shiran Date: Sat, 5 Sep 2026 17:23:48 +0800 Subject: [PATCH] feat: enhance AWS and product management --- src/api/admin/awsService.js | 4 + src/views/aws/AwsGoods.vue | 87 ++-- src/views/aws/AwsInstanceDetail.vue | 67 ++- src/views/aws/AwsRuntimeVisualization.vue | 13 +- .../aws/components/AwsGoodsPolicyForm.vue | 82 ++++ src/views/aws/components/AwsVolumeEditor.vue | 20 + src/views/product/ProductGroup.vue | 230 +++++----- .../product/components/AwsUserGoodsDetail.css | 417 ++++++++++++++++++ .../product/components/AwsUserGoodsDetail.vue | 338 +++++++------- src/views/user/UserDetail.vue | 162 ++++++- 10 files changed, 1085 insertions(+), 335 deletions(-) create mode 100644 src/views/aws/components/AwsGoodsPolicyForm.vue create mode 100644 src/views/aws/components/AwsVolumeEditor.vue create mode 100644 src/views/product/components/AwsUserGoodsDetail.css diff --git a/src/api/admin/awsService.js b/src/api/admin/awsService.js index d6f2d28..4e697b6 100644 --- a/src/api/admin/awsService.js +++ b/src/api/admin/awsService.js @@ -40,6 +40,7 @@ export const deleteAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2. 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 expandAdminAwsRootVolume = (instanceId, sizeGib, key) => http2.post(`${runtimeBase}/root_volume/expand`, { instance_id: instanceId, size_gib: sizeGib }, 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)) @@ -54,3 +55,6 @@ export const retryAdminAwsOperation = (operationId, note = '', key) => http2.pos 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 }) +export const rebuildAdminAwsInstance = (instanceId, imageId, key) => http2.post(`${runtimeBase}/rebuild`, { instance_id: instanceId, image_id: imageId }, withKey(key)) +export const getAdminAwsTrafficLimit = instanceId => http2.get(`${runtimeBase}/traffic_limit`, { params: { instance_id: instanceId } }) +export const updateAdminAwsTrafficLimit = (instanceId, changes, key) => http2.post(`${runtimeBase}/traffic_limit/update`, { instance_id: instanceId, ...changes }, withKey(key)) diff --git a/src/views/aws/AwsGoods.vue b/src/views/aws/AwsGoods.vue index 3f04634..a1df370 100644 --- a/src/views/aws/AwsGoods.vue +++ b/src/views/aws/AwsGoods.vue @@ -15,47 +15,30 @@
- + - - - - - - -
- - 选择 -
-
-
- -
- - - - - - - - - - - 绑定 EIP加密系统盘安装 Agent启用绑定 - 登录凭据密码重置监控安全组数据卷读取 - - + + +
+ +
选择
+ +
+ + + 绑定 EIP加密系统盘安装 Agent启用绑定 +
+
+ +
+
+ +
登录凭据密码重置监控安全组数据卷读取SSH 密钥管理
新增 EIP更换 EIPPTR 反向解析数据盘变更系统盘扩容系统重装流量提升
启用每次更换附加费元 / 次计入用户 EIP 更换实时报价
+
+
+
+
@@ -70,15 +53,18 @@ import { ElMessage } from 'element-plus' import { Plus } from '@element-plus/icons-vue' 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) +import AwsGoodsPolicyForm from './components/AwsGoodsPolicyForm.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(), defaultsFormRef=ref(), constraintsFormRef=ref(), activeFormTab=ref('basic'), 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,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 normalizeVolume=(source={})=>({device_name:source.device_name||'',size_gib:Number(source.size_gib||20),type:source.type||'gp3',iops:Number(source.iops||0),throughput:Number(source.throughput||0),encrypted:source.encrypted!==false,kms_key_id:source.kms_key_id||'',delete_on_termination:source.delete_on_termination===true}) +const normalizeDefaults=(source={})=>({...source,placement_id:source.placement_id||'',image_id:source.image_id||'',instance_type:source.instance_type||'',name:source.name||'',security_group_mode:source.security_group_mode||'',iam_instance_profile_arn:source.iam_instance_profile_arn||'',user_data:source.user_data||'',private_ip_address:source.private_ip_address||'',associate_eip:source.associate_eip===true,monitoring:source.monitoring===true,ebs_optimized:source.ebs_optimized===true,shutdown_behavior:source.shutdown_behavior||'',root_volume:source.root_volume?normalizeVolume(source.root_volume):null,data_volumes:Array.isArray(source.data_volumes)?source.data_volumes.map(normalizeVolume):[],_tag_rows:Object.entries(source.tags||{}).map(([key,value])=>({key,value:String(value)})),install_agent:source.install_agent===true,traffic_limit_gib:Number(source.traffic_limit_gib||0)}) +const normalizeConstraints=(source={})=>({...source,root_volume_min_gib:Number(source.root_volume_min_gib||0),root_volume_max_gib:Number(source.root_volume_max_gib||0),allowed_volume_types:Array.isArray(source.allowed_volume_types)?[...source.allowed_volume_types]:[],associate_eip_locked:source.associate_eip_locked===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:normalizeDefaults(),constraints:normalizeConstraints(),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,allow_user_ssh_key_manage:false,allow_user_eip_add:false,allow_user_eip_replace:false,allow_user_eip_reverse_dns:false,eip_replace_fee_enabled:false,eip_replace_fee_yuan:0,allow_user_volume_mutation:false,allow_user_root_volume_expand:false,allow_user_rebuild:false,allow_user_traffic_limit:false,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'}],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 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 的汇率'}]} 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}} @@ -108,15 +94,18 @@ async function loadAvailableRegions(serviceId){ } } 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 openCreate(){editing.value=false;activeFormTab.value='basic';Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true;loadAvailableRegions(form.service_id)} +async function openEdit(row){editing.value=true;activeFormTab.value='basic';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:normalizeDefaults(data.defaults||{}),constraints:normalizeConstraints(data.constraints||{}),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,allow_user_ssh_key_manage:data.allowUserSshKeyManage===true,allow_user_eip_add:data.allowUserEipAdd===true,allow_user_eip_replace:data.allowUserEipReplace===true,allow_user_eip_reverse_dns:data.allowUserEipReverseDns===true,eip_replace_fee_enabled:data.eipReplaceFeeEnabled===true,eip_replace_fee_yuan:Number(data.eipReplaceFee||0)/100,allow_user_volume_mutation:data.allowUserVolumeMutation===true,allow_user_root_volume_expand:data.allowUserRootVolumeExpand===true,allow_user_rebuild:data.allowUserRebuild===true,allow_user_traffic_limit:data.allowUserTrafficLimit===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}} +function serializeVolume(source){const volume={device_name:String(source.device_name||'').trim(),size_gib:Number(source.size_gib),type:source.type,encrypted:source.encrypted===true,delete_on_termination:source.delete_on_termination===true};if(['gp3','io1','io2'].includes(volume.type)&&Number(source.iops)>0)volume.iops=Number(source.iops);if(volume.type==='gp3'&&Number(source.throughput)>0)volume.throughput=Number(source.throughput);if(volume.encrypted&&source.kms_key_id)volume.kms_key_id=String(source.kms_key_id).trim();if(!volume.device_name)delete volume.device_name;return volume} +function serializeDefaults(source){const defaults={...source},stringFields=['placement_id','image_id','instance_type','name','security_group_mode','iam_instance_profile_arn','user_data','private_ip_address','shutdown_behavior'];for(const key of stringFields){const value=String(defaults[key]||'').trim();if(value)defaults[key]=value;else delete defaults[key]}defaults.root_volume=source.root_volume?serializeVolume(source.root_volume):undefined;defaults.data_volumes=(source.data_volumes||[]).map(serializeVolume);defaults.tags=Object.fromEntries((source._tag_rows||[]).filter(v=>v.key).map(v=>[v.key,String(v.value||'')]));delete defaults._tag_rows;if(!defaults.root_volume)delete defaults.root_volume;if(!defaults.data_volumes.length)delete defaults.data_volumes;if(!Object.keys(defaults.tags).length)delete defaults.tags;if(Number(defaults.traffic_limit_gib)>0)defaults.traffic_limit_gib=Number(defaults.traffic_limit_gib);else delete defaults.traffic_limit_gib;return defaults} +function serializeConstraints(source){return{...source,root_volume_min_gib:Number(source.root_volume_min_gib||0),root_volume_max_gib:Number(source.root_volume_max_gib||0),allowed_volume_types:[...(source.allowed_volume_types||[])],associate_eip_locked:source.associate_eip_locked===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:serializeDefaults(form.defaults),constraints:serializeConstraints(form.constraints),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,allow_user_ssh_key_manage:form.allow_user_ssh_key_manage,allow_user_eip_add:form.allow_user_eip_add,allow_user_eip_replace:form.allow_user_eip_replace,allow_user_eip_reverse_dns:form.allow_user_eip_reverse_dns,eip_replace_fee_enabled:form.allow_user_eip_replace&&form.eip_replace_fee_enabled,eip_replace_fee:Math.round(Number(form.eip_replace_fee_yuan||0)*100),allow_user_volume_mutation:form.allow_user_volume_mutation,allow_user_root_volume_expand:form.allow_user_root_volume_expand,allow_user_rebuild:form.allow_user_rebuild,allow_user_traffic_limit:form.allow_user_traffic_limit,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,'数据卷'],[row.allowUserSshKeyManage,'SSH 密钥'],[row.allowUserEipAdd,'加 EIP'],[row.allowUserEipReplace,'换 EIP'],[row.allowUserEipReverseDns,'PTR'],[row.allowUserVolumeMutation,'磁盘变更'],[row.allowUserRootVolumeExpand,'系统盘扩容'],[row.allowUserRebuild,'重装'],[row.allowUserTrafficLimit,'流量提升'],[row.allowUserEipReplace&&row.eipReplaceFeeEnabled,`换 IP 费 ¥${(Number(row.eipReplaceFee||0)/100).toFixed(2)}`]].filter(v=>v[0]===true).map(v=>v[1])} +async function submit(){try{await formRef.value?.validate()}catch{activeFormTab.value='basic';return}const defaultsError=defaultsFormRef.value?.validate?.();if(defaultsError){activeFormTab.value='defaults';return ElMessage.warning(defaultsError)}const constraintsError=constraintsFormRef.value?.validate?.();if(constraintsError){activeFormTab.value='constraints';return ElMessage.warning(constraintsError)}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()}) diff --git a/src/views/aws/AwsInstanceDetail.vue b/src/views/aws/AwsInstanceDetail.vue index c71ce1a..c1c5f6d 100644 --- a/src/views/aws/AwsInstanceDetail.vue +++ b/src/views/aws/AwsInstanceDetail.vue @@ -2,7 +2,7 @@
← 返回

AWS 实例运维 #{{ instanceId }}

履约审核、远端资源和运行期操作统一管理

刷新
-
+
#{{instance.userGoodsId}} #{{instance.orderId}} @@ -16,29 +16,32 @@
履约记录与失败审核
重新同步审核重试确认解决驳回
{{instance.reviewStatus||'-'}}{{statusText(instance.previousProvisionStatus)}}{{instance.remoteTaskId||'-'}}{{instance.retryCount||0}}{{instance.lastErrorCode||'-'}}{{instance.failureStage||'-'}}{{instance.lastError||'-'}}{{instance.reviewNote||'-'}} -

创建请求

{{pretty(baseDetail?.request)}}

资源快照

{{snapshotText}}
+

开通配置

{{item.label}}{{item.value}}
-
实时详情与访问控制
刷新详情主动刷新{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'读取登录凭据'}}重置密码读取监控
+
实时详情与访问控制
刷新详情主动刷新{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'读取登录凭据'}}重置密码读取监控重装系统流量配置
-

实时详情

{{pretty(runtimeDetails)}}

监控聚合

{{pretty(monitoring)}}
安全组
刷新添加规则
- -
EIP
确保/更换更新 PTR解绑释放
{{pretty(eip)}}
+
安全组名称{{security.group_name||security.name||'-'}}{{security.group_id||security.id||'-'}}
所属 VPC{{security.vpc_id||'-'}}
规则统计{{(security.ingress_rules||[]).length}} 条入站 · {{(security.egress_rules||[]).length}} 条出站
AWS 托管
+ + +
弹性公网 IP
确保/更换更新 PTR解绑释放
+
公网 / 私网地址{{eipCurrent.public_ip||'-'}}{{eipCurrent.private_ip||'-'}}
Allocation ID{{eipCurrent.allocation_id||'-'}}
网络接口{{eipCurrent.network_interface_id||'-'}}
PTR 记录{{eipCurrent.ptr_record||'未设置'}}
已关联
+
数据卷
刷新创建并挂载
- +
运行期异步操作刷新
- +
@@ -60,8 +63,10 @@ - - -
{{pretty(currentOperation)}}
+ + + {{trafficLimit?.enabled?'已启用':'未启用'}}{{trafficLimit?.limit_bytes?bytes(trafficLimit.limit_bytes):'-'}} +

{{operationTypeText(currentOperation.operationType)}}

操作记录 #{{currentOperation.id}}

{{operationText(currentOperation.status)}}
#{{currentOperation.instanceId||'-'}}#{{currentOperation.userGoodsId}}-#{{currentOperation.orderId}}-{{currentOperation.targetRenewPrice==null?'-':`¥${cents(currentOperation.targetRenewPrice)}`}}{{time(currentOperation.nextRetryAt)}}{{time(currentOperation.finishedAt)}}{{currentOperation.retryCount||0}}{{time(currentOperation.UpdatedAt)}}{{currentOperation.errorMessage}}
@@ -69,37 +74,50 @@ 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 { Connection, InfoFilled, Lock, Operation, 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 route=useRoute(),embedded=computed(()=>props.embedded),instanceId=props.instanceId||Number(route.params.instanceId),loading=ref(false),submitting=ref(false),pageError=ref(''),tab=ref(props.embedded?'runtime':'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),trafficLimit=ref(null) +const loginDialog=ref(false),loginUsers=ref([]),loginRetryAfter=ref(0),retryTimer=ref(null),revealed=reactive({}),ruleDialog=ref(false),volumeDialog=ref(false),volumeMode=ref('create'),rootCurrentSize=ref(0),operationDialog=ref(false),currentOperation=ref(null),pollTimer=ref(null),pollStarted=ref(0),rebuildDialog=ref(false),rebuildImageId=ref(''),trafficDialog=ref(false) +const protocols=['tcp','udp','icmp','icmpv6','-1'],volumeTypes=['gp3','gp2','io1','io2','st1','sc1','standard'],volumeTitles={create:'创建并挂载数据卷',modify:'扩容或修改数据卷',attach:'挂载数据卷',root_expand:'扩容系统盘'} 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 trafficForm=reactive({enabled:true,limit_gib:100}) 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 publicManagementRisk=computed(()=>securityRules.value.some(r=>r.direction==='ingress'&&r.protocol==='tcp'&&(r.from_port??r.fromPort)<=22&&(r.to_port??r.toPort)>=22&&['0.0.0.0/0','::/0'].includes(r.cidr))) +const eipCurrent=computed(()=>eip.value?.current||(!eip.value?.available&&eip.value?.public_ip?eip.value:null)) +const requestRows=computed(()=>{const r=baseDetail.value?.request||{},root=r.root_volume||{};return[{label:'实例规格',value:r.instance_type||'-'},{label:'镜像 ID',value:r.image_id||'-'},{label:'系统盘',value:root.size_gib?`${root.size_gib} GiB · ${String(root.type||'').toUpperCase()}`:'-'},{label:'磁盘加密',value:root.encrypted?'已启用':'未启用'},{label:'弹性公网 IP',value:r.associate_eip?'自动关联':'不关联'},{label:'详细监控',value:r.monitoring?'已启用':'未启用'},{label:'管理 Agent',value:r.install_agent?'自动安装':'不安装'},{label:'安全组模式',value:r.security_group_mode==='common_ports'?'常用端口':r.security_group_mode||'-'},{label:'流量额度',value:r.traffic_limit_gib?`${r.traffic_limit_gib} GiB`:'未限制'},{label:'关机行为',value:r.shutdown_behavior==='stop'?'停止实例':r.shutdown_behavior||'-'}]}) +const 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 operationText=s=>({awaiting_payment:'待支付',queued:'排队中',running:'执行中',succeeded:'成功',failed:'失败',rejected:'已驳回'})[s]||s||'-',operationTag=s=>s==='succeeded'?'success':s==='failed'?'danger':s==='rejected'?'info':'warning' +const operationTypeText=value=>({ + 'details.refresh':'刷新实例详情','security_group.rule.add':'添加安全组规则','security_group.rule.delete':'删除安全组规则','eip.replace':'更换弹性 IP','eip.disassociate':'解绑弹性 IP','eip.release':'释放弹性 IP','eip.reverse_dns':'更新 PTR 记录','volume.create':'创建数据卷','volume.modify':'修改数据卷','root_volume.expand':'扩容系统盘','volume.attach':'挂载数据卷','volume.detach':'卸载数据卷','volume.delete':'删除数据卷','password.reset':'重置实例密码','instance.rebuild':'重装系统','traffic_limit.update':'修改流量上限' +})[value]||value||'-' +const actorText=value=>({user:'用户',admin:'管理员',system:'系统'})[value]||value||'-' +const time=value=>value?new Date(value).toLocaleString('zh-CN',{hour12:false}):'-' +const cents=value=>(Number(value||0)/100).toFixed(2) +function bytes(value){const n=Number(value||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]}`} const portText=r=>r.protocol==='-1'?'全部':`${r.from_port??r.fromPort??'-'}-${r.to_port??r.toPort??'-'}` +const directionText=value=>({ingress:'入站',egress:'出站'})[value]||value||'-' +const protocolText=value=>value==='-1'?'全部':String(value||'-').toUpperCase() 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 loadBase(){loading.value=true;pageError.value='';try{const v=await safe(()=>api.getAwsInstanceDetail(instanceId),'加载履约详情失败');if(v){baseDetail.value=v;if(embedded.value&&tab.value==='runtime')await Promise.all([runtimeDetails.value?null:loadRuntime(),monitoring.value?null:loadMonitoring()])}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 loadTraffic(){const v=await safe(()=>api.getAdminAwsTrafficLimit(instanceId),'读取流量上限失败');if(v!==null)trafficLimit.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()} +function tabChanged(name){if(name==='runtime'){if(!runtimeDetails.value)loadRuntime();if(!monitoring.value)loadMonitoring();if(!trafficLimit.value)loadTraffic()}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} @@ -117,9 +135,12 @@ async function deleteRule(row){try{await ElMessageBox.confirm(`确认删除 ${ro 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()})} +function openVolume(mode,row={}){volumeMode.value=mode;rootCurrentSize.value=mode==='root_expand'?Number(row.size_gib||0):0;Object.assign(volumeForm,{volume_id:row.volume_id||'',name:row.name||'',device_name:row.device_name||'/dev/sdf',size_gib:mode==='root_expand'?rootCurrentSize.value+1: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','root_expand'].includes(volumeMode.value)&&(volumeForm.size_gib<1||volumeForm.size_gib>16384))return ElMessage.warning('容量范围为 1-16384 GiB');if(volumeMode.value==='root_expand'&&Number(volumeForm.size_gib)<=rootCurrentSize.value)return ElMessage.warning('目标容量必须大于当前系统盘容量');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):volumeMode.value==='root_expand'?()=>api.expandAdminAwsRootVolume(instanceId,Number(volumeForm.size_gib)):()=>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 submitRebuild(){if(!rebuildImageId.value)return ElMessage.warning('请输入镜像 ID');try{await ElMessageBox.confirm('重装将覆盖系统盘,确认继续?','确认重装',{type:'error'})}catch{return}runAsync(()=>api.rebuildAdminAwsInstance(instanceId,rebuildImageId.value),async()=>{rebuildDialog.value=false;tab.value='operations';await loadOperations()})} +async function openTraffic(){await loadTraffic();const gib=Math.round(Number(trafficLimit.value?.limit_bytes||0)/1024**3);Object.assign(trafficForm,{enabled:trafficLimit.value?.enabled!==false,limit_gib:gib||100});trafficDialog.value=true} +function submitTraffic(){if(trafficForm.enabled&&Number(trafficForm.limit_gib)<=0)return ElMessage.warning('请输入有效流量额度');runAsync(()=>api.updateAdminAwsTrafficLimit(instanceId,{enabled:trafficForm.enabled,limit_gib:Number(trafficForm.limit_gib)}),async()=>{trafficDialog.value=false;await Promise.all([loadTraffic(),loadRuntime()])})} 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}) @@ -129,4 +150,6 @@ onMounted(loadBase);onBeforeUnmount(()=>{stopPoll();clearInterval(retryTimer.val diff --git a/src/views/aws/AwsRuntimeVisualization.vue b/src/views/aws/AwsRuntimeVisualization.vue index 0e75dca..4791ca5 100644 --- a/src/views/aws/AwsRuntimeVisualization.vue +++ b/src/views/aws/AwsRuntimeVisualization.vue @@ -1,5 +1,6 @@