2026-05-14 09:28:51 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
import { NButton } from 'naive-ui'
|
2026-05-24 09:57:01 +08:00
|
|
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
2026-05-14 09:28:51 +08:00
|
|
|
import {
|
|
|
|
|
fetchSkillUsageStats,
|
|
|
|
|
type SkillUsageDailyRow,
|
|
|
|
|
type SkillUsageRow,
|
|
|
|
|
type SkillUsageStats,
|
|
|
|
|
} from '@/api/hermes/skills'
|
|
|
|
|
|
|
|
|
|
const { t } = useI18n()
|
2026-05-24 09:57:01 +08:00
|
|
|
const profilesStore = useProfilesStore()
|
2026-05-14 09:28:51 +08:00
|
|
|
const periodOptions = [7, 30, 90, 365]
|
|
|
|
|
const maxVisibleChartSkills = 6
|
|
|
|
|
const skillPalette = [
|
|
|
|
|
'#2f6eea',
|
|
|
|
|
'#f26d3d',
|
|
|
|
|
'#f49a5c',
|
|
|
|
|
'#9aa7ff',
|
|
|
|
|
'#8f2c8f',
|
|
|
|
|
'#00c2d1',
|
|
|
|
|
]
|
|
|
|
|
const otherSkillColor = '#5b6b84'
|
|
|
|
|
|
|
|
|
|
interface ChartSegment {
|
|
|
|
|
key: string
|
|
|
|
|
label: string
|
|
|
|
|
count: number
|
|
|
|
|
color: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectedDays = ref(7)
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const error = ref('')
|
|
|
|
|
const statsByPeriod = ref<Record<number, SkillUsageStats | undefined>>({})
|
|
|
|
|
let requestSeq = 0
|
|
|
|
|
const latestRequestByPeriod: Record<number, number> = {}
|
|
|
|
|
|
|
|
|
|
const stats = computed(() => statsByPeriod.value[selectedDays.value] ?? null)
|
|
|
|
|
const hasData = computed(() => (stats.value?.summary.total_skill_actions ?? 0) > 0)
|
|
|
|
|
const maxDailyActions = computed(() => Math.max(...(stats.value?.by_day ?? []).map(day => day.total_count), 1))
|
|
|
|
|
const isRefreshing = computed(() => loading.value && !!stats.value)
|
|
|
|
|
const hoveredDayKey = ref<string | null>(null)
|
|
|
|
|
const hoveredDayIndex = ref<number | null>(null)
|
|
|
|
|
const hoveredDay = computed(() => stats.value?.by_day.find(day => day.date === hoveredDayKey.value) ?? null)
|
|
|
|
|
const hoveredSegments = computed(() => hoveredDay.value ? chartSegments(hoveredDay.value) : [])
|
|
|
|
|
const tooltipAlignment = computed(() => {
|
|
|
|
|
const dayCount = stats.value?.by_day.length ?? 0
|
|
|
|
|
if (hoveredDayIndex.value === null || dayCount <= 0) return 'align-right'
|
|
|
|
|
return hoveredDayIndex.value >= dayCount / 2 ? 'align-left' : 'align-right'
|
|
|
|
|
})
|
|
|
|
|
const chartSkills = computed(() => (stats.value?.top_skills ?? []).slice(0, maxVisibleChartSkills))
|
|
|
|
|
const chartSkillSet = computed(() => new Set(chartSkills.value.map(skill => skill.skill)))
|
|
|
|
|
|
|
|
|
|
function formatPercent(value: number): string {
|
|
|
|
|
return `${value.toFixed(1)}%`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatLastUsed(timestamp: number | null): string {
|
|
|
|
|
if (!timestamp) return '—'
|
|
|
|
|
return new Date(timestamp * 1000).toLocaleString()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dailyBarHeight(total: number): string {
|
|
|
|
|
return `${Math.max(2, (total / maxDailyActions.value) * 100)}%`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function segmentFlex(value: number): number {
|
|
|
|
|
return Math.max(value, 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function totalSkillActions(skill: Pick<SkillUsageRow, 'view_count' | 'manage_count' | 'total_count'>): number {
|
|
|
|
|
return skill.total_count || skill.view_count + skill.manage_count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function otherSkillsLabel(): string {
|
|
|
|
|
const hiddenCount = Math.max((stats.value?.top_skills.length ?? 0) - maxVisibleChartSkills, 0)
|
|
|
|
|
return hiddenCount > 0 ? `${t('skillsUsage.otherSkills')} (+${hiddenCount})` : t('skillsUsage.otherSkills')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function colorForSkill(skillName: string): string {
|
|
|
|
|
const index = chartSkills.value.findIndex(skill => skill.skill === skillName)
|
|
|
|
|
return index >= 0 ? skillPalette[index % skillPalette.length] : otherSkillColor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chartSegments(day: SkillUsageDailyRow): ChartSegment[] {
|
|
|
|
|
const daySkills = day.skills ?? []
|
|
|
|
|
const bySkill = new Map(daySkills.map(skill => [skill.skill, totalSkillActions(skill)]))
|
|
|
|
|
const segments: ChartSegment[] = chartSkills.value
|
|
|
|
|
.map(skill => ({
|
|
|
|
|
key: skill.skill,
|
|
|
|
|
label: skill.skill,
|
|
|
|
|
count: bySkill.get(skill.skill) ?? 0,
|
|
|
|
|
color: colorForSkill(skill.skill),
|
|
|
|
|
}))
|
|
|
|
|
.filter(segment => segment.count > 0)
|
|
|
|
|
|
|
|
|
|
const otherTotal = daySkills
|
|
|
|
|
.filter(skill => !chartSkillSet.value.has(skill.skill))
|
|
|
|
|
.reduce((sum, skill) => sum + totalSkillActions(skill), 0)
|
|
|
|
|
|
|
|
|
|
if (otherTotal > 0) {
|
|
|
|
|
segments.push({
|
|
|
|
|
key: 'other-skills',
|
|
|
|
|
label: otherSkillsLabel(),
|
|
|
|
|
count: otherTotal,
|
|
|
|
|
color: otherSkillColor,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return segments
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showTooltip(day: SkillUsageDailyRow, index: number) {
|
|
|
|
|
hoveredDayKey.value = day.date
|
|
|
|
|
hoveredDayIndex.value = index
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideTooltip(day: SkillUsageDailyRow) {
|
|
|
|
|
if (hoveredDayKey.value === day.date) {
|
|
|
|
|
hoveredDayKey.value = null
|
|
|
|
|
hoveredDayIndex.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadStats(days = selectedDays.value, force = false) {
|
|
|
|
|
selectedDays.value = days
|
2026-05-24 09:57:01 +08:00
|
|
|
if (!profilesStore.activeProfileName || profilesStore.profiles.length === 0) {
|
|
|
|
|
await profilesStore.fetchProfiles()
|
|
|
|
|
}
|
2026-05-14 09:28:51 +08:00
|
|
|
const seq = ++requestSeq
|
|
|
|
|
latestRequestByPeriod[days] = seq
|
|
|
|
|
loading.value = true
|
|
|
|
|
if (!statsByPeriod.value[days] || force) error.value = ''
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const next = await fetchSkillUsageStats(days)
|
|
|
|
|
if (latestRequestByPeriod[days] === seq) {
|
|
|
|
|
statsByPeriod.value = {
|
|
|
|
|
...statsByPeriod.value,
|
|
|
|
|
[days]: next,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (seq === requestSeq) error.value = ''
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
if (seq === requestSeq) error.value = err?.message || t('skillsUsage.loadFailed')
|
|
|
|
|
} finally {
|
|
|
|
|
if (seq === requestSeq) loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
void loadStats(7)
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="skills-usage-view">
|
|
|
|
|
<header class="page-header">
|
|
|
|
|
<div class="header-text">
|
|
|
|
|
<h2 class="header-title">{{ t('skillsUsage.title') }}</h2>
|
|
|
|
|
<p class="header-subtitle">{{ t('skillsUsage.subtitle') }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="skills-usage-toolbar">
|
|
|
|
|
<div class="period-selector" role="group" :aria-label="t('skillsUsage.periodSelector')">
|
|
|
|
|
<NButton
|
|
|
|
|
v-for="days in periodOptions"
|
|
|
|
|
:key="days"
|
|
|
|
|
size="small"
|
|
|
|
|
:secondary="selectedDays === days"
|
|
|
|
|
:quaternary="selectedDays !== days"
|
|
|
|
|
:type="selectedDays === days ? 'primary' : 'default'"
|
|
|
|
|
:aria-pressed="selectedDays === days ? 'true' : 'false'"
|
|
|
|
|
@click="loadStats(days)"
|
|
|
|
|
>
|
|
|
|
|
{{ t('skillsUsage.periodLabel', { days }) }}
|
|
|
|
|
</NButton>
|
|
|
|
|
</div>
|
|
|
|
|
<NButton size="small" quaternary :loading="loading" @click="loadStats(selectedDays, true)">
|
|
|
|
|
{{ t('skillsUsage.refresh') }}
|
|
|
|
|
</NButton>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div class="skills-usage-content">
|
|
|
|
|
<div v-if="error && !stats" class="skills-usage-state error">
|
|
|
|
|
{{ error }}
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else-if="loading && !stats" class="skills-usage-state">
|
|
|
|
|
{{ t('common.loading') }}
|
|
|
|
|
</div>
|
|
|
|
|
<template v-else-if="stats">
|
|
|
|
|
<div v-if="error" class="inline-error">
|
|
|
|
|
{{ error }}
|
|
|
|
|
</div>
|
|
|
|
|
<section class="overview-grid">
|
|
|
|
|
<div class="usage-panel chart-panel" :class="{ 'is-refreshing': isRefreshing }" data-testid="skills-usage-chart">
|
|
|
|
|
<div class="panel-header">
|
|
|
|
|
<h3>{{ t('skillsUsage.dailyTrend') }}</h3>
|
|
|
|
|
<span>{{ t('skillsUsage.periodSummary', { days: stats.period_days }) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="!hasData" class="skills-usage-state compact">
|
|
|
|
|
{{ t('skillsUsage.noData') }}
|
|
|
|
|
</div>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div class="skill-bar-chart">
|
|
|
|
|
<div
|
|
|
|
|
v-for="(day, index) in stats.by_day"
|
|
|
|
|
:key="day.date"
|
|
|
|
|
class="skill-bar-col"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
@mouseenter="showTooltip(day, index)"
|
|
|
|
|
@focusin="showTooltip(day, index)"
|
|
|
|
|
@mouseleave="hideTooltip(day)"
|
|
|
|
|
@focusout="hideTooltip(day)"
|
|
|
|
|
>
|
|
|
|
|
<div class="skill-bar-track">
|
|
|
|
|
<div class="skill-bar-fill" :style="{ height: dailyBarHeight(day.total_count) }">
|
|
|
|
|
<div
|
|
|
|
|
v-for="segment in chartSegments(day)"
|
|
|
|
|
:key="segment.key"
|
|
|
|
|
class="skill-bar-segment"
|
|
|
|
|
:data-skill="segment.key"
|
|
|
|
|
:style="{ flex: segmentFlex(segment.count), background: segment.color }"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="hoveredDay" class="floating-tooltip" :class="tooltipAlignment">
|
|
|
|
|
<div class="tooltip-date">{{ hoveredDay.date }}</div>
|
|
|
|
|
<div v-for="segment in hoveredSegments" :key="segment.key" class="tooltip-row">
|
|
|
|
|
<i class="tooltip-dot" :style="{ background: segment.color }" />
|
|
|
|
|
<span>{{ segment.label }}</span>
|
|
|
|
|
<strong>{{ segment.count }}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tooltip-row total">
|
|
|
|
|
<span>{{ t('skillsUsage.totalActions') }}</span>
|
|
|
|
|
<strong>{{ hoveredDay.total_count }}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bar-dates">
|
|
|
|
|
<span>{{ stats.by_day[0]?.date.slice(5) }}</span>
|
|
|
|
|
<span>{{ stats.by_day[stats.by_day.length - 1]?.date.slice(5) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="summary-grid" data-testid="skills-usage-stats" :aria-label="t('skillsUsage.summary')">
|
|
|
|
|
<div class="summary-card primary">
|
|
|
|
|
<div class="summary-label">{{ t('skillsUsage.totalActions') }}</div>
|
|
|
|
|
<div class="summary-value">{{ stats.summary.total_skill_actions }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="summary-card">
|
|
|
|
|
<div class="summary-label">{{ t('skillsUsage.loads') }}</div>
|
|
|
|
|
<div class="summary-value">{{ stats.summary.total_skill_loads }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="summary-card">
|
|
|
|
|
<div class="summary-label">{{ t('skillsUsage.edits') }}</div>
|
|
|
|
|
<div class="summary-value">{{ stats.summary.total_skill_edits }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="summary-card">
|
|
|
|
|
<div class="summary-label">{{ t('skillsUsage.distinctSkills') }}</div>
|
|
|
|
|
<div class="summary-value">{{ stats.summary.distinct_skills_used }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="usage-panel">
|
|
|
|
|
<div class="panel-header">
|
|
|
|
|
<h3>{{ t('skillsUsage.topSkills') }}</h3>
|
|
|
|
|
<span>{{ t('skillsUsage.periodSummary', { days: stats.period_days }) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="!hasData" class="skills-usage-state compact">
|
|
|
|
|
{{ t('skillsUsage.noData') }}
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else class="skills-table" role="table" :aria-label="t('skillsUsage.topSkills')">
|
|
|
|
|
<div class="skills-row table-head" role="row">
|
|
|
|
|
<span role="columnheader">{{ t('skillsUsage.skill') }}</span>
|
|
|
|
|
<span role="columnheader">{{ t('skillsUsage.loads') }}</span>
|
|
|
|
|
<span role="columnheader">{{ t('skillsUsage.edits') }}</span>
|
|
|
|
|
<span role="columnheader">{{ t('skillsUsage.share') }}</span>
|
|
|
|
|
<span role="columnheader">{{ t('skillsUsage.lastUsed') }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-for="skill in stats.top_skills" :key="skill.skill" class="skills-row" role="row">
|
|
|
|
|
<span class="skill-name" role="cell">
|
|
|
|
|
<i class="skill-color-dot" :style="{ background: colorForSkill(skill.skill) }" />
|
|
|
|
|
{{ skill.skill }}
|
|
|
|
|
</span>
|
|
|
|
|
<span role="cell">{{ skill.view_count }}</span>
|
|
|
|
|
<span role="cell">{{ skill.manage_count }}</span>
|
|
|
|
|
<span class="share-cell" role="cell">
|
|
|
|
|
<span class="share-bar"><span :style="{ width: formatPercent(skill.percentage), background: colorForSkill(skill.skill) }" /></span>
|
|
|
|
|
{{ formatPercent(skill.percentage) }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="last-used" role="cell">{{ formatLastUsed(skill.last_used_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
@use '@/styles/variables' as *;
|
|
|
|
|
|
|
|
|
|
.skills-usage-view {
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.page-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
padding: 21px 20px;
|
|
|
|
|
border-bottom: 1px solid $border-color;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-text {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-subtitle {
|
|
|
|
|
margin: 4px 0 0;
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skills-usage-toolbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.period-selector {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skills-usage-content {
|
|
|
|
|
flex: 1;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
max-width: 1120px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
width: 100%;
|
|
|
|
|
scrollbar-width: none;
|
|
|
|
|
-ms-overflow-style: none;
|
|
|
|
|
|
|
|
|
|
&::-webkit-scrollbar {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.overview-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 0.9fr);
|
|
|
|
|
gap: 16px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-card,
|
|
|
|
|
.usage-panel {
|
|
|
|
|
background: $bg-secondary;
|
|
|
|
|
border: 1px solid $border-color;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-card {
|
|
|
|
|
padding: 14px;
|
|
|
|
|
|
|
|
|
|
&.primary {
|
|
|
|
|
grid-column: span 2;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-label {
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-value {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.usage-panel {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
|
|
&.is-refreshing::after {
|
|
|
|
|
content: '';
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 10px;
|
|
|
|
|
right: 12px;
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: $accent-primary;
|
|
|
|
|
box-shadow: 0 0 0 4px $accent-muted;
|
|
|
|
|
animation: refresh-pulse 1s ease-in-out infinite;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes refresh-pulse {
|
|
|
|
|
0%, 100% {
|
|
|
|
|
opacity: 0.45;
|
|
|
|
|
transform: scale(0.8);
|
|
|
|
|
}
|
|
|
|
|
50% {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.inline-error {
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
color: $error;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
|
|
|
|
h3 {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skill-bar-chart {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 3px;
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skill-bar-col {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 3px;
|
|
|
|
|
position: relative;
|
|
|
|
|
outline: none;
|
|
|
|
|
|
|
|
|
|
&:focus-visible .skill-bar-track {
|
|
|
|
|
box-shadow: 0 0 0 2px $accent-muted;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skill-bar-track {
|
|
|
|
|
height: 180px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
background: $bg-card;
|
|
|
|
|
border-radius: 3px 3px 0 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skill-bar-fill {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 2px;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column-reverse;
|
|
|
|
|
border-radius: 3px 3px 0 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
transition: height 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skill-bar-segment {
|
|
|
|
|
min-height: 1px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.floating-tooltip {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 50px;
|
|
|
|
|
width: min(360px, calc(100% - 32px));
|
|
|
|
|
max-height: calc(100% - 70px);
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
background: $bg-secondary;
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
border: 1px solid $border-color;
|
|
|
|
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
|
|
|
|
padding: 8px 10px;
|
|
|
|
|
border-radius: $radius-sm;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
z-index: 20;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
|
|
|
|
&.align-left {
|
|
|
|
|
left: 16px;
|
|
|
|
|
right: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.align-right {
|
|
|
|
|
right: 16px;
|
|
|
|
|
left: auto;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tooltip-date {
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tooltip-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 8px minmax(0, 1fr) auto;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 7px;
|
|
|
|
|
color: $text-secondary;
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
strong {
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.total {
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
padding-top: 4px;
|
|
|
|
|
border-top: 1px solid $border-color;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tooltip-dot {
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bar-dates {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skill-color-dot {
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skills-table {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skills-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(180px, 1fr) 80px 80px minmax(120px, 160px) minmax(160px, 1fr);
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 10px 8px;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
color: $text-secondary;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
|
|
&:not(.table-head):hover {
|
|
|
|
|
background: $bg-card-hover;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.table-head {
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skill-name {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.share-cell {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.share-bar {
|
|
|
|
|
width: 54px;
|
|
|
|
|
height: 6px;
|
|
|
|
|
background: $bg-card;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
display: block;
|
|
|
|
|
height: 100%;
|
|
|
|
|
border-radius: inherit;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.last-used {
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skills-usage-state {
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 60px 0;
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
|
|
&.compact {
|
|
|
|
|
padding: 24px 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.error {
|
|
|
|
|
color: $error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: $breakpoint-mobile) {
|
|
|
|
|
.page-header {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skills-usage-toolbar {
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.overview-grid {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-grid {
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.skills-row {
|
|
|
|
|
grid-template-columns: minmax(140px, 1fr) repeat(3, 64px);
|
|
|
|
|
|
|
|
|
|
span:last-child {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|