@@ -5,9 +5,15 @@
< h1 > 服务管理 < / h1 >
< p > 同步基础服务实例台账 , 统一查看服务名 / 服务 IP / 业务标签 / 实例数 < / p >
< / div >
< el-button type = "primary" :loading = "serviceLoading" @click ="loadServiceLedger "> 同步 < / el -button >
< el-button type = "primary" : loading = "serviceLoading || deliveryLoading" @click ="refreshCurrentTab "> 同步 < / el -button >
< / div >
< el-tabs v-model = "activeTab" class="management-tabs" >
< el -tab -pane label = "服务台账" name = "services" / >
< el-tab-pane label = "交付记录" name = "delivery" / >
< / el-tabs >
< template v-if = "activeTab === 'services'" >
< div class = "stat-row" >
< div class = "stat-card" >
< div class = "label" > 接入 Datacenter < / div >
@@ -151,13 +157,122 @@
< / div >
< / div >
< / div >
< / template >
< template v-else >
< div class = "delivery-toolbar" >
< el-input v-model = "deliverySearch" placeholder="搜索实例名称 / 任务编号 / 主机" clearable / >
< el-select v-model = "deliveryStatusFilter" placeholder="全部交付状态" >
< el -option label = "全部交付状态" value = "" / >
< el-option v-for = "(label, value) in deliveryStatusLabels" :key="value" :label="label" :value="value" / >
< / el-select >
< / div >
< div class = "panel delivery-record-panel" >
< div class = "panel-head" >
< div >
< h3 > 基础服务交付记录 < / h3 >
< span class = "meta" > 共 { { filteredDeliveryRecords . length } } 条 · 当前业务线 : { { currentName } } < / span >
< / div >
< span class = "meta" > { { deliveryLoading ? '同步中...' : deliveryMetaText } } < / span >
< / div >
< div class = "panel-body" >
< table >
< thead >
< tr >
< th > 实例名称 < / th >
< th > 任务编号 < / th >
< th > 连接地址 < / th >
< th > 命名空间 < / th >
< th > 交付状态 < / th >
< th > 凭证 < / th >
< th > 创建时间 < / th >
< th > 操作 < / th >
< / tr >
< / thead >
< tbody >
< tr v-if = "!deliveryLoading && filteredDeliveryRecords.length === 0" >
< td colspan = "8" class = "delivery-empty" > 暂无交付记录 < / td >
< / tr >
< tr v-for = "task in filteredDeliveryRecords" :key="task.id" class="tr-hover" >
< td > < strong class = "mono" > { { task . instance _name } } < / strong > < / td >
< td class = "mono" > { { task . id } } < / td >
< td class = "mono" > { { deliveryEndpoint ( task ) } } < / td >
< td class = "mono" > { { task . namespace } } < / td >
< td > < span class = "tag" :class = "deliveryStatusClass(task.status)" > { { deliveryStatusText ( task . status ) } } < / span > < / td >
< td > < span class = "tag" :class = "credentialStatusClass(task)" > { { credentialStatusText ( task ) } } < / span > < / td >
< td class = "mono" > { { formatDeliveryTime ( task . created _at ) } } < / td >
< td class = "delivery-table-actions" >
< el-button link type = "primary" @click ="openDeliveryDetail(task)" > 查看详情 < / el -button >
< el-button v-if = "credentialEligible(task)" link type="warning" @click="openDeliveryDetail(task)" > 领取凭证 < / el -button >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / div >
< / template >
< el-dialog v-model = "deliveryDetailVisible" width="640px" align -center destroy -on -close >
< template # header >
< div class = "delivery-dialog-head" >
< div >
< span class = "eyebrow" > DELIVERY RECORD < / span >
< h3 > { { selectedDeliveryTask ? . instance _name } } < / h3 >
< / div >
< span v-if = "selectedDeliveryTask" class="tag" :class="deliveryStatusClass(selectedDeliveryTask.status)" > {{ deliveryStatusText ( selectedDeliveryTask.status ) }} < / span >
< / div >
< / template >
< template v-if = "selectedDeliveryTask" >
< dl class = "delivery-detail-grid" >
< div > < dt > 任务编号 < / dt > < dd class = "mono" > { { selectedDeliveryTask . id } } < / dd > < / div >
< div > < dt > 连接地址 < / dt > < dd class = "mono" > { { deliveryEndpoint ( selectedDeliveryTask ) } } < / dd > < / div >
< div > < dt > 命名空间 < / dt > < dd class = "mono" > { { selectedDeliveryTask . namespace } } < / dd > < / div >
< div > < dt > 部署主机 < / dt > < dd class = "mono" > { { selectedDeliveryTask . target _host || '自动调度' } } < / dd > < / div >
< div > < dt > 创建时间 < / dt > < dd > { { formatDeliveryTime ( selectedDeliveryTask . created _at , true ) } } < / dd > < / div >
< div > < dt > 完成时间 < / dt > < dd > { { selectedDeliveryTask . finished _at ? formatDeliveryTime ( selectedDeliveryTask . finished _at , true ) : '-' } } < / dd > < / div >
< / dl >
< p v-if = "selectedDeliveryTask.error_message" class="delivery-error-text" > {{ selectedDeliveryTask.error_message }} < / p >
< div class = "delivery-credential-panel" : class = "{ available: credentialEligible(selectedDeliveryTask) || revealedCredentials.length > 0 }" >
< div class = "delivery-credential-head" >
< div >
< span class = "eyebrow" > ONE - TIME CREDENTIAL < / span >
< h4 > 管理员凭证 < / h4 >
< / div >
< span class = "tag" :class = "credentialStatusClass(selectedDeliveryTask)" > { { credentialStatusText ( selectedDeliveryTask ) } } < / span >
< / div >
< template v-if = "revealedCredentials.length" >
< p > 凭证仅展示一次 ; 请立即复制或下载 , 关闭弹窗后平台不再提供明文密码 。 < / p >
< div class = "delivery-secret" >
< code v-for = "item in revealedCredentials" :key="`${item.username}@${item.host}`" > {{ item.username }} @ {{ item.host }} {{ item.password }} < / code >
< div class = "delivery-secret-actions" >
< el-button :icon = "CopyDocument" @click ="copyDeliveryText(credentialClipboardText)" > 复制凭证 < / el -button >
< el-button :icon = "Download" @click ="downloadDeliveryCredential(selectedDeliveryTask)" > 下载凭证文件 < / el -button >
< el-button type = "success" plain :icon = "CircleCheck" @click ="deliveryDetailVisible = false" > 已保存 , 关闭 < / el -button >
< / div >
< / div >
< / template >
< template v-else-if = "credentialEligible(selectedDeliveryTask)" >
< p > 凭证仅可领取一次 , 领取后服务端立即销毁明文 。 < / p >
< el-button type = "warning" :loading = "credentialRevealing" @click ="revealDeliveryCredential(selectedDeliveryTask)" > 领取一次性凭证 < / el -button >
< / template >
< p v-else-if = "consumedCredentialTasks.has(selectedDeliveryTask.id)" > 该任务的一次性凭证已领取或不可用 , 平台不再提供明文密码 。 < / p >
< p v-else > 仅交付成功的任务提供一次性凭证 。 < / p >
< / div >
< / template >
< / el -dialog >
< / div >
< / template >
< script setup lang = "ts" >
import { computed , ref , watch } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheck , CopyDocument , Download } from '@element-plus/icons-vue'
import { containerServiceApi , emptyContainerServiceSummary , type ContainerServiceSummary , type ContainerWorkload } from '@/api/containerService'
import { deliveryApi , type MySQLServiceLedgerItem } from '@/api/delivery'
import { deliveryApi , type DeliveryTask , type DeploymentCredential , type MySQLServiceLedgerItem } from '@/api/delivery'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -181,6 +296,18 @@ const services = ref<ServiceLedgerRow[]>([])
const serviceLoading = ref ( false )
const serviceError = ref ( '' )
const activeTab = ref ( 'services' )
const deliveryRecords = ref < DeliveryTask [ ] > ( [ ] )
const deliveryLoading = ref ( false )
const deliveryError = ref ( '' )
const deliverySearch = ref ( '' )
const deliveryStatusFilter = ref ( '' )
const deliveryDetailVisible = ref ( false )
const selectedDeliveryTask = ref < DeliveryTask > ( )
const revealedCredentials = ref < DeploymentCredential [ ] > ( [ ] )
const credentialRevealing = ref ( false )
const consumedCredentialTasks = ref < Set < string > > ( new Set ( ) )
const containerServices = ref < ContainerWorkload [ ] > ( [ ] )
const containerSummary = ref < ContainerServiceSummary > ( { ... emptyContainerServiceSummary } )
const containerLoading = ref ( false )
@@ -204,6 +331,165 @@ const containerMetaText = computed(() => {
return errors > 0 ? ` 数据源:Kubernetes Workload API · ${ errors } 项同步失败 ` : '数据源:Kubernetes Workload API'
} )
const deliveryStatusLabels : Record < string , string > = {
pending : '等待执行' ,
validating : '检查中' ,
dispatching : '调度中' ,
running : '交付中' ,
registering : '注册中' ,
finished : '交付完成' ,
register _failed : '注册失败' ,
execution _failed : '执行失败' ,
validation _failed : '检查失败' ,
canceling : '取消中' ,
canceled : '已取消' ,
rollback _pending : '等待回退' ,
rolling _back : '回退中' ,
rolled _back : '已回退' ,
rollback _failed : '回退失败' ,
rollback _acknowledged : '已确认清理' ,
}
const deliveryMetaText = computed ( ( ) => deliveryError . value || '数据源:delivery_tasks' )
const filteredDeliveryRecords = computed ( ( ) => {
const keyword = deliverySearch . value . trim ( ) . toLowerCase ( )
return deliveryRecords . value . filter ( ( task ) => {
const matchesKeyword = ! keyword || [ task . instance _name , task . id , task . target _host , task . target _host _ip ]
. some ( ( value ) => value ? . toLowerCase ( ) . includes ( keyword ) )
const matchesStatus = ! deliveryStatusFilter . value || task . status === deliveryStatusFilter . value
return matchesKeyword && matchesStatus
} )
} )
const credentialClipboardText = computed ( ( ) =>
revealedCredentials . value . map ( ( item ) => ` ${ item . username } @ ${ item . host } ${ item . password } ` ) . join ( '\n' ) ,
)
const loadDeliveryRecords = async ( ) => {
const businessLineId = businessLineStore . current ? . id
if ( ! businessLineId ) {
deliveryRecords . value = [ ]
return
}
deliveryLoading . value = true
deliveryError . value = ''
try {
const items = await deliveryApi . listTasks ( { businessLineId , component : 'mysql' } )
deliveryRecords . value = items . sort ( ( a , b ) => Date . parse ( b . created _at ) - Date . parse ( a . created _at ) )
} catch ( error ) {
deliveryRecords . value = [ ]
deliveryError . value = error instanceof Error ? error . message : '交付记录同步失败'
} finally {
deliveryLoading . value = false
}
}
const refreshCurrentTab = ( ) => {
if ( activeTab . value === 'delivery' ) {
loadDeliveryRecords ( )
} else {
loadServiceLedger ( )
loadContainerServices ( )
}
}
function openDeliveryDetail ( task : DeliveryTask ) {
selectedDeliveryTask . value = task
revealedCredentials . value = [ ]
deliveryDetailVisible . value = true
}
function credentialEligible ( task : DeliveryTask ) {
return [ 'finished' , 'register_failed' ] . includes ( task . status ) && ! consumedCredentialTasks . value . has ( task . id )
}
function credentialStatusText ( task : DeliveryTask ) {
if ( consumedCredentialTasks . value . has ( task . id ) ) return '已领取'
return credentialEligible ( task ) ? '可领取' : '不提供'
}
function credentialStatusClass ( task : DeliveryTask ) {
if ( consumedCredentialTasks . value . has ( task . id ) ) return 'tag-green'
return credentialEligible ( task ) ? 'tag-amber' : ''
}
async function revealDeliveryCredential ( task : DeliveryTask ) {
credentialRevealing . value = true
try {
const items = await deliveryApi . revealCredentials ( task . id )
if ( items . length === 0 ) {
consumedCredentialTasks . value . add ( task . id )
ElMessage . warning ( '该任务没有可领取的凭证' )
return
}
revealedCredentials . value = items
consumedCredentialTasks . value . add ( task . id )
} catch ( error ) {
consumedCredentialTasks . value . add ( task . id )
ElMessage . error ( error instanceof Error ? error . message : '凭证领取失败' )
} finally {
credentialRevealing . value = false
}
}
function deliveryStatusText ( status : string ) {
return deliveryStatusLabels [ status ] || status || '未知状态'
}
function deliveryStatusClass ( status : string ) {
if ( status === 'finished' ) return 'tag-green'
if ( [ 'pending' , 'validating' , 'dispatching' , 'running' , 'registering' ] . includes ( status ) ) return 'tag-blue'
if ( [ 'register_failed' , 'rollback_pending' , 'rolling_back' , 'canceling' ] . includes ( status ) ) return 'tag-amber'
return 'tag-red'
}
function deliveryEndpoint ( task : DeliveryTask ) {
const host = task . target _host _ip || task . target _host
if ( ! host ) return '等待主机分配'
return task . mysql _port ? ` ${ host } : ${ task . mysql _port } ` : host
}
function formatDeliveryTime ( value : string , withYear = false ) {
const date = new Date ( value )
if ( Number . isNaN ( date . getTime ( ) ) ) return value
return new Intl . DateTimeFormat ( 'zh-CN' , {
... ( withYear ? { year : 'numeric' } : { } ) ,
month : '2-digit' ,
day : '2-digit' ,
hour : '2-digit' ,
minute : '2-digit' ,
hour12 : false ,
} ) . format ( date )
}
async function copyDeliveryText ( value : string ) {
if ( ! value ) return
try {
await navigator . clipboard . writeText ( value )
ElMessage . success ( '凭证已复制' )
} catch {
ElMessage . error ( '复制失败,请手动选择文本' )
}
}
function downloadDeliveryCredential ( task : DeliveryTask ) {
if ( ! revealedCredentials . value . length ) return
const lines = [
'# XInfra MySQL delivery credential' ,
` instance= ${ task . instance _name } ` ,
` endpoint= ${ deliveryEndpoint ( task ) } ` ,
... revealedCredentials . value . map ( ( item ) => ` ${ item . username } @ ${ item . host } = ${ item . password } ` ) ,
]
const url = URL . createObjectURL ( new Blob ( [ lines . join ( '\n' ) + '\n' ] , { type : 'text/plain;charset=utf-8' } ) )
const link = document . createElement ( 'a' )
link . href = url
link . download = ` ${ task . instance _name } -credential.txt `
link . click ( )
URL . revokeObjectURL ( url )
ElMessage . success ( '凭证文件已下载,请妥善保管' )
}
const loadServiceLedger = async ( ) => {
const businessLineId = businessLineStore . current ? . id
if ( ! businessLineId ) {
@@ -267,11 +553,194 @@ watch(
( ) => {
loadServiceLedger ( )
loadContainerServices ( )
loadDeliveryRecords ( )
} ,
{ immediate : true } ,
)
watch ( deliveryDetailVisible , ( visible ) => {
if ( ! visible ) revealedCredentials . value = [ ]
} )
< / script >
< style scoped >
/* 所有 样式已 在 global.css 中定义 */
/* 台账部分 样式在 global.css 中定义;以下为交付记录专属样式 */
. management - tabs {
margin - bottom : 14 px ;
}
. management - tabs : deep ( . el - tabs _ _header ) {
margin : 0 ;
}
. delivery - toolbar {
display : grid ;
grid - template - columns : minmax ( 280 px , 1 fr ) 220 px ;
gap : 12 px ;
margin - bottom : 14 px ;
}
. delivery - record - panel . panel - head > div {
display : grid ;
gap : 4 px ;
}
. delivery - record - panel . panel - body {
overflow - x : auto ;
}
. delivery - record - panel table {
min - width : 1120 px ;
}
. delivery - table - actions {
white - space : nowrap ;
}
. delivery - empty {
padding : 48 px 16 px ;
color : var ( -- text - dim ) ;
text - align : center ;
}
. delivery - dialog - head ,
. delivery - credential - head {
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
gap : 16 px ;
}
. delivery - dialog - head h3 ,
. delivery - credential - head h4 {
margin : 4 px 0 0 ;
color : var ( -- text - hi ) ;
}
. delivery - dialog - head h3 {
font - size : 16 px ;
}
. delivery - credential - head h4 {
font - size : 13 px ;
}
. eyebrow {
color : var ( -- text - dim ) ;
font - family : var ( -- mono ) ;
font - size : 10 px ;
letter - spacing : 0.08 em ;
}
. delivery - detail - grid {
display : grid ;
grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ;
gap : 0 20 px ;
margin : 0 ;
}
. delivery - detail - grid > div {
display : flex ;
justify - content : space - between ;
gap : 12 px ;
min - width : 0 ;
padding : 10 px 0 ;
border - bottom : 1 px solid var ( -- line - soft ) ;
}
. delivery - detail - grid dt {
flex : 0 0 auto ;
color : var ( -- text - dim ) ;
font - size : 11 px ;
}
. delivery - detail - grid dd {
min - width : 0 ;
overflow - wrap : anywhere ;
margin : 0 ;
color : var ( -- text - mid ) ;
font - size : 11 px ;
text - align : right ;
}
. delivery - error - text {
margin : 12 px 0 0 ;
color : var ( -- err ) ;
font - size : 11.5 px ;
line - height : 1.5 ;
overflow - wrap : anywhere ;
}
. delivery - credential - panel {
margin - top : 18 px ;
padding : 14 px ;
border : 1 px solid var ( -- line - soft ) ;
border - radius : 7 px ;
background : var ( -- bg - panel - 2 ) ;
}
. delivery - credential - panel . available {
border - color : var ( -- tag - amber - border ) ;
background : var ( -- tag - amber - bg ) ;
}
. delivery - credential - panel > p {
margin : 12 px 0 ;
color : var ( -- text - dim ) ;
font - size : 11.5 px ;
line - height : 1.5 ;
}
. delivery - secret {
display : grid ;
gap : 10 px ;
margin - top : 12 px ;
}
. delivery - secret > code {
overflow - wrap : anywhere ;
padding : 10 px 11 px ;
border : 1 px solid var ( -- line - soft ) ;
border - radius : 5 px ;
background : var ( -- bg - panel ) ;
color : var ( -- text - hi ) ;
font - size : 12 px ;
}
. delivery - secret - actions {
display : flex ;
flex - wrap : wrap ;
gap : 8 px ;
}
. tag . tag - green {
color : var ( -- tag - green - text ) ;
background : var ( -- tag - green - bg ) ;
border - color : var ( -- tag - green - border ) ;
}
. tag . tag - amber {
color : var ( -- tag - amber - text ) ;
background : var ( -- tag - amber - bg ) ;
border - color : var ( -- tag - amber - border ) ;
}
. tag . tag - blue {
color : var ( -- tag - blue - text ) ;
background : var ( -- tag - blue - bg ) ;
border - color : var ( -- tag - blue - border ) ;
}
. tag . tag - red {
color : var ( -- err ) ;
background : var ( -- logo - cc - bg ) ;
border - color : var ( -- err ) ;
}
@ media ( max - width : 900 px ) {
. delivery - toolbar ,
. delivery - detail - grid {
grid - template - columns : 1 fr ;
}
}
< / style >