improvement(wrapped): 全局概览改为年度日历热力图

- card_00_global_overview 输出 annualHeatmap(dailyCounts + highlights)

- 新增 AnnualCalendarHeatmap:横向滚动网格 + 气泡 tooltip + 高光日文案

- GlobalOverviewChart 从 Radar 重构为 Heatmap;Card00 slide 下微调间距

- MessageCharsChart 复用 msg-bubble 样式,统一气泡外观
This commit is contained in:
2977094657
2026-02-19 20:00:21 +08:00
parent 4c9260b781
commit 02bbf9d8e2
5 changed files with 966 additions and 293 deletions

View File

@@ -98,7 +98,9 @@
</div>
</template>
<GlobalOverviewChart :data="card.data || {}" />
<div :class="variant === 'slide' ? 'w-full -mt-2 sm:-mt-4' : 'w-full'">
<GlobalOverviewChart :data="card.data || {}" />
</div>
</WrappedCardShell>
</template>

View File

@@ -0,0 +1,417 @@
<template>
<div class="w-full">
<div v-if="weeks > 0" class="overflow-x-auto" data-wrapped-scroll-x>
<div class="w-max mx-auto" :style="{ '--cell': `${cellPx}px` }">
<!-- Month labels -->
<div
class="grid gap-[2px] text-[11px] text-[#00000066] mb-2"
:style="{ gridTemplateColumns: `36px repeat(${weeks}, var(--cell))` }"
>
<div></div>
<span
v-for="(m, idx) in monthLabels"
:key="idx"
class="wrapped-number whitespace-nowrap"
>
{{ m }}
</span>
</div>
<!-- Grid -->
<div
class="grid gap-[2px] items-stretch"
:style="{
gridTemplateColumns: `36px repeat(${weeks}, var(--cell))`,
gridTemplateRows: `repeat(7, var(--cell))`
}"
>
<div
v-for="(w, wi) in weekdayTicks"
:key="wi"
class="flex items-center wrapped-body text-[11px] text-[#00000066]"
:style="{ gridColumn: '1', gridRow: String(wi + 1) }"
>
{{ w }}
</div>
<div
v-for="(c, idx) in cells"
:key="idx"
class="heatmap-cell rounded-[2px] transition-transform duration-150 hover:scale-125 hover:z-10"
:style="{
backgroundColor: colorFor(c),
transformOrigin: originFor(c),
gridColumn: String((c.col ?? 0) + 2),
gridRow: String((c.row ?? 0) + 1)
}"
@mouseenter="showTooltip(c, $event)"
@mousemove="scheduleTooltipLayout"
@mouseleave="hideTooltip"
></div>
</div>
<div class="mt-4 flex items-center justify-between text-xs text-[#00000066] w-full">
<div class="flex items-center gap-2">
<span class="wrapped-body"></span>
<div class="flex items-center gap-[2px]">
<span
v-for="i in 6"
:key="i"
class="heatmap-legend-cell w-4 h-2 rounded-[2px]"
:style="{ backgroundColor: legendColor(i) }"
/>
</div>
<span class="wrapped-body"></span>
</div>
<div v-if="maxValue > 0" class="wrapped-number">最大 {{ maxValue }}</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="tooltipOpen && tooltipCell && tooltipCell.ymd"
ref="tooltipEl"
class="fixed z-[60] pointer-events-none"
:style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
role="tooltip"
>
<div class="wr-heatmap-tooltip">
<div class="flex justify-center mb-2">
<span class="wr-heatmap-tooltip__time wrapped-number">{{ tooltipCell.ymd }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-end">
<div class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r">
<div class="wrapped-body">{{ tooltipPrimaryText }}</div>
</div>
</div>
<div v-for="(line, i) in tooltipHighlightLines" :key="i" class="flex justify-start">
<div class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-white text-gray-800 bubble-tail-l">
<div class="wrapped-body">{{ line }}</div>
</div>
</div>
</div>
<div
class="wr-heatmap-tooltip__arrow"
:class="tooltipPlacement === 'bottom' ? 'wr-heatmap-tooltip__arrow--top' : 'wr-heatmap-tooltip__arrow--bottom'"
aria-hidden="true"
></div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { heatColor } from '~/utils/wrapped/heatmap'
const props = defineProps({
year: { type: Number, default: new Date().getFullYear() },
// 0-indexed day-of-year array; length should be 365/366
dailyCounts: { type: Array, default: () => [] },
days: { type: Number, default: 0 },
highlights: { type: Array, default: () => [] }
})
// Cell size of each day square (px). Tuned to fit Card00 slide width without truncation.
const cellPx = 15
const MARKER_ORDER = [
'sent_chars_max',
'received_chars_max',
'sent_messages_max',
'received_messages_max',
'added_friends_max',
'sticker_messages_max',
'emoji_chars_max'
]
const isLeapYear = (y) => {
const n = Number(y)
if (!Number.isFinite(n)) return false
return n % 4 === 0 && (n % 100 !== 0 || n % 400 === 0)
}
const daysInYear = computed(() => {
const d = Number(props.days || 0)
const arr = Array.isArray(props.dailyCounts) ? props.dailyCounts : []
if (d > 0) return d
if (arr.length > 0) return arr.length
return isLeapYear(props.year) ? 366 : 365
})
const counts = computed(() => {
const arr = Array.isArray(props.dailyCounts) ? props.dailyCounts : []
const out = []
for (let i = 0; i < daysInYear.value; i += 1) out.push(Number(arr[i] || 0))
return out
})
const highlightsMap = computed(() => {
const hs = Array.isArray(props.highlights) ? props.highlights : []
const map = new Map()
for (const raw of hs) {
const key = typeof raw?.key === 'string' ? raw.key : ''
const doyNum = Number(raw?.doy)
if (!key || !Number.isFinite(doyNum)) continue
const doy = Math.floor(doyNum)
if (doy < 0 || doy >= daysInYear.value) continue
const item = {
key,
label: typeof raw?.label === 'string' && raw.label.trim() ? raw.label.trim() : key,
valueLabel: typeof raw?.valueLabel === 'string' ? raw.valueLabel : ''
}
const arr = map.get(doy) || []
arr.push(item)
map.set(doy, arr)
}
// Sort markers per-day by a stable order to keep UI deterministic.
for (const [doy, arr] of map.entries()) {
arr.sort((a, b) => {
const ia = MARKER_ORDER.indexOf(a.key)
const ib = MARKER_ORDER.indexOf(b.key)
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
})
map.set(doy, arr)
}
return map
})
const maxValue = computed(() => {
let m = 0
for (const v of counts.value) {
const n = Number(v)
if (Number.isFinite(n) && n > m) m = n
}
return m
})
const jan1UtcMs = computed(() => Date.UTC(Number(props.year), 0, 1))
const startWeekday = computed(() => {
const d = new Date(jan1UtcMs.value)
const w = d.getUTCDay() // 0=Sun..6=Sat
return (w + 6) % 7 // 0=Mon..6=Sun
})
const weeks = computed(() => Math.ceil((daysInYear.value + startWeekday.value) / 7))
const weekdayTicks = computed(() => ['周一', '', '周三', '', '周五', '', '周日'])
const monthLabels = computed(() => {
const cols = weeks.value
const out = Array.from({ length: cols }, () => '')
for (let m = 0; m < 12; m += 1) {
const monthStart = Date.UTC(Number(props.year), m, 1)
const doy = Math.round((monthStart - jan1UtcMs.value) / 86400000)
const col = Math.floor((doy + startWeekday.value) / 7)
if (col >= 0 && col < out.length && !out[col]) out[col] = `${m + 1}`
}
return out
})
const cells = computed(() => {
const out = []
const cols = weeks.value
const leading = startWeekday.value
const totalCells = cols * 7
for (let i = 0; i < totalCells; i += 1) {
const col = Math.floor(i / 7)
const row = i % 7
const doy = i - leading
if (doy < 0 || doy >= daysInYear.value) {
out.push({
valid: false,
row,
col,
count: 0,
ymd: '',
highlights: []
})
continue
}
const d = new Date(Date.UTC(Number(props.year), 0, 1 + doy))
const y = d.getUTCFullYear()
const mo = String(d.getUTCMonth() + 1).padStart(2, '0')
const da = String(d.getUTCDate()).padStart(2, '0')
const ymd = `${y}-${mo}-${da}`
const highlights = highlightsMap.value.get(doy) || []
const normalizedHighlights = Array.isArray(highlights) ? highlights : []
out.push({
valid: true,
row,
col,
doy,
ymd,
count: Number(counts.value[doy] || 0),
highlights: normalizedHighlights
})
}
return out
})
const colorFor = (cell) => {
if (!cell || !cell.valid) return 'transparent'
return heatColor(cell.count, maxValue.value)
}
const tooltipOpen = ref(false)
const tooltipCell = ref(null)
const tooltipX = ref(0)
const tooltipY = ref(0)
const tooltipPlacement = ref('top') // 'top' | 'bottom'
const tooltipEl = ref(null)
const tooltipAnchorEl = ref(null)
let tooltipRaf = 0
const tooltipPrimaryText = computed(() => {
const c = tooltipCell.value
if (!c || !c.valid) return ''
const n = Number(c.count) || 0
if (n <= 0) return '这一天没有聊天消息'
return `这一天有 ${n} 条聊天消息`
})
const tooltipHighlightLines = computed(() => {
const c = tooltipCell.value
if (!c || !c.valid) return []
const hs = Array.isArray(c.highlights) ? c.highlights : []
const out = []
for (const h of hs) {
if (!h) continue
const label = String(h.label || h.key || '').trim()
if (!label) continue
const v = String(h.valueLabel || '').trim()
out.push(v ? `${label}${v}` : label)
}
return out
})
const updateTooltipLayout = () => {
if (!import.meta.client) return
const anchor = tooltipAnchorEl.value
const tip = tooltipEl.value
if (!anchor || !tip) return
const a = anchor.getBoundingClientRect()
const t = tip.getBoundingClientRect()
if (!t.width || !t.height) return
const gap = 10
const padding = 10
let left = a.left + a.width / 2 - t.width / 2
left = Math.min(window.innerWidth - padding - t.width, Math.max(padding, left))
let top = a.top - gap - t.height
let placement = 'top'
if (top < padding) {
top = a.bottom + gap
placement = 'bottom'
}
if (top + t.height > window.innerHeight - padding) {
top = window.innerHeight - padding - t.height
}
tooltipX.value = Math.round(left)
tooltipY.value = Math.round(top)
tooltipPlacement.value = placement
}
const scheduleTooltipLayout = () => {
if (!import.meta.client) return
if (!tooltipOpen.value) return
if (tooltipRaf) cancelAnimationFrame(tooltipRaf)
tooltipRaf = requestAnimationFrame(() => {
tooltipRaf = 0
updateTooltipLayout()
})
}
const showTooltip = async (cell, e) => {
if (!cell || !cell.valid || !cell.ymd) return
tooltipCell.value = cell
tooltipAnchorEl.value = e?.currentTarget || null
tooltipOpen.value = true
await nextTick()
updateTooltipLayout()
}
const hideTooltip = () => {
tooltipOpen.value = false
tooltipCell.value = null
tooltipAnchorEl.value = null
}
onMounted(() => {
if (!import.meta.client) return
window.addEventListener('resize', scheduleTooltipLayout)
})
onBeforeUnmount(() => {
if (!import.meta.client) return
window.removeEventListener('resize', scheduleTooltipLayout)
if (tooltipRaf) cancelAnimationFrame(tooltipRaf)
tooltipRaf = 0
})
const legendColor = (i) => {
const m = maxValue.value || 1
const t = i / 6
return heatColor(Math.max(1, t * m), m)
}
const originFor = (cell) => {
if (!cell) return 'center center'
const col = Number(cell.col || 0)
const row = Number(cell.row || 0)
const x = col === 0 ? 'left' : (col === weeks.value - 1 ? 'right' : 'center')
const y = row === 0 ? 'top' : (row === 6 ? 'bottom' : 'center')
return `${x} ${y}`
}
</script>
<style scoped>
.wr-heatmap-tooltip {
@apply relative w-[260px] max-w-[80vw] rounded-2xl border border-[#00000010] bg-[#F5F5F5]/95 backdrop-blur px-3 py-3 shadow-xl;
}
.wr-heatmap-tooltip__time {
@apply inline-flex items-center justify-center px-2 py-[2px] rounded-md border border-[#0000000a] bg-white/70 text-[10px] text-[#00000066];
}
.wr-heatmap-tooltip__arrow {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
}
.wr-heatmap-tooltip__arrow--bottom {
bottom: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid rgba(245, 245, 245, 0.95);
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.06));
}
.wr-heatmap-tooltip__arrow--top {
top: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid rgba(245, 245, 245, 0.95);
filter: drop-shadow(0 -1px 0 rgba(0, 0, 0, 0.06));
}
</style>

View File

@@ -1,271 +1,44 @@
<template>
<div class="w-full">
<div class="overview-card">
<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"
class="overview-grid-line"
stroke-width="1"
/>
<line
v-for="(p, idx) in axisPoints"
:key="idx"
:x1="cx"
:y1="cy"
:x2="p.x"
:y2="p.y"
class="overview-axis-line"
stroke-width="1"
/>
</g>
<!-- Data polygon -->
<polygon
:points="dataPolygonPoints"
class="overview-data-polygon"
stroke-width="2"
/>
<!-- Data nodes + tooltips -->
<g>
<circle
v-for="(p, idx) in dataPoints"
:key="idx"
:cx="p.x"
:cy="p.y"
r="4"
class="overview-data-node"
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"
class="overview-label"
>
{{ 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="overview-progress-bg">
<div class="overview-progress-fill" :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>
<AnnualCalendarHeatmap
:year="year"
:daily-counts="annualDailyCounts"
:days="daysInYear"
:highlights="annualHighlights"
/>
</div>
</template>
<script setup>
import AnnualCalendarHeatmap from '~/components/wrapped/visualizations/AnnualCalendarHeatmap.vue'
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 year = computed(() => {
const v = props.data?.annualHeatmap?.year ?? props.data?.year ?? new Date().getFullYear()
const y = Number(v)
return Number.isFinite(y) ? y : new Date().getFullYear()
})
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 daysInYear = computed(() => {
const d = Number(props.data?.annualHeatmap?.days || 0)
if (Number.isFinite(d) && d > 0) return d
const y = Number(year.value)
const isLeap = y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0)
return isLeap ? 366 : 365
})
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 annualDailyCounts = computed(() => {
const a = props.data?.annualHeatmap
const arr = a?.dailyCounts
return Array.isArray(arr) ? arr : []
})
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
const annualHighlights = computed(() => {
const a = props.data?.annualHeatmap
const hs = a?.highlights
return Array.isArray(hs) ? hs : []
})
</script>
<style>
/* ========== 基础样式 ========== */
.overview-card {
@apply rounded-2xl border border-[#00000010] bg-white/60 backdrop-blur p-4 sm:p-6;
}
.overview-grid-line {
stroke: rgba(0, 0, 0, 0.08);
}
.overview-axis-line {
stroke: rgba(0, 0, 0, 0.10);
}
.overview-data-polygon {
fill: rgba(7, 193, 96, 0.18);
stroke: rgba(7, 193, 96, 0.85);
}
.overview-data-node {
fill: #07C160;
stroke: white;
}
.overview-label {
fill: rgba(0, 0, 0, 0.70);
}
.overview-progress-bg {
@apply h-2 flex-1 rounded-full bg-[#0000000d] overflow-hidden;
}
.overview-progress-fill {
@apply h-full rounded-full bg-[#07C160];
}
</style>

View File

@@ -4,14 +4,14 @@
<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="flex items-start 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="px-3 py-2 text-sm max-w-[85%] relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-white text-gray-800 bubble-tail-l">
<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) }}
@@ -28,8 +28,8 @@
</div>
<!-- Sent (right) -->
<div class="flex items-end gap-2 justify-end">
<div class="bubble-right">
<div class="flex items-start gap-2 justify-end">
<div class="px-3 py-2 text-sm max-w-[85%] relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r">
<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) }}
@@ -321,40 +321,6 @@ const getLabelStyle = (code) => {
@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;