mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
feat(wrapped-ui): 新增年度总结页面与热力图卡片
- 新增 /wrapped PPT 风格滑动浏览(封面 + 卡片页) - 新增 Card#1 组件与 24×7 周-小时热力图可视化 - 首页新增年度总结入口;useApi 增加 getWrappedAnnual;补充 wrapped 背景纹理
This commit is contained in:
23
frontend/components/wrapped/cards/Card01CyberSchedule.vue
Normal file
23
frontend/components/wrapped/cards/Card01CyberSchedule.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative" :variant="variant">
|
||||
<template #badge>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs bg-[#07C160]/10 text-[#07C160] border border-[#07C160]/20">
|
||||
作息规律
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<WeekdayHourHeatmap
|
||||
:weekday-labels="card.data?.weekdayLabels"
|
||||
:hour-labels="card.data?.hourLabels"
|
||||
:matrix="card.data?.matrix"
|
||||
:total-messages="card.data?.totalMessages || 0"
|
||||
/>
|
||||
</WrappedCardShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
card: { type: Object, required: true },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
})
|
||||
</script>
|
||||
54
frontend/components/wrapped/shared/WrappedCardShell.vue
Normal file
54
frontend/components/wrapped/shared/WrappedCardShell.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div v-if="variant === 'panel'" class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden">
|
||||
<div class="px-6 py-5 border-b border-[#F3F3F3]">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold tracking-[0.22em] text-[#00000066]">
|
||||
CARD {{ String(cardId).padStart(2, '0') }}
|
||||
</div>
|
||||
<h2 class="mt-2 text-xl font-bold text-[#000000e6]">{{ title }}</h2>
|
||||
<p v-if="narrative" class="mt-2 text-sm text-[#7F7F7F]">
|
||||
{{ narrative }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="badge" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide 模式:单张卡片占据全页面,背景由外层(年度总结)统一控制 -->
|
||||
<section v-else class="relative h-full w-full overflow-hidden">
|
||||
<div class="relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12 flex flex-col">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold tracking-[0.22em] text-[#00000066]">
|
||||
CARD {{ String(cardId).padStart(2, '0') }}
|
||||
</div>
|
||||
<h2 class="mt-2 text-2xl sm:text-3xl font-bold text-[#000000e6]">{{ title }}</h2>
|
||||
<p v-if="narrative" class="mt-3 text-sm sm:text-base text-[#7F7F7F] max-w-2xl">
|
||||
{{ narrative }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="badge" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 sm:mt-8 flex-1 flex items-center">
|
||||
<div class="w-full">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
cardId: { type: Number, required: true },
|
||||
title: { type: String, required: true },
|
||||
narrative: { type: String, default: '' },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
})
|
||||
</script>
|
||||
84
frontend/components/wrapped/shared/WrappedControls.vue
Normal file
84
frontend/components/wrapped/shared/WrappedControls.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-2xl border border-[#EDEDED] p-5 sm:p-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-end sm:justify-between">
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||
<div v-if="showAccount">
|
||||
<div class="text-xs font-medium text-[#00000099] mb-1">账号</div>
|
||||
<select
|
||||
class="w-full sm:w-56 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160]"
|
||||
:disabled="accountsLoading || accounts.length === 0"
|
||||
:value="modelAccount"
|
||||
@change="$emit('update:account', $event.target.value || '')"
|
||||
>
|
||||
<option value="" :disabled="accounts.length > 0">默认(自动选择)</option>
|
||||
<option v-for="a in accounts" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-medium text-[#00000099] mb-1">年份</div>
|
||||
<select
|
||||
class="w-full sm:w-40 px-3 py-2 rounded-lg border border-[#EDEDED] bg-white text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160]"
|
||||
:value="String(modelYear)"
|
||||
@change="$emit('update:year', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="y in yearOptions" :key="y" :value="String(y)">{{ y }}年</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-[#EDEDED] text-[#07C160] focus:ring-[#07C160]"
|
||||
:checked="modelRefresh"
|
||||
@change="$emit('update:refresh', !!$event.target.checked)"
|
||||
/>
|
||||
<span class="text-sm text-[#7F7F7F]">强制刷新(忽略缓存)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-[#07C160] text-white text-sm font-medium hover:bg-[#06AD56] disabled:opacity-60 disabled:cursor-not-allowed transition"
|
||||
:disabled="loading"
|
||||
@click="$emit('reload')"
|
||||
>
|
||||
<span v-if="!loading">生成报告</span>
|
||||
<span v-else>生成中...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="accountsLoading" class="text-xs text-[#7F7F7F]">
|
||||
{{ showAccount ? '正在加载账号列表...' : '正在检查数据...' }}
|
||||
</div>
|
||||
<div v-else-if="accounts.length === 0" class="text-xs text-[#B37800]">
|
||||
{{ showAccount ? '未发现已解密账号(请先解密数据库)。' : '未发现可用数据(请先解密数据库)。' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
accounts: { type: Array, default: () => [] },
|
||||
accountsLoading: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
modelYear: { type: Number, required: true },
|
||||
modelAccount: { type: String, default: '' },
|
||||
modelRefresh: { type: Boolean, default: false },
|
||||
showAccount: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
defineEmits(['update:year', 'update:account', 'update:refresh', 'reload'])
|
||||
|
||||
const yearOptions = computed(() => {
|
||||
const now = new Date().getFullYear()
|
||||
const years = []
|
||||
for (let i = 0; i < 8; i++) years.push(now - i)
|
||||
// Ensure selected year is present
|
||||
if (props.modelYear && !years.includes(props.modelYear)) years.unshift(props.modelYear)
|
||||
return years
|
||||
})
|
||||
</script>
|
||||
22
frontend/components/wrapped/shared/WrappedDeckBackground.vue
Normal file
22
frontend/components/wrapped/shared/WrappedDeckBackground.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<!-- Shared backdrop for all "Wrapped" slides (keeps cover + cards visually consistent). -->
|
||||
<div class="absolute inset-0 pointer-events-none select-none z-0" aria-hidden="true">
|
||||
<!-- Soft color blobs (brand + warm highlights) -->
|
||||
<div class="absolute -top-24 -left-24 w-80 h-80 bg-[#07C160] opacity-[0.08] rounded-full blur-3xl"></div>
|
||||
<div class="absolute -top-24 -right-24 w-96 h-96 bg-[#F2AA00] opacity-[0.06] rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-28 left-40 w-[28rem] h-[28rem] bg-[#10AEEF] opacity-[0.06] rounded-full blur-3xl"></div>
|
||||
|
||||
<!-- Subtle grid for "data / report" vibe -->
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(rgba(7,193,96,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(7,193,96,0.05)_1px,transparent_1px)] bg-[size:52px_52px] opacity-[0.28]"
|
||||
></div>
|
||||
|
||||
<!-- Grain/noise: adds texture without changing the base color -->
|
||||
<div class="absolute inset-0 wrapped-noise opacity-[0.06]"></div>
|
||||
|
||||
<!-- Gentle vignette so typography stays readable on textured bg -->
|
||||
<div class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white/50 to-transparent"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-white/40 to-transparent"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
94
frontend/components/wrapped/shared/WrappedHero.vue
Normal file
94
frontend/components/wrapped/shared/WrappedHero.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div :class="rootClass">
|
||||
<div v-if="variant !== 'slide'" class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute -top-24 -left-24 w-80 h-80 bg-[#07C160] opacity-[0.08] rounded-full blur-3xl"></div>
|
||||
<div class="absolute -top-20 -right-20 w-96 h-96 bg-[#F2AA00] opacity-[0.07] rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-24 left-40 w-96 h-96 bg-[#10AEEF] opacity-[0.07] rounded-full blur-3xl"></div>
|
||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(7,193,96,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(7,193,96,0.05)_1px,transparent_1px)] bg-[size:52px_52px] opacity-[0.35]"></div>
|
||||
</div>
|
||||
|
||||
<div :class="innerClass">
|
||||
<template v-if="variant === 'slide'">
|
||||
<div class="h-full flex flex-col justify-between">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="text-xs font-semibold tracking-[0.28em] text-[#00000080]">
|
||||
WECHAT · WRAPPED
|
||||
</div>
|
||||
<div class="text-xs font-semibold tracking-[0.22em] text-[#00000055]">
|
||||
年度回望
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 sm:mt-14">
|
||||
<h1 class="text-4xl sm:text-6xl font-black tracking-tight text-[#000000e6] leading-[1.05]">
|
||||
把这一年的聊天
|
||||
<span class="block mt-3 text-[#07C160]">
|
||||
轻轻翻一翻
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div class="mt-7 sm:mt-9 max-w-2xl">
|
||||
<p class="text-base sm:text-lg text-[#00000080] leading-relaxed">
|
||||
有些问候写在对话框里,有些陪伴藏在深夜里。
|
||||
我们不读取内容,只把时间整理成几张卡片,让你温柔地回望这一年。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pb-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-[#00000066]">
|
||||
<!-- Intentionally left blank (avoid "feature bullet list" tone on the cover). -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="text-xs font-semibold tracking-[0.28em] text-[#00000080]">
|
||||
WECHAT · WRAPPED
|
||||
</div>
|
||||
<!-- 年份放到右上角(分享视图不包含账号信息) -->
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs bg-[#00000008] text-[#00000099] border border-[#00000010]"
|
||||
>
|
||||
{{ yearText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-7 flex flex-col gap-2">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-[#000000e6] leading-tight">
|
||||
聊天年度总结
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base text-[#7F7F7F] max-w-2xl">
|
||||
从时间里回看你的聊天节奏。第一张卡:年度赛博作息表(24H × 7Days)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Badges intentionally removed: keep the hero more human and less "feature list". -->
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
year: { type: Number, required: true },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
})
|
||||
|
||||
const yearText = computed(() => `${props.year}年`)
|
||||
|
||||
const rootClass = computed(() => {
|
||||
const base = 'relative overflow-hidden'
|
||||
return props.variant === 'slide'
|
||||
? `${base} h-full w-full`
|
||||
: `${base} rounded-2xl border border-[#EDEDED] bg-white`
|
||||
})
|
||||
|
||||
const innerClass = computed(() => (
|
||||
props.variant === 'slide'
|
||||
? 'relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12'
|
||||
: 'relative px-6 py-7 sm:px-8 sm:py-9'
|
||||
))
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm text-[#7F7F7F]">
|
||||
共 <span class="text-[#07C160] font-semibold">{{ totalMessages }}</span> 条消息
|
||||
</div>
|
||||
<div class="text-xs text-[#00000066]">24H × 7Days</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto" data-wrapped-scroll-x>
|
||||
<div class="min-w-[720px]">
|
||||
<div class="grid gap-[3px] [grid-template-columns:24px_1fr] text-[11px] text-[#00000066] mb-2">
|
||||
<div></div>
|
||||
<div class="grid gap-[3px] [grid-template-columns:repeat(24,minmax(0,1fr))]">
|
||||
<span
|
||||
v-for="(s, idx) in timeLabels"
|
||||
:key="idx"
|
||||
class="col-span-4 font-mono"
|
||||
>
|
||||
{{ s }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-[3px] [grid-template-columns:24px_1fr] items-stretch">
|
||||
<div class="grid gap-[3px] [grid-template-rows:repeat(7,minmax(0,1fr))] text-[11px] text-[#00000066]">
|
||||
<div v-for="(w, wi) in weekdayLabels" :key="wi" class="flex items-center">
|
||||
{{ w }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-[3px] [grid-template-columns:repeat(24,minmax(0,1fr))]">
|
||||
<template v-for="(row, wi) in matrixSafe" :key="wi">
|
||||
<div
|
||||
v-for="(v, hi) in row"
|
||||
:key="`${wi}-${hi}`"
|
||||
class="aspect-square min-h-[10px] rounded-[2px] transition-transform duration-150 hover:scale-125 hover:z-10 relative"
|
||||
:style="{ backgroundColor: colorFor(v) }"
|
||||
:title="tooltipFor(wi, hi, v)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between text-xs text-[#00000066]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>低</span>
|
||||
<div class="flex items-center gap-[2px]">
|
||||
<span v-for="i in 6" :key="i" class="w-4 h-2 rounded-[2px]" :style="{ backgroundColor: legendColor(i) }"></span>
|
||||
</div>
|
||||
<span>高</span>
|
||||
</div>
|
||||
<div v-if="maxValue > 0">最大 {{ maxValue }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { heatColor, maxInMatrix, formatHourRange } from '~/utils/wrapped/heatmap'
|
||||
|
||||
const props = defineProps({
|
||||
weekdayLabels: { type: Array, default: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
|
||||
hourLabels: { type: Array, default: () => Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')) },
|
||||
matrix: { type: Array, default: () => [] },
|
||||
totalMessages: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const matrixSafe = computed(() => {
|
||||
// Expect 7x24, but keep defensive to avoid UI crashes.
|
||||
const m = Array.isArray(props.matrix) ? props.matrix : []
|
||||
const out = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const row = Array.isArray(m[i]) ? m[i] : []
|
||||
const r = []
|
||||
for (let h = 0; h < 24; h++) r.push(Number(row[h] || 0))
|
||||
out.push(r)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const maxValue = computed(() => maxInMatrix(matrixSafe.value))
|
||||
|
||||
const timeLabels = computed(() => {
|
||||
// Show every 4 hours to reduce clutter, inspired by EchoTrace.
|
||||
const labels = []
|
||||
for (let i = 0; i < 24; i += 4) labels.push(props.hourLabels[i] ?? String(i).padStart(2, '0'))
|
||||
return labels
|
||||
})
|
||||
|
||||
const colorFor = (v) => heatColor(v, maxValue.value)
|
||||
|
||||
const tooltipFor = (weekdayIndex, hour, v) => {
|
||||
const w = props.weekdayLabels?.[weekdayIndex] ?? `周${weekdayIndex + 1}`
|
||||
const hr = formatHourRange(hour)
|
||||
const n = Number(v) || 0
|
||||
return `${w} ${hr}:${n} 条`
|
||||
}
|
||||
|
||||
const legendColor = (i) => {
|
||||
const t = i / 6
|
||||
return heatColor(Math.max(1, t * (maxValue.value || 1)), maxValue.value || 1)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user