Files
xinfra/frontend/src/views/service/Management.vue
T

807 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="page-head">
<div>
<h1>服务管理</h1>
<p>同步基础服务实例台账,统一查看服务名 / 服务 IP / 业务标签 / 实例数</p>
</div>
<el-button type="primary" :loading="serviceLoading || deliveryLoading" @click="refreshServices">同步</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">接入资源域</div>
<div class="value">{{ serviceDatacenters }}</div>
<div class="delta">Datacenter / Cluster 合并统计</div>
</div>
<div class="stat-card">
<div class="label">服务总数</div>
<div class="value">{{ totalServiceCount }}</div>
<div class="delta">基础服务 + 容器化服务</div>
</div>
<div class="stat-card">
<div class="label">服务实例总数</div>
<div class="value">{{ serviceInstances }}</div>
<div class="delta">MySQL 实例 + Pod 汇总</div>
</div>
<div class="stat-card">
<div class="label">健康实例占比</div>
<div class="value accent">{{ serviceHealthPercent }}%</div>
<div class="delta warn">{{ unhealthyInstances }} 个实例异常</div>
</div>
<div class="stat-card">
<div class="label">最近同步</div>
<div class="value accent-pill">● {{ serviceError ? '异常' : '正常' }}</div>
<div class="delta">{{ serviceMetaText }}</div>
</div>
</div>
<div class="las-toolbar">
<el-select placeholder="全部机房">
<el-option label="全部机房" value="" />
<el-option label="YZH" value="yzh" />
<el-option label="XS" value="xs" />
<el-option label="JF" value="jf" />
<el-option label="达拉斯" value="dallas" />
<el-option label="新加坡" value="singapore" />
<el-option label="香港" value="hk" />
<el-option label="东南亚" value="sea" />
</el-select>
<el-select placeholder="全部业务标签">
<el-option label="全部业务标签" value="" />
<el-option label="kodo" value="kodo" />
<el-option label="linxi" value="linxi" />
<el-option label="xinfra" value="xinfra" />
<el-option label="las" value="las" />
</el-select>
<el-select placeholder="全部状态">
<el-option label="全部状态" value="" />
<el-option label="健康" value="healthy" />
<el-option label="部分异常" value="partial" />
<el-option label="全部下线" value="offline" />
</el-select>
<el-input placeholder="搜索服务名 / IP" class="search-input" />
</div>
<div class="panel">
<div class="panel-head">
<h3>服务台账 · 按 Consul Datacenter 同步</h3>
<span class="meta">{{ serviceLoading ? '同步中...' : serviceMetaText }}</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>服务名</th>
<th>机房 / Datacenter</th>
<th>业务标签</th>
<th>实例数</th>
<th>健康实例</th>
<th>示例 IP</th>
<th>交付记录</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-if="!serviceLoading && filteredServices.length === 0">
<td colspan="8" class="text-dim">当前业务线暂无基础服务实例</td>
</tr>
<tr v-for="service in pagedServices" :key="service.name" class="tr-hover">
<td class="strong mono">{{ service.name }}</td>
<td><span class="tag" :class="service.dcClass">{{ service.dc }}</span></td>
<td class="mono">{{ service.biz }}</td>
<td class="mono">{{ service.instances }}</td>
<td class="mono">{{ service.healthy }}</td>
<td class="mono">{{ service.ip }}</td>
<td>
<el-button
v-if="latestCredentialTaskForService(service.name)"
link
type="primary"
:icon="Tickets"
@click="openDeliveryDetail(latestCredentialTaskForService(service.name)!)"
>
查看
</el-button>
<span v-else class="tag">已领取</span>
</td>
<td :class="['status-text', service.statusClass]">● {{ service.status }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span>共 {{ filteredServices.length }} 条 · 当前业务线:{{ currentName }}</span>
<el-pagination
v-model:current-page="servicePage"
v-model:page-size="servicePageSize"
:total="filteredServices.length"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
background
small
/>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>容器化服务台账 · 按集群 / Namespace 同步</h3>
<span class="meta">{{ containerLoading ? '同步中...' : containerMetaText }}</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>服务名</th>
<th>集群</th>
<th>Namespace</th>
<th>工作负载</th>
<th>Pod Ready</th>
<th>镜像</th>
<th>业务标签</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-if="!containerLoading && containerServices.length === 0">
<td colspan="8" class="text-dim">当前业务线暂无容器化服务</td>
</tr>
<tr v-for="service in pagedContainerServices" :key="`${service.cluster}-${service.namespace}-${service.name}`" class="tr-hover">
<td class="strong mono">{{ service.name }}</td>
<td><span class="tag">{{ service.cluster }}</span></td>
<td class="mono">{{ service.namespace }}</td>
<td>{{ service.workload }}</td>
<td class="mono">{{ service.ready }}</td>
<td class="mono text-xs">{{ service.image || '-' }}</td>
<td class="mono">{{ service.biz }}</td>
<td :class="['status-text', service.statusClass]">● {{ service.status }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span>共 {{ containerServices.length }} 条容器化服务 · 当前业务线:{{ currentName }}</span>
<el-pagination
v-model:current-page="containerPage"
v-model:page-size="containerPageSize"
:total="containerServices.length"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
background
small
/>
</div>
</div>
</div>
<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 v-if="credentialEligible(selectedDeliveryTask) || revealedCredentials.length > 0" class="credential-handoff">
<div class="credential-head">
<div>
<span class="eyebrow">ONE-TIME CREDENTIAL</span>
<h4>root 管理员凭证</h4>
</div>
<span class="tag tag-amber">仅展示一次</span>
</div>
<p>请立即复制或下载凭证文件;关闭后页面不再显示明文密码。</p>
<template v-if="revealedCredentials.length">
<div class="credential-secret">
<div v-for="item in revealedCredentials" :key="`${item.username}@${item.account_host || item.host}`" class="credential-account">
<div class="credential-field">
<span>用户名</span>
<code>{{ credentialUsername(item) }}</code>
<el-button text :icon="CopyDocument" aria-label="复制用户名" title="复制用户名" @click="copyDeliveryText(credentialUsername(item), '用户名已复制')" />
</div>
<div class="credential-field">
<span>密码</span>
<code>{{ item.password }}</code>
<el-button text :icon="CopyDocument" aria-label="复制密码" title="复制密码" @click="copyDeliveryText(item.password, '密码已复制')" />
</div>
</div>
<div class="credential-actions">
<el-button :icon="Download" @click="downloadDeliveryCredential(selectedDeliveryTask)">下载 Excel 凭证</el-button>
<el-button type="success" plain :icon="CircleCheck" @click="deliveryDetailVisible = false">已保存,关闭</el-button>
</div>
</div>
</template>
<el-button v-else type="warning" :loading="credentialRevealing" @click="revealDeliveryCredential(selectedDeliveryTask)">查看一次性密码</el-button>
</div>
<div v-else-if="['finished', 'register_failed'].includes(selectedDeliveryTask.status)" class="credential-unavailable">
<strong>一次性凭证已关闭</strong>
<span>历史任务仍可查看连接信息,但平台不会再次展示 root 明文密码。</span>
</div>
<p v-else class="delivery-error-text">仅交付成功的任务提供一次性凭证。</p>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheck, CopyDocument, Download, Tickets } from '@element-plus/icons-vue'
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary, type ContainerWorkload } from '@/api/containerService'
import { deliveryApi, type DeliveryTask, type DeploymentCredential, type MySQLServiceLedgerItem } from '@/api/delivery'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
const { currentName } = useBusinessLineMockProfile()
const businessLineStore = useBusinessLineStore()
interface ServiceLedgerRow {
name: string
dc: string
dcClass: string
biz: string
instances: number
healthy: string
healthyCount: number
ip: string
status: string
statusClass: string
}
const services = ref<ServiceLedgerRow[]>([])
const serviceLoading = ref(false)
const serviceError = ref('')
const servicePage = ref(1)
const servicePageSize = ref(10)
const deliveryRecords = ref<DeliveryTask[]>([])
const deliveryLoading = ref(false)
const deliveryDetailVisible = ref(false)
const selectedDeliveryTask = ref<DeliveryTask>()
const revealedCredentials = ref<DeploymentCredential[]>([])
const credentialRevealing = ref(false)
const containerServices = ref<ContainerWorkload[]>([])
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
const containerLoading = ref(false)
const containerError = ref('')
const containerPage = ref(1)
const containerPageSize = ref(10)
const filteredServices = computed(() => services.value)
const pagedServices = computed(() => paginate(filteredServices.value, servicePage.value, servicePageSize.value))
const pagedContainerServices = computed(() => paginate(containerServices.value, containerPage.value, containerPageSize.value))
const totalServiceCount = computed(() => filteredServices.value.length + containerServices.value.length)
const mysqlInstances = computed(() => filteredServices.value.reduce((sum, service) => sum + service.instances, 0))
const serviceInstances = computed(() => mysqlInstances.value + (containerSummary.value.pods || 0))
const healthyInstances = computed(() => {
const mysqlHealthy = filteredServices.value.reduce((sum, service) => sum + service.healthyCount, 0)
return mysqlHealthy + (containerSummary.value.readyPods || 0)
})
const unhealthyInstances = computed(() => Math.max(serviceInstances.value - healthyInstances.value, 0))
const serviceDatacenters = computed(() => {
const domains = new Set<string>()
filteredServices.value.forEach((service) => {
if (service.dc && service.dc !== '-') domains.add(`dc:${service.dc}`)
})
containerServices.value.forEach((service) => {
if (service.cluster) domains.add(`cluster:${service.cluster}`)
})
return domains.size
})
const serviceHealthPercent = computed(() => {
if (serviceInstances.value === 0) return '0.0'
return ((healthyInstances.value / serviceInstances.value) * 100).toFixed(1)
})
const serviceMetaText = computed(() => serviceError.value || '数据源:deployment_results')
const containerMetaText = computed(() => {
if (containerError.value) {
return containerError.value
}
const errors = containerSummary.value.errors?.length || 0
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: '已确认清理',
}
function paginate<T>(items: T[], page: number, pageSize: number) {
const start = (Math.max(page, 1) - 1) * pageSize
return items.slice(start, start + pageSize)
}
const loadDeliveryRecords = async () => {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
deliveryRecords.value = []
return
}
deliveryLoading.value = true
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 = []
ElMessage.error(error instanceof Error ? error.message : '交付记录同步失败')
} finally {
deliveryLoading.value = false
}
}
const refreshServices = async () => {
const businessLineId = businessLineStore.current?.id
if (businessLineId) {
serviceLoading.value = true
try {
await deliveryApi.syncMySQLServices(businessLineId)
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : 'MySQL 实例状态同步失败')
} finally {
serviceLoading.value = false
}
}
await loadServiceLedger()
loadContainerServices()
loadDeliveryRecords()
}
function openDeliveryDetail(task: DeliveryTask) {
selectedDeliveryTask.value = task
revealedCredentials.value = []
deliveryDetailVisible.value = true
}
function latestDeliveryTaskForService(serviceName: string) {
return deliveryRecords.value.find((task) => task.instance_name === serviceName)
}
function latestCredentialTaskForService(serviceName: string) {
const task = latestDeliveryTaskForService(serviceName)
return task && credentialEligible(task) ? task : undefined
}
function credentialEligible(task: DeliveryTask) {
return ['finished', 'register_failed'].includes(task.status) && Boolean(task.credential_available)
}
function markCredentialUnavailable(taskID: string) {
const task = deliveryRecords.value.find((item) => item.id === taskID)
if (task) task.credential_available = false
if (selectedDeliveryTask.value?.id === taskID) {
selectedDeliveryTask.value.credential_available = false
}
}
async function revealDeliveryCredential(task: DeliveryTask) {
credentialRevealing.value = true
try {
const items = await deliveryApi.revealCredentials(task.id)
if (items.length === 0) {
markCredentialUnavailable(task.id)
ElMessage.warning('该任务没有可领取的凭证')
return
}
revealedCredentials.value = items
markCredentialUnavailable(task.id)
} catch (error) {
markCredentialUnavailable(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, message = '已复制') {
if (!value) return
try {
await navigator.clipboard.writeText(value)
ElMessage.success(message)
} catch {
ElMessage.error('复制失败,请手动选择文本')
}
}
function credentialUsername(credential: DeploymentCredential) {
return credential.account_host ? `${credential.username}@${credential.account_host}` : credential.username
}
function escapeExcelCell(value: unknown) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function downloadDeliveryCredential(task: DeliveryTask) {
if (!revealedCredentials.value.length) return
const rows = revealedCredentials.value.map((item) => [
item.service || 'mysql',
item.instance_name || task.instance_name,
item.host || task.target_host_ip || task.target_host || '',
item.port || task.mysql_port || '',
item.username,
item.account_host || '',
item.password,
])
const bodyRows = rows
.map((row) => `<tr>${row.map((cell) => `<td>${escapeExcelCell(cell)}</td>`).join('')}</tr>`)
.join('')
const content = `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<table>
<thead><tr><th>服务</th><th>实例</th><th>主机</th><th>端口</th><th>用户名</th><th>账号域</th><th>密码</th></tr></thead>
<tbody>${bodyRows}</tbody>
</table>
</body>
</html>`
const url = URL.createObjectURL(new Blob([content], { type: 'application/vnd.ms-excel;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = `${task.instance_name}-credential.xls`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('Excel 凭证已下载,请妥善保管')
}
const loadServiceLedger = async () => {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
services.value = []
return
}
serviceLoading.value = true
serviceError.value = ''
try {
const items = await deliveryApi.listMySQLServices(businessLineId)
services.value = items.map(serviceLedgerRow)
} catch (error) {
services.value = []
serviceError.value = error instanceof Error ? error.message : '基础服务实例同步失败'
} finally {
serviceLoading.value = false
}
}
function serviceLedgerRow(item: MySQLServiceLedgerItem): ServiceLedgerRow {
const instances = item.instances || 1
const healthy = item.healthy || 0
return {
name: item.name,
dc: item.datacenter || '-',
dcClass: 'zone-a',
biz: item.business_tag || currentName.value,
instances,
healthy: `${healthy} / ${instances}`,
healthyCount: healthy,
ip: item.address || '-',
status: item.status || '未知',
statusClass: item.status_class || 'warn',
}
}
const loadContainerServices = async () => {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
containerServices.value = []
containerSummary.value = { ...emptyContainerServiceSummary }
return
}
containerLoading.value = true
containerError.value = ''
try {
const data = await containerServiceApi.listWorkloads(businessLineId)
containerServices.value = data.items
containerSummary.value = data.summary
} catch (error) {
containerServices.value = []
containerSummary.value = { ...emptyContainerServiceSummary }
containerError.value = error instanceof Error ? error.message : '容器化服务数据同步失败'
} finally {
containerLoading.value = false
}
}
watch(
() => businessLineStore.current?.id,
() => {
servicePage.value = 1
containerPage.value = 1
loadServiceLedger()
loadContainerServices()
loadDeliveryRecords()
},
{ immediate: true },
)
watch([() => filteredServices.value.length, servicePageSize], ([total]) => {
servicePage.value = Math.min(servicePage.value, Math.max(Math.ceil(total / servicePageSize.value), 1))
})
watch([() => containerServices.value.length, containerPageSize], ([total]) => {
containerPage.value = Math.min(containerPage.value, Math.max(Math.ceil(total / containerPageSize.value), 1))
})
watch(deliveryDetailVisible, (visible) => {
if (!visible) revealedCredentials.value = []
})
</script>
<style scoped>
/* 台账部分样式在 global.css 中定义;以下为交付详情专属样式 */
:global(.delivery-dropdown-item) {
display: grid;
gap: 3px;
min-width: 220px;
padding: 3px 0;
}
:global(.delivery-dropdown-item strong) {
color: var(--text-hi);
font-size: 12px;
}
:global(.delivery-dropdown-item small) {
color: var(--text-dim);
font-size: 10.5px;
}
:global(.delivery-record-dropdown .el-dropdown-menu) {
max-height: min(420px, calc(100vh - 32px));
overflow-y: auto;
overscroll-behavior: contain;
}
.delivery-dialog-head,
.delivery-credential-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.delivery-dialog-head h3,
.delivery-credential-head h4 {
margin: 4px 0 0;
color: var(--text-hi);
}
.delivery-dialog-head h3 {
font-size: 16px;
}
.delivery-credential-head h4 {
font-size: 13px;
}
.eyebrow {
color: var(--text-dim);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.08em;
}
.delivery-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 20px;
margin: 0;
}
.delivery-detail-grid > div {
display: flex;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 10px 0;
border-bottom: 1px solid var(--line-soft);
}
.delivery-detail-grid dt {
flex: 0 0 auto;
color: var(--text-dim);
font-size: 11px;
}
.delivery-detail-grid dd {
min-width: 0;
overflow-wrap: anywhere;
margin: 0;
color: var(--text-mid);
font-size: 11px;
text-align: right;
}
.delivery-error-text {
margin: 12px 0 0;
color: var(--err);
font-size: 11.5px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.credential-handoff {
margin-top: 18px;
padding: 13px;
border: 1px solid var(--tag-amber-border);
border-radius: 7px;
background: var(--tag-amber-bg);
}
.credential-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.credential-head h4 {
margin: 3px 0 0;
color: var(--text-hi);
font-size: 13px;
}
.credential-handoff > p {
margin: 10px 0;
color: var(--text-dim);
font-size: 11.5px;
line-height: 1.5;
}
.credential-secret {
display: grid;
gap: 10px;
}
.credential-account {
display: grid;
gap: 8px;
}
.credential-field {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 32px;
align-items: center;
gap: 8px;
}
.credential-field > span {
color: var(--text-dim);
font-size: 11.5px;
font-weight: 700;
}
.credential-field > code {
overflow-wrap: anywhere;
padding: 9px 10px;
border: 1px solid var(--line-soft);
border-radius: 5px;
background: var(--bg-panel);
color: var(--text-hi);
font-size: 12px;
}
.credential-field .el-button {
width: 32px;
min-width: 32px;
}
.credential-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.credential-unavailable {
display: grid;
gap: 5px;
margin-top: 18px;
padding: 13px;
border: 1px dashed var(--line);
border-radius: 7px;
background: var(--bg-panel-2);
}
.credential-unavailable strong {
color: var(--text-hi);
font-size: 12px;
}
.credential-unavailable span {
color: var(--text-dim);
font-size: 11.5px;
}
.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: 900px) {
.delivery-toolbar,
.delivery-detail-grid {
grid-template-columns: 1fr;
}
}
</style>