feat: enhance AWS and product management
Build and Deploy Vue3 / build (push) Successful in 1m49s
Build and Deploy Vue3 / deploy (push) Successful in 3m19s

This commit is contained in:
shiran
2026-09-05 17:23:48 +08:00
parent d0171659bd
commit 543bbb0b69
10 changed files with 1085 additions and 335 deletions
+4
View File
@@ -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))
+38 -49
View File
@@ -15,47 +15,30 @@
<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-dialog v-model="dialog" :title="editing?'编辑 AWS 商品绑定':'新增 AWS 商品绑定'" width="min(1080px, 94vw)" top="3vh" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="145px">
<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-tabs v-model="activeFormTab" class="form-tabs">
<el-tab-pane label="基础绑定" name="basic">
<div class="tab-panel">
<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-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>
</div>
</el-tab-pane>
<el-tab-pane label="销售目录" name="catalog">
<div class="tab-panel"><el-alert title="白名单留空表示允许全部已发布选项;默认镜像和规格可在“默认创建”页签中设置" type="info" show-icon :closable="false" class="tab-tip"/><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></div>
</el-tab-pane>
<el-tab-pane label="用户能力" name="capabilities">
<div class="tab-panel"><el-form-item label="运行期能力"><div class="capability-options"><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-checkbox v-model="form.allow_user_ssh_key_manage">SSH 密钥管理</el-checkbox></div></el-form-item><el-form-item label="增值能力"><div class="capability-options"><el-checkbox v-model="form.allow_user_eip_add">新增 EIP</el-checkbox><el-checkbox v-model="form.allow_user_eip_replace">更换 EIP</el-checkbox><el-checkbox v-model="form.allow_user_eip_reverse_dns">PTR 反向解析</el-checkbox><el-checkbox v-model="form.allow_user_volume_mutation">数据盘变更</el-checkbox><el-checkbox v-model="form.allow_user_root_volume_expand">系统盘扩容</el-checkbox><el-checkbox v-model="form.allow_user_rebuild">系统重装</el-checkbox><el-checkbox v-model="form.allow_user_traffic_limit">流量提升</el-checkbox></div></el-form-item><el-form-item label="EIP 更换附加费"><div class="fee-setting"><el-checkbox v-model="form.eip_replace_fee_enabled" :disabled="!form.allow_user_eip_replace">启用每次更换附加费</el-checkbox><el-input-number v-model="form.eip_replace_fee_yuan" :min="0" :precision="2" :step="1" :disabled="!form.allow_user_eip_replace || !form.eip_replace_fee_enabled"/><span> / </span><small>计入用户 EIP 更换实时报价</small></div></el-form-item></div>
</el-tab-pane>
<el-tab-pane label="默认创建" name="defaults"><div class="policy-tab"><AwsGoodsPolicyForm ref="defaultsFormRef" section="defaults" :defaults="form.defaults" :constraints="form.constraints" :allow-volume-mutation="form.allow_user_volume_mutation" :allowed-images="form.allowed_images" :allowed-types="form.allowed_types"/></div></el-tab-pane>
<el-tab-pane label="商品约束" name="constraints"><div class="policy-tab"><AwsGoodsPolicyForm ref="constraintsFormRef" section="constraints" :defaults="form.defaults" :constraints="form.constraints" :allow-volume-mutation="form.allow_user_volume_mutation" :allowed-images="form.allowed_images" :allowed-types="form.allowed_types"/></div></el-tab-pane>
</el-tabs>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存</el-button></template>
</el-dialog>
@@ -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()})
</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}.create-tip{margin-bottom:18px}.selector-field{display:flex;width:100%;gap:8px}.selector-field .el-input{flex:1}
.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}.create-tip{margin-bottom:18px}.selector-field{display:flex;width:100%;gap:8px}.selector-field .el-input{flex:1}.capability-options{display:flex;flex-wrap:wrap;gap:0 18px}.capability-options .el-checkbox{margin-right:0}.fee-setting{display:flex;align-items:center;flex-wrap:wrap;gap:10px}.fee-setting .el-input-number{width:150px}.fee-setting>span{color:#606266}.fee-setting small{color:#909399}.form-tabs{min-height:560px}.form-tabs :deep(.el-tabs__header){margin:0;padding:0 6px;border-bottom:1px solid #e6edf5}.form-tabs :deep(.el-tabs__nav-wrap::after){height:0}.form-tabs :deep(.el-tabs__item){height:50px;padding:0 24px}.form-tabs :deep(.el-tabs__content){max-height:68vh;overflow-y:auto;padding-right:4px}.tab-panel{padding:24px 18px 8px}.policy-tab{padding:20px 4px 0}.tab-tip{margin-bottom:20px}@media(max-width:900px){.form-tabs :deep(.el-tabs__item){padding:0 12px}.tab-panel{padding-right:8px;padding-left:8px}}
</style>
+45 -22
View File
@@ -2,7 +2,7 @@
<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">
<div v-if="instance && !embedded" 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>
@@ -16,29 +16,32 @@
<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>
<div class="request-config"><h4>开通配置</h4><div class="request-grid"><div v-for="item in requestRows" :key="item.label"><span>{{item.label}}</span><b>{{item.value}}</b></div></div></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>
<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><el-button type="danger" plain @click="rebuildDialog=true">重装系统</el-button><el-button type="primary" plain @click="openTraffic">流量配置</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>
<div v-if="security" class="security-summary"><span class="security-icon"><el-icon><Lock /></el-icon></span><div><small>安全组名称</small><b>{{security.group_name||security.name||'-'}}</b><code>{{security.group_id||security.id||'-'}}</code></div><div><small>所属 VPC</small><code>{{security.vpc_id||'-'}}</code></div><div><small>规则统计</small><b>{{(security.ingress_rules||[]).length}} 条入站 · {{(security.egress_rules||[]).length}} 条出站</b></div><el-tag type="success" effect="plain">AWS 托管</el-tag></div>
<el-alert v-if="publicManagementRisk" title="SSH 管理端口当前允许所有 IPv4 地址访问" description="实例启用了长期密码登录,建议将 22 端口来源限制为可信管理地址。" type="warning" show-icon :closable="false" class="security-warning"/>
<el-table :data="securityRules" border stripe><el-table-column label="方向" width="90"><template #default="{row}"><el-tag :type="row.direction==='ingress'?'primary':'info'" size="small" effect="plain">{{directionText(row.direction)}}</el-tag></template></el-table-column><el-table-column label="协议" width="90"><template #default="{row}">{{protocolText(row.protocol)}}</template></el-table-column><el-table-column label="端口" width="120"><template #default="{row}">{{portText(row)}}</template></el-table-column><el-table-column prop="cidr" label="来源 / 目标" 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>弹性公网 IP</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>
<div v-if="eipCurrent" class="eip-card"><span class="eip-icon"><el-icon><Connection /></el-icon></span><div class="eip-address"><small>公网 / 私网地址</small><b>{{eipCurrent.public_ip||'-'}}</b><span>{{eipCurrent.private_ip||'-'}}</span></div><div><small>Allocation ID</small><code>{{eipCurrent.allocation_id||'-'}}</code></div><div><small>网络接口</small><code>{{eipCurrent.network_interface_id||'-'}}</code></div><div><small>PTR 记录</small><b>{{eipCurrent.ptr_record||'未设置'}}</b></div><el-tag type="success" effect="plain">已关联</el-tag></div>
<el-empty v-else description="当前未关联弹性公网 IP" :image-size="70"/>
</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-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 v-if="row.root" link type="primary" @click="openVolume('root_expand',row)">扩容系统盘</el-button><template v-else><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></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-table :data="operations" border stripe><el-table-column prop="id" label="ID" width="70"/><el-table-column label="操作类型" min-width="150"><template #default="{row}"><span class="operation-type">{{operationTypeText(row.operationType)}}</span></template></el-table-column><el-table-column label="订单" width="100"><template #default="{row}"><el-link v-if="row.orderId" type="primary" @click="$router.push({path:'/order/list',query:{order_id:row.orderId}})">#{{row.orderId}}</el-link><span v-else>-</span></template></el-table-column><el-table-column label="状态" width="105"><template #default="{row}"><el-tag :type="operationTag(row.status)">{{operationText(row.status)}}</el-tag></template></el-table-column><el-table-column label="目标续费价" width="115"><template #default="{row}">{{row.targetRenewPrice==null?'-':`¥${cents(row.targetRenewPrice)}`}}</template></el-table-column><el-table-column label="下次重试" width="170"><template #default="{row}">{{time(row.nextRetryAt)}}</template></el-table-column><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>
@@ -60,8 +63,10 @@
<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>
<el-dialog v-model="volumeDialog" :title="volumeTitles[volumeMode]" width="560px"><el-alert v-if="volumeMode==='root_expand'" title="系统盘只能增加容量,卷类型、性能、加密和删除策略保持不变" type="info" show-icon :closable="false" class="alert"/><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','root_expand'].includes(volumeMode)" label="目标容量 GiB"><el-input-number v-model="volumeForm.size_gib" :min="volumeMode==='root_expand'?rootCurrentSize+1: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" :loading="submitting" @click="submitVolume">提交</el-button></template></el-dialog>
<el-dialog v-model="rebuildDialog" title="重装 AWS 实例" width="500px"><el-alert title="重装将覆盖系统盘,请确认镜像 ID 和数据备份" type="warning" show-icon :closable="false" class="alert"/><el-form label-width="90px"><el-form-item label="镜像 ID"><el-input v-model.trim="rebuildImageId" placeholder="ami-..."/></el-form-item></el-form><template #footer><el-button @click="rebuildDialog=false">取消</el-button><el-button type="danger" :loading="submitting" @click="submitRebuild">确认重装</el-button></template></el-dialog>
<el-dialog v-model="trafficDialog" title="实例流量上限" width="520px"><el-form label-width="110px"><el-form-item label="当前状态"><el-tag :type="trafficLimit?.enabled?'success':'info'">{{trafficLimit?.enabled?'已启用':'未启用'}}</el-tag></el-form-item><el-form-item label="当前额度">{{trafficLimit?.limit_bytes?bytes(trafficLimit.limit_bytes):'-'}}</el-form-item><el-form-item label="启用"><el-switch v-model="trafficForm.enabled"/></el-form-item><el-form-item label="额度 GiB"><el-input-number v-model="trafficForm.limit_gib" :min="1" :precision="0"/></el-form-item></el-form><template #footer><el-button @click="trafficDialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitTraffic">提交变更</el-button></template></el-dialog>
<el-dialog v-model="operationDialog" title="运行期操作详情" width="680px"><div v-if="currentOperation" class="operation-detail"><div class="operation-hero"><span class="operation-icon"><el-icon><Operation /></el-icon></span><div><h3>{{operationTypeText(currentOperation.operationType)}}</h3><p>操作记录 #{{currentOperation.id}}</p></div><el-tag :type="operationTag(currentOperation.status)" effect="dark">{{operationText(currentOperation.status)}}</el-tag></div><el-progress :percentage="Number(currentOperation.progress||0)" :status="currentOperation.status==='failed'?'exception':currentOperation.status==='succeeded'?'success':''"/><el-descriptions :column="2" border><el-descriptions-item label="实例 ID">#{{currentOperation.instanceId||'-'}}</el-descriptions-item><el-descriptions-item label="用户商品"><el-link v-if="currentOperation.userGoodsId" type="primary" @click="$router.push(`/user-goods/detail/${currentOperation.userGoodsId}`)">#{{currentOperation.userGoodsId}}</el-link><span v-else>-</span></el-descriptions-item><el-descriptions-item label="关联订单"><el-link v-if="currentOperation.orderId" type="primary" @click="$router.push({path:'/order/list',query:{order_id:currentOperation.orderId}})">#{{currentOperation.orderId}}</el-link><span v-else>-</span></el-descriptions-item><el-descriptions-item label="目标续费价">{{currentOperation.targetRenewPrice==null?'-':`¥${cents(currentOperation.targetRenewPrice)}`}}</el-descriptions-item><el-descriptions-item label="下次重试">{{time(currentOperation.nextRetryAt)}}</el-descriptions-item><el-descriptions-item label="完成时间">{{time(currentOperation.finishedAt)}}</el-descriptions-item><el-descriptions-item label="重试次数">{{currentOperation.retryCount||0}}</el-descriptions-item><el-descriptions-item label="更新时间">{{time(currentOperation.UpdatedAt)}}</el-descriptions-item><el-descriptions-item v-if="currentOperation.errorMessage" label="错误信息" :span="2">{{currentOperation.errorMessage}}</el-descriptions-item></el-descriptions></div></el-dialog>
</div>
</template>
@@ -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
<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}}
.request-config{margin-top:18px}.request-config h4{margin:0 0 12px;color:#344054}.request-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.request-grid>div{min-width:0;padding:12px 13px;border:1px solid #e8edf5;border-radius:10px;background:#f8faff}.request-grid span,.request-grid b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.request-grid span{color:#98a2b3;font-size:11px}.request-grid b{margin-top:6px;color:#344054;font-size:12px}.security-summary,.eip-card{display:grid;align-items:center;gap:16px;padding:17px;border:1px solid #dce9fa;border-radius:12px;background:linear-gradient(135deg,#f2f7ff,#fff)}.security-summary{grid-template-columns:auto 1.2fr 1fr 1fr auto;margin-bottom:14px}.security-icon,.eip-icon,.operation-icon{display:grid;place-items:center}.security-icon,.eip-icon{width:42px;height:42px;border-radius:12px;color:#4f7cff;background:#e7f0ff;font-size:20px}.security-summary>div,.eip-card>div{min-width:0}.security-summary small,.security-summary b,.security-summary code,.eip-card small,.eip-card b,.eip-card span,.eip-card code{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.security-summary small,.eip-card small{margin-bottom:5px;color:#98a2b3;font-size:11px}.security-summary b,.security-summary code,.eip-card b,.eip-card code{color:#344054;font-size:12px}.security-summary code,.eip-card code{font-family:Consolas,monospace}.security-warning{margin-bottom:14px}.eip-card{grid-template-columns:auto 1.2fr 1fr 1fr .8fr auto}.eip-address b{font-size:16px}.eip-address span{margin-top:4px;color:#667085;font:11px Consolas,monospace}.operation-type{color:#344054;font-weight:600}.operation-hero{display:flex;align-items:center;gap:12px;margin-bottom:18px;padding:16px;border:1px solid #e4ebf7;border-radius:12px;background:linear-gradient(135deg,#f3f7ff,#fff)}.operation-icon{width:42px;height:42px;flex:none;border-radius:12px;color:#fff;background:linear-gradient(135deg,#4f7cff,#7b61e8);font-size:20px}.operation-hero>div{min-width:0;flex:1}.operation-hero h3{margin:0;color:#29364a;font-size:16px}.operation-hero p{margin:5px 0 0;color:#98a2b3;font-size:12px}.operation-detail>.el-progress{margin-bottom:18px}.operation-detail .el-descriptions{margin-top:16px}@media(max-width:1100px){.request-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.security-summary,.eip-card{grid-template-columns:auto 1fr 1fr}.security-summary>.el-tag,.eip-card>.el-tag{justify-self:start}}@media(max-width:760px){.request-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.security-summary,.eip-card{grid-template-columns:1fr}.security-icon,.eip-icon{display:none}}
</style>
+12 -1
View File
@@ -1,5 +1,6 @@
<template>
<div v-if="details || monitoring" class="runtime-visual">
<el-alert v-if="agentWarning" :title="agentWarning" type="warning" show-icon :closable="false" class="runtime-warning" />
<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>
@@ -10,6 +11,10 @@
<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>
<div v-if="details" class="catalog-grid">
<section class="surface catalog"><header><b><el-icon><Cpu /></el-icon> 计算规格</b><el-tag size="small" :type="instanceType.catalog_match===false?'warning':'success'">{{ sourceText(instanceType.detail_source) }}</el-tag></header><h3>{{instanceType.display_name||instanceType.instance_type||instance.instance_type||'-'}}</h3><p>{{instanceType.description||'暂无规格说明'}}</p><div class="catalog-facts"><span><b>{{instanceType.vcpus||'-'}}</b> vCPU</span><span><b>{{memory(instanceType.memory_mib)}}</b> 内存</span><span><b>{{instanceType.network_performance||'-'}}</b> 网络</span><span v-if="Number(instanceType.gpu_count)>0"><b>{{instanceType.gpu_count}}</b> GPU</span></div></section>
<section class="surface catalog"><header><b><el-icon><Platform /></el-icon> 系统镜像</b><el-tag size="small" :type="imageDetails.catalog_match===false?'warning':'success'">{{ sourceText(imageDetails.detail_source) }}</el-tag></header><h3>{{imageDetails.display_name||imageDetails.name||imageDetails.image_id||instance.image_id||'-'}}</h3><p>{{imageDetails.display_intro||imageDetails.description||'暂无镜像说明'}}</p><div class="catalog-facts"><span><b>{{imageDetails.os_family||imageDetails.platform||'-'}}</b> 系统</span><span><b>{{imageDetails.architecture||'-'}}</b> 架构</span><span><b>{{imageDetails.source_name||imageDetails.owner_alias||'-'}}</b> 来源</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>
@@ -21,14 +26,18 @@
<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'
import { CircleCheck, Connection, Cpu, Monitor, Platform, 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 instanceType=computed(()=>props.details?.instance_type_details||{}),imageDetails=computed(()=>props.details?.image_details||{})
const agentWarning=computed(()=>monitorAgent.value.password_login_warning||'')
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||'-'
const memory=v=>Number(v)>=1024?`${(Number(v)/1024).toFixed(Number(v)%1024?1:0)} GiB`:`${Number(v)||'-'} MiB`
const sourceText=v=>({published_catalog:'商品目录',candidate_cache:'候选缓存',instance_snapshot:'实例快照'})[v]||'来源未知'
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}
@@ -37,5 +46,7 @@ const resize=()=>{lineChart?.resize();polarChart?.resize()};watch(()=>props.moni
</script>
<style scoped>
.runtime-warning{margin-bottom:14px}
.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}}
.catalog-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin-bottom:14px}.catalog h3{margin:0 0 7px;color:#263247;font-size:16px}.catalog>p{min-height:34px;margin:0 0 13px;color:#8793a6;font-size:12px}.catalog-facts{display:flex;flex-wrap:wrap;gap:8px}.catalog-facts span{min-width:90px;padding:8px;border-radius:8px;background:#f6f8fc;color:#98a2b3;font-size:10px}.catalog-facts b{display:block;margin-bottom:3px;color:#344054;font-size:12px}@media(max-width:900px){.catalog-grid{grid-template-columns:1fr}}
</style>
@@ -0,0 +1,82 @@
<template>
<div class="policy-form">
<section v-if="section !== 'constraints'" class="policy-section">
<div class="section-head"><div><b>默认实例配置</b><small>用户未指定时采用以下参数留空表示交由 AWS 或商品配置决定</small></div></div>
<el-row :gutter="14">
<el-col :span="8"><el-form-item label="实例名称"><el-input v-model.trim="defaults.name" clearable placeholder="可选默认名称"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="可用区/放置"><el-input v-model.trim="defaults.placement_id" clearable placeholder="如 us-east-1a"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="私网 IP"><el-input v-model.trim="defaults.private_ip_address" clearable placeholder="留空自动分配"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="默认镜像"><el-select v-model="defaults.image_id" filterable clearable allow-create default-first-option style="width:100%" placeholder="请选择或输入 AMI ID"><el-option v-for="id in allowedImages" :key="id" :label="id" :value="id"/></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="默认规格"><el-select v-model="defaults.instance_type" filterable clearable allow-create default-first-option style="width:100%" placeholder="请选择或输入实例规格"><el-option v-for="type in allowedTypes" :key="type" :label="type" :value="type"/></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="安全组模式"><el-select v-model="defaults.security_group_mode" clearable style="width:100%"><el-option label="常用端口" value="common_ports"/><el-option label="允许全部" value="allow_all"/></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="关机行为"><el-select v-model="defaults.shutdown_behavior" clearable style="width:100%"><el-option label="停止实例" value="stop"/><el-option label="终止实例" value="terminate"/></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="流量额度"><el-input-number v-model="defaults.traffic_limit_gib" :min="0" :precision="0" style="width:100%"/><span class="unit">GiB</span></el-form-item></el-col>
<el-col :span="24"><el-form-item label="IAM Profile"><el-input v-model.trim="defaults.iam_instance_profile_arn" clearable placeholder="IAM Instance Profile ARN"/></el-form-item></el-col>
<el-col :span="24"><el-form-item label="User Data"><el-input v-model="defaults.user_data" type="textarea" :rows="4" placeholder="可选启动脚本或 cloud-init 内容"/></el-form-item></el-col>
</el-row>
<div class="switch-row"><el-checkbox v-model="defaults.associate_eip">关联 EIP</el-checkbox><el-checkbox v-model="defaults.monitoring">开启详细监控</el-checkbox><el-checkbox v-model="defaults.ebs_optimized">EBS 优化</el-checkbox><el-checkbox v-model="defaults.install_agent">安装 Agent</el-checkbox></div>
</section>
<section v-if="section !== 'constraints'" class="policy-section">
<div class="section-head"><div><b>默认系统盘</b><small>配置新实例系统盘关闭后使用镜像或 AWS 默认设置</small></div><el-switch :model-value="!!defaults.root_volume" active-text="自定义" inactive-text="默认" @change="toggleRoot"/></div>
<volume-editor v-if="defaults.root_volume" v-model="defaults.root_volume" :removable="false"/>
</section>
<section v-if="section !== 'constraints'" class="policy-section">
<div class="section-head"><div><b>默认数据盘与运行期模板</b><small>第一项同时作为用户新增数据盘的管理员模板</small></div><el-button type="primary" plain @click="addVolume">添加数据盘</el-button></div>
<el-alert v-if="allowVolumeMutation && !defaults.data_volumes.length" title="尚未配置模板,用户端创建数据盘能力将不可用" type="warning" show-icon :closable="false"/>
<div v-for="(volume,index) in defaults.data_volumes" :key="index" class="volume-item">
<div class="volume-title"><span>数据盘 {{ index + 1 }}</span><el-tag v-if="index===0" size="small" type="success">用户新增模板</el-tag><el-button link type="danger" @click="removeVolume(index)">删除</el-button></div>
<volume-editor v-model="defaults.data_volumes[index]" :removable="false"/>
</div>
<el-empty v-if="!defaults.data_volumes.length" description="未配置默认数据盘" :image-size="54"/>
</section>
<section v-if="section !== 'constraints'" class="policy-section">
<div class="section-head"><div><b>实例标签</b><small>创建实例时写入 AWS 的标签键值</small></div><el-button plain @click="addTag">添加标签</el-button></div>
<div v-for="(tag,index) in defaults._tag_rows" :key="index" class="tag-row"><el-input v-model.trim="tag.key" placeholder="标签键"/><el-input v-model.trim="tag.value" placeholder="标签值"/><el-button link type="danger" @click="defaults._tag_rows.splice(index,1)">删除</el-button></div>
<el-empty v-if="!defaults._tag_rows.length" description="未配置实例标签" :image-size="48"/>
</section>
<section v-if="section !== 'defaults'" class="policy-section constraint-section">
<div class="section-head"><div><b>商品约束</b><small>限制用户可选择的系统盘容量卷类型与 EIP 行为</small></div></div>
<el-row :gutter="14">
<el-col :span="8"><el-form-item label="系统盘最小"><el-input-number v-model="constraints.root_volume_min_gib" :min="0" :max="16384" style="width:100%"/><span class="unit">GiB</span></el-form-item></el-col>
<el-col :span="8"><el-form-item label="系统盘最大"><el-input-number v-model="constraints.root_volume_max_gib" :min="0" :max="16384" style="width:100%"/><span class="unit">GiB</span></el-form-item></el-col>
<el-col :span="8"><el-form-item label="EIP 选择"><el-switch v-model="constraints.associate_eip_locked" active-text="锁定" inactive-text="可选"/></el-form-item></el-col>
<el-col :span="24"><el-form-item label="允许卷类型"><el-select v-model="constraints.allowed_volume_types" multiple clearable style="width:100%" placeholder="未选择表示不额外限制"><el-option v-for="type in volumeTypes" :key="type" :label="type.toUpperCase()" :value="type"/></el-select></el-form-item></el-col>
</el-row>
</section>
</div>
</template>
<script setup>
import VolumeEditor from './AwsVolumeEditor.vue'
const volumeTypes=['gp3','gp2','io1','io2','st1','sc1','standard']
const props=defineProps({section:{type:String,default:'all'},defaults:{type:Object,required:true},constraints:{type:Object,required:true},allowVolumeMutation:Boolean,allowedImages:{type:Array,default:()=>[]},allowedTypes:{type:Array,default:()=>[]}})
const createVolume=(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})
function toggleRoot(enabled){props.defaults.root_volume=enabled?createVolume({device_name:'/dev/sda1'}):null}
function addVolume(){props.defaults.data_volumes.push(createVolume({device_name:`/dev/sd${String.fromCharCode(102+props.defaults.data_volumes.length)}`}))}
function removeVolume(index){props.defaults.data_volumes.splice(index,1)}
function addTag(){props.defaults._tag_rows.push({key:'',value:''})}
function validate(){
if(props.section!=='defaults'){
const min=Number(props.constraints.root_volume_min_gib||0),max=Number(props.constraints.root_volume_max_gib||0)
if(min&&max&&min>max)return'系统盘最小容量不能大于最大容量'
}
if(props.section!=='constraints'){
const volumes=[props.defaults.root_volume,...props.defaults.data_volumes].filter(Boolean)
if(volumes.some(v=>!v.type||Number(v.size_gib)<1||Number(v.size_gib)>16384))return'请检查系统盘和数据盘的容量与卷类型'
if(props.allowVolumeMutation&&!props.defaults.data_volumes.length)return'已开放数据盘变更能力,请先配置至少一个默认数据盘模板'
}
return''
}
defineExpose({validate})
</script>
<style scoped>
.policy-form{width:100%}.policy-section{margin-bottom:16px;padding:16px 16px 4px;border:1px solid #dce7f6;border-radius:12px;background:#f9fbff}.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.section-head b,.section-head small{display:block}.section-head b{color:#263247;font-size:15px}.section-head small{margin-top:5px;color:#909399;font-size:12px}.switch-row{display:flex;flex-wrap:wrap;gap:0 22px;margin:-2px 0 14px}.volume-item{margin-bottom:14px;padding:13px 13px 0;border:1px solid #e1e8f2;border-radius:10px;background:#fff}.volume-title{display:flex;align-items:center;gap:9px;margin-bottom:11px;color:#344054;font-weight:600}.volume-title .el-button{margin-left:auto}.tag-row{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;margin-bottom:10px}.unit{margin-left:7px;color:#909399;font-size:12px}.constraint-section{background:#fffaf2;border-color:#f3dfbd}.policy-section :deep(.el-form-item){margin-bottom:14px}.policy-section :deep(.el-form-item__label){width:92px!important}.policy-section :deep(.el-empty){padding:8px 0 18px}
</style>
@@ -0,0 +1,20 @@
<template>
<el-row :gutter="12" class="volume-editor">
<el-col :span="8"><el-form-item label="设备名"><el-input v-model.trim="model.device_name" placeholder="如 /dev/sdf"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="容量"><el-input-number v-model="model.size_gib" :min="1" :max="16384" style="width:100%"/><span class="unit">GiB</span></el-form-item></el-col>
<el-col :span="8"><el-form-item label="卷类型"><el-select v-model="model.type" style="width:100%"><el-option v-for="type in volumeTypes" :key="type" :label="type.toUpperCase()" :value="type"/></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="IOPS"><el-input-number v-model="model.iops" :min="0" :disabled="!['gp3','io1','io2'].includes(model.type)" style="width:100%"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="吞吐量"><el-input-number v-model="model.throughput" :min="0" :disabled="model.type!=='gp3'" style="width:100%"/><span class="unit">MiB/s</span></el-form-item></el-col>
<el-col :span="8"><el-form-item label="存储策略"><el-checkbox v-model="model.encrypted">加密</el-checkbox><el-checkbox v-model="model.delete_on_termination">随实例删除</el-checkbox></el-form-item></el-col>
<el-col :span="24"><el-form-item label="KMS Key"><el-input v-model.trim="model.kms_key_id" :disabled="!model.encrypted" clearable placeholder="留空使用 AWS 默认密钥"/></el-form-item></el-col>
</el-row>
</template>
<script setup>
const model=defineModel({type:Object,required:true})
const volumeTypes=['gp3','gp2','io1','io2','st1','sc1','standard']
</script>
<style scoped>
.unit{margin-left:7px;color:#909399;font-size:12px}
</style>
+130 -100
View File
@@ -526,8 +526,9 @@
ref="productFormRef"
:model="productForm"
:rules="productRules"
label-position="top"
class="product-form"
label-position="right"
label-width="116px"
class="product-form goods-form"
>
<!-- 顶部封面 + 基本信息 -->
<div class="product-form-header">
@@ -608,40 +609,26 @@
<el-tabs v-model="productFormTab" class="product-form-tabs">
<el-tab-pane label="价格与销售" name="sales">
<div class="form-grid">
<el-form-item prop="price">
<template #label>
<span>商品价格<span class="unit-suffix"></span></span>
</template>
<el-input-number
v-model="productForm.price"
:min="0"
:precision="2"
:step="0.01"
placeholder="请输入价格"
controls-position="right"
style="width: 100%"
/>
<el-form-item label="商品价格" prop="price">
<div class="unit-input-row">
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="单个商品数量" prop="pay_num">
<el-input-number
v-model="productForm.pay_num"
:min="1"
placeholder="请输入数量"
controls-position="right"
style="width: 100%"
/>
<div class="unit-input-row">
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入数量" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item prop="expire_time">
<template #label>
<span>有效期<span class="unit-suffix">0 为永久</span></span>
<span class="form-label-with-help">有效期<el-tooltip content="0 表示永久有效" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
</template>
<el-input-number
v-model="productForm.expire_time"
:min="0"
placeholder="请输入有效期"
controls-position="right"
style="width: 100%"
/>
<div class="unit-input-row">
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="允许续费" prop="can_renew">
<el-switch
@@ -652,17 +639,30 @@
</el-form-item>
<el-form-item prop="renew_price" v-if="productForm.can_renew">
<template #label>
<span>续费价格<span class="unit-suffix">0 沿用商品价格</span></span>
<span class="form-label-with-help">续费价格<el-tooltip content="0 表示沿用商品价格" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
</template>
<el-input-number
v-model="productForm.renew_price"
:min="0"
:precision="2"
:step="0.01"
placeholder="续费基础价格"
controls-position="right"
style="width: 100%"
/>
<div class="unit-input-row">
<el-input-number v-model="productForm.renew_price" :min="0" :precision="2" :step="0.01" placeholder="续费基础价格" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item prop="max_renew_duration" v-if="productForm.can_renew">
<template #label>
<span class="form-label-with-help">最长续费时长<el-tooltip content="限制用户单次续费可选择的最大月数,0 表示不限制" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
</template>
<div class="unit-input-row">
<el-input-number v-model="productForm.max_renew_duration" :min="0" :precision="0" :step="1" placeholder="0 表示不限" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item prop="renew_before_days" v-if="productForm.can_renew">
<template #label>
<span class="form-label-with-help">提前续费天数<el-tooltip content="仅在商品到期前指定天数内开放续费,0 表示随时允许" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
</template>
<div class="unit-input-row">
<el-input-number v-model="productForm.renew_before_days" :min="0" :precision="0" :step="1" placeholder="0 表示不限" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="购买类型" prop="arg_type">
<el-select
@@ -675,21 +675,21 @@
<el-option label="仅自定义参数" value="customize" />
</el-select>
</el-form-item>
<el-form-item label="需要实名" prop="require_real_name">
<el-form-item prop="require_real_name">
<template #label><span class="form-label-with-help">需要实名<el-tooltip content="启用后,用户购买、续费或升级此商品前须完成实名认证" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span></template>
<el-switch
v-model="productForm.require_real_name"
active-text="需要"
inactive-text="不需要"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div>
</el-form-item>
<el-form-item label="远端商品" prop="remote_goods">
<el-form-item prop="remote_goods">
<template #label><span class="form-label-with-help">远端商品<el-tooltip content="AWS 商品需同时将标签设为 aws,保存后到 AWS 商品绑定页配置目录规则" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span></template>
<el-switch
v-model="productForm.remote_goods"
active-text="远端计价/履约"
inactive-text="普通商品"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">AWS 商品请同时将标签设为 aws保存后到 AWS 商品绑定页配置目录规则</div>
</el-form-item>
</div>
</el-tab-pane>
@@ -704,43 +704,33 @@
/>
</el-form-item>
<el-form-item label="库存数量" prop="inventory">
<el-input-number
v-model="productForm.inventory"
:min="0"
:disabled="!productForm.inventory_control"
placeholder="请输入库存"
controls-position="right"
style="width: 100%"
/>
<div class="unit-input-row">
<el-input-number v-model="productForm.inventory" :min="0" :disabled="!productForm.inventory_control" placeholder="请输入库存" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="售罄状态" prop="sold_out">
<el-form-item prop="sold_out">
<template #label><span class="form-label-with-help">售罄状态<el-tooltip content="开启后用户端将无法选购该商品" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span></template>
<el-switch
v-model="productForm.sold_out"
active-text="售罄"
inactive-text="正常"
active-color="#f56c6c"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">开启后用户端将无法选购该商品</div>
</el-form-item>
<el-form-item label="购买限制" prop="max_per_user">
<el-input-number
v-model="productForm.max_per_user"
:min="0"
placeholder="0 表示不限"
controls-position="right"
style="width: 100%"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量0 表示不限制</div>
<el-form-item prop="max_per_user">
<template #label><span class="form-label-with-help">购买限制<el-tooltip content="限制单用户最大购买数量,0 表示不限制" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span></template>
<div class="unit-input-row">
<el-input-number v-model="productForm.max_per_user" :min="0" placeholder="0 表示不限" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="首购最大时长" prop="max_first_purchase_duration">
<el-input-number
v-model="productForm.max_first_purchase_duration"
:min="0"
placeholder="0 表示不限"
controls-position="right"
style="width: 100%"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制用户首次购买的最大时长单位0 表示不限制</div>
<el-form-item prop="max_first_purchase_duration">
<template #label><span class="form-label-with-help">首购最大时长<el-tooltip content="限制用户首次购买的最大月数,0 表示不限制" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span></template>
<div class="unit-input-row">
<el-input-number v-model="productForm.max_first_purchase_duration" :min="0" placeholder="0 表示不限" controls-position="right" />
<span class="unit-text"></span>
</div>
</el-form-item>
</div>
</el-tab-pane>
@@ -754,44 +744,31 @@
inactive-text="禁用"
/>
</el-form-item>
<el-form-item prop="recommend_rebate">
<template #label>
<span>推荐返还<span class="unit-suffix">%</span></span>
</template>
<el-input-number
v-model="productForm.recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="返还百分比"
controls-position="right"
style="width: 100%"
/>
<el-form-item label="推荐返还" prop="recommend_rebate">
<div class="unit-input-row">
<el-input-number v-model="productForm.recommend_rebate" :min="0" :max="100" :disabled="!productForm.recommend" placeholder="返还百分比" controls-position="right" />
<span class="unit-text">%</span>
</div>
</el-form-item>
<el-form-item prop="renew_recommend_rebate">
<template #label>
<span>续费推介返还<span class="unit-suffix">%0 沿用推荐返还</span></span>
<span class="form-label-with-help">续费推介返还<el-tooltip content="0 表示沿用推荐返还比例" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
</template>
<el-input-number
v-model="productForm.renew_recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="续费推介返还百分比"
controls-position="right"
style="width: 100%"
/>
<div class="unit-input-row">
<el-input-number v-model="productForm.renew_recommend_rebate" :min="0" :max="100" :disabled="!productForm.recommend" placeholder="续费推介返还百分比" controls-position="right" />
<span class="unit-text">%</span>
</div>
</el-form-item>
<el-form-item label="推荐词" prop="recommend_words">
<el-input v-model="productForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable style="width: 100%" />
</el-form-item>
<el-form-item label="购买通知" prop="send_notice">
<el-form-item prop="send_notice">
<template #label><span class="form-label-with-help">购买通知<el-tooltip content="用户购买后是否向管理员发送通知" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span></template>
<el-switch
v-model="productForm.send_notice"
active-text="发送"
inactive-text="不发送"
/>
<div style="font-size:12px;color:#909399;margin:4px">用户购买后是否发送通知给管理员</div>
</el-form-item>
</div>
</el-tab-pane>
@@ -856,7 +833,7 @@
import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag, QuestionFilled } from '@element-plus/icons-vue'
import {
getProductGroupList,
createProductGroup,
@@ -973,6 +950,8 @@ const productForm = reactive({
sold_out: false,
max_per_user: 0,
max_first_purchase_duration: 0,
max_renew_duration: 0,
renew_before_days: 0,
send_notice: false,
renew_price: 0,
renew_recommend_rebate: 0,
@@ -1634,6 +1613,8 @@ const handleAddProduct = () => {
remote_goods: false,
max_per_user: 0,
max_first_purchase_duration: 0,
max_renew_duration: 0,
renew_before_days: 0,
recommend_words: ''
})
@@ -1673,6 +1654,8 @@ const handleEditProduct = (product, parentGroupId) => {
sold_out: !!product.soldOut,
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
max_renew_duration: product.maxRenewDuration ?? product.max_renew_duration ?? 0,
renew_before_days: product.renewBeforeDays ?? product.renew_before_days ?? 0,
send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0,
@@ -1707,6 +1690,8 @@ const submitProductForm = () => {
sold_out: productForm.sold_out === true,
max_per_user: Number(productForm.max_per_user) || 0,
max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,
max_renew_duration: Number(productForm.max_renew_duration) || 0,
renew_before_days: Number(productForm.renew_before_days) || 0,
send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0,
@@ -2140,8 +2125,9 @@ watch(() => route.query.good_id, (newId) => {
margin-top: 4px;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
.unit-input-row { display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0; }
.unit-input-row :deep(.el-input-number) { width: 100%; min-width: 0; flex: 1; }
.unit-text { width: 22px; color: #606266; font-size: 13px; flex-shrink: 0; white-space: nowrap; }
.recommend-user-selector {
display: flex;
@@ -2168,6 +2154,41 @@ watch(() => route.query.good_id, (newId) => {
--section-gap: 20px;
}
.goods-form :deep(.el-form-item__label) {
display: inline-flex;
height: 32px;
align-items: center;
justify-content: flex-end;
padding-right: 12px;
color: #303133;
line-height: 32px;
}
.goods-form :deep(.el-form-item__content) {
min-width: 0;
line-height: 32px;
}
.form-label-with-help {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 4px;
white-space: nowrap;
}
.form-label-with-help .el-icon {
color: #909399;
font-size: 14px;
cursor: help;
transition: color .2s ease;
}
.form-label-with-help .el-icon:hover {
color: #409eff;
}
.product-form-header {
display: flex;
gap: 24px;
@@ -2269,6 +2290,10 @@ watch(() => route.query.good_id, (newId) => {
margin-bottom: 12px;
}
.goods-form .basic-fields :deep(.el-form-item) {
align-items: flex-start;
}
.basic-fields :deep(.el-form-item:last-child) {
margin-bottom: 0;
}
@@ -2393,6 +2418,11 @@ watch(() => route.query.good_id, (newId) => {
margin-bottom: 12px;
}
.goods-form .form-grid :deep(.el-form-item) {
min-width: 0;
align-items: flex-start;
}
.form-grid .arg-type-item {
grid-column: span 2;
}
@@ -0,0 +1,417 @@
.aws-overview {
margin-top: 16px;
}
.aws-overview .overview-body {
min-height: 160px;
}
.aws-overview .panel-alert {
margin: 0 0 16px;
}
.aws-overview .instance-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 24px 26px;
overflow: hidden;
border: 1px solid #dce7f8;
border-radius: 16px;
background: radial-gradient(circle at 85% 10%, rgba(121, 92, 246, .13), transparent 27%), linear-gradient(135deg, #eef5ff 0%, #f8fbff 55%, #fff 100%);
box-shadow: 0 10px 30px rgba(40, 70, 120, .06);
}
.aws-overview .hero-main,
.aws-overview .hero-title,
.aws-overview .hero-identifiers,
.aws-overview .hero-actions,
.aws-overview .surface-header,
.aws-overview .surface-header > div,
.aws-overview .management-heading > div {
display: flex;
align-items: center;
}
.aws-overview .hero-main {
min-width: 0;
gap: 17px;
}
.aws-overview .aws-mark {
display: grid;
width: 62px;
height: 62px;
flex: none;
border-radius: 17px;
color: #fff;
background: linear-gradient(145deg, #252f3e, #4b5568);
box-shadow: 0 9px 22px rgba(37, 47, 62, .22);
font: 700 20px Arial, sans-serif;
letter-spacing: -.7px;
place-items: center;
}
.aws-overview .aws-mark::after {
width: 27px;
height: 5px;
margin-top: -15px;
border-bottom: 2px solid #ffad22;
border-radius: 50%;
content: '';
}
.aws-overview .hero-copy {
min-width: 0;
}
.aws-overview .hero-title {
flex-wrap: wrap;
gap: 9px;
}
.aws-overview .hero-title h2 {
margin: 0;
color: #1d2939;
font-size: 21px;
}
.aws-overview .status-dot {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .28);
}
.aws-overview .hero-identifiers {
flex-wrap: wrap;
gap: 7px 17px;
margin-top: 10px;
color: #667085;
font-size: 12px;
}
.aws-overview .hero-identifiers span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.aws-overview .hero-actions {
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.aws-overview .hero-actions .el-button {
margin: 0;
}
.aws-overview .stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 13px;
margin-top: 16px;
}
.aws-overview .stat-card {
display: flex;
position: relative;
min-width: 0;
align-items: center;
gap: 11px;
padding: 16px;
border: 1px solid #e5ebf4;
border-radius: 13px;
background: #fff;
box-shadow: 0 5px 17px rgba(42, 61, 94, .04);
}
.aws-overview .stat-icon,
.aws-overview .surface-icon,
.aws-overview .management-icon,
.aws-overview .volume-icon {
display: grid;
flex: none;
place-items: center;
}
.aws-overview .stat-icon {
width: 41px;
height: 41px;
border-radius: 12px;
font-size: 20px;
}
.aws-overview .blue { color: #4f7cff; background: #edf3ff; }
.aws-overview .cyan { color: #0e9fba; background: #eaf9fc; }
.aws-overview .green { color: #16a57a; background: #eaf8f3; }
.aws-overview .purple { color: #8264df; background: #f2effd; }
.aws-overview .orange { color: #e38a1c; background: #fff4e8; }
.aws-overview .stat-card > div {
min-width: 0;
}
.aws-overview .stat-card small,
.aws-overview .stat-card strong,
.aws-overview .stat-card p {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aws-overview .stat-card small { color: #98a2b3; font-size: 11px; }
.aws-overview .stat-card strong { margin-top: 5px; color: #344054; font-size: 14px; }
.aws-overview .stat-card p { margin: 4px 0 0; color: #8490a3; font-size: 11px; }
.aws-overview .copy-button { position: absolute; top: 8px; right: 8px; }
.aws-overview .visual-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 14px;
margin-top: 14px;
}
.aws-overview .surface {
min-width: 0;
padding: 19px;
border: 1px solid #e5ebf4;
border-radius: 14px;
background: #fff;
box-shadow: 0 5px 18px rgba(42, 61, 94, .035);
}
.aws-overview .surface-header {
justify-content: space-between;
gap: 14px;
}
.aws-overview .surface-header > div {
min-width: 0;
gap: 10px;
}
.aws-overview .surface-icon {
width: 36px;
height: 36px;
border-radius: 10px;
font-size: 18px;
}
.aws-overview .surface-header h3,
.aws-overview .management-heading h3 {
margin: 0;
color: #29364a;
font-size: 15px;
}
.aws-overview .surface-header p,
.aws-overview .management-heading p {
margin: 4px 0 0;
color: #98a2b3;
font-size: 11px;
}
.aws-overview .cost-content,
.aws-overview .traffic-content {
display: flex;
align-items: center;
min-height: 218px;
}
.aws-overview .cost-chart { width: 48%; height: 205px; }
.aws-overview .traffic-chart { width: 55%; height: 205px; }
.aws-overview .cost-legend { width: 52%; }
.aws-overview .cost-legend > div {
display: flex;
align-items: center;
padding: 7px 0;
border-bottom: 1px dashed #edf0f5;
font-size: 12px;
}
.aws-overview .cost-legend i { width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; }
.aws-overview .cost-legend span { flex: 1; color: #667085; }
.aws-overview .cost-legend b { color: #344054; font-family: Consolas, monospace; }
.aws-overview .cost-legend .hourly-rate { margin-top: 5px; border-bottom: 0; }
.aws-overview .cost-legend .hourly-rate span { color: #98a2b3; }
.aws-overview .cost-legend .hourly-rate b { color: #f59e0b; }
.aws-overview .traffic-summary {
display: grid;
width: 45%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.aws-overview .traffic-summary div { padding: 10px; border-radius: 9px; background: #f7f9fc; }
.aws-overview .traffic-summary span,
.aws-overview .traffic-summary b { display: block; }
.aws-overview .traffic-summary span { color: #98a2b3; font-size: 11px; }
.aws-overview .traffic-summary b { margin-top: 5px; overflow: hidden; color: #344054; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.aws-overview .detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.aws-overview .surface-header.compact {
margin-bottom: 15px;
padding-bottom: 13px;
border-bottom: 1px solid #edf1f6;
}
.aws-overview .property-list {
display: grid;
margin: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 22px;
}
.aws-overview .property-list > div {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed #edf0f5;
}
.aws-overview .property-list dt { flex: none; color: #98a2b3; font-size: 12px; }
.aws-overview .property-list dd { min-width: 0; margin: 0; overflow: hidden; color: #344054; font-size: 12px; font-weight: 600; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.aws-overview .mono { font-family: Consolas, 'SFMono-Regular', monospace; }
.aws-overview .volume-list > div {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px dashed #edf0f5;
}
.aws-overview .volume-icon { width: 34px; height: 34px; border-radius: 9px; color: #e58a18; background: #fff4e8; }
.aws-overview .volume-list > div > div { min-width: 0; }
.aws-overview .volume-list strong,
.aws-overview .volume-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aws-overview .volume-list strong { color: #344054; font-size: 12px; }
.aws-overview .volume-list small { margin-top: 3px; color: #98a2b3; font-size: 10px; }
.aws-overview .volume-meta { flex: 1; text-align: right; }
.aws-overview .volume-meta b,
.aws-overview .volume-meta span { display: block; }
.aws-overview .volume-meta b { color: #344054; font-size: 12px; }
.aws-overview .volume-meta span { margin-top: 3px; color: #98a2b3; font-size: 10px; }
.aws-overview .health-list > div {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px dashed #edf0f5;
}
.aws-overview .health-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #b8c0cc; box-shadow: 0 0 0 4px #f2f4f7; }
.aws-overview .health-dot.fresh { background: #23b583; box-shadow: 0 0 0 4px #e8f8f2; }
.aws-overview .health-dot.stale,
.aws-overview .health-dot.syncing,
.aws-overview .health-dot.warning { background: #f3a33b; box-shadow: 0 0 0 4px #fff4e5; }
.aws-overview .health-dot.failed { background: #ef6262; box-shadow: 0 0 0 4px #ffeded; }
.aws-overview .health-list div > div { min-width: 0; flex: 1; }
.aws-overview .health-list strong,
.aws-overview .health-list small { display: block; }
.aws-overview .health-list strong { color: #344054; font-size: 12px; }
.aws-overview .health-list small { margin-top: 3px; color: #98a2b3; font-size: 10px; }
.aws-overview .management-heading {
margin: 22px 0 10px;
padding: 18px 20px;
border: 1px solid #dce7f8;
border-radius: 13px;
background: linear-gradient(135deg, #f3f7ff, #fbfdff);
}
.aws-overview .management-heading > div { gap: 11px; }
.aws-overview .management-icon { width: 38px; height: 38px; border-radius: 11px; color: #fff; background: linear-gradient(135deg, #4f7cff, #7b61e8); font-size: 19px; }
.aws-overview .operations-shell {
margin-top: 14px;
overflow: hidden;
border: 1px solid #e5ebf4;
border-radius: 14px;
background: #fff;
box-shadow: 0 5px 18px rgba(42, 61, 94, .035);
}
.aws-overview .operations-shell .page.embedded {
padding: 0;
background: #fff;
}
.aws-overview .operations-shell .tabs {
margin: 0;
padding: 0 20px 20px;
border-radius: 0;
}
.aws-overview .operations-shell .el-tabs__header {
margin: 0 -20px 18px;
padding: 0 20px;
border-bottom: 1px solid #e8eef6;
background: linear-gradient(135deg, #f8faff, #fff);
}
.aws-overview .operations-shell .el-tabs__nav-wrap::after {
display: none;
}
.aws-overview .operations-shell .el-tabs__item {
height: 54px;
color: #667085;
font-weight: 500;
}
.aws-overview .operations-shell .el-tabs__item.is-active {
color: #4f7cff;
font-weight: 600;
}
.aws-overview .operations-shell .toolbar {
margin: 0 0 16px;
padding: 12px 14px;
border-radius: 10px;
background: #f7f9fc;
}
@media (max-width: 1400px) {
.aws-overview .stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.aws-overview .visual-grid,
.aws-overview .detail-grid { grid-template-columns: 1fr; }
.aws-overview .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.aws-overview .instance-hero { align-items: flex-start; flex-direction: column; }
.aws-overview .hero-actions { justify-content: flex-start; }
.aws-overview .stat-grid { grid-template-columns: 1fr; }
.aws-overview .cost-content,
.aws-overview .traffic-content { align-items: stretch; flex-direction: column; }
.aws-overview .cost-chart,
.aws-overview .traffic-chart,
.aws-overview .cost-legend,
.aws-overview .traffic-summary { width: 100%; }
.aws-overview .traffic-summary { margin-top: -20px; }
.aws-overview .property-list { grid-template-columns: 1fr; }
}
File diff suppressed because one or more lines are too long
+150 -12
View File
@@ -508,8 +508,17 @@
<el-dialog v-model="groupDialogVisible" title="修改用户组" width="400px" append-to-body>
<el-form :model="groupForm" label-width="80px">
<el-form-item label="用户组">
<el-select v-model="groupForm.user_group_id" placeholder="请选择用户组" style="width: 100%">
<el-select
v-model="groupForm.user_group_id"
placeholder="请选择用户组"
style="width: 100%"
filterable
:loading="userGroupLoading"
popper-class="user-detail-user-group-popper"
@visible-change="visible => handleGroupSelectVisible(visible, 'user')"
>
<el-option v-for="item in userGroupList" :key="item.Id" :label="item.Name" :value="item.Id" />
<el-option v-if="userGroupLoading && userGroupList.length" disabled value="__user_group_loading__" label="正在加载更多用户组..." />
</el-select>
</el-form-item>
</el-form>
@@ -642,6 +651,8 @@
style="width: 100%"
:loading="adminGroupLoading"
filterable
popper-class="user-detail-admin-group-popper"
@visible-change="visible => handleGroupSelectVisible(visible, 'admin')"
>
<el-option
v-for="item in adminGroupList"
@@ -649,6 +660,7 @@
:label="`${item.Name || item.name} (ID: ${item.Id || item.id})`"
:value="item.Id || item.id"
/>
<el-option v-if="adminGroupLoading && adminGroupList.length" disabled value="__admin_group_loading__" label="正在加载更多管理员组..." />
</el-select>
</el-form-item>
</el-form>
@@ -663,7 +675,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, onActivated, watch } from 'vue'
import { ref, reactive, nextTick, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { baseURL } from '@/utils/request'
@@ -853,6 +865,10 @@ const groupForm = reactive({
user_group_id: ''
})
const userGroupList = ref([])
const userGroupLoading = ref(false)
const userGroupPage = ref(0)
const userGroupTotal = ref(null)
const userGroupHasMore = ref(true)
//
const realnameDialogVisible = ref(false)
@@ -885,6 +901,15 @@ const adminForm = reactive({
})
const adminGroupList = ref([])
const adminGroupLoading = ref(false)
const adminGroupPage = ref(0)
const adminGroupTotal = ref(null)
const adminGroupHasMore = ref(true)
const GROUP_PAGE_SIZE = 50
const groupSelectScrollBindings = {
user: null,
admin: null
}
//
const handleTabClick = (tab) => {
@@ -1105,14 +1130,103 @@ const handleGroupManage = () => {
groupForm.user_id = userInfo.value.UserId
groupForm.user_group_id = userInfo.value.UserGroupId || ''
groupDialogVisible.value = true
fetchUserGroupList()
fetchUserGroupList(true)
}
const fetchUserGroupList = async () => {
const res = await getUserGroupList({ page: 1, count: 10 })
if (res.data.code == 200) {
userGroupList.value = res.data.data.data
const normalizeGroupPage = (responseData) => {
const payload = responseData?.data ?? responseData
let list = []
if (Array.isArray(payload)) {
list = payload
} else if (Array.isArray(payload?.data)) {
list = payload.data
} else if (Array.isArray(payload?.list)) {
list = payload.list
}
const rawTotal = Array.isArray(payload)
? null
: (payload?.total ?? payload?.all_count ?? payload?.allCount ?? null)
const total = rawTotal === null || rawTotal === undefined ? null : Number(rawTotal)
return {
list,
total: Number.isFinite(total) ? total : null
}
}
const appendUniqueGroups = (currentList, incomingList) => {
const getId = item => item?.Id ?? item?.id ?? item?.GroupId ?? item?.group_id
const seen = new Set(currentList.map(item => String(getId(item))))
return currentList.concat(incomingList.filter(item => {
const key = String(getId(item))
if (seen.has(key)) return false
seen.add(key)
return true
}))
}
const fetchUserGroupList = async (reset = false) => {
if (userGroupLoading.value || (!reset && !userGroupHasMore.value)) return
const page = reset ? 1 : userGroupPage.value + 1
if (reset) {
userGroupList.value = []
userGroupPage.value = 0
userGroupTotal.value = null
userGroupHasMore.value = true
}
userGroupLoading.value = true
try {
const res = await getUserGroupList({ page, count: GROUP_PAGE_SIZE })
if (res.data.code === 200) {
const { list, total } = normalizeGroupPage(res.data)
userGroupList.value = reset ? list : appendUniqueGroups(userGroupList.value, list)
userGroupPage.value = page
userGroupTotal.value = total
userGroupHasMore.value = total !== null
? userGroupList.value.length < total
: list.length === GROUP_PAGE_SIZE
} else {
ElMessage.error(res.data.message || res.data.msg || '获取用户组列表失败')
}
} catch (error) {
if (reset) userGroupList.value = []
ElMessage.error('获取用户组列表失败')
} finally {
userGroupLoading.value = false
}
}
const cleanupGroupSelectScroll = (type) => {
const binding = groupSelectScrollBindings[type]
if (!binding) return
binding.element.removeEventListener('scroll', binding.handler)
groupSelectScrollBindings[type] = null
}
const handleGroupSelectVisible = async (visible, type) => {
cleanupGroupSelectScroll(type)
if (!visible) return
await nextTick()
const className = type === 'admin'
? 'user-detail-admin-group-popper'
: 'user-detail-user-group-popper'
const scrollElement = document.querySelector(`.${className} .el-select-dropdown__wrap`)
if (!scrollElement) return
const handler = () => {
const distanceToBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
if (distanceToBottom > 24) return
if (type === 'admin') fetchAdminGroupList(false)
else fetchUserGroupList(false)
}
scrollElement.addEventListener('scroll', handler, { passive: true })
groupSelectScrollBindings[type] = { element: scrollElement, handler }
}
//
@@ -1739,19 +1853,38 @@ const submitRealnameModify = async () => {
const handleTokenManage = async () => {
adminForm.user_id = userInfo.value.UserId
adminForm.admin_group_id = ''
await fetchAdminGroupList()
adminDialogVisible.value = true
fetchAdminGroupList(true)
}
const fetchAdminGroupList = async () => {
const fetchAdminGroupList = async (reset = false) => {
if (adminGroupLoading.value || (!reset && !adminGroupHasMore.value)) return
const page = reset ? 1 : adminGroupPage.value + 1
if (reset) {
adminGroupList.value = []
adminGroupPage.value = 0
adminGroupTotal.value = null
adminGroupHasMore.value = true
}
adminGroupLoading.value = true
try {
const res = await getAdminGroupList({ params: { page: 1, count: 10 } })
const res = await getAdminGroupList({ page, count: GROUP_PAGE_SIZE })
if (res.data.code === 200) {
adminGroupList.value = res.data.data?.data || res.data.data || []
const { list, total } = normalizeGroupPage(res.data)
adminGroupList.value = reset ? list : appendUniqueGroups(adminGroupList.value, list)
adminGroupPage.value = page
adminGroupTotal.value = total
adminGroupHasMore.value = total !== null
? adminGroupList.value.length < total
: list.length === GROUP_PAGE_SIZE
} else {
ElMessage.error(res.data.message || res.data.msg || '获取管理员组列表失败')
}
} catch (error) {
adminGroupList.value = []
if (reset) adminGroupList.value = []
ElMessage.error('获取管理员组列表失败')
} finally {
adminGroupLoading.value = false
}
@@ -1871,6 +2004,11 @@ watch(() => route.query.user_id, (newUserId, oldUserId) => {
resetUserScopedData()
loadUserData()
})
onBeforeUnmount(() => {
cleanupGroupSelectScroll('user')
cleanupGroupSelectScroll('admin')
})
</script>
<style scoped>