diff --git a/frontend/assets/css/tailwind.css b/frontend/assets/css/tailwind.css index 6fb94bf..cb6674f 100644 --- a/frontend/assets/css/tailwind.css +++ b/frontend/assets/css/tailwind.css @@ -110,6 +110,14 @@ @apply hover:transform hover:scale-[1.02] transition-all duration-300; } + /* Wrapped (年度总结) 背景纹理 */ + .wrapped-noise { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAiIGhlaWdodD0iMTYwIj4KICA8ZmlsdGVyIGlkPSJuIj4KICAgIDxmZVR1cmJ1bGVuY2UgdHlwZT0iZnJhY3RhbE5vaXNlIiBiYXNlRnJlcXVlbmN5PSIwLjgiIG51bU9jdGF2ZXM9IjQiIHN0aXRjaFRpbGVzPSJzdGl0Y2giLz4KICAgIDxmZUNvbG9yTWF0cml4IHR5cGU9InNhdHVyYXRlIiB2YWx1ZXM9IjAiLz4KICA8L2ZpbHRlcj4KICA8cmVjdCB3aWR0aD0iMTYwIiBoZWlnaHQ9IjE2MCIgZmlsdGVyPSJ1cmwoI24pIiBvcGFjaXR5PSIwLjQ1Ii8+Cjwvc3ZnPg=="); + background-repeat: repeat; + background-size: 320px 320px; + mix-blend-mode: multiply; + } + /* 输入框样式 */ .input { @apply w-full px-4 py-3 bg-[#f7f8fa] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-[#07c160] focus:bg-white focus:border-[#07c160] transition-all duration-200; diff --git a/frontend/components/wrapped/cards/Card01CyberSchedule.vue b/frontend/components/wrapped/cards/Card01CyberSchedule.vue new file mode 100644 index 0000000..a45435f --- /dev/null +++ b/frontend/components/wrapped/cards/Card01CyberSchedule.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/components/wrapped/shared/WrappedCardShell.vue b/frontend/components/wrapped/shared/WrappedCardShell.vue new file mode 100644 index 0000000..4be81de --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedCardShell.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/components/wrapped/shared/WrappedControls.vue b/frontend/components/wrapped/shared/WrappedControls.vue new file mode 100644 index 0000000..4de951f --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedControls.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/components/wrapped/shared/WrappedDeckBackground.vue b/frontend/components/wrapped/shared/WrappedDeckBackground.vue new file mode 100644 index 0000000..14172d5 --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedDeckBackground.vue @@ -0,0 +1,22 @@ + + diff --git a/frontend/components/wrapped/shared/WrappedHero.vue b/frontend/components/wrapped/shared/WrappedHero.vue new file mode 100644 index 0000000..a2a90ea --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedHero.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/components/wrapped/visualizations/WeekdayHourHeatmap.vue b/frontend/components/wrapped/visualizations/WeekdayHourHeatmap.vue new file mode 100644 index 0000000..6ae106e --- /dev/null +++ b/frontend/components/wrapped/visualizations/WeekdayHourHeatmap.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index d2bf197..5977948 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -311,6 +311,16 @@ export const useApi = () => { if (!exportId) throw new Error('Missing exportId') return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) } + + // WeChat Wrapped(年度总结) + const getWrappedAnnual = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.year != null) query.set('year', String(params.year)) + if (params && params.account) query.set('account', String(params.account)) + if (params && params.refresh != null) query.set('refresh', String(!!params.refresh)) + const url = '/wrapped/annual' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } return { detectWechat, @@ -339,6 +349,7 @@ export const useApi = () => { createChatExport, getChatExport, listChatExports, - cancelChatExport + cancelChatExport, + getWrappedAnnual } } diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index cf06df5..ea89a4c 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -49,6 +49,14 @@ 聊天预览 + + + + + + 年度总结 + diff --git a/frontend/pages/wrapped/index.vue b/frontend/pages/wrapped/index.vue new file mode 100644 index 0000000..03ef0fe --- /dev/null +++ b/frontend/pages/wrapped/index.vue @@ -0,0 +1,336 @@ + + + diff --git a/frontend/utils/wrapped/heatmap.js b/frontend/utils/wrapped/heatmap.js new file mode 100644 index 0000000..2d38ade --- /dev/null +++ b/frontend/utils/wrapped/heatmap.js @@ -0,0 +1,47 @@ +// Utilities for Wrapped heatmap rendering. + +export const clamp01 = (v) => { + const n = Number(v) + if (!Number.isFinite(n)) return 0 + if (n < 0) return 0 + if (n > 1) return 1 + return n +} + +export const maxInMatrix = (matrix) => { + if (!Array.isArray(matrix)) return 0 + let m = 0 + for (const row of matrix) { + if (!Array.isArray(row)) continue + for (const v of row) { + const n = Number(v) + if (Number.isFinite(n) && n > m) m = n + } + } + return m +} + +// Color inspired by WeChat green, with a slight "gold" shift on high intensity +// (EchoTrace-style accent) while keeping the overall WeChat vibe. +export const heatColor = (value, max) => { + const v = Number(value) || 0 + const m = Number(max) || 0 + if (!(v > 0) || !(m > 0)) return 'rgba(0,0,0,0.05)' + + // Use sqrt scaling to make low values still visible. + const t = clamp01(Math.sqrt(v / m)) + + // Hue from green (~145) -> yellow-green (~95) + const hue = 145 - 50 * t + const sat = 70 + const light = 92 - 42 * t + return `hsl(${hue.toFixed(1)} ${sat}% ${light.toFixed(1)}%)` +} + +export const formatHourRange = (hour) => { + const h = Number(hour) + if (!Number.isFinite(h)) return '' + const hh = String(h).padStart(2, '0') + return `${hh}:00-${hh}:59` +} + diff --git a/frontend/utils/wrapped/types.js b/frontend/utils/wrapped/types.js new file mode 100644 index 0000000..04c8581 --- /dev/null +++ b/frontend/utils/wrapped/types.js @@ -0,0 +1,27 @@ +// JSDoc types for the Wrapped API (kept in JS to match the current codebase). + +/** + * @typedef {Object} WrappedCardBase + * @property {number} id + * @property {string} title + * @property {'global'} scope + * @property {'A'|'B'|'C'|'D'|'E'} category + * @property {'ok'|'error'} status + * @property {string} kind + * @property {string} narrative + * @property {Record} data + */ + +/** + * @typedef {Object} WrappedAnnualResponse + * @property {string} account + * @property {number} year + * @property {'global'} scope + * @property {string|null} username + * @property {number} generated_at + * @property {boolean} cached + * @property {WrappedCardBase[]} cards + */ + +export {} +