perf(start): 优化高分屏卡顿 + 背景图按横竖屏切换

性能:
- 光标圆环去掉 mixBlendMode:difference(跟随时触发全屏重合成,
  高分屏掉帧主因),并删除位置弹簧平滑,改为 1:1 跟手
- .glass-panel 的 backdrop-filter 模糊 20px→10px,每帧重算开销减半

背景:
- 横/竖图分别放 landscape//portrait/ 子目录(同名)
- 新增 useMediaQuery hook,按 (orientation: portrait) 选图,
  转屏实时切换;竖图缺失自动回退同名横图
- 更新 startpage/README 说明新结构

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ryan 2026-06-23 13:52:28 +08:00
parent 5b60185f94
commit 25791f9268
11 changed files with 86 additions and 36 deletions

View File

@ -1,32 +1,42 @@
# Startpage 背景图
导航起始页(`src/components/StartPage.tsx`)的背景图放在这个目录里,
**按昼夜自动切换**
**按昼夜 + 屏幕方向自动切换**。
- 白天 → `light.*`(如 `light.jpg`
- 夜晚 → `dark.*`(如 `dark.png`
## 目录结构
放好这两张文件即可生效,无需改代码。
```
startpage/
├─ landscape/ 横屏图(电脑、手机横放)
│ ├─ No.1.webp ~ No.5.webp 白天候选
│ └─ Night.webp 夜间
└─ portrait/ 竖屏图(手机竖放),命名与 landscape 完全相同
├─ No.1.webp ~ No.5.webp
└─ Night.webp
```
放好文件即可生效,无需改代码。**竖图可以只放一部分**——缺的那张会自动
回退到同名横图。
## 工作方式
`StartPage` 用 Vite 的 `import.meta.glob` 索引本目录,按文件名精确取
`light.*` / `dark.*`,再按昼夜选用。
`StartPage` 用 Vite 的 `import.meta.glob` 分别索引 `landscape/`、`portrait/`
昼夜判断:**优先用天气接口返回的图标码**(晴/多云/阵雨类区分昼夜),
图标码判断不了的天气(雨、雪、雾、阴等无昼夜之分)则用**本地时间兜底**
18:0006:00 视为夜晚)。
- **方向**`useMediaQuery('(orientation: portrait)')` 判断竖屏(高 ≥ 宽)。
竖屏优先取 `portrait/` 的图,转屏/缩窗口会实时切换。
- **白天**:从 `No.1`~`No.5` 里随机取一张(整页生命周期内固定,不乱跳)。
- **夜间**:用 `Night`。判断为本地时间 `0:006:00`
回退顺序:选中的那张缺失 → 用另一张 → 都缺则用目录里排序第一张 →
再无则用中性渐变(构建始终成功)。
回退顺序:竖图缺失 → 同名横图 → 横图里排序第一张 → 再无则用中性渐变
(构建始终成功)。
## 命名与规格
- 必须命名为 `light.*``dark.*`(扩展名任意:`.jpg/.jpeg/.png/.webp`
其它名字(如 `dark33.png`不会被选用,只会白白打进包里,建议删掉
- 格式:`webp`(最小)> `jpg` > `png`
- 横图够大:≥ 1920×1080最好 2560×1440+
- 图上还会叠一层暗色 scrim + 半透明黑遮罩(保证文字可读),所以偏暗、
- 白天图必须叫 `No.<数字>.*`(如 `No.1.webp`),夜间图叫 `Night.*`
其它名字不会被选用,只会白白打进包里。
- 扩展名任意:`.jpg/.jpeg/.png/.webp`,体积 `webp` > `jpg` > `png`
- 横图够大:≥ 1920×1080最好 2560×1440+;竖图按手机比例(如 1080×2340
- 图上还会叠一层暗色 scrim + 半透明黑遮罩(保证文字可读),偏暗、
低对比的图效果最好。
## 本地预览(仅 `npm run dev`
@ -37,3 +47,5 @@
- `?fx=rain` / `?fx=snow` / `?fx=thunder` / `?fx=none` —— 强制动态效果
例:`http://localhost:5173/?bg=dark&fx=snow`
竖屏效果用浏览器开发者工具的设备模拟Device Toolbar切竖屏即可预览。

View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

View File

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 265 KiB

View File

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 366 KiB

View File

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

View File

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 331 KiB

View File

@ -1,13 +1,12 @@
import { useEffect, useState } from 'react'
import { motion, useMotionValue, useSpring } from 'framer-motion'
import { motion, useMotionValue } from 'framer-motion'
// 跟随鼠标的小圆环(仿 Few & Far
// 仅在精确指针设备启用;触摸设备与 reduced-motion 下不挂载。
export function CursorFollower() {
// 位置 1:1 跟手,不做弹簧平滑(平滑在掉帧时会显得发涩/拖影)。
const x = useMotionValue(-100)
const y = useMotionValue(-100)
const springX = useSpring(x, { stiffness: 350, damping: 28, mass: 0.4 })
const springY = useSpring(y, { stiffness: 350, damping: 28, mass: 0.4 })
const [enabled, setEnabled] = useState(false)
const [hovering, setHovering] = useState(false)
@ -47,11 +46,11 @@ export function CursorFollower() {
<motion.div
aria-hidden
className="pointer-events-none fixed left-0 top-0 z-[100]"
style={{ x: springX, y: springY }}
style={{ x, y }}
>
<motion.span
className="block rounded-full border border-accent"
style={{ translateX: '-50%', translateY: '-50%', mixBlendMode: 'difference' }}
style={{ translateX: '-50%', translateY: '-50%' }}
animate={{
width: hovering ? 54 : 24,
height: hovering ? 54 : 24,

View File

@ -2,29 +2,43 @@ import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { fadeUp, stagger } from './motion'
import { useWeather } from '../hooks/useWeather'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { Greeting } from './start/Greeting'
import { SearchBar } from './start/SearchBar'
import { CategoryDock } from './start/CategoryDock'
import { WeatherFx } from './start/WeatherFx'
// 背景图:放 src/assets/startpage/ 下。6:0024:00 从 No.1~No.5 里随机取一张;
// 0:006:00 用 Night。缺图时互相回退再无则用渐变。说明见该目录 README。
const bgFiles = import.meta.glob<string>('../assets/startpage/*.{jpg,jpeg,png,webp}', {
// 背景图:横图放 src/assets/startpage/landscape/,竖图放 src/assets/startpage/portrait/(同名)。
// 6:0024:00 从 No.1~No.5 里随机取一张0:006:00 用 Night。
// 竖屏优先用 portrait/ 的图,缺图则回退到同名横图,再无则用渐变。说明见该目录 README。
const landscapeFiles = import.meta.glob<string>('../assets/startpage/landscape/*.{jpg,jpeg,png,webp}', {
eager: true,
query: '?url',
import: 'default',
})
const portraitFiles = import.meta.glob<string>('../assets/startpage/portrait/*.{jpg,jpeg,png,webp}', {
eager: true,
query: '?url',
import: 'default',
})
function bgByName(name: string): string | undefined {
return Object.entries(bgFiles).find(([p]) => p.toLowerCase().includes(`/${name}.`))?.[1]
interface BgSet {
dayBgs: string[] // 白天候选No.1~No.5(按编号排序)
nightBg?: string // 夜间Night
}
// 白天候选No.1~No.5按编号排序夜间Night。
const dayBgs = Object.entries(bgFiles)
.filter(([p]) => /\/no\.\d+\./i.test(p))
.sort(([a], [b]) => a.localeCompare(b))
.map(([, url]) => url)
const nightBg = bgByName('night')
// 从一组 glob 结果里拆出白天候选与夜间图。
function buildSet(files: Record<string, string>): BgSet {
const dayBgs = Object.entries(files)
.filter(([p]) => /\/no\.\d+\./i.test(p))
.sort(([a], [b]) => a.localeCompare(b))
.map(([, url]) => url)
const nightBg = Object.entries(files).find(([p]) => p.toLowerCase().includes('/night.'))?.[1]
return { dayBgs, nightBg }
}
const landscape = buildSet(landscapeFiles)
const portrait = buildSet(portraitFiles)
// 昼夜判断纯按本地时间0:006:00 为夜。
function isNight(): boolean {
@ -56,8 +70,13 @@ export function StartPage() {
const icon = wx.data && wx.data.ok ? wx.data.icon : undefined
// 竖屏(高≥宽)优先用竖版图,缺图回退到同名横图。
const isPortrait = useMediaQuery('(orientation: portrait)')
// 白天随机选一张,整页生命周期内固定,避免每分钟一拍时背景乱跳。
const [dayIdx] = useState(() => Math.floor(Math.random() * Math.max(dayBgs.length, 1)))
// 索引按两套候选的最大长度取,方向切换时也不越界。
const dayCount = Math.max(landscape.dayBgs.length, portrait.dayBgs.length, 1)
const [dayIdx] = useState(() => Math.floor(Math.random() * dayCount))
// 开发预览(仅 DEV?bg=light|dark 强制背景;?fx=rain|snow|thunder|none 强制动效。
// 例http://localhost:5173/?bg=dark&fx=snow
@ -65,8 +84,11 @@ export function StartPage() {
const ovBg = ov?.get('bg')
const ovFx = ov?.get('fx')
const i = dayIdx % dayCount
const dayBg = (isPortrait ? portrait.dayBgs[i] : undefined) ?? landscape.dayBgs[i] ?? landscape.dayBgs[0]
const nightBg = (isPortrait ? portrait.nightBg : undefined) ?? landscape.nightBg
const night = ovBg ? ovBg === 'dark' : isNight()
const dayBg = dayBgs[dayIdx] ?? dayBgs[0]
const bg = (night ? nightBg : dayBg) ?? dayBg ?? nightBg
let kind = fxKind(icon)

View File

@ -0,0 +1,15 @@
import { useSyncExternalStore } from 'react'
// 订阅一条媒体查询,匹配状态变化时自动重渲染。
// 典型用法useMediaQuery('(orientation: portrait)') 判断竖屏。
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
const mql = window.matchMedia(query)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
},
() => window.matchMedia(query).matches,
() => false, // SSR/首帧兜底:当作不匹配
)
}

View File

@ -212,8 +212,10 @@ body {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 10px 30px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
/* blur 半径直接决定每帧重算模糊的成本 半径×面积高分屏下尤甚
20px10px 观感几乎无差开销减半 .glass-sheet 处的同类取舍 */
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
}
/* ── reduced-motion 降级 ────────────────────────────────────── */