|
| 1 | +<template> |
| 2 | + <WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative || ''" :variant="variant" :wide="true"> |
| 3 | + <div class="w-full"> |
| 4 | + <div class="flex flex-wrap justify-center gap-x-3 gap-y-4 px-3 py-2"> |
| 5 | + <article |
| 6 | + v-for="item in months" |
| 7 | + :key="`month-${item.month}`" |
| 8 | + class="relative flex-shrink-0 monthly-polaroid" |
| 9 | + :class="item.winner ? '' : 'monthly-polaroid--empty'" |
| 10 | + :style="monthCardStyle(item.month)" |
| 11 | + > |
| 12 | + <!-- 有获胜者 --> |
| 13 | + <template v-if="item.winner"> |
| 14 | + <div class="flex items-start gap-1.5 pt-0.5 px-0.5"> |
| 15 | + <!-- 头像 --> |
| 16 | + <div class="polaroid-photo flex-shrink-0"> |
| 17 | + <img |
| 18 | + v-if="winnerAvatar(item) && avatarOk[item.winner.username] !== false" |
| 19 | + :src="winnerAvatar(item)" |
| 20 | + class="w-full h-full object-cover" |
| 21 | + alt="avatar" |
| 22 | + @error="avatarOk[item.winner.username] = false" |
| 23 | + /> |
| 24 | + <span v-else class="wrapped-number text-xl select-none" style="color:var(--accent)"> |
| 25 | + {{ avatarFallback(item.winner.displayName) }} |
| 26 | + </span> |
| 27 | + </div> |
| 28 | + <!-- 右列:姓名 / 月份 / 综合分 / 4 维度 --> |
| 29 | + <div class="flex-1 min-w-0 pt-0.5 flex flex-col justify-between" style="height:70px"> |
| 30 | + <div> |
| 31 | + <div class="flex items-center justify-between gap-1 min-w-0"> |
| 32 | + <div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight" :title="item.winner.displayName"> |
| 33 | + {{ item.winner.displayName }} |
| 34 | + </div> |
| 35 | + <!-- 月份徽章 --> |
| 36 | + <div class="month-badge wrapped-number text-[8px] font-bold flex-shrink-0" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)' }"> |
| 37 | + {{ item.month }}月 |
| 38 | + </div> |
| 39 | + </div> |
| 40 | + <div class="mt-0.5 wrapped-number text-[9px] font-semibold" :style="{ color: 'var(--accent)' }"> |
| 41 | + 综合分 {{ formatScore(item.winner.score100) }} |
| 42 | + </div> |
| 43 | + </div> |
| 44 | + <!-- 4 维度 2×2 --> |
| 45 | + <div class="grid grid-cols-2 gap-x-2 gap-y-1"> |
| 46 | + <div v-for="metric in metricRows(item)" :key="metric.key" class="min-w-0"> |
| 47 | + <div class="flex items-center justify-between wrapped-label text-[8px] text-[#00000066]"> |
| 48 | + <span class="truncate">{{ metric.label }}</span> |
| 49 | + <span v-if="metric.pct !== 100" class="wrapped-number flex-shrink-0 ml-0.5">{{ metric.pct }}</span> |
| 50 | + </div> |
| 51 | + <div class="mt-0.5 h-1 rounded-full bg-[#0000000D] overflow-hidden"> |
| 52 | + <div class="h-full rounded-full" :style="{ width: `${metric.pct}%`, backgroundColor: 'var(--accent)', opacity: '0.75' }" /> |
| 53 | + </div> |
| 54 | + </div> |
| 55 | + </div> |
| 56 | + </div> |
| 57 | + </div> |
| 58 | + <!-- 统计行 --> |
| 59 | + <div class="polaroid-caption"> |
| 60 | + <div class="wrapped-body text-[9px] text-[#00000055] leading-snug"> |
| 61 | + 共 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.totalMessages) }}</span> 条 · |
| 62 | + 互动 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.interaction) }}</span> 次 · |
| 63 | + 活跃 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.activeDays) }}</span> 天 |
| 64 | + </div> |
| 65 | + </div> |
| 66 | + </template> |
| 67 | + |
| 68 | + <!-- 无数据:空白拍立得 --> |
| 69 | + <template v-else> |
| 70 | + <div class="polaroid-photo-empty flex-shrink-0 mx-auto"> |
| 71 | + <span class="text-lg select-none" style="color:var(--accent);opacity:0.25">〜</span> |
| 72 | + </div> |
| 73 | + <div class="polaroid-caption"> |
| 74 | + <div class="flex items-center justify-between gap-1"> |
| 75 | + <div class="wrapped-label text-[9px] text-[#00000044]">本月静悄悄</div> |
| 76 | + <div class="month-badge wrapped-number text-[8px]" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)', opacity: '0.5' }"> |
| 77 | + {{ item.month }}月 |
| 78 | + </div> |
| 79 | + </div> |
| 80 | + </div> |
| 81 | + </template> |
| 82 | + </article> |
| 83 | + </div> |
| 84 | + </div> |
| 85 | + </WrappedCardShell> |
| 86 | +</template> |
| 87 | + |
| 88 | +<script setup> |
| 89 | +import { computed, reactive, watch } from 'vue' |
| 90 | +
|
| 91 | +const props = defineProps({ |
| 92 | + card: { type: Object, required: true }, |
| 93 | + variant: { type: String, default: 'panel' } |
| 94 | +}) |
| 95 | +
|
| 96 | +const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }) |
| 97 | +const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0)) |
| 98 | +const formatScore = (n) => { |
| 99 | + const x = Number(n) |
| 100 | + if (!Number.isFinite(x)) return '0.0' |
| 101 | + return x.toFixed(1) |
| 102 | +} |
| 103 | +const clampPct = (n) => Math.max(0, Math.min(100, Math.round(Number(n || 0) * 100))) |
| 104 | +
|
| 105 | +const mediaBase = process.client ? 'http://localhost:8000' : '' |
| 106 | +const resolveMediaUrl = (value) => { |
| 107 | + const raw = String(value || '').trim() |
| 108 | + if (!raw) return '' |
| 109 | + if (/^https?:\/\//i.test(raw)) { |
| 110 | + try { |
| 111 | + const host = new URL(raw).hostname.toLowerCase() |
| 112 | + if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) { |
| 113 | + return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}` |
| 114 | + } |
| 115 | + } catch {} |
| 116 | + return raw |
| 117 | + } |
| 118 | + return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}` |
| 119 | +} |
| 120 | +
|
| 121 | +const avatarFallback = (name) => { |
| 122 | + const s = String(name || '').trim() |
| 123 | + return s ? s[0] : '?' |
| 124 | +} |
| 125 | +
|
| 126 | +const months = computed(() => { |
| 127 | + const raw = Array.isArray(props.card?.data?.months) ? props.card.data.months : [] |
| 128 | + const byMonth = new Map() |
| 129 | + for (const x of raw) { |
| 130 | + const m = Number(x?.month) |
| 131 | + if (Number.isFinite(m) && m >= 1 && m <= 12) byMonth.set(m, x) |
| 132 | + } |
| 133 | + const out = [] |
| 134 | + for (let m = 1; m <= 12; m += 1) { |
| 135 | + out.push(byMonth.get(m) || { month: m, winner: null, metrics: null, raw: null }) |
| 136 | + } |
| 137 | + return out |
| 138 | +}) |
| 139 | +
|
| 140 | +const avatarOk = reactive({}) |
| 141 | +watch( |
| 142 | + months, |
| 143 | + () => { for (const key of Object.keys(avatarOk)) delete avatarOk[key] }, |
| 144 | + { deep: true, immediate: true } |
| 145 | +) |
| 146 | +
|
| 147 | +const winnerAvatar = (item) => resolveMediaUrl(item?.winner?.avatarUrl) |
| 148 | +
|
| 149 | +const metricRows = (item) => { |
| 150 | + const m = item?.metrics || {} |
| 151 | + return [ |
| 152 | + { key: 'interaction', label: '互动', pct: clampPct(m.interactionScore) }, |
| 153 | + { key: 'speed', label: '速度', pct: clampPct(m.speedScore) }, |
| 154 | + { key: 'continuity', label: '连续', pct: clampPct(m.continuityScore) }, |
| 155 | + { key: 'coverage', label: '覆盖', pct: clampPct(m.coverageScore) } |
| 156 | + ] |
| 157 | +} |
| 158 | +
|
| 159 | +// 12 个月各自独立 accent 色,驱动胶带、徽章、进度条 |
| 160 | +const accents = [ |
| 161 | + '#C96A4E', // 1月 砖红 |
| 162 | + '#5B82C4', // 2月 矢车菊蓝 |
| 163 | + '#4EA87A', // 3月 薄荷绿 |
| 164 | + '#C4953A', // 4月 琥珀金 |
| 165 | + '#8B65B5', // 5月 薰衣草紫 |
| 166 | + '#3A9FB5', // 6月 孔雀蓝 |
| 167 | + '#C45F7A', // 7月 玫瑰粉 |
| 168 | + '#3E7FC4', // 8月 天蓝 |
| 169 | + '#6AA86A', // 9月 苔绿 |
| 170 | + '#C47A3A', // 10月 暖橙 |
| 171 | + '#9B6BAF', // 11月 丁香紫 |
| 172 | + '#4A8EB5', // 12月 冬湖蓝 |
| 173 | +] |
| 174 | +
|
| 175 | +const monthCardStyle = (month) => { |
| 176 | + const idx = Math.max(0, Math.min(11, Number(month || 1) - 1)) |
| 177 | + const rotations = [-9, 5, -4, 11, -2, 8, -7, 3, -10, 6, -3, 9] |
| 178 | + const yOffsets = [5, -4, 6, -5, 3, -7, 3, -4, 7, -3, 5, -4] |
| 179 | + const widths = [172, 165, 178, 168, 175, 163, 172, 167, 165, 178, 165, 170] |
| 180 | + return { |
| 181 | + transform: `rotate(${rotations[idx]}deg) translateY(${yOffsets[idx]}px)`, |
| 182 | + width: `${widths[idx]}px`, |
| 183 | + '--delay': `${idx * 0.07}s`, |
| 184 | + '--accent': accents[idx], |
| 185 | + } |
| 186 | +} |
| 187 | +</script> |
| 188 | +
|
| 189 | +<style scoped> |
| 190 | +/* ── 拍立得卡片基础 ── */ |
| 191 | +.monthly-polaroid { |
| 192 | + background: #FFFDF7; /* 暖奶油底色 */ |
| 193 | + padding: 4px 4px 0; |
| 194 | + border-radius: 3px; |
| 195 | + box-shadow: |
| 196 | + 0 1px 1px rgba(0,0,0,0.06), |
| 197 | + 0 4px 12px rgba(0,0,0,0.10), |
| 198 | + 0 12px 28px rgba(0,0,0,0.08); |
| 199 | + animation: cardAppear 0.4s ease-out both; |
| 200 | + animation-delay: var(--delay, 0s); |
| 201 | +} |
| 202 | +
|
| 203 | +@keyframes cardAppear { |
| 204 | + from { opacity: 0; } |
| 205 | + to { opacity: 1; } |
| 206 | +} |
| 207 | +
|
| 208 | +/* 空月卡片底色更浅 */ |
| 209 | +.monthly-polaroid--empty { |
| 210 | + background: #F7F5F0; |
| 211 | +} |
| 212 | +
|
| 213 | +/* ── 彩色胶带条 ── */ |
| 214 | +.monthly-polaroid::before { |
| 215 | + content: ''; |
| 216 | + position: absolute; |
| 217 | + top: -7px; |
| 218 | + left: 50%; |
| 219 | + width: 38px; |
| 220 | + height: 14px; |
| 221 | + transform: translateX(-50%) rotate(-1deg); |
| 222 | + border-radius: 2px; |
| 223 | + background: var(--accent, #c8a060); |
| 224 | + opacity: 0.55; |
| 225 | + box-shadow: 0 1px 3px rgba(0,0,0,0.12); |
| 226 | + z-index: 1; |
| 227 | +} |
| 228 | +
|
| 229 | +/* ── 头像区域 ── */ |
| 230 | +.polaroid-photo { |
| 231 | + width: 70px; |
| 232 | + height: 70px; |
| 233 | + background: #e0ddd8; |
| 234 | + overflow: hidden; |
| 235 | + display: flex; |
| 236 | + align-items: center; |
| 237 | + justify-content: center; |
| 238 | + border-radius: 4px; /* 照片圆角,更自然 */ |
| 239 | +} |
| 240 | +
|
| 241 | +/* ── 空月占位图 ── */ |
| 242 | +.polaroid-photo-empty { |
| 243 | + width: 70px; |
| 244 | + height: 44px; |
| 245 | + background: #E8E5DF; |
| 246 | + border-radius: 4px; |
| 247 | + display: flex; |
| 248 | + align-items: center; |
| 249 | + justify-content: center; |
| 250 | + margin: 4px auto 0; |
| 251 | +} |
| 252 | +
|
| 253 | +/* ── 月份小徽章 ── */ |
| 254 | +.month-badge { |
| 255 | + border: 1px solid; |
| 256 | + border-radius: 3px; |
| 257 | + padding: 0 3px; |
| 258 | + line-height: 1.6; |
| 259 | +} |
| 260 | +
|
| 261 | +/* ── 底部信息条 ── */ |
| 262 | +.polaroid-caption { |
| 263 | + padding: 5px 5px 6px; |
| 264 | + border-top: 1px solid rgba(0,0,0,0.04); /* 细分隔线,区分照片与文字区 */ |
| 265 | + margin-top: 4px; |
| 266 | +} |
| 267 | +</style> |
0 commit comments