diff --git a/NETWORK_RESILIENCE.md b/NETWORK_RESILIENCE.md new file mode 100644 index 0000000..5224289 --- /dev/null +++ b/NETWORK_RESILIENCE.md @@ -0,0 +1,381 @@ +# 断网请求堆积 Bug 与传输层修复方案 + +> 适用项目:`x8-controller-v2`(x9 / b9 / bd9 / bh9 **尚未同步**) +> 落地日期:2026-09-30 | 真机验证:2026-09-30(设备 192.168.9.127) +> 状态:已修复并通过「假黑洞集成测试 + 真机三阶段协作测试」双重验证;未提交 Git、未部署 + +--- + +## 1. 问题现象 + +设备休眠后有概率断网。此时 controller 端: + +- 所有操作失效,界面看似正常但设备毫无反应; +- 请求**不报错、不超时**,一直挂起; +- 设备唤醒并重新联网后,断网期间积压的请求**排着队依次送达设备**。 + +典型复现:断网期间用户疯狂左右拖动首页音量条 → 设备无反应 → 设备恢复联网后,积压的几十条音量命令按序全部执行 → **设备音量突变**(可能突然拉满)。 + +--- + +## 2. 根因分析:三层时钟,只有操作系统那一层会响 + +### 2.1 应用层:等待时间 = ∞ + +改造前,`client/src/lib/luxsinApi.ts` 中所有设备请求都是裸 `fetch`: + +- 全仓设备请求**零 `timeout`**、**零 `AbortController`**(唯一的 AbortController 在 `aiApi.ts` 的云端流式接口,与设备无关); +- `setSetting` 只对 `peqSelect` 做串行排队,其余设置项**各自独立发出、互不覆盖**; +- 轮询(`msgCount`,3s 一次)**无重入保护**:上一轮没回来,下一轮照样发。 + +结论:应用层永远不会主动放弃一个请求。 + +### 2.2 浏览器层:排队时间 = ∞ + +HTTP/1.1 对同一 origin 有 **6 条并发连接**上限。断网时这 6 个 socket 全部被挂起请求占死,后续请求进入浏览器 **stalled 队列**,而 stalled **没有超时**。于是请求越积越多。 + +### 2.3 操作系统 TCP 层:唯一会响的钟 + +| 场景 | 行为 | 量级 | +| --- | --- | --- | +| 新建连接、SYN 无人应答(黑洞) | 指数退避重传 SYN,受 `net.inet.tcp.keepinit` 约束 | macOS 约 **75s** 才失败 | +| 已建连接、数据未被确认 | 指数退避重传数据 | **分钟级**预算 | +| 连接被拒 / 地址不可达 | 立即 RST / ICMP | **毫秒级** 失败 | + +所以「请求会等待多久」的答案是:**应用层 ∞,浏览器层 ∞,实际由 OS TCP 重传预算决定,黑洞场景下是几十秒到几分钟**。 + +### 2.4 为什么是「音量突变」而不是「全部失败」 + +丢弃只发生在 OS 重传预算耗尽之后。**如果设备先恢复联网**,那些还在重传的挂起请求会全部按序成功送达 —— 一条都不少。这正是音量突变的成因: + +``` +断网期间:setVolume(10) setVolume(35) setVolume(60) ... setVolume(180) ← 全部挂起 +设备恢复:10 → 35 → 60 → ... → 180 ← 按序全部执行,音量阶梯式飙升 +``` + +### 2.5 放大因素 + +- 轮询无重入保护:3s 一条持续挤占连接池; +- `isConnected` 在断网时**不翻转**,UI 不提示掉线,用户以为「没点上」而反复操作,进一步加剧积压; +- PEQ 串行队列被一个挂起请求堵死后,后续 PEQ 操作全部排在它后面。 + +--- + +## 3. 修复方案 + +四层修复,全部集中在传输层,业务调用方**零改动**。 + +### 3.1 统一超时(方案 1) + +所有设备 `fetch` 改走私有 `timedFetch`,到点 `abort` 并抛 `DeviceTimeoutError`。 + +```ts +export const DEVICE_FETCH_TIMEOUT_MS = 4000; + +export class DeviceTimeoutError extends Error { + constructor(url: string) { + super(`Device request timed out: ${url}`); // message 含 "timed out" + this.name = "DeviceTimeoutError"; + } +} +``` + +4s 的取值依据:设备在线时正常响应为毫秒级(实测 1–900ms),4s 足以容纳最差抖动;断网时 4s 内失败,用户感知为「这一下没生效」,而不是永久假死。 + +### 3.2 指令合并 + 重连补发(方案 2) + +`setSetting` 按语义**三分流**: + +| 分流 | 判定 | 行为 | 理由 | +| --- | --- | --- | --- | +| PEQ 事务 | `params.peqSelect !== undefined` | 走原有串行队列,**只加超时**,不合并不补发 | 事务有时序依赖,合并会破坏语义;加超时保证队列能排空 | +| **动作型命令** | 命中 `ACTION_ONLY_SETTING_KEYS` = `{power, bt_play, bt_next}` | **发一次,失败即丢弃,重连后绝不补发** | 语义是「触发一次」。补发会导致用户没按却**意外关机 / 意外切歌**,属安全边界 | +| 状态型设置 | 其余(volume、输入源、EQ 开关等) | 按**通道**合并,保留最新意图,重连后补发一次 | 语义是「让它等于 X」,只有最后一个值有意义 | + +**通道(channel)** = 参数键排序后 join,例如 `{volume:118}` → `"volume"`。合并规则: + +1. 新命令进来 → **立即 abort 同通道在途的旧请求**(last-write-wins),旧 Promise **静默 resolve**(值已被新意图覆盖,不该向用户报错); +2. 每通道**至多一个在途请求**,从根上杜绝堆积; +3. 请求因**超时或网络错误**失败 → 把该意图存入 `pendingIntents`,并向调用方 throw(让 UI 能提示); +4. 因**被新命令取代**而 abort → 丢弃,不存、不报错; +5. **任意**设备请求成功(包括轮询)即视为链路恢复 → `onDeviceResponseOk()` 按通道依次补发**最新意图一次**;同通道已有用户在途命令时跳过(那个更新);补发失败则意图重新挂起。 + +读请求(`syncData` / `msgCount` / `msgAdd` / `syncPeq`)与 PEQ POST:**只加超时,不补发**。 + +#### 语义澄清:为什么不是「超时的一律丢弃」 + +原始设想是「重新联网后只补发最近一次没超时的请求,超时的直接丢弃」。若按字面执行(最后一条也丢弃),会导致 **UI 显示 118、设备停在旧值**,两者永久失配且用户无从察觉。 + +因此实现为:**超时的「传输」丢弃,但每通道保留最新「意图」,重连后补发一次**。既消除音量阶梯突变,又保证最终一致。 + +### 3.3 轮询重入保护(方案 3) + +`client/src/contexts/DeviceContext.tsx`: + +```tsx +const pollInFlightRef = useRef(false); +// ... +const poll = async () => { + if (pollInFlightRef.current) return; // 上轮未 settle 则跳过本轮 + pollInFlightRef.current = true; + try { /* getMsgCount → 必要时 syncData / syncPeq */ } + finally { pollInFlightRef.current = false; } +}; +``` + +### 3.4 错误文案识别(方案 4) + +`client/src/lib/networkError.ts` 的 `NETWORK_FETCH_HINTS` 增加 `"timed out"`,使 `DeviceTimeoutError` 被识别为网络类错误,向用户展示「网络/设备不可达」文案而非通用错误。 + +### 3.5 代码位置 + +| 文件 | 行 | 内容 | +| --- | --- | --- | +| `client/src/lib/luxsinApi.ts` | 327 | `DEVICE_FETCH_TIMEOUT_MS` | +| | 330 | `DeviceTimeoutError` | +| | 341 | `ACTION_ONLY_SETTING_KEYS` | +| | 343 | `WriteSlot`(controller + timedOut 标记) | +| | 352 / 354 / 355 | `pendingIntents` / `inflightWrites` / `flushScheduled` | +| | 406 | `setSetting` 三分流 | +| | 419 | `sendSettingRequest`(PEQ / 动作型) | +| | 433 | `sendCoalescedSetting`(合并 + last-write-wins) | +| | 467 | `onDeviceResponseOk`(重连补发) | +| | 488 | `timedFetch`(统一超时包装) | +| | 384/397/403/423/446/512/540/555/571/587 | 全部 10 个设备请求调用点 | +| `client/src/contexts/DeviceContext.tsx` | 62 / 265-278 | 轮询重入保护 | +| `client/src/lib/networkError.ts` | 12 | `"timed out"` hint | + +--- + +## 4. 测试 + +### 4.1 A:自动化集成测试(假黑洞服务器) + +不依赖真机,随时可跑。原理:用 esbuild 把零外部依赖的 `luxsinApi.ts` 打成单文件 CJS,在 node 里对着一个「hang 模式接受连接但永不响应」的假服务器跑断言。 + +```bash +cd x8-controller-v2 +./node_modules/.bin/esbuild client/src/lib/luxsinApi.ts \ + --bundle --format=cjs --outfile=/tmp/luxsin-api.cjs --log-level=error +node /tmp/req-timeout-test.mjs +``` + +
+测试脚本全文(可存为 scripts/req-timeout-test.mjs 长期保留) + +```js +// 隔离集成测试:验证设备请求超时 + 状态型指令合并/重连补发 + 动作型指令不补发。 +// 假黑洞服务器:hang 模式接受连接但永不响应(模拟设备断网),ok 模式正常 200。 +import http from "node:http"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { LuxsinAPI } = require("/tmp/luxsin-api.cjs"); + +let mode = "hang"; +const received = []; +const server = http.createServer((req, res) => { + received.push({ mode, url: req.url, t: Date.now() }); + if (mode === "hang") return; // 永不响应 + const body = req.url.includes("msgCount") ? "5" : ""; + res.setHeader("Content-Type", "text/plain"); + res.end(body); +}); +await new Promise((r) => server.listen(8099, "127.0.0.1", r)); + +const api = new LuxsinAPI("127.0.0.1:8099"); +const results = {}; +const t0 = Date.now(); +const mark = (key) => (v) => { + results[key] = v && v.name ? { ok: false, name: v.name, dt: Date.now() - t0 } : { ok: true, dt: Date.now() - t0 }; +}; +const p1 = api.setVolume(10).then(mark("p1_vol10"), mark("p1_vol10")); +await new Promise((r) => setTimeout(r, 200)); +const p2 = api.setVolume(20).then(mark("p2_vol20"), mark("p2_vol20")); +const p3 = api.powerOff().then(mark("p3_power"), mark("p3_power")); +const p4 = api.getDeviceState().then(mark("p4_sync"), mark("p4_sync")); +await Promise.all([p1, p2, p3, p4]); + +// 模拟重连:服务器恢复响应;任意成功请求应触发挂起意图补发 +mode = "ok"; +const count = await api.getMsgCount(); +await new Promise((r) => setTimeout(r, 300)); // 等 flush 完成 + +const settingInOkMode = received.filter((r) => r.mode === "ok" && r.url.includes("action=setting")).map((r) => r.url); +console.log(JSON.stringify({ + results, msgCount: count, + hangPhaseReceived: received.filter((r) => r.mode === "hang").map((r) => r.url), + okPhaseSettingReceived: settingInOkMode, + assertions: { + p1_superseded_resolves: results.p1_vol10.ok === true && results.p1_vol10.dt < 1000, + p2_timeout_4s: results.p2_vol20.ok === false && results.p2_vol20.name === "DeviceTimeoutError" && results.p2_vol20.dt >= 3900 && results.p2_vol20.dt < 5000, + p3_action_timeout: results.p3_power.ok === false && results.p3_power.name === "DeviceTimeoutError", + p4_read_timeout: results.p4_sync.ok === false && results.p4_sync.name === "DeviceTimeoutError", + resend_only_latest_volume: settingInOkMode.length === 1 && settingInOkMode[0].includes("volume=20"), + action_never_resent: !settingInOkMode.some((u) => u.includes("power=0")), + }, +}, null, 2)); +server.close(); +process.exit(0); +``` + +
+ +结果:**6 项断言全过**(被取代命令 208ms 静默 resolve;p2/p3/p4 均 4209ms 抛 `DeviceTimeoutError`;重连后仅 `volume=20` 一条补发;`power=0` 与 `volume=10` 零补发)。 + +### 4.2 B:真机三阶段协作测试 + +需要两人配合:测试者操作浏览器,另一人**手动断网 / 恢复设备网络**并在每步之后回报。 + +#### 前置准备 + +1. 启动 dev server:`cd x8-controller-v2 && pnpm dev`,打开 `http://localhost:3000/?ip=<设备IP>`; +2. 开一个**设备可达性探测日志**作为时间基准(每秒一次): + + ```bash + nohup bash -c 'for i in $(seq 1 1800); do printf "%s " "$(date +%H:%M:%S)" >> /tmp/dev-probe.log; \ + curl -s -m 2 -o /dev/null -w "code=%{http_code} t=%{time_total}\n" "http://<设备IP>/msgCount" >> /tmp/dev-probe.log 2>&1 \ + || echo "exit=$?" >> /tmp/dev-probe.log; sleep 1; done' >/dev/null 2>&1 & + ``` + + > ⚠️ **必须探测 `/msgCount`**(应用真正轮询的端点)。`/dev/info.cgi?action=msg_count` 在该固件上**即使设备在线也返回空响应**(curl `exit=52`、浏览器 `ERR_EMPTY_RESPONSE`),用它会把在线误判为离线。 + +3. 在页面内安装 **fetch 探针**(只记录、不改行为),用于取得每条请求的起始时刻 / 耗时 / 结果: + + ```js + window.__origFetch = window.fetch; window.__log = []; + window.fetch = async function (...args) { + const url = typeof args[0] === 'string' ? args[0] : args[0].url; + const t0 = performance.now(); + const rec = { startMs: Math.round(t0), url: url.replace('http://<设备IP>', ''), durMs: null, result: null }; + window.__log.push(rec); + try { const r = await window.__origFetch.apply(this, args); + rec.durMs = Math.round(performance.now() - t0); rec.result = 'status=' + r.status; return r; + } catch (e) { rec.durMs = Math.round(performance.now() - t0); rec.result = e.name + ': ' + e.message; throw e; } + }; + ``` + +4. **脚本化拖动音量条的正确姿势**(关键坑,见 §5):对 slider 的 **wrapper** 依次派发 `pointerdown` / `pointermove` / `pointerup`,按下点必须落在**圆球中心 ±24px** 内,并在 move 与 up 之间 `await` 一个宏任务等 React 重渲染: + + ```js + const input = document.querySelector('input[type=range]'); + const wrapper = input.parentElement; + const min = Number(input.min), max = Number(input.max); // 0 / 200 + Element.prototype.setPointerCapture = function () {}; // 合成指针无 active pointer,打桩避免抛错 + const centerXFor = (rect, v) => rect.left + 9 + ((v - min) / (max - min)) * Math.max(0, rect.width - 18); + const pe = (type, x, y) => new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'touch', clientX: x, clientY: y, isPrimary: true }); + async function dragTo(target) { + const rect = input.getBoundingClientRect(); + const y = rect.top + rect.height / 2; + wrapper.dispatchEvent(pe('pointerdown', centerXFor(rect, Number(input.value)), y)); + wrapper.dispatchEvent(pe('pointermove', centerXFor(rect, target), y)); + await new Promise(r => setTimeout(r, 100)); // 等 React 重渲染,否则提交滞后一步的旧值 + wrapper.dispatchEvent(pe('pointerup', centerXFor(input.getBoundingClientRect(), target), y)); + await new Promise(r => setTimeout(r, 150)); + } + ``` + + 音量换算:`dB = (value - 200) * 0.5`(`value=117` → `-41.5dB`)。**测试请用 ±几档的小幅变化**,避免真机突然大音量。 + +#### Phase A —— 在线基线(设备在网) + +| 步骤 | 断言 | +| --- | --- | +| 刷新页面同步真实状态,记录基线音量 | 页面显示设备真实值 | +| 拖动 3 次(小幅) | 每次产生 1 条 `action=setting&volume=<精确目标值>` 且 **200**;happy path 未被传输层破坏 | + +实测:`setting&volume=120` → 200,`valueAfter=120`(下发值与拖动目标精确一致)。 + +#### Phase B —— 离线洪泛(**请对方断网**) + +> ⚠️ **断网期间浏览器页面绝对不能刷新** —— `pendingIntents` 存活于页面内存,刷新即丢失待补发意图,Phase C 就测不到了。 + +| 步骤 | 断言 | 实测 | +| --- | --- | --- | +| 连续 5 次拖动:112 → 116 → 124 → 128 → **118**,间隔 ~250ms | 命令数与拖动 1:1,不放大 | `settingTotal=5` | +| | 前 4 条被新命令**立即** abort,不等 4s | 各 **~261ms** `AbortError`,且**静默 resolve** | +| | 最后一条不无限挂起 | **3950ms** `TypeError` → 转挂起意图 | +| | 黑洞轮询被 4s 切断 | **4001 / 4001 / 4002ms** `AbortError`(修复前挂到 OS 预算耗尽,分钟级) | +| | 轮询不堆叠 | 严格 **3000ms** 一条,同刻仅 1 条在途 | +| | UI 不卡死、本地反馈正常 | `rAF = 0ms`,slider 未禁用,即时显示 -41.0dB | +| | 无未捕获异常、无错误轰炸 | 5 次失败仅 **1 条** console error,无 unhandled rejection | + +#### Phase C —— 重连补发(**请对方恢复联网**) + +| 断言 | 实测 | +| --- | --- | +| 链路恢复后自动补发,无需用户再操作 | 首个 `/msgCount` → `200` 之后 **8ms**,自动发出 `action=setting&volume=118` → **200** | +| **只补发最后一条意图** | `settingTotal=6` = 5 次拖动 + **1 次补发** | +| 被取代的值**永不补发** | `volume=112/116/124/128` 各只出现 **1 次**(原始失败那次) | +| 补发**不重复** | 其后 20+ 条成功轮询、1 次 syncData/syncPeq,**再无第二条 setting** | +| 设备与 UI 最终一致 | 双方均为 `value=118`(-41.0dB) | + +原始时间轴(相对洪泛起点): + +``` + 102ms setting&volume=112 AbortError(261ms,被 116 取代) + 355ms setting&volume=116 AbortError(261ms,被 124 取代) + 609ms setting&volume=124 AbortError(262ms,被 128 取代) + 862ms setting&volume=128 AbortError(261ms,被 118 取代) + 1116ms setting&volume=118 TypeError(3950ms)→ 存入 pendingIntents + 1749ms msgCount AbortError(4001ms)← 黑洞被 4s 切断 + ... msgCount 每 3000ms 一条,各 1-2ms 快失败,同时只 1 条在途 +94750ms msgCount status=200 ← 链路恢复 +94758ms setting&volume=118 status=200(38ms) ← 自动补发,仅此一条 +``` + +#### 收尾 + +- 把音量**还原到基线**(`setting&volume=117` → 200),并经 `syncData` 回读确认设备侧一致; +- 卸载 fetch 探针(`window.fetch = window.__origFetch`)、终止后台探测进程。 + +### 4.3 覆盖矩阵 + +| 场景 | 集成测试 | 真机 | +| --- | --- | --- | +| 状态型命令合并(last-write-wins) | ✅ | ✅ | +| 超时后保留最新意图 | ✅ | ✅ | +| 重连仅补发一条最新意图 | ✅ | ✅ | +| 读请求超时不补发 | ✅ | ✅(轮询) | +| 轮询重入保护 | — | ✅ | +| **动作型命令绝不补发** | ✅ | ❌ **未验证** | +| PEQ 事务只加超时不合并 | — | ❌ 未验证 | +| 断网期间刷新页面 | — | ❌ 未验证(意图必然丢失,见 §5.3) | + +> 动作型命令未在真机验证的原因:验证它需要真机执行 `power` / `bt_play`,会**真的关机 / 切歌**打断现场。目前该安全边界仅由集成测试断言 `action_never_resent` 覆盖。 + +--- + +## 5. 已知坑与限制 + +### 5.1 探测端点选错 → 误判断网 + +见 §4.2 前置准备第 2 条。`action=msg_count` 在该固件上始终空响应;判断在网一律用 `/msgCount`。 + +### 5.2 脚本化拖动音量条的三个坑 + +1. **按在轨道上无效**:`ThumbOnlyRangeSlider` 对轨道点击/触摸**直接忽略**(防误触高音量),按下点必须落在圆球中心 ±24px(`THUMB_HIT_RADIUS_PX`)内,圆球中心按 `9 + ratio * (trackWidth - 18)` 计算; +2. **提交值滞后一步**:`onRelease` 取的是 `valueRef.current`(上次渲染的 prop)。合成事件不等 React 重渲染,必须在 `pointermove` 与 `pointerup` 之间 `await` 一个宏任务,否则提交的是旧值; +3. **直接改 `input.value` + 派 `change` 事件不会产生任何请求**:音量提交走 `onRelease`(指针抬起),不走 `onChange`;`onChange` 只更新本地显示值。另外原生 input 已被设为 `pointer-events: none`,必须对 wrapper 派发事件;合成指针没有 active pointer,需把 `setPointerCapture` 打桩为 no-op。 + +### 5.3 待补发意图是内存态 + +`pendingIntents` 存活于页面的 `LuxsinAPI` 实例内存中。**断网期间刷新页面 = 永久丢失待补发意图**,恢复后设备停留在旧值而 UI 显示新值。若需覆盖该场景,需另做持久化(如 localStorage)设计,或在页面加载后强制以设备状态为准回写 UI。 + +### 5.4 `isConnected` 仍不随断网翻转 + +本次修复解决的是「请求堆积 + 状态突变」,**未**改动连接态判定:断网期间 UI 仍显示为已连接(只有单次操作失败提示)。若要显式掉线提示,可基于连续 N 次轮询失败来翻转 `isConnected`,属独立议题。 + +### 5.5 未同步到其他项目 + +x9 / b9 / bd9 / bh9 仍是裸 `fetch`,同样存在本 bug。同步时需按项目核对端点与设置键差异(尤其 `ACTION_ONLY_SETTING_KEYS` 的覆盖面),不可直接整文件覆盖。 + +--- + +## 6. 相关文件 + +- 传输层实现:`client/src/lib/luxsinApi.ts` +- 轮询重入保护:`client/src/contexts/DeviceContext.tsx` +- 错误文案识别:`client/src/lib/networkError.ts` +- 离线态截图:`../screenshots/x8-offline-phaseB.png` +- 恢复态截图:`../screenshots/x8-online-phaseC-restored.png` diff --git a/client/public/images/festival/newyear/banner-center.jpg b/client/public/images/festival/newyear/banner-center.jpg new file mode 100644 index 0000000..6a59575 Binary files /dev/null and b/client/public/images/festival/newyear/banner-center.jpg differ diff --git a/client/public/images/festival/newyear/banner-left.jpg b/client/public/images/festival/newyear/banner-left.jpg new file mode 100644 index 0000000..0823bab Binary files /dev/null and b/client/public/images/festival/newyear/banner-left.jpg differ diff --git a/client/public/images/festival/newyear/banner-right.jpg b/client/public/images/festival/newyear/banner-right.jpg new file mode 100644 index 0000000..74883c2 Binary files /dev/null and b/client/public/images/festival/newyear/banner-right.jpg differ diff --git a/client/public/images/festival/newyear/calendar.svg b/client/public/images/festival/newyear/calendar.svg new file mode 100644 index 0000000..c184f18 --- /dev/null +++ b/client/public/images/festival/newyear/calendar.svg @@ -0,0 +1,10 @@ + + + + + + + JAN + + + \ No newline at end of file diff --git a/client/public/images/festival/newyear/clock.svg b/client/public/images/festival/newyear/clock.svg new file mode 100644 index 0000000..7393fec --- /dev/null +++ b/client/public/images/festival/newyear/clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/client/public/images/festival/newyear/confetti.svg b/client/public/images/festival/newyear/confetti.svg new file mode 100644 index 0000000..1ebb1de --- /dev/null +++ b/client/public/images/festival/newyear/confetti.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/client/public/images/festival/newyear/fireworks.svg b/client/public/images/festival/newyear/fireworks.svg new file mode 100644 index 0000000..a540936 --- /dev/null +++ b/client/public/images/festival/newyear/fireworks.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/images/festival/newyear/party-hat.svg b/client/public/images/festival/newyear/party-hat.svg new file mode 100644 index 0000000..f3e3427 --- /dev/null +++ b/client/public/images/festival/newyear/party-hat.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/images/festival/newyear/sparkler.svg b/client/public/images/festival/newyear/sparkler.svg new file mode 100644 index 0000000..33b3f82 --- /dev/null +++ b/client/public/images/festival/newyear/sparkler.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/client/src/components/BottomNav.tsx b/client/src/components/BottomNav.tsx index 04de127..de87961 100644 --- a/client/src/components/BottomNav.tsx +++ b/client/src/components/BottomNav.tsx @@ -39,7 +39,7 @@ interface NavItem { const FORUM_URL = "https://forum.zidoo.tv/index.php"; const festivalIcon = (festival: FestivalId, name: string) => - `${import.meta.env.BASE_URL}images/festival/${festival}/${name}.png`; + `${import.meta.env.BASE_URL}images/festival/${festival}/${name}.${festival === "newyear" ? "svg" : "png"}`; const NAV_ITEMS: NavItem[] = [ { @@ -49,6 +49,7 @@ const NAV_ITEMS: NavItem[] = [ festivalIcons: { christmas: festivalIcon("christmas", "bell"), halloween: festivalIcon("halloween", "ghost"), + newyear: festivalIcon("newyear", "confetti"), }, }, { @@ -58,6 +59,7 @@ const NAV_ITEMS: NavItem[] = [ festivalIcons: { christmas: festivalIcon("christmas", "reindeer"), halloween: festivalIcon("halloween", "skeleton"), + newyear: festivalIcon("newyear", "clock"), }, }, { @@ -67,6 +69,7 @@ const NAV_ITEMS: NavItem[] = [ festivalIcons: { christmas: festivalIcon("christmas", "santa"), halloween: festivalIcon("halloween", "pumpkin"), + newyear: festivalIcon("newyear", "calendar"), }, }, { @@ -76,6 +79,7 @@ const NAV_ITEMS: NavItem[] = [ festivalIcons: { christmas: festivalIcon("christmas", "tree"), halloween: festivalIcon("halloween", "witch"), + newyear: festivalIcon("newyear", "fireworks"), }, }, { @@ -85,6 +89,7 @@ const NAV_ITEMS: NavItem[] = [ festivalIcons: { christmas: festivalIcon("christmas", "gift"), halloween: festivalIcon("halloween", "vampire"), + newyear: festivalIcon("newyear", "party-hat"), }, }, ]; diff --git a/client/src/components/festival/FestivalGreeting.tsx b/client/src/components/festival/FestivalGreeting.tsx index 92699e8..4b04508 100644 --- a/client/src/components/festival/FestivalGreeting.tsx +++ b/client/src/components/festival/FestivalGreeting.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useRef, useState } from "react"; -import { FESTIVAL_GREETINGS, type FestivalId } from "@/lib/festival"; +import { FESTIVAL_GREETINGS, getNewYearDisplayYear, type FestivalId } from "@/lib/festival"; import "./festival.css"; +import "./newyear.css"; /** * VU 区的静态插画横幅:两端角色保持比例,中央背景平铺,文字独立缩放。 @@ -15,6 +16,8 @@ export default function FestivalGreeting({ const textBoxRef = useRef(null); const innerRef = useRef(null); const [scale, setScale] = useState(1); + const [newYear] = useState(() => getNewYearDisplayYear()); + const isNewYear = festival === "newyear"; const artworkRoot = `${import.meta.env.BASE_URL}images/festival/${festival}/`; useLayoutEffect(() => { @@ -60,7 +63,7 @@ export default function FestivalGreeting({ ref={boxRef} className={`festival-greeting festival-greeting--${festival}`} role="img" - aria-label={FESTIVAL_GREETINGS[festival]} + aria-label={isNewYear ? `${FESTIVAL_GREETINGS[festival]} ${newYear}` : FESTIVAL_GREETINGS[festival]} >
))}