|
|
|
|
@ -0,0 +1,631 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
|
|
|
import { storeToRefs } from 'pinia'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import { getAdminDashboard } from '../../api/admin/dashboard'
|
|
|
|
|
import { listCompetitions } from '../../api/admin/competitions'
|
|
|
|
|
import type {
|
|
|
|
|
AdminDashboardData,
|
|
|
|
|
AdminDashboardSetup,
|
|
|
|
|
} from '../../api/admin/types'
|
|
|
|
|
import { useAdminCompetitionStore } from '../../stores/adminCompetition'
|
|
|
|
|
import { competitionStatusLabel } from '../../utils/competitionAdminLabels'
|
|
|
|
|
|
|
|
|
|
type SetupKey = keyof AdminDashboardSetup
|
|
|
|
|
|
|
|
|
|
interface SetupItem {
|
|
|
|
|
key: SetupKey
|
|
|
|
|
label: string
|
|
|
|
|
description: string
|
|
|
|
|
routeName: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const competitionStore = useAdminCompetitionStore()
|
|
|
|
|
const { competitions, selectedCompetitionId } = storeToRefs(competitionStore)
|
|
|
|
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const dashboard = ref<AdminDashboardData | null>(null)
|
|
|
|
|
|
|
|
|
|
const setupItems: SetupItem[] = [
|
|
|
|
|
{
|
|
|
|
|
key: 'published',
|
|
|
|
|
label: '赛事发布',
|
|
|
|
|
description: '对外报名和评审入口依赖发布状态',
|
|
|
|
|
routeName: 'admin-competition-workspace',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'signup_form_schema',
|
|
|
|
|
label: '报名表单',
|
|
|
|
|
description: '选手报名字段由 Schema 渲染',
|
|
|
|
|
routeName: 'admin-competition-workspace',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'tracks',
|
|
|
|
|
label: '赛道配置',
|
|
|
|
|
description: '评审范围和报名分组按赛道隔离',
|
|
|
|
|
routeName: 'admin-competition-workspace',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'pledge',
|
|
|
|
|
label: '承诺书',
|
|
|
|
|
description: '提交报名时需要签署的赛事文本',
|
|
|
|
|
routeName: 'admin-competition-workspace',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'review_form_schema',
|
|
|
|
|
label: '评审表单',
|
|
|
|
|
description: '未配置时评审端将使用系统默认表单',
|
|
|
|
|
routeName: 'admin-competition-workspace',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'reviewer_scopes',
|
|
|
|
|
label: '评审范围',
|
|
|
|
|
description: '评审员必须绑定赛事和赛道后才能看到项目',
|
|
|
|
|
routeName: 'admin-reviewers-list',
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const summary = computed(() => dashboard.value?.summary ?? null)
|
|
|
|
|
const selectedCompetition = computed(() => dashboard.value?.competition ?? null)
|
|
|
|
|
|
|
|
|
|
const stageLabel = computed(() => {
|
|
|
|
|
const c = selectedCompetition.value
|
|
|
|
|
if (!c) return '未选择赛事'
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
const openAt = c.signup_open_at ? new Date(c.signup_open_at).getTime() : null
|
|
|
|
|
const closeAt = c.signup_close_at ? new Date(c.signup_close_at).getTime() : null
|
|
|
|
|
if (!c.published) return '未发布'
|
|
|
|
|
if (openAt && now < openAt) return '报名未开始'
|
|
|
|
|
if (closeAt && now > closeAt) return c.status === 'reviewing' ? '评审中' : '报名已截止'
|
|
|
|
|
if (c.status === 'reviewing') return '评审中'
|
|
|
|
|
if (c.status === 'ended') return '已结束'
|
|
|
|
|
return '报名中'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const metricCards = computed(() => {
|
|
|
|
|
const s = summary.value
|
|
|
|
|
return [
|
|
|
|
|
{ label: '报名总数', value: s?.applications_total ?? 0, hint: '含草稿和已提交' },
|
|
|
|
|
{ label: '已提交', value: s?.applications_submitted ?? 0, hint: '可进入评审范围' },
|
|
|
|
|
{ label: '草稿', value: s?.applications_draft ?? 0, hint: '选手尚未正式提交' },
|
|
|
|
|
{ label: '待评项目', value: s?.applications_pending_review ?? 0, hint: '已提交但暂无评分' },
|
|
|
|
|
{ label: '已评项目', value: s?.applications_reviewed ?? 0, hint: '至少已有一条评分' },
|
|
|
|
|
{ label: '评审记录', value: s?.review_scores_total ?? 0, hint: '累计提交评分数' },
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const reviewProgressPercent = computed(() => {
|
|
|
|
|
const s = summary.value
|
|
|
|
|
if (!s || s.applications_submitted === 0) return 0
|
|
|
|
|
return Math.round((s.applications_reviewed / s.applications_submitted) * 100)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const basePath = computed(() =>
|
|
|
|
|
import.meta.env.BASE_URL.endsWith('/')
|
|
|
|
|
? import.meta.env.BASE_URL.slice(0, -1)
|
|
|
|
|
: import.meta.env.BASE_URL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const publicApplyPath = computed(() => {
|
|
|
|
|
const slug = selectedCompetition.value?.slug
|
|
|
|
|
return slug ? `${basePath.value}/c/${encodeURIComponent(slug)}/apply` : ''
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const publicReviewPath = computed(() => {
|
|
|
|
|
const slug = selectedCompetition.value?.slug
|
|
|
|
|
return slug ? `${basePath.value}/c/${encodeURIComponent(slug)}/review` : ''
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function ensureCompetitionsLoaded() {
|
|
|
|
|
if (competitions.value.length > 0) return
|
|
|
|
|
const page = await listCompetitions({ page: 1, per_page: 100 })
|
|
|
|
|
competitionStore.setCompetitionList(page.data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDashboard() {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
await ensureCompetitionsLoaded()
|
|
|
|
|
const id = selectedCompetitionId.value
|
|
|
|
|
if (!id) {
|
|
|
|
|
dashboard.value = null
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
dashboard.value = await getAdminDashboard(id)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
dashboard.value = null
|
|
|
|
|
ElMessage.error(e instanceof Error ? e.message : '首页数据加载失败')
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDateTime(value: string | null | undefined): string {
|
|
|
|
|
if (!value) return '未配置'
|
|
|
|
|
const d = new Date(value)
|
|
|
|
|
return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusTagType(status: string): 'success' | 'info' | 'warning' | 'danger' | 'primary' {
|
|
|
|
|
if (status === 'submitted' || status === 'published' || status === 'signup_open') return 'success'
|
|
|
|
|
if (status === 'draft') return 'info'
|
|
|
|
|
if (status === 'reviewing') return 'primary'
|
|
|
|
|
if (status === 'signup_closed' || status === 'ended') return 'warning'
|
|
|
|
|
return 'info'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applicationStatusLabel(status: string): string {
|
|
|
|
|
if (status === 'submitted') return '已提交'
|
|
|
|
|
if (status === 'draft') return '草稿'
|
|
|
|
|
return status
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyText(text: string) {
|
|
|
|
|
if (!text) return
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(`${window.location.origin}${text}`)
|
|
|
|
|
ElMessage.success('已复制完整链接')
|
|
|
|
|
} catch {
|
|
|
|
|
ElMessage.error('复制失败,请手动选择文本')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch(selectedCompetitionId, () => {
|
|
|
|
|
void loadDashboard()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
void loadDashboard()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="dashboard" v-loading="loading">
|
|
|
|
|
<template v-if="dashboard && selectedCompetition">
|
|
|
|
|
<section class="dashboard-head">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="eyebrow">运营工作台</div>
|
|
|
|
|
<h1>{{ selectedCompetition.name }}</h1>
|
|
|
|
|
<div class="meta-line">
|
|
|
|
|
<el-tag :type="statusTagType(selectedCompetition.status)" effect="plain">
|
|
|
|
|
{{ competitionStatusLabel(selectedCompetition.status) }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
<el-tag :type="selectedCompetition.published ? 'success' : 'info'" effect="plain">
|
|
|
|
|
{{ selectedCompetition.published ? '已发布' : '未发布' }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
<span>当前阶段:{{ stageLabel }}</span>
|
|
|
|
|
<span>slug:{{ selectedCompetition.slug }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="time-line">
|
|
|
|
|
<span>报名开始:{{ formatDateTime(selectedCompetition.signup_open_at) }}</span>
|
|
|
|
|
<span>报名截止:{{ formatDateTime(selectedCompetition.signup_close_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="head-actions">
|
|
|
|
|
<RouterLink :to="{ name: 'admin-competition-workspace' }">
|
|
|
|
|
<el-button type="danger">赛事配置</el-button>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
<RouterLink :to="{ name: 'admin-competitions-list' }">
|
|
|
|
|
<el-button>赛事列表</el-button>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="metric-grid" aria-label="核心指标">
|
|
|
|
|
<div v-for="card in metricCards" :key="card.label" class="metric-card">
|
|
|
|
|
<div class="metric-label">{{ card.label }}</div>
|
|
|
|
|
<div class="metric-value">{{ card.value }}</div>
|
|
|
|
|
<div class="metric-hint">{{ card.hint }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="main-grid">
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-head">
|
|
|
|
|
<h2>配置完整度</h2>
|
|
|
|
|
<span class="panel-subtitle">影响报名、提交和评审流转</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="setup-list">
|
|
|
|
|
<div v-for="item in setupItems" :key="item.key" class="setup-row">
|
|
|
|
|
<el-tag
|
|
|
|
|
:type="dashboard.setup[item.key] ? 'success' : item.key === 'review_form_schema' ? 'warning' : 'danger'"
|
|
|
|
|
effect="plain"
|
|
|
|
|
size="small"
|
|
|
|
|
>
|
|
|
|
|
{{ dashboard.setup[item.key] ? '已完成' : item.key === 'review_form_schema' ? '默认' : '待处理' }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
<div class="setup-copy">
|
|
|
|
|
<strong>{{ item.label }}</strong>
|
|
|
|
|
<span>{{ item.description }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<RouterLink :to="{ name: item.routeName }">
|
|
|
|
|
<el-button link type="danger">处理</el-button>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-head">
|
|
|
|
|
<h2>评审进度</h2>
|
|
|
|
|
<span class="panel-subtitle">仅统计已提交报名</span>
|
|
|
|
|
</div>
|
|
|
|
|
<el-progress
|
|
|
|
|
:percentage="reviewProgressPercent"
|
|
|
|
|
:stroke-width="12"
|
|
|
|
|
:show-text="false"
|
|
|
|
|
/>
|
|
|
|
|
<div class="progress-kpis">
|
|
|
|
|
<div>
|
|
|
|
|
<strong>{{ summary?.applications_reviewed ?? 0 }}</strong>
|
|
|
|
|
<span>已评项目</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<strong>{{ summary?.applications_pending_review ?? 0 }}</strong>
|
|
|
|
|
<span>待评项目</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<strong>{{ summary?.reviewers_active ?? 0 }}/{{ summary?.reviewers_total ?? 0 }}</strong>
|
|
|
|
|
<span>启用评审员</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<strong>{{ summary?.reviewer_scopes_total ?? 0 }}</strong>
|
|
|
|
|
<span>评审范围</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<RouterLink :to="{ name: 'admin-reviewers-list' }">
|
|
|
|
|
<el-button type="danger" plain>管理评审员与范围</el-button>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="main-grid main-grid--wide">
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-head">
|
|
|
|
|
<h2>赛道分布</h2>
|
|
|
|
|
<span class="panel-subtitle">
|
|
|
|
|
已启用 {{ summary?.tracks_enabled ?? 0 }} / {{ summary?.tracks_total ?? 0 }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<el-table :data="dashboard.track_stats" stripe style="width: 100%">
|
|
|
|
|
<el-table-column prop="track_title" label="赛道" min-width="150" show-overflow-tooltip />
|
|
|
|
|
<el-table-column prop="track_code" label="代码" min-width="100" show-overflow-tooltip />
|
|
|
|
|
<el-table-column label="状态" width="90">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small" effect="plain">
|
|
|
|
|
{{ row.is_enabled ? '启用' : '停用' }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column prop="applications_count" label="报名" width="90" />
|
|
|
|
|
<el-table-column prop="submitted_count" label="已提交" width="90" />
|
|
|
|
|
<el-table-column prop="reviewer_scope_count" label="评审范围" width="100" />
|
|
|
|
|
<template #empty>
|
|
|
|
|
<span class="muted">暂无赛道,请进入赛事配置维护。</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-head">
|
|
|
|
|
<h2>常用入口</h2>
|
|
|
|
|
<span class="panel-subtitle">按当前赛事生成</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="shortcut-list">
|
|
|
|
|
<RouterLink :to="{ name: 'admin-competition-workspace' }" class="shortcut">
|
|
|
|
|
<strong>报名列表与赛事配置</strong>
|
|
|
|
|
<span>查看报名详情、附件、分数和赛事设置</span>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
<RouterLink :to="{ name: 'admin-public-source-channels' }" class="shortcut">
|
|
|
|
|
<strong>公开来源渠道</strong>
|
|
|
|
|
<span>生成首页入口链接和来源归因</span>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
<button class="shortcut shortcut-button" type="button" @click="copyText(publicApplyPath)">
|
|
|
|
|
<strong>复制报名链接</strong>
|
|
|
|
|
<span>{{ publicApplyPath || '当前赛事暂无 slug' }}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="shortcut shortcut-button" type="button" @click="copyText(publicReviewPath)">
|
|
|
|
|
<strong>复制评审链接</strong>
|
|
|
|
|
<span>{{ publicReviewPath || '当前赛事暂无 slug' }}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="panel">
|
|
|
|
|
<div class="panel-head">
|
|
|
|
|
<h2>最近报名</h2>
|
|
|
|
|
<span class="panel-subtitle">最近更新或提交的 8 条记录</span>
|
|
|
|
|
</div>
|
|
|
|
|
<el-table :data="dashboard.recent_applications" stripe style="width: 100%">
|
|
|
|
|
<el-table-column prop="project_name" label="项目" min-width="180" show-overflow-tooltip />
|
|
|
|
|
<el-table-column prop="player_name" label="负责人" min-width="100" show-overflow-tooltip />
|
|
|
|
|
<el-table-column prop="track_title" label="赛道" min-width="130" show-overflow-tooltip />
|
|
|
|
|
<el-table-column label="状态" width="100">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag :type="statusTagType(row.status)" size="small" effect="plain">
|
|
|
|
|
{{ applicationStatusLabel(row.status) }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column prop="submitted_review_count" label="评分数" width="90" />
|
|
|
|
|
<el-table-column label="提交时间" min-width="170">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
{{ formatDateTime(row.submitted_at) }}
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="操作" width="120" fixed="right">
|
|
|
|
|
<template #default>
|
|
|
|
|
<RouterLink :to="{ name: 'admin-competition-workspace' }">
|
|
|
|
|
<el-button type="danger" link>进入列表</el-button>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<template #empty>
|
|
|
|
|
<span class="muted">暂无报名记录。</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table>
|
|
|
|
|
</section>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<el-empty v-else-if="!loading" description="暂无可管理的赛事">
|
|
|
|
|
<RouterLink :to="{ name: 'admin-competition-new' }">
|
|
|
|
|
<el-button type="danger">新建赛事</el-button>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
</el-empty>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.dashboard {
|
|
|
|
|
max-width: 1360px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dashboard-head {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
padding-bottom: 16px;
|
|
|
|
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.eyebrow {
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dashboard-head h1 {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
font-weight: 650;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.meta-line,
|
|
|
|
|
.time-line {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: var(--el-text-color-regular);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.time-line {
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.head-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metric-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metric-card,
|
|
|
|
|
.panel {
|
|
|
|
|
background: var(--el-bg-color);
|
|
|
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metric-card {
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metric-label {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metric-value {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
color: var(--el-text-color-primary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metric-hint {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.main-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
|
|
|
|
|
gap: 16px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.main-grid--wide {
|
|
|
|
|
grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-head {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-bottom: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-head h2 {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
font-weight: 650;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-subtitle {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.setup-list {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.setup-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 58px minmax(0, 1fr) 48px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.setup-row:last-child {
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.setup-copy {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 3px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.setup-copy strong {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: var(--el-text-color-primary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.setup-copy span {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progress-kpis {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progress-kpis div {
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
background: var(--el-fill-color-lighter);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progress-kpis strong {
|
|
|
|
|
display: block;
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
line-height: 1.1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progress-kpis span {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-top: 5px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.shortcut-list {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.shortcut {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding: 11px 12px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
color: inherit;
|
|
|
|
|
background: var(--el-fill-color-lighter);
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.shortcut:hover {
|
|
|
|
|
border-color: var(--el-color-danger-light-5);
|
|
|
|
|
background: var(--el-color-danger-light-9);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.shortcut strong {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.shortcut span {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.shortcut-button {
|
|
|
|
|
font: inherit;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.muted {
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1200px) {
|
|
|
|
|
.metric-grid {
|
|
|
|
|
grid-template-columns: repeat(3, minmax(140px, 1fr));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.main-grid,
|
|
|
|
|
.main-grid--wide {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 720px) {
|
|
|
|
|
.dashboard-head {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.head-actions {
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metric-grid {
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|