feat(wrapped-ui): 年度总结页支持懒加载与复古模式,新增概览/字数卡片

- wrapped 页面改为:先拉 meta/年份列表,再按页请求单张卡片,首屏更快
- 新增 Card#0 全局概览页(含图表)
- 新增 Card#2 消息字数页(含键盘敲击统计与图表)
- 新增复古模式:像素字体资源 + CRT Overlay,支持一键开关
- 调整 shared 组件、types/useApi,更新前端依赖与 lock
This commit is contained in:
2977094657
2026-01-31 14:54:43 +08:00
parent 77a60bde70
commit 645dc1cff1
42 changed files with 2901 additions and 172 deletions

View File

@@ -0,0 +1,236 @@
<template>
<div class="w-full">
<div class="rounded-2xl border border-[#00000010] bg-white/60 backdrop-blur p-4 sm:p-6">
<div class="flex items-center justify-between gap-4">
<div class="wrapped-label text-xs text-[#00000066]">年度聊天画像</div>
<div class="wrapped-body text-xs text-[#00000066]">Radar</div>
</div>
<div class="mt-4 grid gap-6 sm:grid-cols-[280px_1fr] items-center">
<div class="w-full max-w-[320px] mx-auto">
<svg viewBox="0 0 220 220" class="w-full h-auto select-none">
<!-- Grid -->
<g>
<polygon
v-for="i in rings"
:key="i"
:points="gridPolygonPoints(i / rings)"
fill="none"
stroke="rgba(0,0,0,0.08)"
stroke-width="1"
/>
<line
v-for="(p, idx) in axisPoints"
:key="idx"
:x1="cx"
:y1="cy"
:x2="p.x"
:y2="p.y"
stroke="rgba(0,0,0,0.10)"
stroke-width="1"
/>
</g>
<!-- Data polygon -->
<polygon
:points="dataPolygonPoints"
fill="rgba(7,193,96,0.18)"
stroke="rgba(7,193,96,0.85)"
stroke-width="2"
/>
<!-- Data nodes + tooltips -->
<g>
<circle
v-for="(p, idx) in dataPoints"
:key="idx"
:cx="p.x"
:cy="p.y"
r="4"
fill="#07C160"
stroke="white"
stroke-width="1.5"
>
<title>{{ p.title }}</title>
</circle>
</g>
<!-- Labels -->
<g>
<text
v-for="(l, idx) in labels"
:key="idx"
:x="l.x"
:y="l.y"
:text-anchor="l.anchor"
dominant-baseline="middle"
font-size="11"
fill="rgba(0,0,0,0.70)"
>
{{ l.label }}
</text>
</g>
</svg>
</div>
<div class="grid gap-3">
<div
v-for="m in metrics"
:key="m.key"
class="flex items-center justify-between gap-4"
>
<div class="wrapped-body text-sm text-[#00000099]">{{ m.name }}</div>
<div class="flex items-center gap-3 min-w-[160px]">
<div class="h-2 flex-1 rounded-full bg-[#0000000d] overflow-hidden">
<div class="h-full rounded-full bg-[#07C160]" :style="{ width: Math.round(m.norm * 100) + '%' }" />
</div>
<div
:class="[
'wrapped-number text-sm w-[74px] text-right',
m.display === '—' ? 'text-[#00000055]' : 'text-[#07C160] font-semibold'
]"
>
{{ m.display }}
</div>
</div>
</div>
<!-- Note removed per UI requirement (keep layout compact). -->
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
data: { type: Object, default: () => ({}) }
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const formatFloat = (n, digits = 1) => {
const v = Number(n)
if (!Number.isFinite(v)) return '0'
return v.toFixed(digits)
}
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0))
const logNorm = (v, maxLog) => {
const n = Number(v) || 0
const ml = Number(maxLog) || 1
if (n <= 0) return 0
return clamp01(Math.log10(1 + n) / ml)
}
const totalMessages = computed(() => Number(props.data?.totalMessages || 0))
const activeDays = computed(() => Number(props.data?.activeDays || 0))
const messagesPerDay = computed(() => Number(props.data?.messagesPerDay || 0))
const topContactMessages = computed(() => Number(props.data?.topContact?.messages || 0))
const topGroupMessages = computed(() => Number(props.data?.topGroup?.messages || 0))
const topKindPct = computed(() => {
const ratio = Number(props.data?.topKind?.ratio || 0)
if (!Number.isFinite(ratio) || ratio <= 0) return 0
return Math.max(0, Math.min(100, Math.round(ratio * 100)))
})
const metrics = computed(() => [
{
key: 'totalMessages',
name: '发送消息',
label: '发送',
display: `${formatInt(totalMessages.value)}`,
norm: logNorm(totalMessages.value, 6)
},
{
key: 'activeDays',
name: '发消息天数',
label: '天数',
display: `${formatInt(activeDays.value)}/365`,
norm: clamp01(activeDays.value / 365)
},
{
key: 'messagesPerDay',
name: '日均发送',
label: '日均',
display: `${formatFloat(messagesPerDay.value, 1)}`,
norm: logNorm(messagesPerDay.value, 3)
},
{
key: 'topContactMessages',
name: '发得最多的人',
label: '常发',
display: topContactMessages.value > 0 ? `${formatInt(topContactMessages.value)}` : '—',
norm: logNorm(topContactMessages.value, 5)
},
{
key: 'topGroupMessages',
name: '发言最多的群',
label: '发言',
display: topGroupMessages.value > 0 ? `${formatInt(topGroupMessages.value)}` : '—',
norm: logNorm(topGroupMessages.value, 5)
},
{
key: 'topKindPct',
name: '最常用表达',
label: '表达',
display: topKindPct.value > 0 ? `${topKindPct.value}%` : '—',
norm: clamp01(topKindPct.value / 100)
}
])
const rings = 5
const cx = 110
const cy = 110
const radius = 74
const axisPoints = computed(() => {
const n = metrics.value.length
return metrics.value.map((_, idx) => {
const a = (Math.PI * 2 * idx) / n - Math.PI / 2
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a), a }
})
})
const gridPolygonPoints = (t) => {
const pts = axisPoints.value.map((p) => `${cx + (p.x - cx) * t},${cy + (p.y - cy) * t}`)
return pts.join(' ')
}
const dataPoints = computed(() => {
const pts = []
const n = metrics.value.length
for (let i = 0; i < n; i++) {
const m = metrics.value[i]
const a = (Math.PI * 2 * i) / n - Math.PI / 2
const r = radius * clamp01(m.norm)
const x = cx + r * Math.cos(a)
const y = cy + r * Math.sin(a)
pts.push({ x, y, title: `${m.name}${m.display}` })
}
return pts
})
const dataPolygonPoints = computed(() => dataPoints.value.map((p) => `${p.x},${p.y}`).join(' '))
const labels = computed(() => {
const out = []
const n = metrics.value.length
for (let i = 0; i < n; i++) {
const m = metrics.value[i]
const a = (Math.PI * 2 * i) / n - Math.PI / 2
const r = radius + 18
const x = cx + r * Math.cos(a)
const y = cy + r * Math.sin(a)
const cos = Math.cos(a)
let anchor = 'middle'
if (cos > 0.35) anchor = 'start'
else if (cos < -0.35) anchor = 'end'
out.push({ x, y, label: m.label, anchor })
}
return out
})
</script>

View File

@@ -0,0 +1,416 @@
<template>
<div class="w-full">
<!-- 聊天气泡区域 -->
<div class="rounded-2xl border border-[#00000010] bg-[#F5F5F5] p-3 sm:p-4">
<div class="flex flex-col gap-3">
<!-- Received (left) -->
<div class="flex items-end gap-2">
<div class="avatar-box bg-white">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="#07C160" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3h10a2 2 0 0 1 2 2v14H6V5a2 2 0 0 1 2-2z" />
<path d="M6 7H4a2 2 0 0 0-2 2v10h4" />
</svg>
</div>
<div class="bubble-left">
<div class="wrapped-label text-xs text-[#00000066]">你收到的字</div>
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6]">
{{ formatInt(receivedChars) }}
</div>
<div class="mt-1 wrapped-body text-xs text-[#7F7F7F]">
<template v-if="receivedA4Text">{{ receivedA4Text }}</template>
<template v-else-if="receivedChars > 0">这么多字都是别人认真对你的回应</template>
<template v-else>今年还没有收到文字消息</template>
</div>
<div v-if="receivedA4 && receivedA4.a4 && receivedA4.a4.sheets > 0" class="mt-1 text-[10px] text-[#00000055] wrapped-label">
{{ formatInt(receivedA4.a4.sheets) }} A4 · 堆叠高度约 {{ receivedA4.a4.heightText }}
</div>
</div>
</div>
<!-- Sent (right) -->
<div class="flex items-end gap-2 justify-end">
<div class="bubble-right">
<div class="wrapped-label text-xs text-[#00000080] text-right">你发送的字</div>
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6] text-right">
{{ formatInt(sentChars) }}
</div>
<div class="mt-1 wrapped-body text-xs text-[#00000099] text-right">
<template v-if="sentBookText">{{ sentBookText }}</template>
<template v-else-if="sentChars > 0">这么多字是你打出的每一次认真</template>
<template v-else>今年还没有文字消息</template>
</div>
</div>
<div class="avatar-box bg-[#95EC69]">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="#1f2d1f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</div>
</div>
</div>
</div>
<!-- 键盘磨损可视化 -->
<div class="keyboard-outer">
<div class="keyboard-inner">
<!-- 顶部信息 -->
<div class="keyboard-header">
<div class="keyboard-dots">
<span class="dot dot-red"></span>
<span class="dot dot-yellow"></span>
<span class="dot dot-green"></span>
</div>
<div class="keyboard-stats">{{ formatInt(totalKeyHits) }} KEYSTROKES</div>
</div>
<!-- 键盘主体 -->
<div class="keyboard-body">
<div v-for="(row, ri) in keyboardRows" :key="ri" class="kb-row">
<div
v-for="key in row"
:key="key.code + key.label"
class="kb-key"
:class="[`kb-w-${key.w || 1}`, { 'kb-space': key.isSpace }]"
:style="getKeyStyle(key.code)"
>
<div class="kb-key-top" :style="getKeyTopStyle(key.code)">
<span v-if="key.sub" class="kb-sub">{{ key.sub }}</span>
<span v-if="key.label" class="kb-label" :class="{ 'kb-label-sm': key.isFunc }" :style="getLabelStyle(key.code)">{{ key.label }}</span>
<div v-if="key.isSpace" class="kb-space-bar"></div>
</div>
</div>
</div>
</div>
<!-- 底部品牌 -->
<div class="keyboard-brand">WeChat Mechanical KB</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
data: { type: Object, default: () => ({}) }
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const sentChars = computed(() => Number(props.data?.sentChars || 0))
const receivedChars = computed(() => Number(props.data?.receivedChars || 0))
const sentBookText = computed(() => props.data?.sentBook?.text || '')
const receivedA4 = computed(() => props.data?.receivedA4 || null)
const receivedA4Text = computed(() => receivedA4.value?.text || '')
// 从后端获取键盘统计数据
const keyboardData = computed(() => props.data?.keyboard || null)
// 总敲击次数(优先使用后端数据)
const totalKeyHits = computed(() => {
// 注意totalKeyHits 可能为 0比如今年没发出文字消息不能用 truthy 判断。
const backend = Number(keyboardData.value?.totalKeyHits)
if (Number.isFinite(backend)) return backend
// 回退:粗略估算(仅基于"你发送的字",假设拼音输入 + 一定比例空格)
const letterHits = Math.round(sentChars.value * 2.8)
return letterHits + Math.round(letterHits * 0.15)
})
// 获取各键的敲击次数(优先使用后端精确数据)
const keyHitsMap = computed(() => {
const backendHits = keyboardData.value?.keyHits
const backendSpace = Number(keyboardData.value?.spaceHits || 0)
if (backendHits && typeof backendHits === 'object') {
// 后端把空格次数单独放在 spaceHits这里合并进 keyHitsMap 以便空格键也能显示磨损。
return backendSpace > 0 ? { ...backendHits, space: backendSpace } : backendHits
}
// 回退:使用默认频率估算(仅基于"你发送的字"
const defaultFreq = {
a: 0.121, i: 0.118, n: 0.098, e: 0.089, u: 0.082, g: 0.072, h: 0.065,
o: 0.052, z: 0.048, s: 0.042, x: 0.038, y: 0.036, d: 0.032, l: 0.028,
j: 0.026, b: 0.022, c: 0.020, w: 0.018, m: 0.016, f: 0.014, t: 0.012,
r: 0.010, p: 0.009, k: 0.007, q: 0.005, v: 0.001,
}
const letterHits = Math.round(sentChars.value * 2.8)
const spaceHits = Math.round(letterHits * 0.15)
const result = {}
for (const [k, freq] of Object.entries(defaultFreq)) {
result[k] = Math.round(freq * letterHits)
}
if (spaceHits > 0) result.space = spaceHits
return result
})
// 计算磨损程度0-1基于实际敲击次数
const getWear = (code) => {
const k = code.toLowerCase()
const hits = Number(keyHitsMap.value[k] || 0)
if (!Number.isFinite(hits) || hits <= 0) return 0
const values = Object.values(keyHitsMap.value).map((v) => Number(v) || 0)
const maxHits = Math.max(...values, 1)
// 小数量级键(如数字/标点)容易"看起来没变化",用对数缩放增强可视化差异。
const ratio = Math.log1p(hits) / Math.log1p(maxHits)
return Math.min(1, Math.pow(ratio, 1.6))
}
// 键盘布局
const keyboardRows = [
[
{ code: '`', label: '`', sub: '~' }, { code: '1', label: '1', sub: '!' },
{ code: '2', label: '2', sub: '@' }, { code: '3', label: '3', sub: '#' },
{ code: '4', label: '4', sub: '$' }, { code: '5', label: '5', sub: '%' },
{ code: '6', label: '6', sub: '^' }, { code: '7', label: '7', sub: '&' },
{ code: '8', label: '8', sub: '*' }, { code: '9', label: '9', sub: '(' },
{ code: '0', label: '0', sub: ')' }, { code: '-', label: '-', sub: '_' },
{ code: '=', label: '=', sub: '+' }, { code: 'backspace', label: '⌫', w: 2, isFunc: true },
],
[
{ code: 'tab', label: 'Tab', w: 1.5, isFunc: true },
{ code: 'q', label: 'Q' }, { code: 'w', label: 'W' }, { code: 'e', label: 'E' },
{ code: 'r', label: 'R' }, { code: 't', label: 'T' }, { code: 'y', label: 'Y' },
{ code: 'u', label: 'U' }, { code: 'i', label: 'I' }, { code: 'o', label: 'O' },
{ code: 'p', label: 'P' }, { code: '[', label: '[', sub: '{' },
{ code: ']', label: ']', sub: '}' }, { code: '\\', label: '\\', sub: '|', w: 1.5 },
],
[
{ code: 'caps', label: 'Caps', w: 1.75, isFunc: true },
{ code: 'a', label: 'A' }, { code: 's', label: 'S' }, { code: 'd', label: 'D' },
{ code: 'f', label: 'F' }, { code: 'g', label: 'G' }, { code: 'h', label: 'H' },
{ code: 'j', label: 'J' }, { code: 'k', label: 'K' }, { code: 'l', label: 'L' },
{ code: ';', label: ';', sub: ':' }, { code: "'", label: "'", sub: '"' },
{ code: 'enter', label: 'Enter', w: 2.25, isFunc: true },
],
[
{ code: 'shift', label: 'Shift', w: 2.25, isFunc: true },
{ code: 'z', label: 'Z' }, { code: 'x', label: 'X' }, { code: 'c', label: 'C' },
{ code: 'v', label: 'V' }, { code: 'b', label: 'B' }, { code: 'n', label: 'N' },
{ code: 'm', label: 'M' }, { code: ',', label: ',', sub: '<' },
{ code: '.', label: '.', sub: '>' }, { code: '/', label: '/', sub: '?' },
{ code: 'shift', label: 'Shift', w: 2.75, isFunc: true },
],
[
{ code: 'ctrl', label: 'Ctrl', w: 1.25, isFunc: true },
{ code: 'alt', label: 'Alt', w: 1.25, isFunc: true },
{ code: 'space', label: '', w: 6.25, isSpace: true },
{ code: 'alt', label: 'Alt', w: 1.25, isFunc: true },
{ code: 'ctrl', label: 'Ctrl', w: 1.25, isFunc: true },
],
]
// 键帽样式
const getKeyStyle = (code) => {
const w = getWear(code)
// Light theme: clean keys are bright; wear makes keys warmer and slightly darker.
const baseL = 94 - w * 18
const sat = 8 + w * 20
return {
'--key-bg': `hsl(40, ${sat}%, ${baseL}%)`,
'--key-bg-dark': `hsl(40, ${sat}%, ${baseL - 6}%)`,
'--key-border': `hsl(40, ${Math.max(0, sat - 2)}%, ${baseL - 18}%)`,
}
}
const getKeyTopStyle = (code) => {
const w = getWear(code)
const highlight = 0.55 - w * 0.35
const depth = 0.12 + w * 0.06
return {
background: `linear-gradient(180deg, var(--key-bg) 0%, var(--key-bg-dark) 100%)`,
// 用连续函数替代"阈值切换",避免出现"没到阈值就看不出变化"的感觉。
boxShadow: `inset 0 1px 0 rgba(255,255,255,${highlight}), inset 0 -1px 2px rgba(0,0,0,${depth})`,
}
}
const getLabelStyle = (code) => {
const w = getWear(code)
return {
opacity: 1 - w * 0.85,
filter: `blur(${w * 1.8}px)`,
}
}
</script>
<style scoped>
/* 头像 */
.avatar-box {
@apply w-8 h-8 rounded-lg border border-[#00000010] flex items-center justify-center flex-shrink-0;
}
/* 气泡 - 左侧 */
.bubble-left {
@apply relative max-w-[85%] bg-white shadow-sm rounded-xl px-3 py-2;
}
.bubble-left::before {
content: '';
position: absolute;
left: -6px;
bottom: 8px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 6px solid #fff;
filter: drop-shadow(-1px 0 0 rgba(0,0,0,0.05));
}
/* 气泡 - 右侧 */
.bubble-right {
@apply relative max-w-[85%] bg-[#95EC69] shadow-sm rounded-xl px-3 py-2;
}
.bubble-right::after {
content: '';
position: absolute;
right: -6px;
bottom: 8px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 6px solid #95EC69;
filter: drop-shadow(1px 0 0 rgba(0,0,0,0.05));
}
/* 键盘外框 */
.keyboard-outer {
@apply mt-3 rounded-2xl p-1;
background: linear-gradient(145deg, #ffffff, #e8e8e8);
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.keyboard-inner {
@apply rounded-xl p-3;
background: linear-gradient(180deg, #fbfbfb, #f0f0f0);
border: 1px solid rgba(0,0,0,0.06);
}
.keyboard-header {
@apply flex items-center justify-between mb-2 px-1;
}
.keyboard-dots {
@apply flex items-center gap-1.5;
}
.dot {
@apply w-2 h-2 rounded-full;
}
.dot-red { background: #ff5f57; }
.dot-yellow { background: #febc2e; }
.dot-green { background: #28c840; }
.keyboard-stats {
@apply text-[10px] text-[#00000066] tracking-wider;
font-family: ui-monospace, monospace;
}
.keyboard-body {
@apply rounded-lg p-2;
background: #f4f4f5;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.12);
}
.kb-row {
@apply flex justify-center gap-[3px] mb-[3px];
}
.kb-row:last-child {
@apply mb-0;
}
/* 键帽 */
.kb-key {
--unit: 22px;
height: 26px;
width: var(--unit);
position: relative;
}
@media (min-width: 640px) {
.kb-key {
--unit: 28px;
height: 32px;
}
}
/* 宽度变体 */
.kb-w-1 { width: var(--unit); }
.kb-w-1\.25 { width: calc(var(--unit) * 1.25 + 3px * 0.25); }
.kb-w-1\.5 { width: calc(var(--unit) * 1.5 + 3px * 0.5); }
.kb-w-1\.75 { width: calc(var(--unit) * 1.75 + 3px * 0.75); }
.kb-w-2 { width: calc(var(--unit) * 2 + 3px); }
.kb-w-2\.25 { width: calc(var(--unit) * 2.25 + 3px * 1.25); }
.kb-w-2\.75 { width: calc(var(--unit) * 2.75 + 3px * 1.75); }
.kb-w-6\.25 { width: calc(var(--unit) * 6.25 + 3px * 5.25); }
.kb-key::before {
content: '';
position: absolute;
inset: 0;
top: 2px;
background: #d4d4d8;
border-radius: 4px;
}
.kb-key-top {
position: absolute;
inset: 0;
bottom: 2px;
border-radius: 4px;
border: 1px solid var(--key-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.kb-sub {
font-size: 7px;
line-height: 1;
color: #666;
margin-bottom: 1px;
}
@media (min-width: 640px) {
.kb-sub {
font-size: 8px;
}
}
.kb-label {
font-size: 10px;
font-weight: 500;
color: #262626;
line-height: 1;
text-shadow: 0 1px 0 rgba(255,255,255,0.6);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
}
@media (min-width: 640px) {
.kb-label {
font-size: 11px;
}
}
.kb-label-sm {
font-size: 7px !important;
font-weight: 400;
}
@media (min-width: 640px) {
.kb-label-sm {
font-size: 8px !important;
}
}
.kb-space-bar {
width: 50%;
height: 3px;
background: rgba(0,0,0,0.12);
border-radius: 2px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.18);
}
.keyboard-brand {
@apply mt-2 text-center text-[8px] text-[#00000025] tracking-[0.15em] uppercase;
}
</style>

View File

@@ -1,10 +1,10 @@
<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 class="wrapped-body text-sm text-[#7F7F7F]">
<span class="wrapped-number text-[#07C160] font-semibold">{{ totalMessages }}</span> 条消息
</div>
<div class="text-xs text-[#00000066]">24H × 7Days</div>
<div class="wrapped-label text-xs text-[#00000066]">24H x 7Days</div>
</div>
<div class="mt-4 overflow-x-auto" data-wrapped-scroll-x>
@@ -15,7 +15,7 @@
<span
v-for="(s, idx) in timeLabels"
:key="idx"
class="col-span-4 font-mono"
class="col-span-4 wrapped-number"
>
{{ s }}
</span>
@@ -24,7 +24,7 @@
<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">
<div v-for="(w, wi) in weekdayLabels" :key="wi" class="flex items-center wrapped-body">
{{ w }}
</div>
</div>
@@ -35,7 +35,7 @@
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) }"
:style="{ backgroundColor: colorFor(v), transformOrigin: originFor(wi, hi) }"
:title="tooltipFor(wi, hi, v)"
/>
</template>
@@ -46,13 +46,13 @@
<div class="mt-4 flex items-center justify-between text-xs text-[#00000066]">
<div class="flex items-center gap-2">
<span></span>
<span class="wrapped-body"></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>
<span class="wrapped-body"></span>
</div>
<div v-if="maxValue > 0">最大 {{ maxValue }}</div>
<div v-if="maxValue > 0" class="wrapped-number">最大 {{ maxValue }}</div>
</div>
</div>
</template>
@@ -102,4 +102,12 @@ const legendColor = (i) => {
const t = i / 6
return heatColor(Math.max(1, t * (maxValue.value || 1)), maxValue.value || 1)
}
const originFor = (weekdayIndex, hour) => {
// Avoid hover scaling pushing scrollWidth/scrollHeight and showing scrollbars:
// keep the "outer" edges anchored on the first/last row/col.
const x = hour === 0 ? 'left' : (hour === 23 ? 'right' : 'center')
const y = weekdayIndex === 0 ? 'top' : (weekdayIndex === 6 ? 'bottom' : 'center')
return `${x} ${y}`
}
</script>