元旦节皮肤
@@ -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
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>测试脚本全文(可存为 scripts/req-timeout-test.mjs 长期保留)</summary>
|
||||
|
||||
```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);
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
结果:**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`
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs><linearGradient id="paper" x2=".7" y2="1"><stop stop-color="#fff7db"/><stop offset="1" stop-color="#e6c886"/></linearGradient></defs>
|
||||
<rect x="9" y="11" width="47" height="47" rx="7" fill="#9b6833"/>
|
||||
<rect x="7" y="8" width="47" height="47" rx="6" fill="url(#paper)" stroke="#f6d98f" stroke-width="2"/>
|
||||
<path d="M7 14a6 6 0 0 1 6-6h35a6 6 0 0 1 6 6v9H7Z" fill="#b84846"/>
|
||||
<path d="M19 4v9m23-9v9" stroke="#fff1c2" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="30.5" y="20" text-anchor="middle" fill="#fff8dc" font-family="Arial,sans-serif" font-size="9" font-weight="700" letter-spacing="2">JAN</text>
|
||||
<path d="m26 33 7-5v20m-7 0h14" stroke="#a83835" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="m53 39 2 5 5 2-5 2-2 5-2-5-5-2 5-2Z" fill="#fff1b5" stroke="#d4a74f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs><linearGradient id="gold" x2="1" y2="1"><stop stop-color="#fff4bb"/><stop offset=".5" stop-color="#d5a346"/><stop offset="1" stop-color="#ffe6a0"/></linearGradient></defs>
|
||||
<circle cx="32" cy="33" r="26" fill="url(#gold)"/>
|
||||
<circle cx="32" cy="33" r="21.5" fill="#142139" stroke="#fff0bd"/>
|
||||
<g stroke="#eac778" stroke-width="2.3" stroke-linecap="round"><path d="M32 15v3m0 30v3M14 33h3m30 0h3M19 20l2 2m22 22 2 2m0-26-2 2M21 44l-2 2"/></g>
|
||||
<path d="M32 19v14l-5-10" stroke="#fff2bc" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#ef9b73"/>
|
||||
<path d="m52 2 2 5 5 2-5 2-2 5-2-5-5-2 5-2Z" fill="#fff0b7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 756 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs><linearGradient id="cone" x2="1" y2="1"><stop stop-color="#ffecaa"/><stop offset="1" stop-color="#ba752d"/></linearGradient></defs>
|
||||
<path d="m8 57 10-33 23 23Z" fill="url(#cone)" stroke="#ffe9ae" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="m13 42 10 9m-7-18 17 15" stroke="#c65351" stroke-width="5"/>
|
||||
<ellipse cx="29.5" cy="35.5" rx="6.5" ry="16" transform="rotate(-45 29.5 35.5)" fill="#30233a" stroke="#f5d88b" stroke-width="2"/>
|
||||
<path d="M32 28q-1-13 8-12t8-10M37 33q5-10 16-8M27 23q-9-7-4-16" stroke="#f9d880" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="m47 35 5 4m-6 9 5-1M9 19l4-4m25-9 3-3" stroke="#e68686" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="m55 10 1.5 4 4 1.5-4 1.5-1.5 4-1.5-4-4-1.5 4-1.5Z" fill="#fff0b5"/>
|
||||
<circle cx="17" cy="6" r="2" fill="#a9cfe3"/><circle cx="59" cy="32" r="2" fill="#a9cfe3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs><radialGradient id="gold"><stop stop-color="#fff6ce"/><stop offset="1" stop-color="#e6a848"/></radialGradient></defs>
|
||||
<g stroke="#f8d780" stroke-width="2.8" stroke-linecap="round">
|
||||
<path d="M31 7v9m0 23v7M8 27h10m24 0h11M14 10l8 9m18 16 7 8M48 10l-9 9M21 36l-9 8"/>
|
||||
<path d="m22 5 3 8m14-8-3 8M8 17l9 4m27 12 8 4M7 37l10-4m28-13 9-4" stroke-width="1.6"/>
|
||||
</g>
|
||||
<path d="m31 17 3 7 8 3-8 3-3 8-3-8-8-3 8-3Z" fill="url(#gold)"/>
|
||||
<path d="M31 43q-7 10-2 17m5-11q3 5 1 10" stroke="#e6a848" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<g fill="#ef8c82"><circle cx="5" cy="8" r="2"/><circle cx="57" cy="42" r="2.5"/><circle cx="53" cy="4" r="1.8"/></g>
|
||||
<g fill="#fff4c5"><circle cx="5" cy="49" r="1.5"/><circle cx="59" cy="26" r="1.5"/><circle cx="22" cy="50" r="1.5"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 874 B |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="hat" x1="10" y1="22" x2="45" y2="51" gradientUnits="userSpaceOnUse"><stop stop-color="#435574"/><stop offset=".45" stop-color="#162139"/><stop offset="1" stop-color="#090f1c"/></linearGradient>
|
||||
<linearGradient id="gold" x1="8" y1="24" x2="46" y2="58" gradientUnits="userSpaceOnUse"><stop stop-color="#fff1ba"/><stop offset=".45" stop-color="#ecc065"/><stop offset="1" stop-color="#b77826"/></linearGradient>
|
||||
</defs>
|
||||
<path d="m42 49 11-33" stroke="#efc66e" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="m52 5 1 7m0 9 1 5m-13-11 6 1m10 1 5 1M44 7l5 5m8 9 4 4m-2-18-3 5m-12 12 5-5" stroke="#ffe8a5" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="m53 10 2 5 5 2-5 2-2 5-2-5-5-2 5-2Z" fill="#fff4ca"/>
|
||||
<g transform="rotate(-12 28 40)">
|
||||
<ellipse cx="27" cy="51" rx="24" ry="7" fill="url(#hat)" stroke="url(#gold)" stroke-width="1.8"/>
|
||||
<path d="m12 24 3 26q12 7 25 0l3-26Z" fill="url(#hat)" stroke="url(#gold)" stroke-width="1.5"/>
|
||||
<path d="m14 40 1 8q12 7 25 0l1-8q-14 6-27 0Z" fill="url(#gold)"/>
|
||||
<ellipse cx="27.5" cy="24" rx="15.5" ry="5" fill="#263650" stroke="url(#gold)" stroke-width="1.5"/>
|
||||
<path d="m29 38 1.5 3 3.5.5-2.5 2.4.6 3.5L29 46l-3.1 1.4.6-3.5-2.5-2.4 3.5-.5Z" fill="#fff5d1"/>
|
||||
</g>
|
||||
<path d="m6 13 2-4m10 2 3 3m-15 23-3 2" stroke="#ef8d77" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="31" cy="9" r="1.5" fill="#f8dc93"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="m22 59 10-30" stroke="#d8bb7c" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="m35 19 2 6 6 2-6 2-2 6-2-6-6-2 6-2Z" fill="#fff1c1"/>
|
||||
<path d="M35 9v5m0 26v4M17 27h5m25 0h5M22 14l4 4m18 18 4 4m0-27-4 5m-18 18-4 4" stroke="#e9c36d" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="15" r="1.5" fill="#ef8c82"/><circle cx="53" cy="37" r="1.5" fill="#ef8c82"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
@@ -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"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(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]}
|
||||
>
|
||||
<div
|
||||
className="festival-greeting-backdrop"
|
||||
@@ -78,6 +81,15 @@ export default function FestivalGreeting({
|
||||
/>
|
||||
))}
|
||||
<div className="festival-greeting-shade" aria-hidden="true" />
|
||||
{isNewYear && (
|
||||
<img
|
||||
src={`${artworkRoot}calendar.svg`}
|
||||
className="newyear-greeting-calendar"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={textBoxRef}
|
||||
className="festival-greeting-title"
|
||||
@@ -88,7 +100,20 @@ export default function FestivalGreeting({
|
||||
className={`${festival}-greeting-text festival-greeting-inner`}
|
||||
style={{ transform: `scale(${scale})` }}
|
||||
>
|
||||
{FESTIVAL_GREETINGS[festival].split(" ").map(word => (
|
||||
{isNewYear ? (
|
||||
<>
|
||||
<div className="newyear-greeting-heading">
|
||||
<div className="newyear-greeting-words">
|
||||
<span>Happy</span>
|
||||
<span>New Year</span>
|
||||
</div>
|
||||
<strong className="newyear-greeting-year">{newYear}</strong>
|
||||
</div>
|
||||
<span className="newyear-greeting-tagline">
|
||||
NEW YEAR · NEW CHAPTER · MORE POSSIBILITIES
|
||||
</span>
|
||||
</>
|
||||
) : FESTIVAL_GREETINGS[festival].split(" ").map(word => (
|
||||
<span key={word} className="festival-greeting-line">
|
||||
{word}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { memo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import "./newyear.css";
|
||||
|
||||
const ROOT = `${import.meta.env.BASE_URL}images/festival/newyear/`;
|
||||
|
||||
/** 新年礼帽与仙女棒只替换视觉层,拖拽命中和音量提交仍由滑块管理。 */
|
||||
export const NewYearVolumeThumb = memo(function NewYearVolumeThumb() {
|
||||
return (
|
||||
<img
|
||||
src={`${ROOT}party-hat.svg`}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="newyear-volume-thumb"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/** 两端占位仍为 24px:最小音量是小仙女棒,最大音量是绽放的烟花。 */
|
||||
export function NewYearVolumeIcon({ dim = false }: { dim?: boolean }) {
|
||||
return (
|
||||
<span className="newyear-volume-icon-slot" aria-hidden="true">
|
||||
<img
|
||||
src={`${ROOT}${dim ? "sparkler" : "fireworks"}.svg`}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className={cn("newyear-volume-icon", dim && "newyear-volume-icon--dim")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 卡片顶部的金色彩带与星屑,不覆盖端口、不拦截指针事件。 */
|
||||
export const NewYearCardDecoration = memo(function NewYearCardDecoration() {
|
||||
return (
|
||||
<div className="newyear-card-decoration" aria-hidden="true">
|
||||
<svg viewBox="0 0 600 20" preserveAspectRatio="none" focusable="false">
|
||||
<path
|
||||
d="M0 2Q150 19 300 5T600 2"
|
||||
fill="none"
|
||||
stroke="#e7bd6b"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.7"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
{[7, 20, 35, 51, 67, 82, 95].map((left, index) => (
|
||||
<span
|
||||
key={left}
|
||||
className="newyear-card-star"
|
||||
style={{ left: `${left}%`, top: index % 2 ? 7 : 2 }}
|
||||
>
|
||||
{index % 2 ? "·" : "✦"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/* 元旦视觉层:保留滑轨尺寸、青色音量反馈与所有交互热区。 */
|
||||
.newyear-volume-thumb {
|
||||
display: block;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
filter: drop-shadow(0 2px 4px #000) drop-shadow(0 0 3px rgba(242, 196, 98, 0.35));
|
||||
}
|
||||
|
||||
.newyear-volume-icon-slot {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.newyear-volume-icon {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
max-width: none;
|
||||
transform: translate(-50%, -50%);
|
||||
filter: drop-shadow(0 0 3px rgba(242, 196, 98, 0.3));
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.newyear-volume-icon--dim {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.newyear-card-decoration {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 20px;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.newyear-card-decoration * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.newyear-card-decoration > svg {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.newyear-card-star {
|
||||
position: absolute;
|
||||
color: #e7bd6b;
|
||||
opacity: 0.75;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.festival-greeting--newyear {
|
||||
background: #071221;
|
||||
}
|
||||
|
||||
.festival-greeting--newyear .festival-greeting-art {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.festival-greeting--newyear .festival-greeting-shade {
|
||||
background: linear-gradient(90deg, transparent, rgba(3, 9, 20, 0.3) 40%, rgba(3, 9, 20, 0.3) 60%, transparent);
|
||||
}
|
||||
|
||||
/* 标题整体仍由共享组件测量缩放,窄屏按三行排列,宽屏增加副标题。 */
|
||||
.festival-greeting.festival-greeting--newyear[data-layout] .newyear-greeting-text {
|
||||
flex-direction: column;
|
||||
color: #ffe8ae;
|
||||
text-shadow: 0 1px 1px #71390d, 0 0 8px rgba(236, 145, 52, 0.6);
|
||||
}
|
||||
|
||||
.newyear-greeting-heading,
|
||||
.newyear-greeting-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.newyear-greeting-words {
|
||||
font-family: "Snell Roundhand", "Apple Chancery", "URW Chancery L", Georgia, cursive;
|
||||
font-size: 36px;
|
||||
font-style: italic;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.newyear-greeting-year {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 43px;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
color: #ffd57a;
|
||||
}
|
||||
|
||||
.newyear-greeting-tagline {
|
||||
display: none;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 8px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 2px;
|
||||
color: #f5da9b;
|
||||
text-shadow: 0 1px 3px #000;
|
||||
}
|
||||
|
||||
.newyear-greeting-calendar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
left: 22px;
|
||||
bottom: 4px;
|
||||
transform: rotate(-9deg);
|
||||
filter: drop-shadow(0 2px 4px #000);
|
||||
}
|
||||
|
||||
.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-calendar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-heading,
|
||||
.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-words {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-words > span + span {
|
||||
margin-left: 0.22em;
|
||||
}
|
||||
|
||||
.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-year {
|
||||
margin-left: 18px;
|
||||
font-size: 54px;
|
||||
}
|
||||
|
||||
.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-tagline {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [api, setApi] = useState<LuxsinAPI | null>(null);
|
||||
const lastMsgCountRef = useRef<number>(-1);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
/** 轮询重入保护:上一轮未 settle(如断网挂起至超时)则跳过本轮,避免挂起请求堆叠。 */
|
||||
const pollInFlightRef = useRef(false);
|
||||
const autoConnectStartedRef = useRef(false);
|
||||
|
||||
const setDeviceIp = useCallback((newIp: string) => {
|
||||
@@ -260,6 +262,8 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!isConnected || isDemoMode || !api) return;
|
||||
|
||||
const poll = async () => {
|
||||
if (pollInFlightRef.current) return;
|
||||
pollInFlightRef.current = true;
|
||||
try {
|
||||
const count = await api.getMsgCount();
|
||||
if (count !== lastMsgCountRef.current) {
|
||||
@@ -270,6 +274,8 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
} catch {
|
||||
// Silently fail polling
|
||||
} finally {
|
||||
pollInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
============================================================ */
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type FestivalId = "christmas" | "halloween";
|
||||
export type FestivalId = "christmas" | "halloween" | "newyear";
|
||||
|
||||
/** Inclusive [month, day] windows. Christmas: Dec 20 – Dec 26; Halloween: Oct 26 – Nov 1. */
|
||||
const FESTIVAL_WINDOWS: Record<
|
||||
@@ -15,6 +15,8 @@ const FESTIVAL_WINDOWS: Record<
|
||||
> = {
|
||||
christmas: { from: [12, 20], to: [12, 26] },
|
||||
halloween: { from: [10, 26], to: [11, 1] },
|
||||
// 元旦接续圣诞节,跨年窗口包含首尾日期。
|
||||
newyear: { from: [12, 27], to: [1, 3] },
|
||||
};
|
||||
|
||||
function inWindow(
|
||||
@@ -55,8 +57,14 @@ export function useFestival(): FestivalId | null {
|
||||
return useMemo(() => getFestival(), []);
|
||||
}
|
||||
|
||||
/** 跨年夜展示即将到来的年份,其余月份预览展示当年。 */
|
||||
export function getNewYearDisplayYear(now: Date = new Date()): number {
|
||||
return now.getFullYear() + (now.getMonth() === 11 ? 1 : 0);
|
||||
}
|
||||
|
||||
/** 首页 VU 区的节日问候语,键与 FestivalId 对应。 */
|
||||
export const FESTIVAL_GREETINGS: Record<FestivalId, string> = {
|
||||
christmas: "Merry Christmas",
|
||||
halloween: "Happy Halloween",
|
||||
newyear: "Happy New Year",
|
||||
};
|
||||
|
||||
@@ -322,9 +322,37 @@ function normalizeDeviceHost(input: string): string {
|
||||
// ============================================================
|
||||
// API Class
|
||||
// ============================================================
|
||||
|
||||
/** 设备请求统一超时:超时即 abort 并抛 DeviceTimeoutError,避免断网时请求无限挂起堆叠。 */
|
||||
export const DEVICE_FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
/** 设备请求超时错误;文案含 "timed out" 以便 networkError 识别为网络类错误。 */
|
||||
export class DeviceTimeoutError extends Error {
|
||||
constructor(url: string) {
|
||||
super(`Device request timed out: ${url}`);
|
||||
this.name = "DeviceTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作型设置键:语义是“触发一次”(关机/蓝牙播控),失败即丢弃,
|
||||
* 重连后绝不补发(否则设备会在用户无预期时关机或切歌)。
|
||||
*/
|
||||
const ACTION_ONLY_SETTING_KEYS = new Set(["power", "bt_play", "bt_next"]);
|
||||
|
||||
interface WriteSlot {
|
||||
controller: AbortController;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export class LuxsinAPI {
|
||||
private baseUrl: string;
|
||||
private peqRequestQueue: Promise<void> = Promise.resolve();
|
||||
/** 状态型设置按通道(参数键组合)合并:超时/断网后保留每通道最新意图,重连后补发一次。 */
|
||||
private pendingIntents = new Map<string, Record<string, string | number>>();
|
||||
/** 每通道至多一个在途写请求;新命令 abort 旧在途请求(last-write-wins)。 */
|
||||
private inflightWrites = new Map<string, WriteSlot>();
|
||||
private flushScheduled = false;
|
||||
|
||||
/** EQ 写入、试听、选择和目录读取按调用顺序执行;单次失败不阻塞后续重试。 */
|
||||
private enqueuePeqRequest<T>(request: () => Promise<T>): Promise<T> {
|
||||
@@ -353,7 +381,7 @@ export class LuxsinAPI {
|
||||
|
||||
async getDeviceState(): Promise<DeviceState> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`);
|
||||
const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi?action=syncData`);
|
||||
const text = await response.text();
|
||||
const decoded = decodeCustomBase64(text.trim());
|
||||
const state = JSON.parse(decoded) as DeviceState;
|
||||
@@ -366,30 +394,122 @@ export class LuxsinAPI {
|
||||
}
|
||||
|
||||
async getMsgCount(): Promise<number> {
|
||||
const response = await fetch(`${this.baseUrl}/msgCount`);
|
||||
const response = await this.timedFetch(`${this.baseUrl}/msgCount`);
|
||||
const text = await response.text();
|
||||
return parseInt(text.trim(), 10);
|
||||
}
|
||||
|
||||
async refreshState(): Promise<void> {
|
||||
await fetch(`${this.baseUrl}/msgAdd`);
|
||||
await this.timedFetch(`${this.baseUrl}/msgAdd`);
|
||||
}
|
||||
|
||||
async setSetting(params: Record<string, string | number>): Promise<void> {
|
||||
const request = async () => {
|
||||
// EQ 选择与 PEQ 读写共用串行队列:只加超时(保证队列排空),不合并不补发。
|
||||
if (params.peqSelect !== undefined) {
|
||||
return this.enqueuePeqRequest(() => this.sendSettingRequest(params));
|
||||
}
|
||||
// 动作型命令:发一次,超时/断网即丢弃,重连后不补发。
|
||||
if (Object.keys(params).some((k) => ACTION_ONLY_SETTING_KEYS.has(k))) {
|
||||
return this.sendSettingRequest(params);
|
||||
}
|
||||
const channel = Object.keys(params).sort().join(",");
|
||||
return this.sendCoalescedSetting(channel, params);
|
||||
}
|
||||
|
||||
private async sendSettingRequest(params: Record<string, string | number>): Promise<void> {
|
||||
const query = Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("&");
|
||||
const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
|
||||
if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态型设置按通道合并发送:新命令 abort 同通道旧在途请求(旧传输直接丢弃),
|
||||
* 每通道至多保留一个在途的“最新意图”;超时或网络错误时把意图挂入 pendingIntents,
|
||||
* 待任意设备请求成功(即重连)后由 onDeviceResponseOk 补发一次。
|
||||
* 被新命令取代的旧 Promise 静默 resolve——其值已被更新意图覆盖,无需向调用方报错。
|
||||
*/
|
||||
private async sendCoalescedSetting(
|
||||
channel: string,
|
||||
params: Record<string, string | number>,
|
||||
): Promise<void> {
|
||||
const previous = this.inflightWrites.get(channel);
|
||||
if (previous) previous.controller.abort();
|
||||
this.pendingIntents.delete(channel);
|
||||
const slot: WriteSlot = { controller: new AbortController(), timedOut: false };
|
||||
this.inflightWrites.set(channel, slot);
|
||||
try {
|
||||
const query = Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("&");
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
|
||||
const response = await this.timedFetch(
|
||||
`${this.baseUrl}/dev/info.cgi?action=setting&${query}`,
|
||||
undefined,
|
||||
slot,
|
||||
);
|
||||
if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`);
|
||||
};
|
||||
if (params.peqSelect !== undefined) return this.enqueuePeqRequest(request);
|
||||
return request();
|
||||
this.pendingIntents.delete(channel);
|
||||
} catch (error) {
|
||||
const superseded = !slot.timedOut && slot.controller.signal.aborted;
|
||||
if (!superseded) {
|
||||
// 超时或真实网络错误:保留最新意图,等重连后补发;仍向调用方报错。
|
||||
this.pendingIntents.set(channel, params);
|
||||
throw error;
|
||||
}
|
||||
// 被更新命令取代:本次传输丢弃,不补发、不报错。
|
||||
} finally {
|
||||
if (this.inflightWrites.get(channel) === slot) this.inflightWrites.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
/** 任意设备请求成功即视为链路恢复:把挂起的每通道最新意图依次补发一次。 */
|
||||
private onDeviceResponseOk() {
|
||||
if (this.flushScheduled || this.pendingIntents.size === 0) return;
|
||||
const entries: Array<[string, Record<string, string | number>]> = [];
|
||||
this.pendingIntents.forEach((params, channel) => entries.push([channel, params]));
|
||||
this.pendingIntents.clear();
|
||||
this.flushScheduled = true;
|
||||
void (async () => {
|
||||
for (const [channel, params] of entries) {
|
||||
// 同通道已有用户在途命令时它比挂起意图更新,跳过补发。
|
||||
if (this.inflightWrites.has(channel)) continue;
|
||||
try {
|
||||
await this.sendCoalescedSetting(channel, params);
|
||||
} catch {
|
||||
// 仍不可达:sendCoalescedSetting 已把意图重新挂起,等下一次成功事件。
|
||||
}
|
||||
}
|
||||
this.flushScheduled = false;
|
||||
})();
|
||||
}
|
||||
|
||||
/** 设备请求统一超时包装:到点 abort 并抛 DeviceTimeoutError;成功则触发挂起意图补发。 */
|
||||
private async timedFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
slot?: WriteSlot,
|
||||
): Promise<Response> {
|
||||
const own: WriteSlot = slot ?? { controller: new AbortController(), timedOut: false };
|
||||
const timer = setTimeout(() => {
|
||||
own.timedOut = true;
|
||||
own.controller.abort();
|
||||
}, DEVICE_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: own.controller.signal });
|
||||
this.onDeviceResponseOk();
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (own.timedOut) throw new DeviceTimeoutError(url);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async getPeqState(): Promise<PeqState> {
|
||||
return this.enqueuePeqRequest(async () => {
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
|
||||
const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
|
||||
if (!response.ok) throw new Error(`PEQ read failed: ${response.status}`);
|
||||
const text = await response.text();
|
||||
const decoded = decodeCustomBase64(text.trim());
|
||||
@@ -417,7 +537,7 @@ export class LuxsinAPI {
|
||||
const encoded = encodeCustomBase64(JSON.stringify(body));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: form.toString(),
|
||||
@@ -432,7 +552,7 @@ export class LuxsinAPI {
|
||||
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: form.toString(),
|
||||
@@ -448,7 +568,7 @@ export class LuxsinAPI {
|
||||
const encoded = encodeCustomBase64(JSON.stringify(payload));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: form.toString(),
|
||||
@@ -464,7 +584,7 @@ export class LuxsinAPI {
|
||||
const encoded = encodeCustomBase64(JSON.stringify(payload));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: form.toString(),
|
||||
|
||||
@@ -8,6 +8,8 @@ const NETWORK_FETCH_HINTS = [
|
||||
"network request failed",
|
||||
"load failed",
|
||||
"internet connection appears to be offline",
|
||||
// DeviceTimeoutError:设备请求超时基本等价于设备不可达。
|
||||
"timed out",
|
||||
];
|
||||
|
||||
export function isLikelyNetworkFetchError(error: unknown): boolean {
|
||||
|
||||
@@ -20,6 +20,11 @@ import HalloweenVolumeThumb from "@/components/festival/HalloweenVolumeThumb";
|
||||
import HalloweenPumpkinIcon from "@/components/festival/HalloweenPumpkinIcon";
|
||||
import HalloweenCardDecoration from "@/components/festival/HalloweenCardDecoration";
|
||||
import FestivalGreeting from "@/components/festival/FestivalGreeting";
|
||||
import {
|
||||
NewYearCardDecoration,
|
||||
NewYearVolumeIcon,
|
||||
NewYearVolumeThumb,
|
||||
} from "@/components/festival/NewYearDecoration";
|
||||
import "@/components/festival/christmas.css";
|
||||
import "@/components/festival/halloween.css";
|
||||
import "@/components/festival/festival.css";
|
||||
@@ -203,6 +208,7 @@ export default function Home() {
|
||||
const festival = useFestival();
|
||||
const isChristmas = festival === "christmas";
|
||||
const isHalloween = festival === "halloween";
|
||||
const isNewYear = festival === "newyear";
|
||||
const powerActionRef = useRef(false);
|
||||
const hpEqLastTapRef = useRef<number | null>(null);
|
||||
const hpEqNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -559,6 +565,7 @@ export default function Home() {
|
||||
style={{ background: "rgba(18,18,20,0.8)", minHeight: 140 }}>
|
||||
{isChristmas && <ChristmasCardDecoration />}
|
||||
{isHalloween && <HalloweenCardDecoration />}
|
||||
{isNewYear && <NewYearCardDecoration />}
|
||||
{/* Stylized device illustration */}
|
||||
<div className="w-full relative" style={{ height: 140 }}>
|
||||
{/* Device body */}
|
||||
@@ -687,6 +694,7 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={volumeControlsLocked}
|
||||
aria-label={homeText.volumeMinConfirmOk ?? "设为最小音量"}
|
||||
onClick={() => guardVolumeChange(() => setVolumeConfirm("min"))}
|
||||
className={cn(
|
||||
"text-white/50 active:text-white transition-colors flex-shrink-0",
|
||||
@@ -697,6 +705,8 @@ export default function Home() {
|
||||
<ChristmasBellIcon dim />
|
||||
) : isHalloween ? (
|
||||
<HalloweenPumpkinIcon dim />
|
||||
) : isNewYear ? (
|
||||
<NewYearVolumeIcon dim />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
@@ -744,7 +754,9 @@ export default function Home() {
|
||||
? () => <ChristmasVolumeThumb />
|
||||
: isHalloween
|
||||
? () => <HalloweenVolumeThumb />
|
||||
: undefined
|
||||
: isNewYear
|
||||
? () => <NewYearVolumeThumb />
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -752,6 +764,7 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={volumeControlsLocked}
|
||||
aria-label={homeText.volumeMaxConfirmOk ?? "设为最大音量"}
|
||||
onClick={() => guardVolumeChange(() => setVolumeConfirm("max"))}
|
||||
className={cn(
|
||||
"text-white/50 active:text-white transition-colors flex-shrink-0",
|
||||
@@ -762,6 +775,8 @@ export default function Home() {
|
||||
<ChristmasBellIcon />
|
||||
) : isHalloween ? (
|
||||
<HalloweenPumpkinIcon />
|
||||
) : isNewYear ? (
|
||||
<NewYearVolumeIcon />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 运行:node --test scripts/festival-regression.test.mjs;无需硬件。
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
const require = createRequire(import.meta.url);
|
||||
// pnpm 中以渲染器的解析路径统一 React 实例,避免测试环境的重复副本。
|
||||
const react = createRequire(require.resolve("react-dom/server"))("react");
|
||||
const { createElement } = react;
|
||||
const { renderToStaticMarkup } = require("react-dom/server");
|
||||
const src = fileURLToPath(new URL("../client/src", import.meta.url));
|
||||
const base = "/x8/v2/modern/";
|
||||
async function loadModule(relative) {
|
||||
const result = await build({
|
||||
entryPoints: [`${src}/${relative}`],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
jsx: "automatic",
|
||||
external: ["react", "react/*"],
|
||||
loader: { ".css": "empty" },
|
||||
alias: { "@": src },
|
||||
define: { "import.meta.env.BASE_URL": JSON.stringify(base) },
|
||||
});
|
||||
const module = { exports: {} };
|
||||
const moduleRequire = id => id === "react" ? react : require(id);
|
||||
new Function("require", "module", "exports", result.outputFiles[0].text)(moduleRequire, module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
const { getFestival, getNewYearDisplayYear, FESTIVAL_GREETINGS } = await loadModule("lib/festival.ts");
|
||||
const { NewYearVolumeThumb, NewYearVolumeIcon, NewYearCardDecoration } = await loadModule("components/festival/NewYearDecoration.tsx");
|
||||
const { default: FestivalGreeting } = await loadModule("components/festival/FestivalGreeting.tsx");
|
||||
const date = (year, month, day, hour = 12) => new Date(year, month - 1, day, hour);
|
||||
|
||||
for (const [year, month, day, expected] of [
|
||||
[2026, 12, 19, null], [2026, 12, 20, "christmas"],
|
||||
[2026, 12, 26, "christmas"], [2026, 12, 27, "newyear"],
|
||||
[2026, 12, 31, "newyear"], [2027, 1, 1, "newyear"],
|
||||
[2027, 1, 3, "newyear"], [2027, 1, 4, null],
|
||||
[2026, 10, 25, null], [2026, 10, 26, "halloween"],
|
||||
[2026, 11, 1, "halloween"], [2026, 11, 2, null],
|
||||
[2027, 12, 27, "newyear"], [2028, 1, 3, "newyear"],
|
||||
]) {
|
||||
test(`日期窗口 ${year}-${month}-${day} → ${expected}`, () => {
|
||||
for (const hour of [0, 12, 23]) {
|
||||
assert.equal(getFestival(date(year, month, day, hour), ""), expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("URL 强制开关与未知值回退不改变原节日行为", () => {
|
||||
for (const festival of ["newyear", "christmas", "halloween"]) {
|
||||
assert.equal(getFestival(date(2026, 6, 1), `?festival=${festival}`), festival);
|
||||
}
|
||||
for (const off of ["off", "none"]) {
|
||||
assert.equal(getFestival(date(2026, 12, 31), `?festival=${off}`), null);
|
||||
}
|
||||
for (const unknown of ["", "invalid", "toString", "__proto__"]) {
|
||||
assert.equal(getFestival(date(2026, 12, 31), `?festival=${unknown}`), "newyear");
|
||||
}
|
||||
assert.equal(getFestival(date(2026, 12, 25), "?festival=newyear"), "newyear");
|
||||
});
|
||||
|
||||
test("年份在十二月指向下一年,一月保持同一跨年年份", () => {
|
||||
assert.equal(getNewYearDisplayYear(date(2026, 12, 27)), 2027);
|
||||
assert.equal(getNewYearDisplayYear(date(2026, 12, 31)), 2027);
|
||||
assert.equal(getNewYearDisplayYear(date(2027, 1, 1)), 2027);
|
||||
assert.equal(getNewYearDisplayYear(date(2027, 6, 1)), 2027);
|
||||
assert.equal(getNewYearDisplayYear(date(2027, 12, 31)), 2028);
|
||||
});
|
||||
|
||||
test("滑块及大小音量使用独立元旦素材,无圣诞老人、铃铛或万圣节图案", () => {
|
||||
const cases = [
|
||||
[NewYearVolumeThumb, {}, "party-hat.svg"],
|
||||
[NewYearVolumeIcon, { dim: true }, "sparkler.svg"],
|
||||
[NewYearVolumeIcon, {}, "fireworks.svg"],
|
||||
];
|
||||
for (const [Component, props, asset] of cases) {
|
||||
const html = renderToStaticMarkup(createElement(Component, props));
|
||||
assert.ok(html.includes(`${base}images/festival/newyear/${asset}`));
|
||||
assert.doesNotMatch(html, /christmas|halloween|santa|bell\.png/);
|
||||
assert.match(html, /draggable="false"/);
|
||||
}
|
||||
const decoration = renderToStaticMarkup(createElement(NewYearCardDecoration));
|
||||
assert.match(decoration, /aria-hidden="true"/);
|
||||
});
|
||||
|
||||
test("三套节日横幅正常渲染,元旦包含动态年份与 JAN 1 日历", () => {
|
||||
for (const festival of ["newyear", "christmas", "halloween"]) {
|
||||
const html = renderToStaticMarkup(createElement(FestivalGreeting, { festival }));
|
||||
assert.ok(html.includes(FESTIVAL_GREETINGS[festival]));
|
||||
assert.ok(html.includes(`${base}images/festival/${festival}/banner-center.jpg`));
|
||||
if (festival === "newyear") {
|
||||
assert.ok(html.includes(String(getNewYearDisplayYear())));
|
||||
assert.ok(html.includes("calendar.svg"));
|
||||
assert.match(html, /NEW YEAR · NEW CHAPTER · MORE POSSIBILITIES/);
|
||||
} else {
|
||||
assert.doesNotMatch(html, /newyear-greeting-year|calendar\.svg/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("元旦全部素材及五个导航映射存在,部署子路径得到保留", () => {
|
||||
const assets = ["party-hat.svg", "sparkler.svg", "fireworks.svg", "calendar.svg", "clock.svg", "confetti.svg",
|
||||
"banner-left.jpg", "banner-center.jpg", "banner-right.jpg"];
|
||||
for (const asset of assets) {
|
||||
assert.ok(existsSync(new URL(`../client/public/images/festival/newyear/${asset}`, import.meta.url)), asset);
|
||||
}
|
||||
const nav = readFileSync(`${src}/components/BottomNav.tsx`, "utf8");
|
||||
const names = [...nav.matchAll(/newyear: festivalIcon\("newyear", "([^"]+)"\)/g)].map(match => match[1]);
|
||||
assert.deepEqual(names, ["confetti", "clock", "calendar", "fireworks", "party-hat"]);
|
||||
});
|
||||