13 Commits
Author SHA1 Message Date
shiran 295da4da57 Merge pull request 'feat: add AWS and MF consumption management' (#38) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m42s
Build and Deploy Vue3 / deploy (push) Successful in 59s
Reviewed-on: #38
2026-08-24 21:17:42 +08:00
shiran 7b37d04b87 Merge pull request 'feat: add default user group settings' (#37) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m51s
Build and Deploy Vue3 / deploy (push) Successful in 55s
Reviewed-on: #37
2026-08-17 21:29:03 +08:00
shiran 74e3ca414a Merge pull request 'feat: support disabling KVM hosts' (#36) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m49s
Build and Deploy Vue3 / deploy (push) Successful in 54s
Reviewed-on: #36
2026-08-09 12:52:18 +08:00
shiran 139145964a Merge pull request 'fix: deduplicate expired token handling' (#35) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m59s
Build and Deploy Vue3 / deploy (push) Successful in 53s
Reviewed-on: #35
2026-08-08 17:31:34 +08:00
shiran b839d15826 Merge pull request 'fix: improve VM migration state and chain details' (#34) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m52s
Build and Deploy Vue3 / deploy (push) Successful in 53s
Reviewed-on: #34
2026-08-04 19:03:00 +08:00
shiran cc544d0403 Merge pull request 'fix: refresh VM data from user goods detail' (#33) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m46s
Build and Deploy Vue3 / deploy (push) Successful in 52s
Reviewed-on: #33
2026-08-04 18:00:17 +08:00
shiran 50af5d401e Merge pull request 'feat: adapt migration queue aggregate APIs' (#32) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m54s
Build and Deploy Vue3 / deploy (push) Successful in 52s
Reviewed-on: #32
2026-08-04 17:17:49 +08:00
shiran 9ce79176cd Merge pull request 'fix: 修复镜像选择器分页总数' (#31) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m47s
Build and Deploy Vue3 / deploy (push) Successful in 56s
Reviewed-on: #31
2026-08-04 16:28:49 +08:00
shiran 5d03bd0e5e Merge pull request 'fix: 将迁移记录移入主控服务详情' (#30) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m57s
Build and Deploy Vue3 / deploy (push) Successful in 51s
Reviewed-on: #30
2026-08-04 09:34:55 +08:00
shiran 6f591a998f Merge pull request 'feat: 支持商品参数隐藏配置' (#29) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m53s
Build and Deploy Vue3 / deploy (push) Successful in 51s
Reviewed-on: #29
2026-08-04 08:09:37 +08:00
shiran 64530d5e2e Merge pull request 'feat: extend SMS signature and setting file support' (#28) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 2m7s
Build and Deploy Vue3 / deploy (push) Successful in 49s
Reviewed-on: #28
2026-07-24 22:07:29 +08:00
shiran 22a88e3a39 Merge pull request 'fix: 虚拟机网络管理改为解绑逻辑,不再删除底层网络' (#27) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 6m20s
Build and Deploy Vue3 / deploy (push) Successful in 41s
Reviewed-on: #27
2026-07-07 18:03:00 +08:00
shiran f62b4db0e1 Merge pull request 'feat: 用户详情代金券管理与优惠模块修复' (#26) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Successful in 40s
Reviewed-on: #26
2026-06-26 17:12:38 +08:00
20 changed files with 191 additions and 1603 deletions
+2 -48
View File
@@ -1,13 +1,6 @@
import { http2 } from '@/utils/request.js'
const base = '/api/v1/admin/server/aws_service'
const runtimeBase = `${base}/instances/runtime`
export const createAwsIdempotencyKey = (prefix = 'aws') => {
const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `${prefix}-${random}`.slice(0, 128)
}
const withKey = (key) => ({ headers: { 'Idempotency-Key': key || createAwsIdempotencyKey() } })
export const getAwsServiceList = (params) => http2.get(`${base}/list`, { params })
export const getAwsServiceDetail = (id) => http2.get(`${base}/detail`, { params: { id } })
@@ -22,44 +15,5 @@ export const createAwsGoods = (data) => http2.post(`${base}/goods/create`, data)
export const updateAwsGoods = (data) => http2.post(`${base}/goods/update`, data)
export const deleteAwsGoods = (id) => http2.delete(`${base}/goods/delete`, { params: { id } })
export const getAwsInstanceDetail = (identifier) => http2.get(`${base}/instances/detail`, {
params: typeof identifier === 'object' ? identifier : { instance_id: identifier }
})
export const actionAdminAwsInstance = (instanceId, action, reason) => http2.post(`${base}/instances/action`, {
instance_id: instanceId,
action,
...(action === 'suspend' ? { reason } : {})
})
export const retryAwsInstance = (instanceId, note = '') => http2.post(`${base}/instances/retry`, { instance_id: instanceId, note })
export const resyncAwsInstance = (instanceId, note = '') => http2.post(`${base}/instances/resync`, { instance_id: instanceId, note })
export const resolveAwsInstance = (instanceId, resolution, note = '') => http2.post(`${base}/instances/resolve`, { instance_id: instanceId, resolution, note })
export const getAdminAwsRuntimeDetails = instanceId => http2.get(`${runtimeBase}/details`, { params: { instance_id: instanceId } })
export const refreshAdminAwsRuntimeDetails = (instanceId, key) => http2.post(`${runtimeBase}/details/refresh`, { instance_id: instanceId }, withKey(key))
export const getAdminAwsLoginInfo = instanceId => http2.post(`${runtimeBase}/login_info`, { instance_id: instanceId })
export const resetAdminAwsPassword = (instanceId, key) => http2.post(`${runtimeBase}/password/reset`, { instance_id: instanceId }, withKey(key))
export const getAdminAwsMonitoring = instanceId => http2.get(`${runtimeBase}/monitoring`, { params: { instance_id: instanceId } })
export const getAdminAwsSecurityGroup = instanceId => http2.get(`${runtimeBase}/security_group`, { params: { instance_id: instanceId } })
export const addAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2.post(`${runtimeBase}/security_group/rules/add`, { instance_id: instanceId, rule }, withKey(key))
export const deleteAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2.post(`${runtimeBase}/security_group/rules/delete`, { instance_id: instanceId, rule }, withKey(key))
export const getAdminAwsVolumes = instanceId => http2.get(`${runtimeBase}/volumes`, { params: { instance_id: instanceId } })
export const createAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/create`, data, withKey(key))
export const modifyAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/modify`, data, withKey(key))
export const 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))
export const getAdminAwsEip = instanceId => http2.get(`${runtimeBase}/eip`, { params: { instance_id: instanceId } })
export const replaceAdminAwsEip = (data, key) => http2.post(`${runtimeBase}/eip/replace`, data, withKey(key))
export const disassociateAdminAwsEip = (instanceId, key) => http2.post(`${runtimeBase}/eip/disassociate`, { instance_id: instanceId }, withKey(key))
export const releaseAdminAwsEip = (instanceId, key) => http2.post(`${runtimeBase}/eip/release`, { instance_id: instanceId }, withKey(key))
export const updateAdminAwsEipReverseDns = (data, key) => http2.post(`${runtimeBase}/eip/reverse_dns`, data, withKey(key))
export const getAdminAwsOperations = (instanceId, params = {}) => http2.get(`${runtimeBase}/operations`, { params: { instance_id: instanceId, ...params } })
export const getAdminAwsOperationDetail = operationId => http2.get(`${runtimeBase}/operations/detail`, { params: { operation_id: operationId } })
export const retryAdminAwsOperation = (operationId, note = '', key) => http2.post(`${runtimeBase}/operations/retry`, { operation_id: operationId, note }, withKey(key))
export const resyncAdminAwsOperation = operationId => http2.post(`${runtimeBase}/operations/resync`, null, { params: { operation_id: operationId } })
export const resolveAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/resolve`, { operation_id: operationId, note })
export const rejectAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/reject`, { operation_id: operationId, note })
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))
export const getAwsInstanceList = (params) => http2.get(`${base}/instances/list`, { params })
export const retryAwsInstance = (instanceId) => http2.post(`${base}/instances/retry`, null, { params: { instance_id: instanceId } })
-10
View File
@@ -114,16 +114,6 @@ export const renewUserGoods = (data) => http2.post(`${GOODS_BASE}/renew`, fd(dat
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params })
export const getUserVmTrafficHourly = (params) => http2.get(`${BASE}/traffic_hourly`, { params })
// ========== 虚拟机专用操作日志 ==========
const OPERATION_RECORD_BASE = '/api/v1/admin/host_service/point/vm/operation_record'
export const getVmOperationRecordList = (params) => http2.get(`${OPERATION_RECORD_BASE}/list`, { params })
export const getVmOperationRecordDetail = (id) => http2.get(`${OPERATION_RECORD_BASE}/detail`, { params: { id } })
export const refreshVmOperationRecord = (id) => http2.post(
`${OPERATION_RECORD_BASE}/refresh`,
fd({ id }),
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
// ========== 流量策略 ==========
// 测试未通过(接口新增,待联调)
export const getUserVmTrafficPolicy = (params) => http2.get(`${BASE}/traffic_policy`, { params })
@@ -1,99 +0,0 @@
<template>
<el-dialog v-model="visible" title="选择商品或商品组" width="720px" append-to-body destroy-on-close @close="close">
<el-alert type="info" :closable="false" show-icon title="选择商品将绑定已有商品;选择商品组将自动创建商品并绑定。"/>
<div class="tree-panel">
<el-tree
ref="treeRef"
node-key="key"
lazy
:load="loadNode"
:props="treeProps"
highlight-current
:expand-on-click-node="false"
@node-click="selectNode"
>
<template #default="{ data }">
<div class="tree-node">
<el-icon class="node-icon"><FolderOpened v-if="data.type==='group'"/><Box v-else/></el-icon>
<span class="node-name">{{ data.name }}</span>
<el-tag size="small" :type="data.type==='group'?'primary':'success'">{{ data.type==='group'?'商品组':'商品' }}</el-tag>
<span class="node-id">#{{ data.id }}</span>
</div>
</template>
</el-tree>
</div>
<div class="selected-result">
<span class="result-label">当前选择</span>
<template v-if="selected">
<el-tag size="small" :type="selected.type==='group'?'primary':'success'">{{ selected.type==='group'?'商品组':'商品' }}</el-tag>
<strong>{{ selected.name }}</strong>
<span class="node-id">#{{ selected.id }}</span>
</template>
<span v-else class="empty-text">尚未选择</span>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="confirm">确定选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Box, FolderOpened } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getProductGroupList, getProductList } from '@/api/admin/product.js'
const props=defineProps({
modelValue:{type:Boolean,default:false},
currentType:{type:String,default:''},
currentId:{type:[String,Number],default:''},
currentName:{type:String,default:''}
})
const emit=defineEmits(['update:modelValue','confirm'])
const visible=ref(false), selected=ref(null), treeRef=ref()
const treeProps={label:'name',children:'children',isLeaf:'isLeaf'}
const list=data=>data?.data?.data||data?.data?.list||[]
const groupNode=group=>({key:`group_${group.id}`,id:group.id,type:'group',name:group.name||`商品组 ${group.id}`,level:Number(group.level)||1,isLeaf:false})
const productNode=product=>({key:`product_${product.id}`,id:product.id,type:'product',name:product.name||`商品 ${product.id}`,isLeaf:true})
watch(()=>props.modelValue,value=>{
visible.value=value
if(value)selected.value=props.currentType&&props.currentId?{type:props.currentType,id:props.currentId,name:props.currentName||`${props.currentType==='group'?'商品组':'商品'} ${props.currentId}`} : null
})
watch(visible,value=>emit('update:modelValue',value))
async function loadNode(node,resolve){
try{
if(node.level===0){
const response=await getProductGroupList({page:1,count:100,level:1})
if(response.data?.code!==200)throw new Error(response.data?.message||response.data?.msg)
return resolve(list(response.data).filter(item=>item.delete!==true).map(groupNode))
}
if(node.data?.type==='group'){
const groupId=node.data.id
const childLevel=(node.data.level||1)+1
const [groupsResponse,productsResponse]=await Promise.all([
getProductGroupList({page:1,count:100,parent_id:groupId,level:childLevel}),
getProductList({page:1,count:100,good_group_id:groupId,delete:false})
])
const groups=groupsResponse.data?.code===200?list(groupsResponse.data).filter(item=>item.delete!==true).map(groupNode):[]
const products=productsResponse.data?.code===200?list(productsResponse.data).filter(item=>item.delete!==true).map(productNode):[]
return resolve([...groups,...products])
}
resolve([])
}catch(error){
console.error('加载商品层级失败:',error)
ElMessage.error('加载商品层级失败')
resolve([])
}
}
function selectNode(data){selected.value={type:data.type,id:data.id,name:data.name};treeRef.value?.setCurrentKey(data.key)}
function close(){visible.value=false}
function confirm(){if(!selected.value)return;emit('confirm',{...selected.value});close()}
</script>
<style scoped>
.tree-panel{height:420px;margin-top:16px;padding:8px;border:1px solid var(--el-border-color);border-radius:8px;overflow:auto}.tree-node{display:flex;align-items:center;gap:8px;width:100%;padding-right:12px}.node-icon{color:var(--el-color-primary)}.node-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-id,.empty-text{color:var(--el-text-color-secondary)}.selected-result{display:flex;align-items:center;gap:8px;min-height:32px;margin-top:14px}.result-label{margin-right:4px;color:var(--el-text-color-secondary)}
</style>
+2 -1
View File
@@ -186,7 +186,8 @@ export const menus = [
icon: 'Cloudy',
children: [
{ path: '/aws/services', title: '服务配置' },
{ path: '/aws/goods', title: '商品绑定' }
{ path: '/aws/goods', title: '商品绑定' },
{ path: '/aws/instances', title: '履约实例' }
]
},
{
+6
View File
@@ -689,6 +689,12 @@ const routes = [
name: 'AwsGoods',
component: () => import('../views/aws/AwsGoods.vue'),
meta: { title: '商品绑定' }
},
{
path: 'instances',
name: 'AwsInstances',
component: () => import('../views/aws/AwsInstances.vue'),
meta: { title: '履约实例' }
}
]
},
+22 -72
View File
@@ -10,102 +10,52 @@
<el-table-column prop="exchangeRate" label="汇率" width="90"/>
<el-table-column prop="gracePeriodHours" label="宽限期(h)" width="100"/>
<el-table-column label="强制策略" min-width="210"><template #default="{row}"><el-tag v-if="row.forceAssociateEip" size="small">EIP</el-tag><el-tag v-if="row.forceEncryptedRootVolume" size="small" type="success">加密系统盘</el-tag><el-tag v-if="row.forceInstallAgent" size="small" type="warning">Agent</el-tag></template></el-table-column>
<el-table-column label="用户能力" min-width="260"><template #default="{row}"><el-tag v-for="cap in enabledCapabilities(row)" :key="cap" size="small" class="tag" type="info">{{cap}}</el-tag><span v-if="!enabledCapabilities(row).length">全部关闭</span></template></el-table-column>
<el-table-column label="状态" width="80"><template #default="{row}"><el-tag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</el-tag></template></el-table-column>
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-popconfirm title="确认删除无历史记录的绑定?" @confirm="remove(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm></template></el-table-column>
</el-table>
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
<el-dialog v-model="dialog" :title="editing?'编辑 AWS 商品绑定':'新增 AWS 商品绑定'" width="min(1080px, 94vw)" top="3vh" destroy-on-close>
<el-dialog v-model="dialog" :title="editing?'编辑 AWS 商品绑定':'新增 AWS 商品绑定'" width="820px" top="4vh" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="145px">
<el-alert v-if="!editing && form.goods_target_type==='group'" class="create-tip" type="info" :closable="false" show-icon title="保存后将根据所选商品组自动创建 AWS 商品,并完成原子绑定。"/>
<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-row :gutter="18"><el-col :span="12"><el-form-item label="商品 ID" prop="good_id"><el-input-number v-model="form.good_id" :min="1" :disabled="editing" style="width:100%"/></el-form-item></el-col><el-col :span="12"><el-form-item label="AWS 服务" prop="service_id"><el-select v-model="form.service_id" style="width:100%"><el-option v-for="s in services" :key="s.id" :label="`${s.name} (#${s.id})`" :value="s.id"/></el-select></el-form-item></el-col></el-row>
<el-form-item label="允许区域" prop="allowed_regions"><el-select v-model="form.allowed_regions" multiple filterable allow-create default-first-option placeholder="输入 Region 后回车" style="width:100%"/></el-form-item>
<el-form-item label="镜像分类白名单"><el-select v-model="form.allowed_image_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="镜像白名单"><el-select v-model="form.allowed_images" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="规格分类白名单"><el-select v-model="form.allowed_type_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-form-item label="规格白名单"><el-select v-model="form.allowed_types" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
<el-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="默认创建参数" prop="defaults_text"><el-input v-model="form.defaults_text" type="textarea" :rows="7" placeholder="{}" class="json"/></el-form-item>
<el-form-item label="商品约束" prop="constraints_text"><el-input v-model="form.constraints_text" type="textarea" :rows="6" placeholder="{}" class="json"/></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存</el-button></template>
</el-dialog>
<ProductGoodsTreeSelector v-model="goodsSelectorVisible" :current-type="form.goods_target_type" :current-id="form.goods_target_id" :current-name="form.goods_target_name" @confirm="selectGoodsTarget"/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { createAwsGoods, deleteAwsGoods, getAwsGoodsDetail, getAwsGoodsList, getAwsServiceList, testAwsService, updateAwsGoods } from '@/api/admin/awsService.js'
import ProductGoodsTreeSelector from '@/components/admin/ProductGoodsTreeSelector.vue'
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
import { createAwsGoods, deleteAwsGoods, getAwsGoodsDetail, getAwsGoodsList, getAwsServiceList, updateAwsGoods } from '@/api/admin/awsService.js'
const route=useRoute(), loading=ref(false), submitting=ref(false), rows=ref([]), services=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref()
const query=reactive({page:1,count:20,service_id:route.query.service_id?Number(route.query.service_id):undefined})
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 empty=()=>({id:null,good_id:null,service_id:null,allowed_regions:[],allowed_image_categories:[],allowed_images:[],allowed_type_categories:[],allowed_types:[],defaults_text:'{}',constraints_text:'{}',force_associate_eip:false,force_encrypted_root_volume:true,force_install_agent:true,grace_period_hours:168,exchange_rate:1,enabled:true})
const 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 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 jsonRule=(_,value,cb)=>{try{const v=JSON.parse(value||'{}');if(!v||Array.isArray(v)||typeof v!=='object')throw new Error();cb()}catch{cb(new Error('请输入有效的 JSON 对象'))}}
const rules={good_id:[{required:true,message:'请输入商品 ID'}],service_id:[{required:true,message:'请选择服务'}],allowed_regions:[{type:'array',required:true,min:1,message:'至少填写一个区域'}],exchange_rate:[{required:true,message:'请输入大于 0 的汇率'}],defaults_text:[{validator:jsonRule}],constraints_text:[{validator:jsonRule}]}
const body=r=>r?.data||{}
async function loadServices(){const b=body(await getAwsServiceList({page:1,count:100}));if(b.code===200)services.value=b.data?.data||b.data?.list||[]}
async function load(){loading.value=true;try{const params={...query};if(!params.service_id)delete params.service_id;const b=body(await getAwsGoodsList(params));if(b.code===200){rows.value=b.data?.data||b.data?.list||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
const serviceName=id=>services.value.find(v=>v.id===id)?.name||'服务'
function search(){query.page=1;load()} function reset(){query.service_id=undefined;search()}
async function loadAvailableRegions(serviceId){
const requestId=++regionRequestId
regionOptions.value=[]
if(!serviceId){regionsLoading.value=false;return}
regionsLoading.value=true
try{
const b=body(await testAwsService(serviceId))
if(requestId!==regionRequestId)return
if(b.code!==200){ElMessage.error(b.message||'读取可用区域失败');return}
const regions=Array.isArray(b.data?.regions)?b.data.regions:[]
regionOptions.value=regions.filter(region=>typeof region==='string'||region?.ready!==false).map(region=>{
if(typeof region==='string')return{id:region,label:region}
const id=region.id||region.region||region.code
const name=region.name_zh||region.nameZh||region.name_en||region.nameEn||''
return{id,label:name?`${name} (${id})`:id}
}).filter(region=>region.id)
if(!regionOptions.value.length)ElMessage.warning('当前 AWS 服务暂无已就绪的可用区域')
}catch{
if(requestId===regionRequestId)ElMessage.error('读取可用区域失败')
}finally{
if(requestId===regionRequestId)regionsLoading.value=false
}
}
function changeService(serviceId){form.allowed_regions=[];formRef.value?.clearValidate('allowed_regions');loadAvailableRegions(serviceId)}
function openCreate(){editing.value=false;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 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}}
function openCreate(){editing.value=false;Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true}
async function openEdit(row){editing.value=true;let data=row;try{const b=body(await getAwsGoodsDetail(row.id));if(b.code===200)data=b.data}catch{}Object.assign(form,{id:data.id,good_id:data.goodId,service_id:data.serviceId,allowed_regions:[...(data.allowedRegions||[])],allowed_image_categories:[...(data.allowedImageCategories||[])],allowed_images:[...(data.allowedImages||[])],allowed_type_categories:[...(data.allowedTypeCategories||[])],allowed_types:[...(data.allowedTypes||[])],defaults_text:JSON.stringify(data.defaults||{},null,2),constraints_text:JSON.stringify(data.constraints||{},null,2),force_associate_eip:!!data.forceAssociateEip,force_encrypted_root_volume:!!data.forceEncryptedRootVolume,force_install_agent:!!data.forceInstallAgent,grace_period_hours:data.gracePeriodHours??168,exchange_rate:data.exchangeRate??1,enabled:!!data.enabled});dialog.value=true}
function payload(){return{service_id:form.service_id,allowed_regions:form.allowed_regions,allowed_image_categories:form.allowed_image_categories,allowed_images:form.allowed_images,allowed_type_categories:form.allowed_type_categories,allowed_types:form.allowed_types,defaults:JSON.parse(form.defaults_text||'{}'),constraints:JSON.parse(form.constraints_text||'{}'),force_associate_eip:form.force_associate_eip,force_encrypted_root_volume:form.force_encrypted_root_volume,force_install_agent:form.force_install_agent,grace_period_hours:form.grace_period_hours,exchange_rate:form.exchange_rate,enabled:form.enabled}}
async function submit(){await formRef.value?.validate();submitting.value=true;try{const data=payload();if(editing.value)data.id=form.id;else data.good_id=form.good_id;const b=body(await(editing.value?updateAwsGoods(data):createAwsGoods(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}}
async function 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}.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}}
.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}
</style>
-196
View File
@@ -1,196 +0,0 @@
<template>
<div class="page" :class="{ embedded }" v-loading="loading">
<div v-if="!embedded" class="header"><div class="header-title"><el-button link @click="$router.back()">← 返回</el-button><div><h2>AWS 实例运维 #{{ instanceId }}</h2><p>履约审核、远端资源和运行期操作统一管理</p></div></div><el-button @click="loadBase">刷新</el-button></div>
<el-alert v-if="pageError" :title="pageError" type="error" show-icon :closable="false" class="alert"/>
<div v-if="instance && !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>
<el-descriptions-item label="服务"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:instance.serviceId}})">#{{instance.serviceId}}</el-link></el-descriptions-item>
<el-descriptions-item label="履约状态"><el-tag :type="statusTag(instance.provisionStatus)">{{statusText(instance.provisionStatus)}}</el-tag></el-descriptions-item>
<el-descriptions-item label="Region">{{instance.region||'-'}}</el-descriptions-item><el-descriptions-item label="可用区">{{instance.placementId||'-'}}</el-descriptions-item><el-descriptions-item label="远端实例">{{instance.remoteAwsInstanceId||instance.remoteInstanceId||'-'}}</el-descriptions-item><el-descriptions-item label="待执行">{{instance.pendingAction||'-'}}</el-descriptions-item>
</el-descriptions>
</div>
<div v-if="instance" class="toolbar">
<span>管理员控制</span>
<div>
<el-button type="warning" :disabled="lifecycleSubmitting" @click="openSuspend">管理员暂停</el-button>
<el-button type="success" plain :disabled="lifecycleSubmitting" @click="resumeInstance">解除暂停</el-button>
</div>
</div>
<el-dialog v-model="suspendDialog" title="管理员暂停实例" width="500px" :close-on-click-modal="false" :close-on-press-escape="!lifecycleSubmitting" :show-close="!lifecycleSubmitting">
<el-alert title="暂停后用户将无法操作此实例,只有管理员解除暂停后才能恢复操作。" type="warning" show-icon :closable="false" class="alert"/>
<el-form ref="suspendFormRef" :model="suspendForm" label-width="90px">
<el-form-item label="暂停原因" prop="reason" :rules="[{ required: true, whitespace: true, message: '请输入暂停原因', trigger: 'blur' }]">
<el-input v-model="suspendForm.reason" type="textarea" :rows="4" :disabled="lifecycleSubmitting" placeholder="请输入暂停原因"/>
</el-form-item>
</el-form>
<template #footer><el-button :disabled="lifecycleSubmitting" @click="suspendDialog=false">取消</el-button><el-button type="warning" :loading="lifecycleSubmitting" @click="submitSuspend">确认暂停</el-button></template>
</el-dialog>
<el-tabs v-if="instance" v-model="tab" class="tabs" @tab-change="tabChanged">
<el-tab-pane label="履约审核" name="fulfillment">
<div class="toolbar"><span>履约记录与失败审核</span><div><el-button @click="fulfillmentAction('resync')">重新同步</el-button><el-button v-if="instance.provisionStatus==='failed'" type="warning" @click="fulfillmentAction('retry')">审核重试</el-button><el-button v-if="instance.provisionStatus==='failed'" type="success" @click="fulfillmentAction('resolved')">确认解决</el-button><el-button v-if="instance.provisionStatus==='failed'" type="danger" plain @click="fulfillmentAction('reject')">驳回</el-button></div></div>
<el-descriptions :column="2" border><el-descriptions-item label="审核状态">{{instance.reviewStatus||'-'}}</el-descriptions-item><el-descriptions-item label="上次状态">{{statusText(instance.previousProvisionStatus)}}</el-descriptions-item><el-descriptions-item label="远端任务">{{instance.remoteTaskId||'-'}}</el-descriptions-item><el-descriptions-item label="重试次数">{{instance.retryCount||0}}</el-descriptions-item><el-descriptions-item label="错误代码">{{instance.lastErrorCode||'-'}}</el-descriptions-item><el-descriptions-item label="失败阶段">{{instance.failureStage||'-'}}</el-descriptions-item><el-descriptions-item label="错误信息" :span="2">{{instance.lastError||'-'}}</el-descriptions-item><el-descriptions-item label="审核备注" :span="2">{{instance.reviewNote||'-'}}</el-descriptions-item></el-descriptions>
<div class="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><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-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>
<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 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 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>
<el-dialog v-model="loginDialog" title="AWS 登录凭据" width="820px" destroy-on-close @closed="clearCredentials">
<el-alert title="敏感信息仅在当前弹窗临时展示,关闭后立即清除" type="warning" show-icon :closable="false" class="credential-alert"/>
<div class="credential-grid">
<div v-for="(user,ui) in loginUsers" :key="ui" class="credential-card">
<div class="credential-user"><span class="user-avatar"><el-icon><User /></el-icon></span><div><h4>{{user.username||'未知用户'}}</h4><span>{{(user.methods||[]).length}} 种登录方式</span></div></div>
<section v-for="(method,mi) in user.methods||[]" :key="mi" class="credential-method" :class="{unavailable:method.credential?.available===false}">
<div class="method-head"><div><b>{{credentialType(method)}}</b></div><el-tag :type="method.credential?.available===false?'danger':'success'">{{method.credential?.available===false?'不可用':'可用'}}</el-tag></div>
<el-alert v-if="method.credential?.error" :title="method.credential.error" type="error" show-icon :closable="false"/>
<div class="credential-meta-grid"><div><span>主机地址</span><code :title="method.host">{{method.host||'-'}}</code></div><div><span>协议</span><code>{{String(method.protocol||'ssh').toUpperCase()}}</code></div><div><span>端口</span><code>{{method.port||'-'}}</code></div><div><span>用户名</span><code>{{user.username||'-'}}</code></div></div>
<div v-if="isPassword(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>登录密码</span></div><div class="secret-display"><code>{{revealed[`${ui}-${mi}-p`]?method.credential.password:mask(method.credential.password)}}</code></div><div class="secret-actions"><el-button @click="toggleSecret(`${ui}-${mi}-p`)">{{revealed[`${ui}-${mi}-p`]?'隐藏密码':'查看密码'}}</el-button><el-button type="primary" plain @click="copy(method.credential.password)">复制密码</el-button></div></div>
<div v-if="isPrivateKey(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>SSH 私钥</span><el-tooltip :content="`密钥指纹:${method.credential?.fingerprint||'暂无'}`" placement="top"><el-button class="fingerprint-button" circle link :icon="InfoFilled" aria-label="查看密钥指纹"/></el-tooltip></div><div class="secret-display"><code>{{method.credential?.key_name||'EC2 Key Pair'}}</code></div><div class="secret-actions"><el-button @click="downloadPrivateKey(method.credential.private_key,method.credential?.key_name)">下载私钥</el-button><el-button type="primary" plain @click="copy(method.credential.private_key)">复制私钥</el-button></div></div>
</section>
</div>
</div>
<el-empty v-if="!loginUsers.length" description="暂无可用登录凭据"/>
</el-dialog>
<el-dialog v-model="ruleDialog" title="添加安全组规则" width="520px"><el-form :model="rule" label-width="90px"><el-form-item label="方向"><el-select v-model="rule.direction"><el-option label="入站" value="ingress"/><el-option label="出站" value="egress"/></el-select></el-form-item><el-form-item label="协议"><el-select v-model="rule.protocol"><el-option v-for="p in protocols" :key="p" :label="p" :value="p"/></el-select></el-form-item><el-form-item label="端口范围"><el-input-number v-model="rule.from_port" :min="-1" :max="65535"/> - <el-input-number v-model="rule.to_port" :min="-1" :max="65535"/></el-form-item><el-form-item label="CIDR"><el-input v-model.trim="rule.cidr"/></el-form-item><el-form-item label="说明"><el-input v-model="rule.description" maxlength="200"/></el-form-item></el-form><template #footer><el-button @click="ruleDialog=false">取消</el-button><el-button type="primary" @click="addRule">确认</el-button></template></el-dialog>
<el-dialog v-model="volumeDialog" :title="volumeTitles[volumeMode]" width="560px"><el-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>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
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(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 suspendDialog=ref(false),suspendFormRef=ref(null),lifecycleSubmitting=ref(false)
const suspendForm=reactive({reason:''})
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 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 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=>({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;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'){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}}
function openSuspend(){suspendForm.reason='';suspendFormRef.value?.clearValidate();suspendDialog.value=true}
async function submitSuspend(){
if(lifecycleSubmitting.value)return
if(!await suspendFormRef.value?.validate().catch(()=>false))return
await submitLifecycleAction('suspend',suspendForm.reason.trim())
}
async function resumeInstance(){
if(lifecycleSubmitting.value)return
try{await ElMessageBox.confirm(`确认解除实例 #${instanceId} 的管理员暂停?解除后用户可恢复操作。`,'解除暂停',{type:'warning',confirmButtonText:'确认解除',cancelButtonText:'取消'})}catch{return}
await submitLifecycleAction('resume')
}
async function submitLifecycleAction(action,reason){
if(lifecycleSubmitting.value)return
lifecycleSubmitting.value=true
try{
const result=envelope(await api.actionAdminAwsInstance(instanceId,action,reason))
if(result.code!==200)throw new Error(result.message||'实例操作失败')
suspendDialog.value=false
ElMessage.success(action==='suspend'?'管理员暂停已受理':'解除暂停已受理')
await Promise.all([loadBase(),loadRuntime()])
}catch(e){ElMessage.error(message(e,'实例操作失败'))}finally{lifecycleSubmitting.value=false}
}
async function fulfillmentAction(type){const note=await promptNote({resync:'重新同步履约',retry:'审核重试',resolved:'确认解决',reject:'驳回履约'}[type]);if(note===null)return;let call;if(type==='resync')call=()=>api.resyncAwsInstance(instanceId,note);else if(type==='retry')call=()=>api.retryAwsInstance(instanceId,note);else call=()=>api.resolveAwsInstance(instanceId,type,note);const v=await safe(call,'履约操作失败');if(v!==null){ElMessage.success('操作成功');loadBase()}}
function stopPoll(){clearTimeout(pollTimer.value);pollTimer.value=null}
function pollOperation(id,onSuccess){stopPoll();pollStarted.value=Date.now();const tick=async()=>{if(Date.now()-pollStarted.value>=120000)return ElMessage.warning('操作仍在处理,请稍后手动刷新');try{const r=await api.getAdminAwsOperationDetail(id);const op=dataOf(r);if(['queued','running'].includes(op?.status)){pollTimer.value=setTimeout(tick,5000);return}if(op?.status==='succeeded'){ElMessage.success('操作完成');await onSuccess?.()}else ElMessage.error(op?.errorMessage||'操作失败')}catch{pollTimer.value=setTimeout(tick,5000)}};pollTimer.value=setTimeout(tick,5000)}
async function runAsync(call,onSuccess){if(submitting.value)return;submitting.value=true;try{const r=await call();const b=envelope(r);if(b.code!==200)throw new Error(b.message||'提交失败');const op=b.data;ElMessage.success('操作已接受');if(op?.operation_id)pollOperation(op.operation_id,onSuccess)}catch(e){ElMessage.error(message(e,'操作提交失败'))}finally{submitting.value=false}}
function refreshRuntime(){runAsync(()=>api.refreshAdminAwsRuntimeDetails(instanceId),loadRuntime)}
function startLoginRetry(seconds){clearInterval(retryTimer.value);loginRetryAfter.value=Number(seconds)||5;retryTimer.value=setInterval(()=>{loginRetryAfter.value--;if(loginRetryAfter.value<=0){clearInterval(retryTimer.value);retryTimer.value=null}},1000)}
async function readLogin(){if(loginRetryAfter.value)return;try{const r=await api.getAdminAwsLoginInfo(instanceId);const b=envelope(r);if(b.code!==200)throw new Error(b.message||'读取登录凭据失败');loginUsers.value=b.data?.users||[];loginDialog.value=true}catch(e){if(e?.response?.status===429)startLoginRetry(e.response.headers?.['retry-after']||5);ElMessage.error(message(e,'读取登录凭据失败'))}}
function clearCredentials(){loginUsers.value=[];Object.keys(revealed).forEach(k=>delete revealed[k])}
function toggleSecret(k){revealed[k]=!revealed[k]} async function copy(v){if(!v)return ElMessage.warning('暂无可复制内容');await navigator.clipboard.writeText(v);ElMessage.success('已复制')}
async function confirmPasswordReset(){try{await ElMessageBox.confirm('重置后原密码可能立即失效,确认继续?','重置实例密码',{type:'warning'})}catch{return}runAsync(()=>api.resetAdminAwsPassword(instanceId))}
function validRule(){if(!protocols.includes(rule.protocol)||!['ingress','egress'].includes(rule.direction))return'方向或协议无效';if(!Number.isInteger(rule.from_port)||!Number.isInteger(rule.to_port)||rule.from_port< -1||rule.to_port>65535||rule.from_port>rule.to_port)return'端口范围无效';if(!(/^(?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}$|^[0-9a-fA-F:]+\/\d{1,3}$/).test(rule.cidr))return'CIDR 无效';return''}
function addRule(){const err=validRule();if(err)return ElMessage.warning(err);runAsync(()=>api.addAdminAwsSecurityGroupRule(instanceId,{...rule}),async()=>{ruleDialog.value=false;await loadNetwork()})}
async function deleteRule(row){try{await ElMessageBox.confirm(`确认删除 ${row.protocol} ${portText(row)} ${row.cidr}`,'删除安全组规则',{type:'warning'})}catch{return}const r={direction:row.direction||'ingress',protocol:row.protocol,from_port:row.from_port??row.fromPort,to_port:row.to_port??row.toPort,cidr:row.cidr,description:row.description||''};runAsync(()=>api.deleteAdminAwsSecurityGroupRule(instanceId,r),loadNetwork)}
async function ensureEip(){const allocation=await promptNote('确保或更换 EIP(可填写 Allocation ID');if(allocation===null)return;runAsync(()=>api.replaceAdminAwsEip({instance_id:instanceId,allocation_id:allocation}),loadNetwork)}
async function setPtr(){const domain=await promptNote('更新 EIP PTR(填写域名,留空清除)');if(domain===null)return;runAsync(()=>api.updateAdminAwsEipReverseDns({instance_id:instanceId,domain_name:domain}),loadNetwork)}
async function dangerEip(type){try{await ElMessageBox.confirm(type==='release'?'释放 EIP 后可能无法恢复,确认继续?':'确认解绑当前 EIP',type==='release'?'释放 EIP':'解绑 EIP',{type:'error'})}catch{return}runAsync(()=>type==='release'?api.releaseAdminAwsEip(instanceId):api.disassociateAdminAwsEip(instanceId),loadNetwork)}
function openVolume(mode,row={}){volumeMode.value=mode;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})
onMounted(loadBase);onBeforeUnmount(()=>{stopPoll();clearInterval(retryTimer.value);clearCredentials()})
</script>
<style scoped>
.page{padding:20px;background:#f5f7fa}.page.embedded{padding:0;background:transparent}.header,.header-title,.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px}.header{padding:20px 24px;margin-bottom:16px;border:1px solid #d9ecff;border-radius:14px;background:linear-gradient(135deg,#edf5ff,#f9fbff);box-shadow:0 7px 22px rgba(53,84,136,.06)}h2{margin:0}.header p{margin:5px 0 0;color:#909399}.alert{margin-bottom:14px}.summary{margin-bottom:16px}.embedded .summary{margin-top:18px}.tabs{padding:0 20px 22px;border-radius:12px;background:#fff}.embedded .tabs{padding:0 0 8px}.toolbar{margin:18px 0;font-weight:600}.toolbar.sub{margin-top:28px}.split{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px}.split section{min-width:0}pre{max-height:430px;overflow:auto;padding:14px;border:1px solid #ebeef5;border-radius:6px;background:#f7f8fa;font:12px/1.6 Consolas,monospace;white-space:pre-wrap;word-break:break-all}.credential{margin-bottom:18px}.credential h4{margin:0 0 10px}.credential .el-descriptions{margin-bottom:10px}.secret{display:inline-block;max-width:70%;margin-right:8px;font-family:Consolas,monospace;white-space:pre-wrap;word-break:break-all;vertical-align:top}.pager{justify-content:flex-end;margin-top:16px}.raw-collapse{margin-top:16px}:deep(.el-tabs__item){transition:color .2s,transform .2s}:deep(.el-tabs__item:hover){transform:translateY(-1px)}:deep(.el-tabs__active-bar){height:3px;border-radius:4px;background:linear-gradient(90deg,#4f7cff,#7c5cff)}@media(max-width:1000px){.split{grid-template-columns:1fr}}
.credential-alert{margin-bottom:16px}.credential-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,360px));gap:16px;align-items:start}.credential-card{min-width:0;margin:0;padding:15px;border:1px solid #dfe8f5;border-radius:15px;background:#fff;box-shadow:0 7px 22px rgba(42,64,99,.06);box-sizing:border-box}.credential-user{display:flex;align-items:center;gap:11px;margin-bottom:13px;padding-bottom:13px;border-bottom:1px solid #edf1f6}.user-avatar{display:grid;width:40px;height:40px;border-radius:11px;background:#edf4ff;color:#4f7cff;font-size:19px;place-items:center}.credential-user h4{margin:0;color:#263247}.credential-user div>span{display:block;margin-top:4px;color:#98a2b3;font-size:12px}.credential-method{margin-top:10px;padding:13px;border:1px solid #e9eef5;border-radius:11px;background:#fbfcfe}.credential-method.unavailable{opacity:.7}.method-head,.method-head>div{display:flex;align-items:center}.method-head{justify-content:space-between;gap:12px}.method-head>div{min-width:0;align-items:flex-start;flex-direction:column;gap:3px}.method-head b{color:#344054}.method-head span{color:#98a2b3;font-size:11px}.credential-meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.credential-meta-grid>div{min-width:0;padding:8px 9px;border-radius:8px;background:#fff}.credential-meta-grid span,.credential-meta-grid code{display:block}.credential-meta-grid span{margin-bottom:4px;color:#98a2b3;font-size:11px}.credential-meta-grid code{overflow:hidden;color:#475467;font:12px Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.secret-block{margin-top:11px;padding:11px;border:1px solid #e6ebf3;border-radius:9px;background:#fff}.credential-secret-card{display:flex;min-height:138px;flex-direction:column;box-sizing:border-box}.secret-label{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px}.secret-label span{color:#344054;font-weight:600}.fingerprint-button{font-size:16px}.secret-display{display:flex;min-height:40px;align-items:center;flex:1}.secret-display code{display:block;width:100%;overflow:hidden;padding:10px;border-radius:7px;background:#f6f8fb;color:#344054;font:13px Consolas,monospace;text-overflow:ellipsis;white-space:nowrap;box-sizing:border-box}.secret-actions{display:flex;gap:7px;margin-top:10px}.secret-actions .el-button{flex:1;margin:0}@media(max-width:760px){.credential-grid{grid-template-columns:1fr}}
.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>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="page">
<div class="header"><div><h2>AWS 履约实例</h2><p>查看远端实例快照并审核失败任务</p></div><el-button @click="load">刷新</el-button></div>
<div class="filters"><el-select v-model="query.status" clearable placeholder="全部履约状态" @change="search"><el-option v-for="s in statuses" :key="s" :label="statusText(s)" :value="s"/></el-select><el-button @click="reset">重置</el-button></div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="实例 ID" width="90"/>
<el-table-column label="用户商品" width="105"><template #default="{row}"><el-link type="primary" @click="$router.push(`/user-goods/detail/${row.userGoodsId}`)">{{row.userGoodsId}}</el-link></template></el-table-column>
<el-table-column label="订单" width="100"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/order/list',query:{order_id:row.orderId}})">{{row.orderId}}</el-link></template></el-table-column>
<el-table-column label="服务" width="95"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:row.serviceId}})">{{row.serviceId}}</el-link></template></el-table-column>
<el-table-column prop="region" label="Region" min-width="125"/>
<el-table-column prop="remoteAwsInstanceId" label="AWS 实例" min-width="160" show-overflow-tooltip/>
<el-table-column label="履约状态" width="110"><template #default="{row}"><el-tag :type="statusType(row.provisionStatus)">{{statusText(row.provisionStatus)}}</el-tag></template></el-table-column>
<el-table-column prop="pendingAction" label="待执行" width="90"/>
<el-table-column prop="retryCount" label="重试" width="65"/>
<el-table-column prop="lastError" label="最近错误" min-width="180" show-overflow-tooltip/>
<el-table-column label="最近同步" width="170"><template #default="{row}">{{formatTime(row.lastSyncedAt)}}</template></el-table-column>
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="showDetail(row)">详情</el-button><el-popconfirm v-if="row.provisionStatus==='failed'" title="确认审核并重试该履约?" @confirm="retry(row)"><template #reference><el-button link type="warning" :loading="retryingId===row.id">重试</el-button></template></el-popconfirm></template></el-table-column>
</el-table>
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
<el-drawer v-model="drawer" title="AWS 履约详情" size="600px">
<template v-if="current"><el-descriptions :column="2" border><el-descriptions-item label="实例 ID">{{current.id}}</el-descriptions-item><el-descriptions-item label="状态">{{statusText(current.provisionStatus)}}</el-descriptions-item><el-descriptions-item label="远端实例">{{current.remoteAwsInstanceId||'-'}}</el-descriptions-item><el-descriptions-item label="任务 ID">{{current.remoteTaskId||'-'}}</el-descriptions-item><el-descriptions-item label="错误代码">{{current.lastErrorCode||'-'}}</el-descriptions-item><el-descriptions-item label="下次重试">{{formatTime(current.nextRetryAt)}}</el-descriptions-item><el-descriptions-item label="错误信息" :span="2">{{current.lastError||'-'}}</el-descriptions-item></el-descriptions><h4>资源快照</h4><pre class="snapshot">{{snapshotText}}</pre></template>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAwsInstanceList, retryAwsInstance } from '@/api/admin/awsService.js'
const statuses=['pending','submitting','provisioning','active','failed','stopping','stopped','terminating','terminated']
const labels={pending:'等待中',submitting:'提交中',provisioning:'创建中',active:'运行中',failed:'失败',stopping:'停止中',stopped:'已停止',terminating:'终止中',terminated:'已终止'}
const loading=ref(false), retryingId=ref(null), rows=ref([]), total=ref(0), drawer=ref(false), current=ref(null)
const query=reactive({page:1,count:20,status:undefined})
const body=r=>r?.data||{}
const statusText=s=>labels[s]||s||'-'
const statusType=s=>s==='active'?'success':s==='failed'?'danger':['pending','submitting','provisioning','stopping','terminating'].includes(s)?'warning':'info'
const formatTime=t=>t?new Date(t).toLocaleString('zh-CN',{hour12:false}):'-'
const snapshotText=computed(()=>{const raw=current.value?.resourceSnapshot;if(!raw)return '暂无资源快照';try{return JSON.stringify(typeof raw==='string'?JSON.parse(raw):raw,null,2)}catch{return String(raw)}})
async function load(){loading.value=true;try{const params={...query};if(!params.status)delete params.status;const b=body(await getAwsInstanceList(params));if(b.code===200){rows.value=b.data?.list||b.data?.data||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
function search(){query.page=1;load()} function reset(){query.status=undefined;search()} function showDetail(row){current.value=row;drawer.value=true}
async function retry(row){retryingId.value=row.id;try{const b=body(await retryAwsInstance(row.id));if(b.code===200){ElMessage.success('已接受重试');load()}else ElMessage.error(b.message||'重试失败')}finally{retryingId.value=null}}
onMounted(load)
</script>
<style scoped>
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:230px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.snapshot{padding:16px;border-radius:8px;background:#f5f7fa;white-space:pre-wrap;word-break:break-all;max-height:55vh;overflow:auto}
</style>
-52
View File
@@ -1,52 +0,0 @@
<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>
<article><span class="icon purple"><el-icon><Cpu /></el-icon></span><div><small>规格 / 可用区</small><strong>{{ instance.instance_type || '-' }}</strong><i>{{ instance.availability_zone || '-' }}</i></div></article>
<article><span class="icon orange"><el-icon><CircleCheck /></el-icon></span><div><small>Agent</small><strong>{{ agent.status === 'online' ? '在线' : agent.status || '未连接' }}</strong><i>{{ agent.os || '-' }} · {{ agent.version || '-' }}</i></div></article>
</div>
<div v-if="details" class="resource-grid">
<section class="surface"><header><b><el-icon><Tickets /></el-icon> 实例与费用</b><small>{{ time(instance.synced_at) }}</small></header><div class="props"><div v-for="r in rows" :key="r[0]"><span>{{ r[0] }}</span><b>{{ r[1] || '-' }}</b></div></div><div class="costs"><div><span>本月</span><b>{{ money(instance.month_cost) }}</b></div><div><span>预计本月</span><b>{{ money(instance.projected_month_cost) }}</b></div><div><span>完整月</span><b>{{ money(instance.full_month_cost) }}</b></div></div></section>
<section class="surface"><header><b><el-icon><TrendCharts /></el-icon> 流量与资源</b><small>{{ limit.measurement_source === 'agent' ? 'Agent 计量' : 'AWS 计量' }}</small></header><div class="traffic"><strong>{{ bytes(limit.current_usage_bytes) }}</strong><span>/ {{ bytes(limit.limit_bytes) }}</span></div><el-progress :percentage="trafficPercent" :stroke-width="9"/><div class="counts"><span><b>{{ details.security_groups?.length || 0 }}</b> 安全组</span><span><b>{{ details.storage?.volumes?.length || 0 }}</b> 数据卷</span><span><b>{{ details.network?.elastic_ips?.length || 0 }}</b> EIP</span></div></section>
</div>
<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>
</template>
</div>
<el-empty v-else description="点击上方按钮读取实时详情或监控数据" />
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { CircleCheck, Connection, Cpu, Monitor, 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}
async function draw(){await nextTick();if(!props.monitoring||!lineRef.value||!polarRef.value)return;dispose();lineChart=echarts.init(lineRef.value);polarChart=echarts.init(polarRef.value);const all=props.monitoring.traffic?.series||[],rows=all.some(v=>v.interface_name==='__host__')?all.filter(v=>v.interface_name==='__host__'):all;lineChart.setOption({color:['#4f7cff','#16b8a6'],tooltip:{trigger:'axis'},legend:{top:0},grid:{left:55,right:18,top:42,bottom:28},xAxis:{type:'category',boundaryGap:false,data:rows.map(v=>new Date(v.bucket_at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}))},yAxis:{type:'value',axisLabel:{formatter:v=>bytes(v)},splitLine:{lineStyle:{color:'#edf1f7'}}},series:[{name:'入站',type:'line',smooth:true,symbol:'none',areaStyle:{opacity:.1},data:rows.map(v=>v.avg_rx_bytes_per_second||0)},{name:'出站',type:'line',smooth:true,symbol:'none',areaStyle:{opacity:.08},data:rows.map(v=>v.avg_tx_bytes_per_second||0)}]});polarChart.setOption({color:['#4f7cff','#8b5cf6','#16b8a6'],tooltip:{trigger:'item'},angleAxis:{max:100,show:false},radiusAxis:{type:'category',data:['CPU','内存','磁盘'],axisLine:{show:false},axisTick:{show:false}},polar:{radius:['20%','84%']},series:[{type:'bar',coordinateSystem:'polar',roundCap:true,barWidth:13,data:[Number(metrics.value.cpu_percent||0),ratio(metrics.value.memory_used_bytes,metrics.value.memory_total_bytes),ratio(metrics.value.disk_used_bytes,metrics.value.disk_total_bytes)].map(v=>Number(v.toFixed(1))),label:{show:true,position:'middle',formatter:'{c}%',color:'#fff'}}]})}
const resize=()=>{lineChart?.resize();polarChart?.resize()};watch(()=>props.monitoring,draw,{deep:true});window.addEventListener('resize',resize);onBeforeUnmount(()=>{dispose();window.removeEventListener('resize',resize)})
</script>
<style scoped>
.runtime-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>
@@ -1,82 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
+100 -130
View File
@@ -526,9 +526,8 @@
ref="productFormRef"
:model="productForm"
:rules="productRules"
label-position="right"
label-width="116px"
class="product-form goods-form"
label-position="top"
class="product-form"
>
<!-- 顶部封面 + 基本信息 -->
<div class="product-form-header">
@@ -609,26 +608,40 @@
<el-tabs v-model="productFormTab" class="product-form-tabs">
<el-tab-pane label="价格与销售" name="sales">
<div class="form-grid">
<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 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>
<el-form-item label="单个商品数量" prop="pay_num">
<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-input-number
v-model="productForm.pay_num"
:min="1"
placeholder="请输入数量"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="expire_time">
<template #label>
<span class="form-label-with-help">有效期<el-tooltip content="0 表示永久有效" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
<span>有效期<span class="unit-suffix">0 为永久</span></span>
</template>
<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-input-number
v-model="productForm.expire_time"
:min="0"
placeholder="请输入有效期"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="允许续费" prop="can_renew">
<el-switch
@@ -639,30 +652,17 @@
</el-form-item>
<el-form-item prop="renew_price" 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>
<span>续费价格<span class="unit-suffix">0 沿用商品价格</span></span>
</template>
<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-input-number
v-model="productForm.renew_price"
:min="0"
:precision="2"
:step="0.01"
placeholder="续费基础价格"
controls-position="right"
style="width: 100%"
/>
</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 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-form-item label="需要实名" prop="require_real_name">
<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 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-form-item label="远端商品" prop="remote_goods">
<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,33 +704,43 @@
/>
</el-form-item>
<el-form-item label="库存数量" prop="inventory">
<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-input-number
v-model="productForm.inventory"
:min="0"
:disabled="!productForm.inventory_control"
placeholder="请输入库存"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<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-form-item label="售罄状态" prop="sold_out">
<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 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 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>
<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 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>
</div>
</el-tab-pane>
@@ -744,31 +754,44 @@
inactive-text="禁用"
/>
</el-form-item>
<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 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>
<el-form-item prop="renew_recommend_rebate">
<template #label>
<span class="form-label-with-help">续费推介返还<el-tooltip content="0 表示沿用推荐返还比例" placement="top"><el-icon><QuestionFilled /></el-icon></el-tooltip></span>
<span>续费推介返还<span class="unit-suffix">%0 沿用推荐返还</span></span>
</template>
<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-input-number
v-model="productForm.renew_recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="续费推介返还百分比"
controls-position="right"
style="width: 100%"
/>
</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 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-form-item label="购买通知" prop="send_notice">
<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>
@@ -833,7 +856,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, QuestionFilled } from '@element-plus/icons-vue'
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
import {
getProductGroupList,
createProductGroup,
@@ -950,8 +973,6 @@ 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,
@@ -1613,8 +1634,6 @@ const handleAddProduct = () => {
remote_goods: false,
max_per_user: 0,
max_first_purchase_duration: 0,
max_renew_duration: 0,
renew_before_days: 0,
recommend_words: ''
})
@@ -1654,8 +1673,6 @@ 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,
@@ -1690,8 +1707,6 @@ 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,
@@ -2125,9 +2140,8 @@ watch(() => route.query.good_id, (newId) => {
margin-top: 4px;
}
.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; }
.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; }
.recommend-user-selector {
display: flex;
@@ -2154,41 +2168,6 @@ 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;
@@ -2290,10 +2269,6 @@ 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;
}
@@ -2418,11 +2393,6 @@ 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;
}
-12
View File
@@ -210,8 +210,6 @@
</el-card>
<!-- 类型适配栏目区域 -->
<AwsUserGoodsDetail v-else-if="isAwsGoods" ref="awsDetailRef" :goods-id="goodsId" />
<UserVmDetail v-else-if="currentTag === '云服务器'" ref="vmDetailRef" embedded :goods-id="goodsId" />
<el-card v-else-if="detail.good" shadow="hover" class="related-card">
@@ -338,7 +336,6 @@ import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import UserVmDetail from '@/views/user-vm/UserVmDetail.vue'
import AwsUserGoodsDetail from './components/AwsUserGoodsDetail.vue'
import dayjs from 'dayjs'
const route = useRoute()
@@ -348,7 +345,6 @@ const goodsId = computed(() => parseInt(route.params.id) || 0)
const loading = ref(false)
const refreshing = ref(false)
const vmDetailRef = ref(null)
const awsDetailRef = ref(null)
const submitLoading = ref(false)
const detail = ref(null)
const detailExpanded = ref(false)
@@ -391,10 +387,6 @@ const expireStatusClass = computed(() => {
const goBack = () => router.push('/user-goods/list')
const currentTag = computed(() => (detail.value?.tag || detail.value?.good?.tag || '').toLowerCase())
const isAwsGoods = computed(() => {
const tag = currentTag.value.replace(/\s+/g, '')
return tag === 'aws' || tag.includes('aws云服务器') || tag.includes('aws服务器')
})
// deleteAt
const isDeleted = computed(() => !!(detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at))
@@ -466,10 +458,6 @@ const refreshDetail = async () => {
await nextTick()
await vmDetailRef.value?.refresh?.()
}
if (isAwsGoods.value && !isDeleted.value) {
await nextTick()
await awsDetailRef.value?.refresh?.()
}
} finally {
refreshing.value = false
}
@@ -1,417 +0,0 @@
.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
-4
View File
@@ -564,9 +564,6 @@
</div>
</el-tab-pane>
<el-tab-pane v-if="isVmGoods" label="操作日志" name="operations">
<VmOperationRecords v-if="activeTab === 'operations'" :user-goods-id="userGoodsId" :vm-id="Number(vm?.id || 0)" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
@@ -1218,7 +1215,6 @@ import UserSelector from '@/components/UserSelector/index.vue'
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
import UserVmVolumeSelector from '@/components/admin/UserVmVolumeSelector.vue'
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
import VmOperationRecords from './components/VmOperationRecords.vue'
import dayjs from 'dayjs'
import * as echarts from 'echarts'
@@ -1,70 +0,0 @@
<template>
<div class="operation-records">
<div class="filters">
<el-input v-model.trim="filters.keyword" clearable placeholder="搜索操作、任务或错误" style="width:220px" @keyup.enter="search" />
<el-select v-model="filters.status" clearable placeholder="全部状态" style="width:130px" @change="search">
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="filters.operator_type" clearable placeholder="全部操作者" style="width:130px" @change="search">
<el-option label="管理员" value="admin" /><el-option label="用户" value="user" /><el-option label="系统" value="system" />
</el-select>
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" style="width:350px" @change="search" />
<el-button type="primary" @click="search">查询</el-button><el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe border empty-text="暂无操作日志">
<el-table-column prop="id" label="ID" width="76" />
<el-table-column label="操作" min-width="160"><template #default="{row}"><strong>{{ operationLabel(row.operation_type) }}</strong><div class="muted">{{ row.note || row.operation_type || '-' }}</div></template></el-table-column>
<el-table-column label="操作者" width="120"><template #default="{row}">{{ operatorLabel(row.operator_type) }}<div class="muted">#{{ row.operator_id || '-' }}</div></template></el-table-column>
<el-table-column label="状态" width="105"><template #default="{row}"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="task_id" label="任务 ID" min-width="170" show-overflow-tooltip />
<el-table-column label="开始时间" width="168"><template #default="{row}">{{ formatTime(row.started_at) }}</template></el-table-column>
<el-table-column label="耗时" width="100"><template #default="{row}">{{ duration(row) }}</template></el-table-column>
<el-table-column prop="error_message" label="错误信息" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right"><template #default="{row}"><el-button link type="primary" @click="showDetail(row.id)">详情</el-button><el-button v-if="canRefresh(row)" link type="warning" :loading="refreshingId===row.id" @click="refreshRecord(row)">核对状态</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page" v-model:page-size="count" :page-sizes="[10,20,50,100]" :total="total" layout="total,sizes,prev,pager,next" class="pagination" @current-change="load" @size-change="sizeChanged" />
<el-dialog v-model="detailVisible" title="虚拟机操作日志详情" width="760px" destroy-on-close>
<el-descriptions v-if="detail" :column="2" border>
<el-descriptions-item label="日志 ID">#{{ detail.id }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="操作类型">{{ operationLabel(detail.operation_type) }}</el-descriptions-item><el-descriptions-item label="操作者">{{ operatorLabel(detail.operator_type) }} #{{ detail.operator_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户商品">#{{ detail.user_good_id || '-' }}</el-descriptions-item><el-descriptions-item label="虚拟机">#{{ detail.vm_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="服务 / 主机">#{{ detail.service_id || '-' }} / #{{ detail.host_id || '-' }}</el-descriptions-item><el-descriptions-item label="任务 ID">{{ detail.task_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="请求方式 / 路由" :span="2">{{ detail.method || '-' }} {{ detail.route || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.note || '-' }}</el-descriptions-item><el-descriptions-item label="错误" :span="2">{{ detail.error_message || '-' }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ formatTime(detail.started_at) }}</el-descriptions-item><el-descriptions-item label="完成时间">{{ formatTime(detail.finished_at) }}</el-descriptions-item>
</el-descriptions>
<div v-if="detail?.request_snapshot || detail?.result_snapshot" class="snapshots"><section v-if="detail.request_snapshot"><h4>请求快照</h4><pre>{{ pretty(detail.request_snapshot) }}</pre></section><section v-if="detail.result_snapshot"><h4>结果快照</h4><pre>{{ pretty(detail.result_snapshot) }}</pre></section></div>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getVmOperationRecordDetail, getVmOperationRecordList, refreshVmOperationRecord } from '@/api/admin/userVm'
const props = defineProps({ userGoodsId: { type: Number, required: true }, vmId: { type: Number, default: 0 } })
const loading=ref(false),rows=ref([]),page=ref(1),count=ref(10),total=ref(0),dateRange=ref([]),detailVisible=ref(false),detail=ref(null),refreshingId=ref(0)
const filters=reactive({keyword:'',status:'',operator_type:''})
const statuses=[['pending','等待中'],['running','执行中'],['success','成功'],['failed','失败'],['aborted','已中止'],['unknown','未知']].map(([value,label])=>({value,label}))
const bodyOf=r=>r?.data||r||{},errorText=(e,f)=>e?.response?.data?.message||e?.message||f
const statusLabel=s=>Object.fromEntries(statuses.map(v=>[v.value,v.label]))[s]||s||'-'
const statusType=s=>s==='success'?'success':s==='failed'?'danger':s==='aborted'?'info':['pending','running'].includes(s)?'warning':''
const operatorLabel=s=>({admin:'管理员',user:'用户',system:'系统'})[s]||s||'-'
const operationLabel=s=>({start:'启动',stop:'关机',reboot:'重启',rebuild:'重装系统',migrate:'迁移',change_password:'重置密码',reset_mac:'重置 MAC',rescue:'救援模式',exit_rescue:'退出救援',create_snapshot:'创建快照',restore_snapshot:'恢复快照',delete_snapshot:'删除快照',create_backup:'创建备份',restore_backup:'恢复备份',delete_backup:'删除备份'})[s]||s||'-'
const formatTime=v=>v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'-'
const duration=row=>{if(!row.started_at)return'-';const end=row.finished_at?new Date(row.finished_at).getTime():Date.now(),seconds=Math.max(0,Math.round((end-new Date(row.started_at).getTime())/1000));return seconds<60?`${seconds} `:`${Math.floor(seconds/60)}${seconds%60}`}
const pretty=v=>JSON.stringify(v,null,2)
const canRefresh=row=>['unknown','running'].includes(row.status)&&Boolean(row.task_id||row.migration_record_id)
async function load(){loading.value=true;try{const params={page:page.value,count:count.value,user_good_id:props.userGoodsId};if(props.vmId)params.vm_id=props.vmId;if(filters.keyword)params.keyword=filters.keyword;if(filters.status)params.status=filters.status;if(filters.operator_type)params.operator_type=filters.operator_type;if(dateRange.value?.length)[params.started_at_start,params.started_at_end]=dateRange.value;const b=bodyOf(await getVmOperationRecordList(params));if(b.code!==200)throw new Error(b.message||'读取操作日志失败');rows.value=b.data?.data||[];total.value=Number(b.data?.all_count||0)}catch(e){ElMessage.error(errorText(e,'读取操作日志失败'))}finally{loading.value=false}}
function search(){page.value=1;load()}function sizeChanged(){page.value=1;load()}
async function showDetail(id){try{const b=bodyOf(await getVmOperationRecordDetail(id));if(b.code!==200)throw new Error(b.message||'读取详情失败');detail.value=b.data?.record||null;detailVisible.value=true}catch(e){ElMessage.error(errorText(e,'读取详情失败'))}}
async function refreshRecord(row){refreshingId.value=row.id;try{const b=bodyOf(await refreshVmOperationRecord(row.id));if(b.code!==200)throw new Error(b.message||'状态核对失败');ElMessage.success('状态核对完成');await load()}catch(e){ElMessage.error(errorText(e,'状态核对失败'))}finally{refreshingId.value=0}}
onMounted(load)
</script>
<style scoped>
.filters{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0}.muted{margin-top:3px;color:#909399;font-size:12px}.pagination{justify-content:flex-end;margin-top:16px}.snapshots{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px}.snapshots section{min-width:0}.snapshots h4{margin:0 0 8px}.snapshots pre{max-height:300px;overflow:auto;margin:0;padding:12px;border:1px solid #e4e7ed;border-radius:6px;background:#f7f8fa;font:12px/1.6 Consolas,monospace;white-space:pre-wrap;word-break:break-all}@media(max-width:900px){.snapshots{grid-template-columns:1fr}}
</style>
+12 -150
View File
@@ -508,17 +508,8 @@
<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%"
filterable
:loading="userGroupLoading"
popper-class="user-detail-user-group-popper"
@visible-change="visible => handleGroupSelectVisible(visible, 'user')"
>
<el-select v-model="groupForm.user_group_id" placeholder="请选择用户组" style="width: 100%">
<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>
@@ -651,8 +642,6 @@
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"
@@ -660,7 +649,6 @@
: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>
@@ -675,7 +663,7 @@
</template>
<script setup>
import { ref, reactive, nextTick, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'
import { ref, reactive, onMounted, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { baseURL } from '@/utils/request'
@@ -865,10 +853,6 @@ 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)
@@ -901,15 +885,6 @@ 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) => {
@@ -1130,103 +1105,14 @@ const handleGroupManage = () => {
groupForm.user_id = userInfo.value.UserId
groupForm.user_group_id = userInfo.value.UserGroupId || ''
groupDialogVisible.value = true
fetchUserGroupList(true)
fetchUserGroupList()
}
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 fetchUserGroupList = async () => {
const res = await getUserGroupList({ page: 1, count: 10 })
if (res.data.code == 200) {
userGroupList.value = res.data.data.data
}
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 }
}
//
@@ -1853,38 +1739,19 @@ 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 (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
}
const fetchAdminGroupList = async () => {
adminGroupLoading.value = true
try {
const res = await getAdminGroupList({ page, count: GROUP_PAGE_SIZE })
const res = await getAdminGroupList({ params: { page: 1, count: 10 } })
if (res.data.code === 200) {
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 || '获取管理员组列表失败')
adminGroupList.value = res.data.data?.data || res.data.data || []
}
} catch (error) {
if (reset) adminGroupList.value = []
ElMessage.error('获取管理员组列表失败')
adminGroupList.value = []
} finally {
adminGroupLoading.value = false
}
@@ -2004,11 +1871,6 @@ watch(() => route.query.user_id, (newUserId, oldUserId) => {
resetUserScopedData()
loadUserData()
})
onBeforeUnmount(() => {
cleanupGroupSelectScroll('user')
cleanupGroupSelectScroll('admin')
})
</script>
<style scoped>
@@ -304,7 +304,7 @@ const submitRestore = async () => {
selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
}
if (selectedBridgeNetwork.value) {
fd.append('network_ids', selectedBridgeNetwork.value.id)
fd.append('internet_network_id', selectedBridgeNetwork.value.id)
}
const res = await restoreRecycleBin(fd)
if (res?.data?.code === 200) {
+1 -1
View File
@@ -24029,7 +24029,7 @@
},
"/api/v1/admin/good/user_vm/migrate": {
"post": {
"summary": "迁移虚拟机(更换宿主机,不保留数据",
"summary": "迁移虚拟机(更换宿主机)",
"deprecated": false,
"description": "",
"tags": [