feat: 优化身份卡片样式

This commit is contained in:
digua
2026-03-27 00:04:24 +08:00
committed by digua
parent 792cb0e1ee
commit d1d817718f
4 changed files with 355 additions and 100 deletions
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
(e: 'openIncrementalImport'): void
(e: 'openSessionIndex'): void
(e: 'openMessageExport'): void
}>()
</script>
<template>
<div class="flex flex-col gap-2">
<span class="mb-1 px-1 text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">
{{ t('analysis.overview.tools') }}
</span>
<button
class="group flex w-full items-center justify-between rounded-lg border border-gray-200 bg-white px-3.5 py-2.5 text-sm font-medium text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.02)] transition-all hover:border-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:border-white/10 dark:bg-[#161616] dark:text-gray-300 dark:hover:border-white/20 dark:hover:bg-[#1f1f1f] dark:hover:text-white"
@click="emit('openIncrementalImport')"
>
<div class="flex items-center gap-2.5">
<UIcon
name="i-heroicons-plus-circle"
class="h-4 w-4 text-gray-400 transition-colors group-hover:text-pink-500 dark:text-gray-500"
/>
{{ t('analysis.tooltip.incrementalImport') }}
</div>
<UIcon
name="i-heroicons-arrow-right-circle"
class="h-4 w-4 text-gray-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-gray-600"
/>
</button>
<button
class="group flex w-full items-center justify-between rounded-lg border border-gray-200 bg-white px-3.5 py-2.5 text-sm font-medium text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.02)] transition-all hover:border-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:border-white/10 dark:bg-[#161616] dark:text-gray-300 dark:hover:border-white/20 dark:hover:bg-[#1f1f1f] dark:hover:text-white"
@click="emit('openSessionIndex')"
>
<div class="flex items-center gap-2.5">
<UIcon
name="i-heroicons-clock"
class="h-4 w-4 text-gray-400 transition-colors group-hover:text-blue-500 dark:text-gray-500"
/>
{{ t('analysis.tooltip.sessionIndex') }}
</div>
<UIcon
name="i-heroicons-arrow-right-circle"
class="h-4 w-4 text-gray-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-gray-600"
/>
</button>
<button
class="group flex w-full items-center justify-between rounded-lg border border-gray-200 bg-white px-3.5 py-2.5 text-sm font-medium text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.02)] transition-all hover:border-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:border-white/10 dark:bg-[#161616] dark:text-gray-300 dark:hover:border-white/20 dark:hover:bg-[#1f1f1f] dark:hover:text-white"
@click="emit('openMessageExport')"
>
<div class="flex items-center gap-2.5">
<UIcon
name="i-heroicons-document-arrow-down"
class="h-4 w-4 text-gray-400 transition-colors group-hover:text-green-500 dark:text-gray-500"
/>
{{ t('analysis.messageExport.title') }}
</div>
<UIcon
name="i-heroicons-arrow-right-circle"
class="h-4 w-4 text-gray-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-gray-600"
/>
</button>
</div>
</template>
@@ -1,81 +1,305 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@vueuse/core'
import * as echarts from 'echarts/core'
import { HeatmapChart } from 'echarts/charts'
import { CalendarComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { EChartsOption } from 'echarts'
import type { AnalysisSession } from '@/types/base'
import type { DailyActivity } from '@/types/analysis'
import { formatDateRange } from '@/utils'
const { t } = useI18n()
echarts.use([HeatmapChart, CalendarComponent, TooltipComponent, VisualMapComponent, CanvasRenderer])
const { t, locale } = useI18n()
const isDark = useDark()
const props = defineProps<{
session: AnalysisSession
dailyActivity: DailyActivity[]
totalDurationDays: number
totalDailyAvgMessages: number
timeRange: { start: number; end: number } | null
}>()
// 聊天记录起止时间(完整范围)
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
const fullTimeRangeText = computed(() => {
if (!props.timeRange) return ''
return formatDateRange(props.timeRange.start, props.timeRange.end, 'YYYY/MM/DD')
})
// 滚动 12 个月日期范围(与 GitHub 贡献图一致)
const calendarRange = computed(() => {
const today = new Date()
const yearAgo = new Date(today)
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
yearAgo.setDate(yearAgo.getDate() + 1)
const fmt = (d: Date) => d.toISOString().slice(0, 10)
return [fmt(yearAgo), fmt(today)]
})
// 转换 DailyActivity 为 ECharts 日历数据
const chartData = computed(() => {
const [startStr, endStr] = calendarRange.value
const startDate = new Date(startStr + 'T00:00:00Z')
const endDate = new Date(endStr + 'T00:00:00Z')
const dict = new Map<string, number>()
props.dailyActivity
.filter((d) => d.date >= startStr && d.date <= endStr)
.forEach((d) => dict.set(d.date, d.messageCount))
const res: any[] = []
const curr = new Date(startDate)
while (curr <= endDate) {
const dStr = curr.toISOString().slice(0, 10)
const val = dict.get(dStr) || 0
res.push({
value: [dStr, val],
itemStyle: val === 0 ? { color: emptyColor.value } : undefined,
})
curr.setUTCDate(curr.getUTCDate() + 1)
}
return res
})
const maxValue = computed(() => {
if (props.dailyActivity.length === 0) return 10
return Math.max(...props.dailyActivity.map((d) => d.messageCount), 1)
})
const themeColors = {
light: ['#fce4ec', '#f8a4b8', '#f06292', '#e91e63'],
dark: ['#3d1f24', '#6b2f3a', '#a34557', '#ee4567'],
}
// GitHub 风格:标准的空白格底色
const emptyColor = computed(() => (isDark.value ? '#161b22' : '#ebedf0'))
const chartOption = computed<EChartsOption>(() => ({
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const date = params.value[0]
const value = params.value[1]
return `${date}<br/>${t('views.message.calendarTooltipMessages')}: ${value}`
},
},
visualMap: {
min: 1,
max: maxValue.value,
calculable: false,
orient: 'horizontal',
left: 'center',
bottom: 0,
itemWidth: 10,
itemHeight: 80,
text: [`${maxValue.value}`, '1'],
inRange: {
color: isDark.value ? themeColors.dark : themeColors.light,
},
textStyle: {
color: isDark.value ? '#8b949e' : '#6b7280',
fontSize: 10,
},
show: true,
},
calendar: {
top: 20,
left: 30,
cellSize: [13, 13],
range: calendarRange.value,
itemStyle: {
borderWidth: 0,
color: 'transparent',
},
yearLabel: { show: false },
monthLabel: {
show: true,
color: isDark.value ? '#8b949e' : '#6b7280',
fontSize: 10,
nameMap: [
t('common.month.jan'),
t('common.month.feb'),
t('common.month.mar'),
t('common.month.apr'),
t('common.month.may'),
t('common.month.jun'),
t('common.month.jul'),
t('common.month.aug'),
t('common.month.sep'),
t('common.month.oct'),
t('common.month.nov'),
t('common.month.dec'),
],
},
dayLabel: {
show: true,
firstDay: 1,
color: isDark.value ? '#8b949e' : '#9ca3af',
fontSize: 10,
nameMap: [
t('common.weekday.sun'),
t('common.weekday.mon'),
t('common.weekday.tue'),
t('common.weekday.wed'),
t('common.weekday.thu'),
t('common.weekday.fri'),
t('common.weekday.sat'),
],
},
splitLine: { show: false },
},
series: [
{
type: 'heatmap',
coordinateSystem: 'calendar',
data: chartData.value,
itemStyle: {
borderRadius: 3,
borderWidth: 2,
borderColor: isDark.value ? '#0f0f0f' : '#ffffff',
},
},
],
}))
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
chartInstance.setOption(chartOption.value)
}
function updateChart() {
if (!chartInstance) return
chartInstance.setOption(chartOption.value, { notMerge: true })
}
function handleResize() {
chartInstance?.resize()
}
watch([() => props.dailyActivity, locale], () => updateChart())
watch(isDark, () => {
chartInstance?.dispose()
initChart()
})
onMounted(() => {
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div
class="relative overflow-hidden rounded-3xl bg-gradient-to-br from-pink-500 to-pink-600 p-8 shadow-sm ring-1 ring-pink-600 dark:bg-gray-800/50 dark:from-transparent dark:to-transparent dark:ring-gray-800"
class="relative overflow-hidden rounded-[24px] border border-gray-200/60 bg-white p-8 shadow-sm dark:border-white/5 dark:bg-[#0f0f0f]"
>
<div class="relative flex gap-8">
<div class="flex-1">
<div>
<div class="flex items-center gap-3">
<h2 class="text-3xl font-black tracking-tight text-white">{{ session.name }}</h2>
<span
class="rounded-full bg-white px-3 py-1 text-xs font-medium text-pink-600 dark:bg-pink-500/10 dark:text-pink-400"
>
{{ session.platform.toUpperCase() }}
</span>
<!-- 左侧身份信息 + 日历 -->
<div class="min-w-0 flex-1 flex flex-col">
<!-- 上方身份信息 + 统计数据 -->
<div class="flex flex-wrap items-center justify-between gap-8 pb-4">
<!-- 身份信息 -->
<div>
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
{{ session.name }}
</h2>
<div
class="flex items-center gap-1.5 rounded-full bg-pink-50 px-2.5 py-1 text-xs font-semibold text-pink-600 ring-1 ring-inset ring-pink-500/20 dark:bg-pink-500/10 dark:text-pink-400 dark:ring-pink-500/20"
>
<span class="h-1.5 w-1.5 rounded-full bg-pink-500"></span>
<span>{{ session.platform.toUpperCase() }}</span>
</div>
</div>
<div class="mt-4 flex flex-col gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-2">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
<UIcon v-if="session.type === 'group'" name="i-heroicons-user-group" class="h-3.5 w-3.5" />
<UIcon v-else name="i-heroicons-user" class="h-3.5 w-3.5" />
</div>
<span class="truncate">
{{
session.type === 'private'
? t('analysis.overview.identity.privateChat')
: t('analysis.overview.identity.groupChat')
}}
· {{ t('analysis.overview.identity.analysisReport') }}
</span>
</div>
<div v-if="fullTimeRangeText" class="flex items-center gap-2">
<div class="flex h-6 w-6 shrink-0 items-center justify-center">
<UIcon name="i-heroicons-calendar" class="h-4 w-4 opacity-70" />
</div>
<span class="truncate font-mono text-xs opacity-90">{{ fullTimeRangeText }}</span>
</div>
</div>
</div>
<!-- 紧凑统计数据 (贴靠右侧) -->
<div class="flex shrink-0 gap-6">
<div class="flex flex-col gap-1 text-center">
<span class="text-2xl font-black font-mono tracking-tight text-gray-900 dark:text-white">
{{ session.messageCount.toLocaleString() }}
</span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('analysis.overview.identity.totalMessages') }}
</span>
</div>
<div class="flex flex-col gap-1 text-center">
<span class="text-2xl font-black font-mono tracking-tight text-gray-900 dark:text-white">
{{ totalDurationDays.toLocaleString() }}
</span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('analysis.overview.identity.durationDays') }}
</span>
</div>
<div class="flex flex-col gap-1 text-center">
<span class="text-2xl font-black font-mono tracking-tight text-gray-900 dark:text-white">
{{ totalDailyAvgMessages.toLocaleString() }}
</span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('analysis.overview.identity.dailyAvgMessages') }}
</span>
</div>
</div>
<p class="mt-2 text-lg font-medium text-pink-100 dark:text-gray-400">
{{
session.type === 'private'
? t('analysis.overview.identity.privateChat')
: t('analysis.overview.identity.groupChat')
}}
·
<span class="opacity-80">{{ t('analysis.overview.identity.analysisReport') }}</span>
</p>
<!-- 聊天记录起止时间 -->
<p v-if="fullTimeRangeText" class="mt-2 text-sm font-medium text-pink-100/90 dark:text-gray-400">
{{ fullTimeRangeText }}
</p>
</div>
<div class="mt-8 grid grid-cols-3 gap-6">
<div class="rounded-2xl bg-white/10 px-6 py-4 dark:bg-gray-800">
<p class="text-3xl font-black tracking-tight text-white">
{{ session.messageCount.toLocaleString() }}
</p>
<p class="mt-1 text-sm font-medium text-pink-100 dark:text-gray-400">
{{ t('analysis.overview.identity.totalMessages') }}
</p>
<!-- 热力图区域 -->
<div class="mt-8 pt-2">
<div class="flex items-center justify-between mb-2">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">
Activity Heatmap
</span>
</div>
<div class="rounded-2xl bg-white/10 px-6 py-4 dark:bg-gray-800">
<p class="text-3xl font-black tracking-tight text-white">{{ totalDurationDays }}</p>
<p class="mt-1 text-sm font-medium text-pink-100 dark:text-gray-400">
{{ t('analysis.overview.identity.durationDays') }}
</p>
</div>
<div class="rounded-2xl bg-white/10 px-6 py-4 dark:bg-gray-800">
<p class="text-3xl font-black tracking-tight text-white">{{ totalDailyAvgMessages }}</p>
<p class="mt-1 text-sm font-medium text-pink-100 dark:text-gray-400">
{{ t('analysis.overview.identity.dailyAvgMessages') }}
</p>
<div class="overflow-x-auto overflow-y-hidden scrollbar-hide py-1">
<div ref="chartRef" class="h-[140px] min-w-[700px] lg:w-full" />
</div>
</div>
</div>
<!-- 右侧工具区插槽 -->
<div v-if="$slots.tools" class="flex flex-none flex-col justify-end">
<div
v-if="$slots.tools"
class="flex flex-none flex-col justify-end w-56 border-l border-gray-100 pl-8 dark:border-white/5"
>
<slot name="tools" />
</div>
</div>
@@ -11,6 +11,7 @@ import { useOverviewStatistics } from '@/composables/analysis/useOverviewStatist
import { useDailyTrend } from '@/composables/analysis/useDailyTrend'
import OverviewStatCards from '@/components/analysis/Overview/OverviewStatCards.vue'
import OverviewIdentityCard from '@/components/analysis/Overview/OverviewIdentityCard.vue'
import OverviewActionTools from '@/components/analysis/Overview/OverviewActionTools.vue'
import DailyTrendCard from '@/components/analysis/Overview/DailyTrendCard.vue'
const { t } = useI18n()
@@ -108,41 +109,21 @@ watch(
</script>
<template>
<div class="main-content space-y-6 p-6">
<div class="main-content mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
<!-- 群聊身份卡 -->
<OverviewIdentityCard
:session="session"
:daily-activity="dailyActivity"
:total-duration-days="totalDurationDays"
:total-daily-avg-messages="totalDailyAvgMessages"
:time-range="timeRange"
>
<template #tools>
<div class="flex flex-col gap-2">
<span class="mb-0.5 text-xs font-semibold tracking-wide text-white/60 dark:text-gray-500">
{{ t('analysis.overview.tools') }}
</span>
<button
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/20 dark:bg-gray-700 dark:hover:bg-gray-600"
@click="emit('openIncrementalImport')"
>
<UIcon name="i-heroicons-plus-circle" class="h-4 w-4 shrink-0" />
{{ t('analysis.tooltip.incrementalImport') }}
</button>
<button
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/20 dark:bg-gray-700 dark:hover:bg-gray-600"
@click="emit('openSessionIndex')"
>
<UIcon name="i-heroicons-clock" class="h-4 w-4 shrink-0" />
{{ t('analysis.tooltip.sessionIndex') }}
</button>
<button
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/20 dark:bg-gray-700 dark:hover:bg-gray-600"
@click="emit('openMessageExport')"
>
<UIcon name="i-heroicons-document-arrow-down" class="h-4 w-4 shrink-0" />
{{ t('analysis.messageExport.title') }}
</button>
</div>
<OverviewActionTools
@open-incremental-import="emit('openIncrementalImport')"
@open-session-index="emit('openSessionIndex')"
@open-message-export="emit('openMessageExport')"
/>
</template>
</OverviewIdentityCard>
@@ -11,6 +11,7 @@ import { useOverviewStatistics } from '@/composables/analysis/useOverviewStatist
import { useDailyTrend } from '@/composables/analysis/useDailyTrend'
import OverviewStatCards from '@/components/analysis/Overview/OverviewStatCards.vue'
import OverviewIdentityCard from '@/components/analysis/Overview/OverviewIdentityCard.vue'
import OverviewActionTools from '@/components/analysis/Overview/OverviewActionTools.vue'
import DailyTrendCard from '@/components/analysis/Overview/DailyTrendCard.vue'
const { t } = useI18n()
@@ -124,41 +125,21 @@ watch(
</script>
<template>
<div class="main-content space-y-6 p-6">
<div class="main-content mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
<!-- 私聊身份卡 -->
<OverviewIdentityCard
:session="session"
:daily-activity="dailyActivity"
:total-duration-days="totalDurationDays"
:total-daily-avg-messages="totalDailyAvgMessages"
:time-range="timeRange"
>
<template #tools>
<div class="flex flex-col gap-2">
<span class="mb-0.5 text-xs font-semibold tracking-wide text-white/60 dark:text-gray-500">
{{ t('analysis.overview.tools') }}
</span>
<button
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/20 dark:bg-gray-700 dark:hover:bg-gray-600"
@click="emit('openIncrementalImport')"
>
<UIcon name="i-heroicons-plus-circle" class="h-4 w-4 shrink-0" />
{{ t('analysis.tooltip.incrementalImport') }}
</button>
<button
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/20 dark:bg-gray-700 dark:hover:bg-gray-600"
@click="emit('openSessionIndex')"
>
<UIcon name="i-heroicons-clock" class="h-4 w-4 shrink-0" />
{{ t('analysis.tooltip.sessionIndex') }}
</button>
<button
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/20 dark:bg-gray-700 dark:hover:bg-gray-600"
@click="emit('openMessageExport')"
>
<UIcon name="i-heroicons-document-arrow-down" class="h-4 w-4 shrink-0" />
{{ t('analysis.messageExport.title') }}
</button>
</div>
<OverviewActionTools
@open-incremental-import="emit('openIncrementalImport')"
@open-session-index="emit('openSessionIndex')"
@open-message-export="emit('openMessageExport')"
/>
</template>
</OverviewIdentityCard>