feat(frontend): 服务目录支持一主多从交付模式
- MySQL 新增'一主多从'模式:有序多选主机、replica_count(1-7)输入、动态拓扑预览 - precheck 校验库存主机数与选择数量,支持留空自动调度 - 交付负载携带 topology/replica_count/target_hosts,已交付主机解析 target_host_ips 列表 - 主机选择占位符退化为常量,移除冗余三元表达式
This commit is contained in:
@@ -25,9 +25,11 @@ export interface CreateMySQLDeliveryPayload {
|
||||
instance_name: string
|
||||
mysql_version: string
|
||||
topology: string
|
||||
replica_count?: number
|
||||
mysql_port?: number
|
||||
data_disk: string
|
||||
target_host?: string
|
||||
target_hosts?: string[]
|
||||
cpu_milli: number
|
||||
memory_mi: number
|
||||
storage_gi: number
|
||||
@@ -77,6 +79,8 @@ export interface DeliveryTask {
|
||||
instance_name: string
|
||||
target_host?: string
|
||||
target_host_ip?: string
|
||||
target_hosts?: string
|
||||
target_host_ips?: string
|
||||
mysql_port?: number
|
||||
postgresql_port?: number
|
||||
status: string
|
||||
|
||||
@@ -101,9 +101,9 @@
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
{{ isPostgreSQL && deliveryForm.mode === 'primary_replica' ? '部署主机(按角色顺序)' : '部署主机' }}
|
||||
{{ (isPostgreSQL && deliveryForm.mode === 'primary_replica') || (activeServiceKey === 'mysql' && deliveryForm.mode === 'replica') ? '部署主机(按角色顺序)' : '部署主机' }}
|
||||
<el-select
|
||||
v-if="isPostgreSQL && deliveryForm.mode === 'primary_replica'"
|
||||
v-if="(isPostgreSQL && deliveryForm.mode === 'primary_replica') || (activeServiceKey === 'mysql' && deliveryForm.mode === 'replica')"
|
||||
v-model="deliveryForm.targetHosts"
|
||||
multiple
|
||||
:multiple-limit="deliveryForm.replicaCount + 1"
|
||||
@@ -116,7 +116,7 @@
|
||||
<el-option v-for="host in targetHosts" :key="host.name" :label="hostLabel(host)" :value="host.name" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label v-if="isPostgreSQL && deliveryForm.mode === 'primary_replica'" class="form-field">
|
||||
<label v-if="(isPostgreSQL && deliveryForm.mode === 'primary_replica') || (activeServiceKey === 'mysql' && deliveryForm.mode === 'replica')" class="form-field">
|
||||
从节点数量
|
||||
<el-input-number v-model="deliveryForm.replicaCount" :min="1" :max="7" controls-position="right" />
|
||||
<small class="field-help">需要 {{ deliveryForm.replicaCount + 1 }} 台不同主机;当前模板可用 {{ targetHosts.length }} 台。</small>
|
||||
@@ -334,8 +334,14 @@
|
||||
<div><span>数据位置</span><strong>{{ deliveryForm.dataDisk }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="topology" :class="`nodes-${topologyNodes.length}`">
|
||||
<div v-for="node in topologyNodes" :key="node.name" class="node-card">
|
||||
<div class="topology-shell" :class="{ 'is-replica': topologyNodes.length > 1 }">
|
||||
<div class="topology-title">
|
||||
<strong>{{ topologyNodes.length }} 节点{{ topologyNodes.length > 1 ? `(1 主 ${topologyNodes.length - 1} 从)` : '' }}</strong>
|
||||
<span>{{ topologyNodes.length > 1 ? 'GTID 异步复制 · 从节点默认只读' : '单实例部署' }}</span>
|
||||
</div>
|
||||
<div v-if="topologyNodes.length > 1" class="topology-connector"><span>主库复制链路</span></div>
|
||||
<div class="topology" :class="`nodes-${topologyNodes.length}`">
|
||||
<div v-for="(node, index) in topologyNodes" :key="node.name" class="node-card" :class="{ primary: index === 0, replica: index > 0 }">
|
||||
<div class="node-head">
|
||||
<span class="node-icon">{{ activeService.icon }}</span>
|
||||
<strong>{{ node.name }}</strong>
|
||||
@@ -346,6 +352,7 @@
|
||||
<span>{{ node.role }}</span>
|
||||
<span>{{ storageLabel }} 配额</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -423,6 +430,13 @@
|
||||
<span class="tag" :class="connectionReady ? 'tag-green' : 'tag-amber'">{{ connectionReady ? '可连接' : '等待地址' }}</span>
|
||||
</div>
|
||||
<div class="endpoint-value">{{ resultAddress }}</div>
|
||||
<div v-if="replicaEndpoints.length" class="replica-endpoints">
|
||||
<span class="eyebrow">REPLICA ENDPOINTS</span>
|
||||
<div v-for="endpoint in replicaEndpoints" :key="endpoint" class="replica-endpoint">
|
||||
<span>从节点</span><code>{{ endpoint }}</code>
|
||||
<el-button text :icon="CopyDocument" aria-label="复制从节点连接地址" title="复制从节点连接地址" @click="copyText(endpoint, '从节点地址已复制')" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="access-note">{{ isPostgreSQL ? '使用 AWX 托管的 postgres 管理员凭据连接。' : '使用交付时设置的 root 管理员账号连接。' }}</p>
|
||||
<div class="command-block">
|
||||
<code>{{ databaseCommand }}</code>
|
||||
@@ -534,7 +548,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowDown, Back, CircleCheck, Connection, CopyDocument, DocumentCopy, Download, Monitor, Promotion, Refresh, Setting, Tickets } from '@element-plus/icons-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
|
||||
import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeliveryTask, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
@@ -617,6 +631,7 @@ interface WorkbenchSnapshot {
|
||||
deploymentId: string
|
||||
deliveryError: string
|
||||
deliveredHost: string
|
||||
deliveredHosts?: string[]
|
||||
deliveredPort?: number
|
||||
lastDeliveryStatus: string
|
||||
deliveryLog: string
|
||||
@@ -660,7 +675,7 @@ const basicServices = ref<Service[]>([
|
||||
icon: 'My',
|
||||
bgColor: 'var(--logo-w-bg)',
|
||||
iconColor: 'var(--tag-blue-text)',
|
||||
description: '单实例标准化交付,完成后自动注册进 CloudDM / open-cdm 数据源,统一审核 SQL 上线。',
|
||||
description: '支持单实例和两节点异步主从交付,完成后自动注册进 CloudDM / open-cdm 数据源。',
|
||||
playbook: 'roles/mysql-deploy',
|
||||
version: 'v8.0',
|
||||
status: '可交付',
|
||||
@@ -669,6 +684,7 @@ const basicServices = ref<Service[]>([
|
||||
versions: ['MySQL 8.0', 'MySQL 8.4'],
|
||||
modes: [
|
||||
{ value: 'single', label: '单实例' },
|
||||
{ value: 'replica', label: '一主多从' },
|
||||
],
|
||||
specs: ['1C / 2G', '2C / 4G', '4C / 8G', '8C / 16G', '16C / 32G'],
|
||||
disks: ['20 GB', '50 GB', '100 GB', '500 GB', '1000 GB'],
|
||||
@@ -816,6 +832,7 @@ const canceling = ref(false)
|
||||
const deploymentId = ref('')
|
||||
const deliveryError = ref('')
|
||||
const deliveredHost = ref('')
|
||||
const deliveredHosts = ref<string[]>([])
|
||||
const deliveredPort = ref<number>()
|
||||
const deliveryTargets = ref<DeliveryTarget[]>([])
|
||||
const selectedTargetId = ref<number>()
|
||||
@@ -931,10 +948,15 @@ const topologyNodes = computed(() => {
|
||||
]
|
||||
}
|
||||
return activeServiceKey.value === 'mysql'
|
||||
? [
|
||||
{ name: 'db-tx-021', ip: '10.24.18.21', role: '主节点' },
|
||||
{ name: 'db-tx-022', ip: '10.24.18.22', role: '从节点' },
|
||||
]
|
||||
? Array.from({ length: deliveryForm.replicaCount + 1 }, (_, index) => {
|
||||
const selectedName = deliveryForm.targetHosts[index]
|
||||
const host = targetHosts.value.find((item) => item.name === selectedName) || targetHosts.value[index]
|
||||
return {
|
||||
name: host?.name || `等待调度 ${index + 1}`,
|
||||
ip: host?.ip || '由 AWX Inventory 分配',
|
||||
role: index === 0 ? '主节点' : `从节点 ${index}`,
|
||||
}
|
||||
})
|
||||
: [
|
||||
{ name: 'ngx-tx-011', ip: '10.24.31.11', role: '主节点' },
|
||||
{ name: 'ngx-tx-012', ip: '10.24.31.12', role: '备用节点' },
|
||||
@@ -949,6 +971,7 @@ const runnerPreview = computed(() => {
|
||||
`component: ${activeService.value?.key || '-'}`,
|
||||
`instance: ${deliveryForm.instanceName}`,
|
||||
`topology: ${currentModeLabel.value}`,
|
||||
...(deliveryForm.mode === 'replica' ? [`replica_count: ${deliveryForm.replicaCount}`] : []),
|
||||
`limit: ${selectedTargetHostText.value}`,
|
||||
`${isPostgreSQL.value ? 'data_root' : 'data_disk'}: ${deliveryForm.dataDisk}`,
|
||||
`resources: ${deliveryForm.spec} / ${storageLabel.value}`,
|
||||
@@ -968,13 +991,14 @@ const credentialEligible = computed(() => {
|
||||
const revealedRootCredential = computed(() => revealedCredentials.value.find((item) => item.username === 'root'))
|
||||
const resultReady = computed(() => !taskStateLoading.value && !running.value && isTerminalDeliveryStatus(lastDeliveryStatus.value))
|
||||
const selectedTargetHostText = computed(() => {
|
||||
if (isPostgreSQL.value && deliveryForm.mode === 'primary_replica') {
|
||||
if ((isPostgreSQL.value && deliveryForm.mode === 'primary_replica') || (activeServiceKey.value === 'mysql' && deliveryForm.mode === 'replica')) {
|
||||
return deliveryForm.targetHosts.length ? deliveryForm.targetHosts.join(', ') : '由 AWX Inventory 分配'
|
||||
}
|
||||
return deliveryForm.targetHost || '由 AWX Inventory 分配'
|
||||
})
|
||||
const previewAddress = computed(() => `${selectedHost.value?.ip || 'AWX 自动分配'}:${deliveryForm.port || '自动端口'}`)
|
||||
const resultAddress = computed(() => connectionReady.value ? `${deliveredHost.value}:${deliveredPort.value}` : '交付接口未返回地址')
|
||||
const replicaEndpoints = computed(() => deliveredHosts.value.slice(1).map((host) => `${host}:${deliveredPort.value || ''}`.replace(/:$/, '')))
|
||||
const connectionUri = computed(() => {
|
||||
if (!connectionReady.value) return ''
|
||||
return isPostgreSQL.value ? `postgresql://postgres@${deliveredHost.value}:${deliveredPort.value}/postgres` : `mysql://root@${deliveredHost.value}:${deliveredPort.value}`
|
||||
@@ -1134,6 +1158,7 @@ function persistWorkbench() {
|
||||
deploymentId: deploymentId.value,
|
||||
deliveryError: deliveryError.value,
|
||||
deliveredHost: deliveredHost.value,
|
||||
deliveredHosts: deliveredHosts.value,
|
||||
deliveredPort: deliveredPort.value,
|
||||
lastDeliveryStatus: lastDeliveryStatus.value,
|
||||
deliveryLog: deliveryLog.value,
|
||||
@@ -1197,6 +1222,7 @@ async function restoreWorkbench() {
|
||||
deploymentId.value = snapshot.deploymentId
|
||||
deliveryError.value = snapshot.deliveryError
|
||||
deliveredHost.value = snapshot.deliveredHost
|
||||
deliveredHosts.value = snapshot.deliveredHosts?.length ? snapshot.deliveredHosts : (snapshot.deliveredHost ? [snapshot.deliveredHost] : [])
|
||||
deliveredPort.value = snapshot.deliveredPort
|
||||
lastDeliveryStatus.value = snapshot.lastDeliveryStatus
|
||||
deliveryLog.value = snapshot.deliveryLog
|
||||
@@ -1209,7 +1235,7 @@ async function restoreWorkbench() {
|
||||
taskStateLoading.value = true
|
||||
try {
|
||||
const data = await deliveryApi.getTask(snapshot.deploymentId)
|
||||
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
|
||||
updateDeliveredHosts(data.task)
|
||||
deliveredPort.value = (isPostgreSQL.value ? data.task.postgresql_port : data.task.mysql_port) || deliveredPort.value
|
||||
lastDeliveryStatus.value = data.task.status
|
||||
credentialAvailable.value = isPostgreSQL.value ? false : data.task.credential_available
|
||||
@@ -1304,6 +1330,7 @@ function resetExecutionState() {
|
||||
canceling.value = false
|
||||
deploymentId.value = ''
|
||||
deliveredHost.value = ''
|
||||
deliveredHosts.value = []
|
||||
deliveredPort.value = undefined
|
||||
deliveryLog.value = '[ready] 等待创建交付任务...'
|
||||
seenEventIds.value = new Set()
|
||||
@@ -1352,6 +1379,24 @@ async function precheck() {
|
||||
ElMessage.warning('当前没有可用的 AWX 部署模板,请联系平台管理员配置')
|
||||
return
|
||||
}
|
||||
if (activeServiceKey.value === 'mysql' && deliveryForm.mode === 'replica' && targetHosts.value.length < 2) {
|
||||
precheckPassed.value = false
|
||||
ElMessage.error('一主一从至少需要两个可用的 AWX Inventory 主机')
|
||||
return
|
||||
}
|
||||
if (activeServiceKey.value === 'mysql' && deliveryForm.mode === 'replica') {
|
||||
const requiredHosts = deliveryForm.replicaCount + 1
|
||||
if (targetHosts.value.length < requiredHosts) {
|
||||
precheckPassed.value = false
|
||||
ElMessage.warning(`一主 ${deliveryForm.replicaCount} 从需要 ${requiredHosts} 台不同主机,当前 Inventory 只有 ${targetHosts.value.length} 台`)
|
||||
return
|
||||
}
|
||||
if (deliveryForm.targetHosts.length > 0 && deliveryForm.targetHosts.length !== requiredHosts) {
|
||||
precheckPassed.value = false
|
||||
ElMessage.warning(`请按主节点、从节点顺序选择 ${requiredHosts} 台主机,或清空后由平台自动分配`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!isPostgreSQL.value && deliveryForm.port !== undefined && (deliveryForm.port < 13306 || deliveryForm.port > 13999)) {
|
||||
ElMessage.warning('手动端口必须在 13306–13999 范围内')
|
||||
return
|
||||
@@ -1488,10 +1533,12 @@ function mysqlDeliveryPayload(businessLineId: number) {
|
||||
namespace: normalizeDNSLabel(currentName.value),
|
||||
instance_name: normalizeDNSLabel(deliveryForm.instanceName),
|
||||
mysql_version: mysqlVersionValue(deliveryForm.version),
|
||||
topology: 'standalone',
|
||||
topology: deliveryForm.mode === 'replica' ? 'primary_replica' : 'standalone',
|
||||
replica_count: deliveryForm.mode === 'replica' ? deliveryForm.replicaCount : 0,
|
||||
mysql_port: deliveryForm.port || 0,
|
||||
data_disk: deliveryForm.dataDisk,
|
||||
target_host: deliveryForm.targetHost || undefined,
|
||||
target_hosts: deliveryForm.mode === 'replica' && deliveryForm.targetHosts.length ? deliveryForm.targetHosts : undefined,
|
||||
cpu_milli: resources.cpuMilli,
|
||||
memory_mi: resources.memoryMi,
|
||||
storage_gi: deliveryForm.storageGi,
|
||||
@@ -1549,6 +1596,12 @@ function hostLabel(host: TargetHostOption) {
|
||||
return host.ip ? `${host.name} · ${host.ip}` : host.name
|
||||
}
|
||||
|
||||
function updateDeliveredHosts(task: DeliveryTask) {
|
||||
const hosts = (task.target_host_ips || '').split(',').map((host) => host.trim()).filter(Boolean)
|
||||
deliveredHosts.value = hosts.length ? hosts : (task.target_host_ip ? [task.target_host_ip] : deliveredHosts.value)
|
||||
deliveredHost.value = task.target_host_ip || deliveredHost.value || deliveredHosts.value[0] || ''
|
||||
}
|
||||
|
||||
function startTaskPolling(id: string, resetEvents = false) {
|
||||
stopPolling()
|
||||
if (resetEvents) seenEventIds.value = new Set()
|
||||
@@ -1564,7 +1617,7 @@ function stopPolling() {
|
||||
async function pollTask(id: string) {
|
||||
try {
|
||||
const data = await deliveryApi.getTask(id)
|
||||
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
|
||||
updateDeliveredHosts(data.task)
|
||||
deliveredPort.value = (isPostgreSQL.value ? data.task.postgresql_port : data.task.mysql_port) || deliveredPort.value
|
||||
credentialAvailable.value = isPostgreSQL.value ? false : data.task.credential_available
|
||||
upsertDeliveryHistory(id, data.task.status, data.task.error_message || '', data.task.created_at)
|
||||
@@ -2346,6 +2399,13 @@ h4 {
|
||||
.preview-panel {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-panel > .section-head {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.section-head,
|
||||
@@ -2720,14 +2780,22 @@ h4 {
|
||||
.preview-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.preview-facts div {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border-left: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.preview-facts div:first-child {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.preview-facts span,
|
||||
@@ -2742,12 +2810,11 @@ h4 {
|
||||
}
|
||||
|
||||
.preview-facts strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-mid);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
@@ -2798,7 +2865,63 @@ h4 {
|
||||
.topology {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
align-content: start;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.topology::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.topology::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.topology-shell {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
|
||||
.topology-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.topology-title span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topology-connector {
|
||||
position: relative;
|
||||
height: 1px;
|
||||
margin: 0 12px 10px;
|
||||
border-top: 1px dashed var(--accent);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.topology-connector span {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
left: 50%;
|
||||
padding: 0 8px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-panel-2);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topology.nodes-1 {
|
||||
@@ -2813,19 +2936,44 @@ h4 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.topology.nodes-4,
|
||||
.topology.nodes-5,
|
||||
.topology.nodes-6,
|
||||
.topology.nodes-7,
|
||||
.topology.nodes-8 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.node-card {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
min-height: 112px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
|
||||
.node-card.primary {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel-2));
|
||||
}
|
||||
|
||||
.node-card.replica {
|
||||
border-color: color-mix(in srgb, var(--text-dim) 45%, var(--line));
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.node-head strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
@@ -2843,6 +2991,7 @@ h4 {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.yaml-preview,
|
||||
@@ -2859,7 +3008,8 @@ h4 {
|
||||
}
|
||||
|
||||
.yaml-preview {
|
||||
min-height: 180px;
|
||||
min-height: 0;
|
||||
max-height: 260px;
|
||||
margin: 0 0 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
@@ -2871,6 +3021,9 @@ h4 {
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user