feat(wrapped-ui): 新增 Win98 主题与桌面化外观

- 主题系统新增 win98(显示名 Windows 98,快捷键扩展到 F1-F4),并区分 retro(pixel/CRT) 与桌面 GUI 主题
- 年度总结页新增 Win98 桌面背景与底部任务栏(背景色/视口高度适配)
- 封面与卡片 slide 形态支持 Win98 窗口外观(title bar/icon/controls)
- 主题切换器补充 Win98 选项并新增 Win98 专属切换器
- 新增 Win98 图标资源(Start + 桌面图标)
This commit is contained in:
2977094657
2026-02-02 00:04:54 +08:00
parent 7ce6abecca
commit 980f15d0a4
19 changed files with 767 additions and 29 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="variant === 'panel'" class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden">
<div v-if="variant === 'panel'" class="window 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>
@@ -20,24 +20,56 @@
<!-- 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>
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
<div :class="slideContainerClass">
<!-- Win98把整页内容包进一个窗口 -->
<div v-if="isWin98" class="window w-full flex-1 flex flex-col overflow-hidden">
<div class="title-bar">
<div class="title-bar-text">
<img class="title-bar-icon" src="/assets/images/windows-0.png" alt="" aria-hidden="true" />
<span>{{ title }}</span>
</div>
<div class="title-bar-controls" aria-hidden="true">
<button type="button" aria-label="Minimize" tabindex="-1"></button>
<button type="button" aria-label="Maximize" tabindex="-1"></button>
<button type="button" aria-label="Close" tabindex="-1"></button>
</div>
</div>
<div class="window-body flex-1 flex flex-col min-h-0">
<slot name="narrative">
<p v-if="narrative" class="mt-3 wrapped-body text-sm sm:text-base text-[#7F7F7F] max-w-2xl whitespace-pre-wrap">
<p v-if="narrative" class="wrapped-body text-sm sm:text-base whitespace-pre-wrap">
{{ narrative }}
</p>
</slot>
<div class="mt-4 flex-1 min-h-0 overflow-auto">
<div class="w-full">
<slot />
</div>
</div>
</div>
<slot name="badge" />
</div>
<div class="mt-6 sm:mt-8 flex-1 flex items-center">
<div class="w-full">
<slot />
<!-- 其他主题保持原样 -->
<template v-else>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
<slot name="narrative">
<p v-if="narrative" class="mt-3 wrapped-body text-sm sm:text-base text-[#7F7F7F] max-w-2xl whitespace-pre-wrap">
{{ narrative }}
</p>
</slot>
</div>
<slot name="badge" />
</div>
</div>
<div class="mt-6 sm:mt-8 flex-1 flex items-center">
<div class="w-full">
<slot />
</div>
</div>
</template>
</div>
</section>
</template>
@@ -49,6 +81,15 @@ defineProps({
narrative: { type: String, default: '' },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const { theme } = useWrappedTheme()
const isWin98 = computed(() => theme.value === 'win98')
const slideContainerClass = computed(() => (
isWin98.value
? 'relative h-full max-w-5xl mx-auto px-6 pt-2 pb-4 sm:px-8 sm:pt-3 sm:pb-6 flex flex-col'
: 'relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12 flex flex-col'
))
</script>
<style>

View File

@@ -154,4 +154,26 @@ const yearOptions = computed(() => {
.wrapped-theme-dos .controls-btn:hover:not(:disabled) {
background-color: #44ff44;
}
/* Win98 特殊样式 */
.wrapped-theme-win98 .controls-panel {
border-radius: 0;
border: 1px solid #808080;
background: #c0c0c0;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #000000;
}
.wrapped-theme-win98 .controls-select {
border-radius: 0;
}
.wrapped-theme-win98 .controls-btn {
border-radius: 0;
}
.wrapped-theme-win98 .controls-warning {
color: #800000;
}
</style>

View File

@@ -1,6 +1,6 @@
<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">
<div v-if="theme !== 'win98'" 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>
@@ -18,5 +18,61 @@
<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>
<!-- Win98: classic desktop icons (purely decorative) -->
<div v-else class="absolute inset-0 pointer-events-none select-none z-0" aria-hidden="true">
<div class="win98-desktop-icons">
<div v-for="it in desktopIcons" :key="it.label" class="win98-desktop-icon">
<img class="win98-desktop-icon__img" :src="it.src" :alt="it.label" />
<div class="win98-desktop-icon__label">{{ it.label }}</div>
</div>
</div>
</div>
</template>
<script setup>
const { theme } = useWrappedTheme()
const desktopIcons = [
{ label: '我的文档', src: '/assets/images/win98-icons/folder.png' },
{ label: '图片', src: '/assets/images/win98-icons/photos.png' },
{ label: '收件箱', src: '/assets/images/win98-icons/mail.png' },
{ label: '回收站', src: '/assets/images/win98-icons/recycle.png' }
]
</script>
<style scoped>
.win98-desktop-icons {
position: absolute;
top: 84px; /* leave space for top-left controls */
left: 14px;
display: flex;
flex-direction: column;
gap: 18px;
}
.win98-desktop-icon {
width: 74px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.win98-desktop-icon__img {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.win98-desktop-icon__label {
max-width: 74px;
padding: 0 2px;
font-size: 12px;
line-height: 1.1;
color: #ffffff;
text-align: center;
text-shadow: 1px 1px 0 #000000;
word-break: break-word;
}
</style>

View File

@@ -9,7 +9,57 @@
<div :class="innerClass">
<template v-if="variant === 'slide'">
<div class="h-full flex flex-col justify-between">
<!-- Win98封面也做成一个窗口 -->
<div v-if="isWin98" class="window h-full w-full flex flex-col overflow-hidden">
<div class="title-bar">
<div class="title-bar-text">
<img class="title-bar-icon" src="/assets/images/windows-0.png" alt="" aria-hidden="true" />
<span>WECHAT WRAPPED</span>
</div>
<div class="title-bar-controls" aria-hidden="true">
<button type="button" aria-label="Minimize" tabindex="-1"></button>
<button type="button" aria-label="Maximize" tabindex="-1"></button>
<button type="button" aria-label="Close" tabindex="-1"></button>
</div>
</div>
<div class="window-body flex-1 overflow-hidden">
<div class="h-full flex flex-col justify-between">
<div class="flex items-start justify-between gap-4">
<div class="wrapped-label text-xs text-[#00000080]">
WECHAT WRAPPED
</div>
<div class="wrapped-body text-xs text-[#00000055]">
年度回望
</div>
</div>
<div class="mt-10 sm:mt-14">
<h1 class="wrapped-title text-4xl sm:text-6xl text-[#000000e6] leading-[1.05]">
{{ randomTitle.main }}
<span class="block mt-3 win98-hero-highlight">
{{ randomTitle.highlight }}
</span>
</h1>
<div class="mt-7 sm:mt-9 max-w-2xl">
<p class="wrapped-body text-base sm:text-lg text-[#00000080]">
{{ randomSubtitle }}
</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>
</div>
</div>
<!-- 其他主题保持原样 -->
<div v-else class="h-full flex flex-col justify-between">
<div class="flex items-start justify-between gap-4">
<div class="wrapped-label text-xs text-[#00000080]">
WECHAT WRAPPED
@@ -190,6 +240,9 @@ const props = defineProps({
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const { theme } = useWrappedTheme()
const isWin98 = computed(() => theme.value === 'win98')
const yearText = computed(() => `${props.year}`)
const rootClass = computed(() => {
@@ -199,9 +252,19 @@ const rootClass = computed(() => {
: `${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'
))
const innerClass = computed(() => {
if (props.variant !== 'slide') return 'relative px-6 py-7 sm:px-8 sm:py-9'
if (isWin98.value) return 'relative h-full max-w-5xl mx-auto px-6 pt-2 pb-4 sm:px-8 sm:pt-3 sm:pb-6'
return 'relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12'
})
</script>
<style scoped>
/* Win98封面标题的高亮句做成“选中/标题栏”感觉 */
.win98-hero-highlight {
display: inline-block;
padding: 2px 8px;
background: #000080;
color: #ffffff;
}
</style>

View File

@@ -10,7 +10,8 @@ const themeSwitcherComponent = computed(() => {
const map = {
off: resolveComponent('WrappedThemeSwitcherModern'),
gameboy: resolveComponent('WrappedThemeSwitcherGameboy'),
dos: resolveComponent('WrappedThemeSwitcherDos')
dos: resolveComponent('WrappedThemeSwitcherDos'),
win98: resolveComponent('WrappedThemeSwitcherWin98')
}
return map[theme.value] || map.off
})

View File

@@ -15,7 +15,7 @@
</div>
<!-- 状态提示 -->
<div class="dos-status">
Press F1-F3 to switch theme
Press F1-F4 to switch theme
</div>
</div>
</template>
@@ -26,7 +26,8 @@ const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'Modern' },
{ value: 'gameboy', label: 'GameBoy' },
{ value: 'dos', label: 'DOS' }
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'Win98' }
]
</script>

View File

@@ -25,7 +25,8 @@ const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'MODERN' },
{ value: 'gameboy', label: 'GAME BOY' },
{ value: 'dos', label: 'DOS' }
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'WIN98' }
]
const selectTheme = (value) => {

View File

@@ -25,6 +25,7 @@ const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'Modern' },
{ value: 'gameboy', label: 'Game Boy' },
{ value: 'dos', label: 'DOS' }
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'Win98' }
]
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="win98-switcher select-none">
<span class="win98-switcher__label">Theme</span>
<div class="win98-switcher__group" role="group" aria-label="Theme switcher">
<button
v-for="t in themes"
:key="t.value"
type="button"
class="win98-switcher__btn"
:class="{ 'is-active': theme === t.value }"
@click="setTheme(t.value)"
>
{{ t.label }}
</button>
</div>
</div>
</template>
<script setup>
const { theme, setTheme } = useWrappedTheme()
const themes = [
{ value: 'off', label: 'Modern' },
{ value: 'gameboy', label: 'Game Boy' },
{ value: 'dos', label: 'DOS' },
{ value: 'win98', label: 'Win98' }
]
</script>
<style scoped>
.win98-switcher {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.win98-switcher__label {
color: rgba(0, 0, 0, 0.75);
}
/* Bevel group container */
.win98-switcher__group {
display: inline-flex;
align-items: stretch;
padding: 2px;
background: #c0c0c0;
border: 1px solid #808080;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #000000;
}
.win98-switcher__btn {
padding: 4px 10px;
background: #c0c0c0;
color: #000000;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
line-height: 1;
}
.win98-switcher__btn:hover {
filter: brightness(1.03);
}
.win98-switcher__btn.is-active {
background: #000080 !important;
color: #ffffff !important;
border-color: #000080 !important;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="win98-taskbar" @wheel.stop.prevent>
<button
type="button"
class="win98-start"
aria-label="Start"
:aria-pressed="startPressed ? 'true' : 'false'"
@mousedown="startPressed = true"
@mouseup="startPressed = false"
@mouseleave="startPressed = false"
>
<img class="win98-start__icon" src="/assets/images/windows-0.png" alt="" aria-hidden="true" />
<span class="win98-start__text">Start</span>
</button>
<div class="win98-taskbar__divider" aria-hidden="true"></div>
<button
type="button"
class="win98-task"
:title="title"
tabindex="-1"
aria-label="Active window"
>
{{ title }}
</button>
<div class="win98-taskbar__spacer" aria-hidden="true"></div>
<div class="win98-tray" aria-label="System tray">
<div class="win98-tray__clock" :title="timeText">
{{ timeText }}
</div>
</div>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
defineProps({
title: { type: String, default: 'WeChat Wrapped' }
})
const startPressed = ref(false)
const timeText = ref('--:--')
let timer = null
const formatWin98Time = (d) => {
try {
// Win98 screenshot style: 12-hour + AM/PM
return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' }).format(d)
} catch {
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${mm}`
}
}
const updateClock = () => { timeText.value = formatWin98Time(new Date()) }
onMounted(() => {
updateClock()
timer = setInterval(updateClock, 30_000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
timer = null
})
</script>
<style scoped>
.win98-taskbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 40px;
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
background: #c0c0c0;
border-top: 2px solid #ffffff;
z-index: 40;
}
.win98-start {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 10px 0 8px;
font-weight: 700;
}
.win98-start__icon {
width: 16px;
height: 16px;
image-rendering: pixelated;
}
.win98-start__text {
line-height: 1;
}
.win98-taskbar__divider {
width: 2px;
height: 28px;
background: #808080;
box-shadow: 1px 0 0 #ffffff;
}
.win98-task {
height: 30px;
min-width: 160px;
max-width: 56vw;
padding: 0 10px;
font-weight: 400;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.win98-task {
/* Active window task button: depressed + dither fill (Win95-ish) */
background: var(--win98-dither) !important;
box-shadow: none !important;
border-top: 1px solid var(--win98-dkshadow) !important;
border-left: 1px solid var(--win98-dkshadow) !important;
border-right: 1px solid var(--win98-hi) !important;
border-bottom: 1px solid var(--win98-hi) !important;
}
.win98-start[aria-pressed="true"] {
/* Start button pressed: depressed + dither */
background: var(--win98-dither) !important;
box-shadow: none !important;
border-top: 1px solid var(--win98-dkshadow) !important;
border-left: 1px solid var(--win98-dkshadow) !important;
border-right: 1px solid var(--win98-hi) !important;
border-bottom: 1px solid var(--win98-hi) !important;
}
.win98-taskbar__spacer {
flex: 1;
}
.win98-tray {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 8px;
background: #c0c0c0;
border-top: 1px solid var(--win98-shadow);
border-left: 1px solid var(--win98-shadow);
border-right: 1px solid var(--win98-hi);
border-bottom: 1px solid var(--win98-hi);
}
.win98-tray__clock {
font-size: 11px;
color: #000000;
line-height: 1;
white-space: nowrap;
}
</style>

View File

@@ -38,6 +38,25 @@
>[+]</button>
</div>
<!-- Win98 风格 -->
<div v-else-if="theme === 'win98'" class="year-win98">
<div class="win98-year-box">
<button
class="win98-arrow"
:disabled="!canGoPrev"
@click="prevYear"
aria-label="Previous year"
></button>
<span class="win98-year-value">{{ modelValue }}</span>
<button
class="win98-arrow"
:disabled="!canGoNext"
@click="nextYear"
aria-label="Next year"
></button>
</div>
</div>
<!-- Modern 风格下拉菜单默认 -->
<div v-else class="year-modern">
<div class="relative inline-flex items-center">
@@ -240,4 +259,57 @@ onBeforeUnmount(() => {
min-width: 50px;
text-align: center;
}
/* ========== Win98 风格 ========== */
.year-win98 {
font-family: "MS Sans Serif", Tahoma, "Microsoft Sans Serif", "Segoe UI", sans-serif;
font-size: 11px;
}
.win98-year-box {
display: inline-flex;
align-items: center;
background: #c0c0c0;
padding: 2px;
border: 1px solid #808080;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #000000;
}
.win98-year-value {
min-width: 62px;
text-align: center;
color: #000000;
padding: 2px 8px;
background: #ffffff;
border: 1px solid #808080;
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #ffffff;
}
.win98-arrow {
width: 24px;
height: 22px;
background: #c0c0c0;
border: 1px solid #808080;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #000000;
cursor: pointer;
font: inherit;
line-height: 1;
}
.win98-arrow:active:not(:disabled) {
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #ffffff;
}
.win98-arrow:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>