CMVR-IOT-UI/src/views/index.vue

1066 lines
25 KiB
Vue
Raw Normal View History

2025-08-21 14:11:58 +08:00
<template>
<div class="home-dashboard">
<section class="hero-panel">
<div class="hero-content">
<div class="platform-state">
<span class="state-dot"></span>
CMVR 智能测试平台
</div>
<h1>机器人智能测试管理系统</h1>
<p class="hero-description">
连接设备编排流程执行测试与巡检任务集中管理机器人全生命周期测试业务
</p>
<div class="hero-actions">
<el-button type="primary" size="large" @click="router.push('/flow')">
<el-icon><Connection /></el-icon>
流程编排
</el-button>
<el-button size="large" class="cockpit-button" @click="router.push('/inspection/cockpit')">
<el-icon><Monitor /></el-icon>
巡检驾驶舱
</el-button>
</div>
</div>
<div class="hero-time" aria-label="当前时间">
<span class="time-label">{{ greeting }}{{ displayName }}</span>
<strong>{{ currentTime }}</strong>
<span>{{ currentDate }}</span>
</div>
</section>
<section class="overview-section" aria-labelledby="overview-title">
<div class="section-heading">
<div>
<span class="section-kicker">SYSTEM OVERVIEW</span>
<h2 id="overview-title">系统概览</h2>
</div>
<el-tooltip content="刷新首页数据" placement="top">
<el-button class="refresh-button" circle :loading="loading" @click="loadDashboardData">
<el-icon v-if="!loading"><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
<div class="metric-grid">
<article v-for="item in metrics" :key="item.key" class="metric-card">
<div class="metric-icon" :class="`metric-icon--${item.tone}`">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div class="metric-content">
<span>{{ item.label }}</span>
<strong>{{ displayMetric(item.key) }}</strong>
<small>{{ item.description }}</small>
</div>
<span v-if="stats[item.key].restricted" class="metric-state">无权限</span>
</article>
</div>
</section>
<section class="workspace-grid">
<div class="dashboard-panel task-panel">
<div class="panel-heading">
<div>
<span class="section-kicker">LIVE OPERATIONS</span>
<h2>任务运行态</h2>
</div>
<button class="text-action" type="button" @click="navigateToFeature(features[2])">
查看全部
<el-icon><ArrowRight /></el-icon>
</button>
</div>
<div v-if="activeTasks.length" class="task-list">
<button
v-for="task in activeTasks"
:key="task.id"
class="task-row"
type="button"
@click="navigateToFeature(features[2])"
>
<span class="task-status-dot" :class="`status-${task.status}`"></span>
<span class="task-main">
<strong>{{ task.taskName || '未命名任务' }}</strong>
<small>{{ task.robotName || '暂未分配机器人' }}</small>
</span>
<span class="task-meta">
<el-tag :type="inspectionStatus(task.status).type" effect="light" size="small">
{{ inspectionStatus(task.status).label }}
</el-tag>
<small>{{ formatTime(task.startTime || task.createTime) }}</small>
</span>
<el-icon class="row-arrow"><ArrowRight /></el-icon>
</button>
</div>
<div v-else class="empty-state">
<div class="empty-icon"><el-icon><CircleCheck /></el-icon></div>
<strong>{{ activeTasksRestricted ? '暂无任务查看权限' : '当前没有运行中的任务' }}</strong>
<span>{{ activeTasksRestricted ? '请联系管理员分配巡检任务权限' : '新的巡检任务启动后会显示在这里' }}</span>
</div>
</div>
<div class="dashboard-panel capability-panel">
<div class="panel-heading">
<div>
<span class="section-kicker">CAPABILITIES</span>
<h2>能力中心</h2>
</div>
</div>
<div class="feature-grid">
<button
v-for="feature in features"
:key="feature.title"
class="feature-item"
type="button"
@click="navigateToFeature(feature)"
>
<span class="feature-icon" :style="{ color: feature.color, backgroundColor: feature.background }">
<el-icon><component :is="feature.icon" /></el-icon>
</span>
<span class="feature-copy">
<strong>{{ feature.title }}</strong>
<small>{{ feature.description }}</small>
</span>
<el-icon class="feature-arrow"><ArrowRight /></el-icon>
</button>
</div>
</div>
</section>
<section class="dashboard-panel recent-panel">
<div class="panel-heading">
<div>
<span class="section-kicker">RECENT EXECUTIONS</span>
<h2>最近测试实例</h2>
</div>
<button class="text-action" type="button" @click="navigateToFeature(features[4])">
执行记录
<el-icon><ArrowRight /></el-icon>
</button>
</div>
<div v-if="recentInstances.length" class="recent-list">
<div v-for="instance in recentInstances" :key="instance.id" class="recent-item">
<div class="recent-order">{{ String(instance.order).padStart(2, '0') }}</div>
<div class="recent-info">
<strong>{{ instance.taskName || '未命名测试实例' }}</strong>
<span>{{ instance.remark || '机器人测试任务' }}</span>
</div>
<span class="recent-time">{{ formatTime(instance.createTime) }}</span>
<el-tag :type="testStatus(instance.status).type" effect="plain" size="small">
{{ testStatus(instance.status).label }}
</el-tag>
</div>
</div>
<div v-else class="recent-empty">
<el-icon><DocumentChecked /></el-icon>
<span>{{ recentInstancesRestricted ? '暂无测试实例查看权限' : '暂无测试执行记录' }}</span>
</div>
</section>
2025-08-21 14:11:58 +08:00
</div>
</template>
<script setup name="Index">
import {
ArrowRight,
CircleCheck,
Connection,
Cpu,
DataAnalysis,
DocumentChecked,
Grid,
Headset,
Monitor,
Odometer,
Operation,
Refresh,
SetUp,
} from '@element-plus/icons-vue'
import { listRegister } from '@/api/device/register'
import { getTaskList } from '@/api/inspection/task'
import { getRunTaskList } from '@/api/inspection/runTask'
import { listConfig } from '@/api/test/config'
import { listInst } from '@/api/test/log'
import auth from '@/plugins/auth'
import usePermissionStore from '@/store/modules/permission'
import useUserStore from '@/store/modules/user'
const router = useRouter()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
const loading = ref(false)
const now = ref(new Date())
const activeTasks = ref([])
const recentInstances = ref([])
const activeTasksRestricted = ref(false)
const recentInstancesRestricted = ref(false)
let clockTimer
const stats = reactive({
devices: { value: null, restricted: false },
workflows: { value: null, restricted: false },
inspections: { value: null, restricted: false },
instances: { value: null, restricted: false },
})
const metrics = [
{ key: 'devices', label: '接入设备', description: '已注册设备总数', icon: Cpu, tone: 'blue' },
{ key: 'workflows', label: '测试流程', description: '可用测试编排', icon: Connection, tone: 'green' },
{ key: 'inspections', label: '巡检任务', description: '已配置巡检任务', icon: Odometer, tone: 'orange' },
{ key: 'instances', label: '测试实例', description: '累计执行实例', icon: DataAnalysis, tone: 'red' },
]
const features = [
{
title: '设备接入',
description: '配置机器人与终端设备',
icon: SetUp,
color: '#2563eb',
background: '#eaf2ff',
menuTitles: ['设备注册', '设备配置管理', '设备管理'],
fallback: '/device/register',
},
{
title: '流程编排',
description: '构建可视化测试工作流',
icon: Operation,
color: '#0f8a73',
background: '#e7f6f2',
menuTitles: ['任务流程编排', '流程编排'],
fallback: '/flow',
},
{
title: '巡检执行',
description: '调度机器人巡检任务',
icon: Monitor,
color: '#c56a16',
background: '#fff3e5',
menuTitles: ['执行任务', '运行任务', '巡检任务执行'],
fallback: '/inspection/runTask',
},
{
title: '巡检配置',
description: '管理地图、点位与任务',
icon: Grid,
color: '#6b57c7',
background: '#f0edff',
menuTitles: ['巡检任务', '巡检地图', '地图管理'],
fallback: '/inspection/task',
},
{
title: '测试记录',
description: '追踪执行结果与日志',
icon: DocumentChecked,
color: '#d14343',
background: '#ffeded',
menuTitles: ['任务历史', '测试记录', '任务实例', '执行记录'],
fallback: '/test/history',
},
{
title: '语料中心',
description: '管理语音与合成资源',
icon: Headset,
color: '#087f9c',
background: '#e8f7fa',
menuTitles: ['语料库', '语料管理', '车载语料'],
fallback: '/corpusManger/corpus',
},
]
2025-08-21 14:11:58 +08:00
const displayName = computed(() => userStore.nickName || userStore.name || '用户')
const currentTime = computed(() => new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now.value))
const currentDate = computed(() => new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
}).format(now.value))
const greeting = computed(() => {
const hour = now.value.getHours()
if (hour < 6) return '夜深了'
if (hour < 12) return '上午好'
if (hour < 18) return '下午好'
return '晚上好'
})
function displayMetric(key) {
const metric = stats[key]
if (metric.restricted) return '--'
return metric.value === null ? '--' : metric.value.toLocaleString('zh-CN')
}
function hasPermission(permission) {
return auth.hasPermi(permission)
}
function createMetricRequest(key, permission, request) {
if (!hasPermission(permission)) {
stats[key].restricted = true
return null
}
stats[key].restricted = false
return request()
.then((response) => {
stats[key].value = Number(response.total ?? response.rows?.length ?? 0)
})
.catch(() => {
stats[key].value = null
})
}
async function loadDashboardData() {
if (loading.value) return
loading.value = true
const requests = [
createMetricRequest('devices', 'device:register:list', () => listRegister({ pageNum: 1, pageSize: 1 })),
createMetricRequest('workflows', 'test:config:list', () => listConfig({ pageNum: 1, pageSize: 1 })),
createMetricRequest('inspections', 'inspection:task:list', () => getTaskList({ pageNum: 1, pageSize: 1 })),
createMetricRequest('instances', 'test:inst:list', () => listInst({ pageNum: 1, pageSize: 5 })),
].filter(Boolean)
if (hasPermission('inspection:taskInstance:list')) {
activeTasksRestricted.value = false
requests.push(
getRunTaskList({ pageNum: 1, pageSize: 5, statusList: ['1', '3', '5'] })
.then((response) => {
activeTasks.value = (response.rows || []).slice(0, 4)
})
.catch(() => {
activeTasks.value = []
}),
)
} else {
activeTasksRestricted.value = true
activeTasks.value = []
}
if (hasPermission('test:inst:list')) {
recentInstancesRestricted.value = false
requests.push(
listInst({ pageNum: 1, pageSize: 4 })
.then((response) => {
recentInstances.value = (response.rows || []).slice(0, 4).map((item, index) => ({
...item,
order: index + 1,
}))
})
.catch(() => {
recentInstances.value = []
}),
)
} else {
recentInstancesRestricted.value = true
recentInstances.value = []
}
await Promise.allSettled(requests)
loading.value = false
}
function inspectionStatus(status) {
return {
'0': { label: '已完成', type: 'success' },
'1': { label: '执行中', type: 'warning' },
'2': { label: '失败', type: 'danger' },
'3': { label: '已暂停', type: 'primary' },
'4': { label: '已终止', type: 'info' },
'5': { label: '等待执行', type: 'info' },
}[String(status)] || { label: '未知', type: 'info' }
}
function testStatus(status) {
return {
'0': { label: '已完成', type: 'success' },
'1': { label: '执行中', type: 'warning' },
'2': { label: '失败', type: 'danger' },
}[String(status)] || { label: '未知', type: 'info' }
}
function formatTime(value) {
if (!value) return '尚未开始'
const date = new Date(String(value).replace(/-/g, '/'))
if (Number.isNaN(date.getTime())) return String(value)
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
function joinPath(parentPath, routePath) {
if (!routePath) return parentPath || '/'
if (routePath.startsWith('/')) return routePath
const parent = parentPath && parentPath !== '/' ? parentPath.replace(/\/$/, '') : ''
return `${parent}/${routePath}`.replace(/\/+/g, '/')
}
function flattenMenus(routes, parentPath = '', result = []) {
for (const route of routes || []) {
const path = joinPath(parentPath, route.path)
if (!route.hidden && route.meta?.title) {
result.push({ title: route.meta.title, path })
}
if (route.children?.length) flattenMenus(route.children, path, result)
}
return result
}
function resolveFeaturePath(feature) {
const menus = flattenMenus(permissionStore.sidebarRouters)
const exact = menus.find((menu) => feature.menuTitles.includes(menu.title))
if (exact) return exact.path
const partial = menus.find((menu) => feature.menuTitles.some((title) => menu.title.includes(title)))
return partial?.path || feature.fallback
}
function navigateToFeature(feature) {
router.push(resolveFeaturePath(feature))
}
onMounted(() => {
loadDashboardData()
clockTimer = window.setInterval(() => {
now.value = new Date()
}, 1000)
})
onBeforeUnmount(() => {
window.clearInterval(clockTimer)
})
2025-08-21 14:11:58 +08:00
</script>
<style scoped lang="scss">
.home-dashboard {
width: 100%;
height: 100%;
overflow-y: auto;
padding: 0 2px 24px;
color: var(--el-text-color-primary);
}
.hero-panel {
position: relative;
min-height: 236px;
padding: 36px 42px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
border-radius: 6px;
background-image: url('../assets/images/index-background.jpg');
background-position: center 48%;
background-size: cover;
color: #fff;
}
.hero-panel::before {
content: '';
position: absolute;
inset: 0;
background: rgba(8, 24, 37, 0.72);
}
.hero-content,
.hero-time {
position: relative;
z-index: 1;
}
.hero-content {
max-width: 760px;
}
.platform-state {
display: inline-flex;
align-items: center;
gap: 9px;
margin-bottom: 13px;
color: rgba(255, 255, 255, 0.82);
font-size: 13px;
font-weight: 600;
}
.state-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #35d2a0;
box-shadow: 0 0 0 5px rgba(53, 210, 160, 0.16);
animation: status-pulse 2.2s ease-in-out infinite;
}
.hero-content h1 {
2025-11-20 16:44:51 +08:00
margin: 0;
font-size: 32px;
line-height: 1.28;
font-weight: 700;
letter-spacing: 0;
2025-11-20 16:44:51 +08:00
}
.hero-description {
max-width: 670px;
margin: 12px 0 22px;
color: rgba(255, 255, 255, 0.76);
font-size: 15px;
line-height: 1.7;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hero-actions :deep(.el-button) {
min-width: 126px;
margin: 0;
border-radius: 4px;
font-weight: 600;
}
.hero-actions :deep(.el-button--primary) {
border-color: #1688e7;
background: #1688e7;
}
.cockpit-button {
border-color: rgba(255, 255, 255, 0.42) !important;
background: rgba(255, 255, 255, 0.1) !important;
color: #fff !important;
}
.cockpit-button:hover {
border-color: #fff !important;
background: rgba(255, 255, 255, 0.18) !important;
}
.hero-time {
min-width: 190px;
padding-left: 26px;
border-left: 1px solid rgba(255, 255, 255, 0.24);
display: flex;
flex-direction: column;
align-items: flex-end;
}
.hero-time strong {
margin: 6px 0 2px;
font-size: 42px;
line-height: 1;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.hero-time > span:last-child,
.time-label {
color: rgba(255, 255, 255, 0.72);
font-size: 13px;
}
.overview-section {
padding: 24px 0 20px;
}
.section-heading,
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.section-heading {
margin-bottom: 14px;
}
.section-kicker {
display: block;
margin-bottom: 4px;
color: var(--el-color-primary);
font-size: 10px;
line-height: 1.2;
font-weight: 700;
}
.section-heading h2,
.panel-heading h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 18px;
line-height: 1.35;
letter-spacing: 0;
}
.refresh-button {
flex: 0 0 auto;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
position: relative;
min-height: 116px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-bg-color);
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.metric-card:hover {
transform: translateY(-2px);
border-color: var(--el-border-color);
box-shadow: 0 10px 24px rgba(28, 44, 64, 0.08);
}
.metric-icon {
width: 48px;
height: 48px;
flex: 0 0 48px;
display: grid;
place-items: center;
border-radius: 6px;
font-size: 24px;
}
.metric-icon--blue { color: #2563eb; background: #eaf2ff; }
.metric-icon--green { color: #0f8a73; background: #e7f6f2; }
.metric-icon--orange { color: #c56a16; background: #fff3e5; }
.metric-icon--red { color: #d14343; background: #ffeded; }
.metric-content {
min-width: 0;
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
column-gap: 10px;
}
.metric-content > span {
color: var(--el-text-color-regular);
font-size: 14px;
}
.metric-content strong {
grid-column: 2;
grid-row: 1 / span 2;
color: var(--el-text-color-primary);
font-size: 28px;
line-height: 1;
font-weight: 650;
font-variant-numeric: tabular-nums;
2025-11-20 16:44:51 +08:00
}
.metric-content small {
margin-top: 7px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.metric-state {
2025-11-20 16:44:51 +08:00
position: absolute;
right: 12px;
bottom: 8px;
color: var(--el-text-color-placeholder);
font-size: 10px;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 16px;
margin-bottom: 16px;
}
.dashboard-panel {
min-width: 0;
padding: 20px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-bg-color);
}
.panel-heading {
min-height: 40px;
margin-bottom: 14px;
}
.text-action {
border: 0;
padding: 5px 0;
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
color: var(--el-color-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.text-action:hover {
color: var(--el-color-primary-light-3);
}
.task-list {
display: flex;
flex-direction: column;
}
.task-row {
2025-11-20 16:44:51 +08:00
width: 100%;
min-height: 63px;
padding: 11px 6px;
border: 0;
border-top: 1px solid var(--el-border-color-extra-light);
display: grid;
grid-template-columns: 10px minmax(130px, 1fr) auto 18px;
align-items: center;
gap: 12px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.task-row:first-child {
border-top: 0;
}
.task-row:hover {
background: var(--el-fill-color-light);
}
.task-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-color-info);
}
.task-status-dot.status-1 { background: var(--el-color-warning); }
.task-status-dot.status-3 { background: var(--el-color-primary); }
.task-status-dot.status-5 { background: var(--el-color-info); }
.task-main,
.task-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.task-main strong {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-main small,
.task-meta small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
.task-meta {
align-items: flex-end;
}
.row-arrow,
.feature-arrow {
color: var(--el-text-color-placeholder);
}
.empty-state {
min-height: 184px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-regular);
text-align: center;
}
.empty-icon {
width: 44px;
height: 44px;
margin-bottom: 10px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--el-color-success-light-9);
color: var(--el-color-success);
font-size: 22px;
}
.empty-state strong {
font-size: 14px;
}
.empty-state > span {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.feature-item {
min-width: 0;
min-height: 76px;
padding: 10px;
border: 1px solid transparent;
border-radius: 5px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 14px;
align-items: center;
gap: 9px;
background: var(--el-fill-color-extra-light);
color: inherit;
text-align: left;
cursor: pointer;
transition: border-color 160ms ease, background-color 160ms ease;
}
.feature-item:hover {
border-color: var(--el-border-color);
background: var(--el-fill-color-light);
}
.feature-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 5px;
font-size: 20px;
}
.feature-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.feature-copy strong {
color: var(--el-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.feature-copy small {
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-panel {
margin-bottom: 2px;
}
.recent-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 26px;
}
.recent-item {
min-width: 0;
min-height: 62px;
border-top: 1px solid var(--el-border-color-extra-light);
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto auto;
align-items: center;
gap: 11px;
}
.recent-item:nth-child(-n + 2) {
border-top: 0;
}
.recent-order {
color: var(--el-text-color-placeholder);
font-size: 17px;
font-weight: 600;
font-variant-numeric: tabular-nums;
2025-11-20 16:44:51 +08:00
}
.recent-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.recent-info strong,
.recent-info span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-info strong {
color: var(--el-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.recent-info span,
.recent-time {
color: var(--el-text-color-secondary);
font-size: 11px;
}
.recent-empty {
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.recent-empty .el-icon {
font-size: 19px;
}
:global(html.dark) .hero-panel::before {
background: rgba(4, 13, 22, 0.82);
}
:global(html.dark) .metric-card:hover {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22);
}
:global(html.dark) .metric-icon,
:global(html.dark) .feature-icon {
background: var(--el-fill-color) !important;
}
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 0 4px rgba(53, 210, 160, 0.12); }
50% { box-shadow: 0 0 0 7px rgba(53, 210, 160, 0.04); }
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-grid {
grid-template-columns: 1fr;
}
}
2026-07-28 16:26:07 +08:00
@media (max-width: 768px) {
.home-dashboard {
padding-bottom: 16px;
}
.hero-panel {
min-height: 0;
padding: 28px 22px;
align-items: flex-start;
flex-direction: column;
}
.hero-content h1 {
font-size: 26px;
}
.hero-description {
font-size: 14px;
}
.hero-time {
width: 100%;
padding: 18px 0 0;
border-top: 1px solid rgba(255, 255, 255, 0.24);
border-left: 0;
align-items: flex-start;
}
.hero-time strong {
font-size: 34px;
}
.metric-grid,
.feature-grid,
.recent-list {
grid-template-columns: 1fr;
}
.recent-item:nth-child(2) {
border-top: 1px solid var(--el-border-color-extra-light);
2026-07-28 16:26:07 +08:00
}
}
2025-08-21 14:11:58 +08:00
@media (max-width: 520px) {
.metric-grid {
gap: 10px;
}
.metric-card {
min-height: 104px;
padding: 16px;
}
.metric-content {
grid-template-columns: 1fr;
}
.metric-content strong {
grid-column: 1;
grid-row: auto;
margin-top: 5px;
font-size: 24px;
}
.task-row {
grid-template-columns: 10px minmax(0, 1fr) 16px;
}
.task-meta {
display: none;
}
.recent-item {
grid-template-columns: 28px minmax(0, 1fr) auto;
}
.recent-time {
display: none;
}
}
</style>