diff --git a/README.md b/README.md index 9298aab0..9019458e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Anatomy Gallery](plugins/zch0227/anatomy-gallery/) | A gallery for interactive isometric figures made with the anatomy skill: hang them in rooms you organize, view light and dark editions full screen, keep every version, and have an Agent create or edit figures from the gallery with a per-run process log. The anatomy skill is bundled. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | | [仓库透视 / Repo X-Ray](plugins/yujiachen-y/repo-xray/) | Understand a codebase from its Git history: a code map you can drill into, hotspots and bug magnets, high-risk files, owners and knowledge loss, commit rhythm and firefighting, and the tech stack. Works with GitHub, GitLab, and local repositories; analysis runs on your machine | [yujiachen-y](https://github.com/yujiachen-y) | | [Disk Insight](plugins/zch0227/disk-insight/) | See where disk space went on macOS and Windows with a sunburst space map, find junk files sorted into three safety tiers, large and duplicate files, and how much code repositories and Git worktrees take; ask an Agent about any item, and permanently delete what you select after a confirmation that lists every item. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | +| [A/时差保安 / Timezone Guard](plugins/liuchen19960902/timezone-guard/) | Check the other side's local time before you message: the target city's clock turns into a green "now is fine", yellow "keep it short", or red "go to sleep" verdict. Requires MiniMax Code 3.1.2 or later | [FineCat](https://github.com/LiuChen19960902) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index 17946852..08b4ccd0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -75,6 +75,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | | [仓库透视](plugins/yujiachen-y/repo-xray/README.zh-CN.md) | 从 Git 历史看懂一个代码仓库:可逐层下钻的代码地图、热点与 Bug 磁铁、高风险文件、负责人与知识流失、提交节奏与救火记录、技术栈;支持 GitHub、GitLab 和本机仓库,全部在本机分析 | [yujiachen-y](https://github.com/yujiachen-y) | | [磁盘透视](plugins/zch0227/disk-insight/README.zh-CN.md) | 查看 macOS 和 Windows 的磁盘空间去向:旭日图空间地图,按三档风险分类的垃圾文件,大文件和重复文件,代码仓库和 Git worktree 的占用;任何条目都可以问 Agent,选中的条目在确认框里逐一列出,确认后直接永久删除。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | +| [A/时差保安](plugins/liuchen19960902/timezone-guard/README.zh-CN.md) | 发消息前先看对方那边几点:按对方城市当地时间给出「现在可以聊 / 短聊就好 / 先睡,别发」三档判定。需要 MiniMax Code 3.1.2 或更高版本 | [FineCat](https://github.com/LiuChen19960902) |
预览:Token 用量看板 diff --git a/plugins/liuchen19960902/timezone-guard/.minimax-plugin/plugin.json b/plugins/liuchen19960902/timezone-guard/.minimax-plugin/plugin.json new file mode 100644 index 00000000..e87d85d5 --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/.minimax-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "timezone-guard", + "displayName": "A/时差保安", + "version": "1.0.1", + "description": "别在对方睡着的时区跟 Claude 聊天。A/时差保安实时对照对方当地时间:绿了正常聊,黄了短聊,红了先睡。", + "author": "FineCat", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "打开A/时差保安,看看洛杉矶现在该不该开口", + "洛杉矶现在几点,对照上海" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/liuchen19960902/timezone-guard/LICENSE b/plugins/liuchen19960902/timezone-guard/LICENSE new file mode 100644 index 00000000..7defdd89 --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FineCat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/liuchen19960902/timezone-guard/README.md b/plugins/liuchen19960902/timezone-guard/README.md new file mode 100644 index 00000000..66879f0f --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/README.md @@ -0,0 +1,136 @@ +# A/时差保安 (Timezone Guard) + +[English](README.md) | [简体中文](README.zh-CN.md) + +One clock, one verdict: is it a reasonable time to message someone in another time zone? The verdict +follows the **other** city's local time, because what matters is whether *they* are awake. + +Author: [FineCat](https://github.com/LiuChen19960902) · Version: `1.0.1` + +![A/时差保安 showing the verdict, with the target city clock above the local one](docs/preview.png) + +*The page itself is in Chinese. This screenshot is the light theme, taken while Los Angeles was at +20:23 (the evening band); the dark theme is below.* + +![A/时差保安 in the dark theme](docs/preview-dark.png) + +## What it answers + +**Whether to speak up right now.** + +The verdict reads the **target city's local time**, not yours — it is their convenience that +matters. Your own city is shown for reference only. The thresholds are the same for every city, +regardless of hemisphere or local habits. + +| Target local time | Band | Verdict | +| --- | --- | --- | +| 09:00–18:00 | Day (green) | Now is fine | +| 18:00–22:00 | Evening (yellow) | Keep it short | +| 06:00–09:00 | Early morning (yellow) | Don't go deep | +| 22:00–06:00 | Night (red) | Go to sleep, don't send | + +## What is on the page + +- **One verdict** — a 24 px status-coloured headline, the single subject of the page. Colour is + never the only carrier of meaning; text and shape carry it too. +- **Two clocks** — the target city on top, your own below, each with its date and short time zone + name (PDT, GMT+8, and so on). 12-hour and 24-hour formats are both available. +- **A 24-cell strip** — one cell per hour of the target city's day, drawn as a sun, a half sun on + the horizon, or a moon. The current hour is filled with the verdict colour, and the four + thresholds land exactly on cell boundaries. +- **A one-line tip** — under the verdict, one line reads "their band + time range + one sentence + of advice", and below it an optional time-difference note appears when your own side is the + later one. No message templates, no copy button, and nothing is pushed into a chat box. +- **Every time zone** — all 418 time zones the runtime knows about are covered, and in the tables + shipped with this package each one has a Chinese name (the count follows the runtime's own time + zone database; a zone the runtime adds later and the shipped tables do not know yet falls back + to its English name). Country-level entries such as "China (default)" are there too. Either + city can be changed on its own, and the ⇄ in the title swaps the two in one click. + +Times and offsets are computed locally with the browser's `Intl` and the runtime's own time zone +database, so daylight saving time is handled for you. **No network, no account, no push, not a +calendar.** + +## Install + +Copy the whole directory, **including the hidden `.minimax-plugin` directory**, into the MiniMax +Code plugins directory: + +| System | Target path | +| --- | --- | +| Windows | `C:\Users\\.minimax\plugins\timezone-guard` | +| macOS | `/Users//.minimax/plugins/timezone-guard` | +| Linux | `/home//.minimax/plugins/timezone-guard` | + +`.minimax` is a hidden directory: on Windows enable "Hidden items" in File Explorer, on macOS press +`Cmd + Shift + .`. If your client uses a custom data directory, put the package in that +directory's `plugins/` folder instead. + +Then restart MiniMax Code, confirm the plugin is recognised and enabled, and ask the Agent to open +"A/时差保安" — or say "打开A/时差保安,看看洛杉矶现在该不该开口". + +To update, close the app and exit MiniMax Code first, then replace the whole plugin directory. + +## Tested environment + +MiniMax Code desktop **3.1.4** on Windows 11 Home (10.0.26300, x64). This package was developed +and tested against 3.1.4. + +Requires MiniMax Code **3.1.2** or later: the package declares `surface.window` (the size of the +popped-out window), so older clients cannot install it at all. + +Checked inside the client: install and open; the verdict switches correctly across its four time +bands (day, early morning, evening, night); either city can be changed on its own, and the ⇄ in +the title swaps the pair with one click (checked with a mouse click and with Enter / Space — both +clocks, the time difference, the hour strip, the verdict and the two city inputs all follow the +swap and persist); the city list +covers every time zone the runtime knows about (418 on the tested runtime), each with a Chinese +name and no dead entry; the 12/24-hour toggle persists; both the light and the dark theme render +correctly; the page renders at the panel's default width. + +Layout was additionally measured against this package's own client files in Chromium 155 at 400 / +460 / 560 / 680 / 1100 / 1440 CSS pixels wide: no horizontal overflow, no double scrollbar, no +stray whitespace under the card. + +**Not verified:** macOS and Linux; popping the page out into its own window (`surface.window`) was +never exercised — the page is a single responsive column either way. + +## Data & access + +- **Files read** — only the four static files inside its own package directory: + `miniapp/client/index.html`, `styles.css`, `cities.js`, `app.js`. Nothing outside the package is + read, and the disk is never scanned. +- **Files written** — none. `context.dataDir` is not used and nothing is written anywhere. +- **Browser storage** — interface preferences only, under the key `timezone-guard.prefs.v1`: the + selected pair of cities, the theme, and the 12/24-hour format. No session and no content data. +- **Network** — none. The page loads its assets from the plugin's own loopback server; there is no + telemetry, and no remote font, icon, or script. +- **Processes** — none spawned. +- **Agent turns** — none. The package declares no `agentAccess` and starts no Agent turn. +- **Credentials** — no API key, no account, no configuration of any kind. + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest +package.json Mini App declaration +miniapp/miniapp.json Payload roots, Node entry, page route +miniapp/client/index.html The page served at /dashboard, including the SVG sprite +miniapp/client/styles.css Visual layer +miniapp/client/app.js Time zone logic, verdict, city picker, 24-hour strip +miniapp/client/cities.js City list and Chinese names for every time zone +miniapp/node/server.mjs Node entry: start(context) → { dispose } +miniapp/node/miniapp-api.ts Type declarations for the runtime context +docs/preview.png Screenshot of the page, light theme +docs/preview-dark.png Screenshot of the page, dark theme +icon.png Plugin icon +``` + +The page is plain HTML, CSS, and JavaScript — no framework, no CDN, no build step, fully offline. +The Node entry only serves `/dashboard` and those four files on the port the Host assigns. There +are **no third-party runtime dependencies**: the Node side uses built-in modules only, and the +browser side uses native APIs only. + +## License + +[MIT](./LICENSE). diff --git a/plugins/liuchen19960902/timezone-guard/README.zh-CN.md b/plugins/liuchen19960902/timezone-guard/README.zh-CN.md new file mode 100644 index 00000000..cba106e5 --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/README.zh-CN.md @@ -0,0 +1,84 @@ +# A/时差保安 + +[English](README.md) | 简体中文 + +发消息之前先看一眼对方那边几点。一个只有结论、没有废话的时差仪表:**该正常聊、该短聊、还是该先睡**,一律按对方城市的当地时间判定。 + +作者:[FineCat](https://github.com/LiuChen19960902) · 版本:`1.0.1` + +![A/时差保安:判定区给出「短聊就好」,下方是对方城市与自己的两个时钟](docs/preview.png) + +*浅色主题。截图拍摄时洛杉矶是 20:23,落在傍晚档;深色主题见下图。* + +![A/时差保安:深色主题](docs/preview-dark.png) + +## 它回答什么 + +**此刻该不该开口跟对方说话。** + +判定只看**对方城市的当地时间** —— 不方便的是对方,不是你;自己的城市只作对照。所以人在上海盯洛杉矶,和人在伦敦盯上海,是同一套标准。 + +| 对方当地时间 | 档位 | 结论 | +| --- | --- | --- | +| 09:00–18:00 | 白天(绿) | 现在可以聊 | +| 18:00–22:00 | 傍晚(黄) | 短聊就好 | +| 06:00–09:00 | 清晨(黄) | 先别展开 | +| 22:00–06:00 | 深夜(红) | 先睡,别发 | + +阈值对所有城市完全一致,不因东西半球或作息文化而变。 + +## 页面里有什么 + +- **一个判定** —— 24 px 状态色大字,页面的唯一主角。颜色之外始终有文字和形状,不靠颜色单独传意。 +- **两个时钟** —— 对方城市在上,自己的在下,各带日期和时区短名(PDT、GMT+8 这类)。12H / 24H 可切换。 +- **一条 24 格图标带** —— 对方城市一天里的每一小时占一格,画成太阳、贴地平线的半日或月亮;当前那一格铺上判定色。四个阈值正好落在格子边界上。 +- **一句话提醒** —— 判定下方一行写明「对方档位 + 时间段 + 一句话建议」,再往下是可选的时差提醒(只有你这边更晚时才出现)。不生成话术、不提供一键复制、不往聊天框里塞东西。 +- **所有时区都能选** —— 运行时认识的 418 个时区全部覆盖,在随包的这套表里每个都有中文名(数量随运行时 tzdata 版本增减;若运行时新增了表里还没有的时区,该条目会退回英文名)。另按国家给出「中国(默认)」这类国家级条目。两侧的城市都能单独切换,标题中间那个 ⇄ 还能一键对调。 + +时间与偏移全部由浏览器的 `Intl` 和运行时自带的时区数据库算出,夏令时自动处理。**不联网、无账号、无推送、不做日历。** + +## 安装与使用 + +把整个目录(**含隐藏的 `.minimax-plugin` 目录**)复制到用户主目录下的 `.minimax/plugins/`: + +| 系统 | 目标路径 | +| --- | --- | +| Windows | `C:\Users\<用户名>\.minimax\plugins\timezone-guard` | +| macOS | `/Users/<用户名>/.minimax/plugins/timezone-guard` | +| Linux | `/home/<用户名>/.minimax/plugins/timezone-guard` | + +`.minimax` 是隐藏目录:Windows 上需在文件资源管理器开启「显示隐藏的项目」,macOS 上按 `Cmd + Shift + .`。只要用过 MiniMax Code,这个目录通常已经存在。如果客户端使用了自定义数据目录,请放到那个目录下的 `plugins/` 里。 + +然后重启 MiniMax Code,确认插件已被识别并启用,让 Agent 帮你打开「A/时差保安」,或直接说「打开A/时差保安,看看洛杉矶现在该不该开口」。 + +更新时先关闭应用并退出 MiniMax Code,再整体替换插件目录。卸载同理,删除该目录即可。 + +## 已验证环境 + +MiniMax Code 桌面版 **3.1.4**,Windows 11 家庭版(10.0.26300,x64)。本插件在 3.1.4 上开发并测试。 + +需要 MiniMax Code **3.1.2** 或更高版本:包里声明了 `surface.window`(弹出独立窗口的尺寸),更早的版本装不上这个包。 + +在客户端里验证过:安装与打开;判定在白天 / 清晨 / 傍晚 / 深夜四个时段档各自正确切换;两个城市分别可切换,标题里的 ⇄ 点击后可一键对调(鼠标点击与键盘 Enter / Space 都试过,对调后两个时钟、时差、图标带、判定与两个城市输入框全部跟着翻转并持久化);城市库覆盖运行时全部时区(实测运行时为 418 个)、每个都有中文名、无失效条目;12H / 24H 切换并持久化;浅色与深色两套主题;面板默认宽度下渲染正常。 + +布局另在本包的 client 文件上用 Chromium 155 直接测过 400 / 460 / 560 / 680 / 1100 / 1440 px 六档宽度:无横向溢出、无双滚动条、卡片底部无多余留白。 + +**未验证:** macOS 与 Linux;把面板弹成独立窗口(`surface.window`)这条路径没有实测过 —— 页面本身仍是一列响应式布局。 + +## 数据与访问范围 + +- **读取的文件** —— 只有它自己包目录下的四个静态文件:`miniapp/client/index.html`、`styles.css`、`cities.js`、`app.js`。不读包目录以外的任何文件,也不扫描磁盘。 +- **写入的文件** —— 无。不写 `context.dataDir`,不写任何地方。 +- **浏览器存储** —— 只存界面偏好,键名 `timezone-guard.prefs.v1`:选中的两个城市、主题、12H/24H 制式。不存会话、不存任何内容数据。 +- **网络** —— 无。页面只从插件自己的回环服务加载静态资源,没有遥测,没有远程字体、图标或脚本。 +- **子进程** —— 无。 +- **Agent 轮次** —— 无。包没有声明 `agentAccess`,运行期不发起任何 Agent 调用。 +- **凭据** —— 不需要 API key、不需要账号、不需要任何配置。 + +## 源码与验证情况 + +页面是 `miniapp/client/index.html`(无框架、无 CDN、无构建步骤,离线可用),视觉层在 `styles.css`,逻辑在 `app.js`,城市库与全时区中文名在 `cities.js`;Node 入口是 `miniapp/node/server.mjs`,它只负责把 `/dashboard` 和这四个静态文件挂到宿主分配的端口上;供编辑器类型检查用的 Host API 类型定义在 `miniapp/node/miniapp-api.ts`。**没有任何第三方运行时依赖** —— Node 侧只用内置模块,浏览器侧只用原生 API。 + +## 许可 + +[MIT](LICENSE)。 diff --git a/plugins/liuchen19960902/timezone-guard/docs/preview-dark.png b/plugins/liuchen19960902/timezone-guard/docs/preview-dark.png new file mode 100644 index 00000000..25dfec80 Binary files /dev/null and b/plugins/liuchen19960902/timezone-guard/docs/preview-dark.png differ diff --git a/plugins/liuchen19960902/timezone-guard/docs/preview.png b/plugins/liuchen19960902/timezone-guard/docs/preview.png new file mode 100644 index 00000000..c5ee22c2 Binary files /dev/null and b/plugins/liuchen19960902/timezone-guard/docs/preview.png differ diff --git a/plugins/liuchen19960902/timezone-guard/icon.png b/plugins/liuchen19960902/timezone-guard/icon.png new file mode 100644 index 00000000..fa611fe8 Binary files /dev/null and b/plugins/liuchen19960902/timezone-guard/icon.png differ diff --git a/plugins/liuchen19960902/timezone-guard/miniapp/client/app.js b/plugins/liuchen19960902/timezone-guard/miniapp/client/app.js new file mode 100644 index 00000000..a7e36c74 --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/miniapp/client/app.js @@ -0,0 +1,1156 @@ +'use strict'; + +/** + * A/时差保安 — 纯本地计算,零外部依赖、零网络请求。 + * + * 时间与偏移全部通过 IANA 时区 + Intl 计算,夏令时由运行时 tzdata 自动处理。 + */ + +/* ------------------------------------------------------------------ * + * 城市库 + * ------------------------------------------------------------------ */ + +// 重点城市与全部时区中文名放在 cities.js 里,与逻辑分开便于维护。 +const CITIES = Array.isArray(window.TZ_CITIES) ? window.TZ_CITIES : []; + +/** 空输入时展示的常用城市。 */ +const POPULAR = [ + '洛杉矶', '纽约', '旧金山', '伦敦', '巴黎', '柏林', '东京', '首尔', '新加坡', + '曼谷', '悉尼', '多伦多', '温哥华', '迪拜', '莫斯科', '上海', '北京', '香港', + '台北', '奥克兰', '圣保罗', +]; + +/* ------------------------------------------------------------------ * + * 判定阈值与文案 + * ------------------------------------------------------------------ */ + +/** + * 固定三段阈值(所有城市一致): + * 09:00–18:00 白天(绿) + * 18:00–22:00 傍晚(黄) + * 22:00–06:00 深夜(红) + * 06:00–09:00 清晨按黄灯处理:对方刚醒,一句试探即可,别展开。 + * + * verdict 是给用户看的一句话结论;range 是该档的时间区间; + * advice 是原「养号窗口」那一行的内容,已并入判定区。 + * tone 是这一档在图标带里的分档名,sky 决定画太阳、半日还是月亮 + * (城市旁的身份图标与图标带里的 24 格刻度共用同一套形状)。 + * 整页只有判定区有状态色,不再有第二套彩色标签。 + */ +const BAND_COPY = { + day: { + label: '白天', + color: 'green', + verdict: '现在可以聊', + range: '09:00–18:00', + advice: '正常聊,不用刻意', + tone: 'day', + sky: 'sun', + }, + dawn: { + label: '清晨', + color: 'yellow', + verdict: '先别展开', + range: '06:00–09:00', + advice: '刚醒不久,别堆长消息', + tone: 'dusk', + sky: 'dusk', + }, + dusk: { + label: '傍晚', + color: 'yellow', + verdict: '短聊就好', + range: '18:00–22:00', + advice: '仍可聊,偏下班口味', + tone: 'dusk', + sky: 'dusk', + }, + night: { + label: '深夜', + color: 'red', + verdict: '先睡,别发', + range: '22:00–06:00', + advice: '大概率已睡,不建议开口', + tone: 'night', + sky: 'moon', + }, +}; + +/** 城市旁的昼夜图标:太阳 / 半日 / 月亮。 */ +const SKY_ICONS = { sun: '#i-sun', dusk: '#i-dusk', moon: '#i-moon' }; + +/* ------------------------------------------------------------------ * + * 城市索引 + * ------------------------------------------------------------------ */ + +/** @typedef {{zone: string, title: string, en: string, countryZh: string, subtitle: string, tokens: string[], zoneText: string, zoneDir: string, baseExact: number, basePrefix: number, city: object|null}} Entry */ + +/** @type {Entry[]} */ +let INDEX = []; + +/** + * 部分 IANA 名称在不同 ICU / tzdata 版本下存在与否不同 + * (Europe/Kyiv vs Europe/Kiev、Asia/Kolkata vs Asia/Calcutta …), + * 这里为它们准备退路,保证任何时候都拿得到一个可用时区。 + */ +const ZONE_FALLBACKS = { + 'Europe/Kyiv': ['Europe/Kiev'], + 'Asia/Ho_Chi_Minh': ['Asia/Saigon'], + 'Asia/Kolkata': ['Asia/Calcutta'], + 'America/Montreal': ['America/Toronto'], + 'America/Argentina/Buenos_Aires': ['America/Rosario'], +}; + +const resolvedZoneCache = new Map(); + +/** + * 返回一个在当前运行时确实可用的时区 ID;都不行时返回 null。 + * + * 注意:城市名对应的时区名不一定存在。旧金山、西雅图没有独立的 + * America/* 时区,慕尼黑没有 Europe/Munich——这类城市必须归到 + * 本国主时区,否则 new Intl.DateTimeFormat() 会抛 RangeError。 + */ +function resolveZone(zone) { + if (typeof zone !== 'string' || !zone) return null; + const trimmed = zone.trim(); + if (!trimmed) return null; + if (resolvedZoneCache.has(trimmed)) return resolvedZoneCache.get(trimmed); + let resolved = null; + for (const candidate of [trimmed, ...(ZONE_FALLBACKS[trimmed] || [])]) { + try { + // 取规范化后的 ID,避免同一个时区出现大小写 / 别名两种写法, + // 让「是否同一时区」的比较始终可靠。 + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: candidate }).resolvedOptions().timeZone; + resolved = canonical || candidate; + break; + } catch { + /* 试下一个候选 */ + } + } + resolvedZoneCache.set(trimmed, resolved); + return resolved; +} + +function buildIndex() { + const zones = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []; + /** @type {Entry[]} */ + const entries = []; + const usedZones = new Set(); + + // 1) 重点城市(带中文名、口语别名、常用缩写) + // 当前运行时不支持的时区直接不进候选,避免用户选中后整页崩掉。 + const usableCities = CITIES.map((c) => ({ ...c, rawZone: c.zone, zone: resolveZone(c.zone) })).filter((c) => c.zone); + for (const c of usableCities) { + usedZones.add(c.zone); + // tokens 里额外带上时区原名,这样按 IANA 全名/别名也能搜到 + // (例如「Europe/Kyiv」在部分 ICU 里会被规范化成 Europe/Kiev)。 + const tokens = [c.zh, c.en, c.abbr, c.countryZh, c.countryEn, c.rawZone, ...(c.alias || '').split('|')] + .filter(Boolean) + .map((t) => t.toLowerCase()); + entries.push({ + zone: c.zone, + title: c.zh, + en: c.en, + countryZh: c.countryZh, + subtitle: `${c.en} · ${c.countryZh}`, + tokens, + zoneText: c.zone.toLowerCase(), + zoneDir: c.zone.split('/')[0].toLowerCase(), + baseExact: 0, + basePrefix: 0, + city: c, + }); + } + + // 2) 其余时区补中文名 —— 保证「全世界每个地方」都能用中文搜到。 + // 注意:与精选城市共用同一时区时**不能**整条跳过, + // 例如孟买与加尔各答同属 Asia/Calcutta,两个中文名都该能搜到; + // 这里按「中文名是否重复」来去重,而不是按时区。 + const zoneNames = window.TZ_ZONE_NAMES || {}; + /** @type {{zone: string, zh: string, en: string, countryZh: string}[]} */ + const extraCities = []; + const usedNames = new Set(usableCities.map((c) => c.zh)); + for (const [rawZone, info] of Object.entries(zoneNames)) { + const zone = resolveZone(rawZone); + if (!zone) continue; + usedZones.add(zone); + const zh = info[0]; + if (usedNames.has(zh)) continue; + usedNames.add(zh); + const countryZh = info[1] || ''; + // 第三项可选:现代英文名(IANA 遗留尾段有时是老拼写,如 Katmandu / Kiev)。 + const en = info[2] || zone.split('/').pop().replace(/_/g, ' '); + extraCities.push({ zone, zh, en, countryZh }); + entries.push({ + zone, + title: zh, + en, + countryZh, + subtitle: `${en} · ${countryZh}`, + tokens: [zh, en.toLowerCase(), countryZh.toLowerCase(), zone.toLowerCase()].filter(Boolean), + zoneText: zone.toLowerCase(), + zoneDir: zone.split('/')[0].toLowerCase(), + baseExact: 2, + basePrefix: 4, + city: null, + }); + } + + // 3) 国家级条目:仅当该国绝大多数城市落在同一个时区时才给出, + // 否则搜「美国」会直接列出纽约/芝加哥/洛杉矶等具体城市,避免误导。 + // 统计口径必须包含第 2 步的时区,否则俄罗斯会因只留莫斯科而被误判为单时区。 + const byCountry = new Map(); + const addToCountry = (countryZh, item) => { + if (!countryZh) return; + if (!byCountry.has(countryZh)) byCountry.set(countryZh, []); + byCountry.get(countryZh).push(item); + }; + for (const c of usableCities) addToCountry(c.countryZh, c); + for (const c of extraCities) addToCountry(c.countryZh, c); + + for (const [countryZh, list] of byCountry) { + const counts = new Map(); + for (const c of list) counts.set(c.zone, (counts.get(c.zone) || 0) + 1); + let best = null; + for (const [zone, n] of counts) if (!best || n > best.n) best = { zone, n }; + if (!best || best.n / list.length < 0.6) continue; + const rep = list.find((c) => c.zone === best.zone); + const repEn = rep.en || ''; + entries.push({ + zone: best.zone, + title: `${countryZh}(默认)`, + en: repEn, + countryZh, + subtitle: `${rep.zh} · ${countryZh}`, + tokens: [countryZh, repEn.toLowerCase(), best.zone.toLowerCase()].filter(Boolean), + zoneText: best.zone.toLowerCase(), + zoneDir: best.zone.split('/')[0].toLowerCase(), + // 完全匹配时排在该国具体城市之前(搜「中国」直接选中国); + // 前缀匹配时反而要靠后,否则「荷兰」会抢在「纽约」前面。 + baseExact: -2, + basePrefix: 3, + city: { + zone: best.zone, + zh: countryZh, + en: repEn, + abbr: '', + countryZh, + countryEn: '', + }, + }); + } + + // 4) 兜底:运行时新增、但两张表都没收录的时区,给英文名。 + for (const rawZone of zones) { + const zone = resolveZone(rawZone); + if (!zone || usedZones.has(zone)) continue; + usedZones.add(zone); + const tail = zone.split('/').pop().replace(/_/g, ' '); + entries.push({ + zone, + title: tail, + en: tail, + countryZh: '', + subtitle: zone, + tokens: [tail.toLowerCase()], + zoneText: zone.toLowerCase(), + zoneDir: zone.split('/')[0].toLowerCase(), + baseExact: 3, + basePrefix: 6, + city: null, + }); + } + + return entries; +} + +/** scoreEntry 的「无匹配」哨兵。必须与任何真实分数区分开: + * 国家默认项的完全匹配分是 -2,若拿 -1 当哨兵会被误判成未命中。 */ +const NO_MATCH = -1000; + +/** + * 模糊匹配。分级越小越靠前: + * 0 标题完全匹配 1 关键词完全匹配 + * 2 标题前缀 3 关键词前缀 + * 4 时区全名完全匹配 5 时区目录前缀 6 时区路径包含 7 全文包含 + * + * entry.baseExact / entry.basePrefix 按条目类型整体偏移: + * 精选城市 (0/0)、国家默认项 (-2/3)、IANA 兜底 (3/6)。 + */ +function scoreEntry(entry, query) { + const title = entry.title.toLowerCase(); + if (title === query) return entry.baseExact; + for (const token of entry.tokens) { + if (token === query) return entry.baseExact + 1; + } + if (query.includes('/')) { + if (entry.zoneText === query) return entry.basePrefix + 4; + if (entry.zoneText.startsWith(query)) return entry.basePrefix + 5; + if (entry.zoneText.includes(query)) return entry.basePrefix + 6; + } + if (title.startsWith(query)) return entry.basePrefix + 2; + for (const token of entry.tokens) { + if (token.startsWith(query)) return entry.basePrefix + 3; + } + if (query.length >= 3 && entry.zoneDir === query) return entry.basePrefix + 5; + if (query.length >= 3 && entry.tokens.some((t) => t.includes(query))) return entry.basePrefix + 7; + return NO_MATCH; +} + +// 候选上限。搜一个国家名可能命中几十个城市(美国 40+), +// 上限过低会把正确结果截掉,所以放宽到 60。 +function searchEntries(query, limit = 60) { + const q = query.trim().toLowerCase(); + if (!q) { + const popular = new Set(POPULAR); + return INDEX.filter((e) => popular.has(e.title)).slice(0, limit); + } + const scored = []; + for (const entry of INDEX) { + const score = scoreEntry(entry, q); + if (score > NO_MATCH) scored.push({ entry, score }); + } + scored.sort((a, b) => a.score - b.score || a.entry.title.length - b.entry.title.length); + return scored.slice(0, limit).map((s) => s.entry); +} + +/* ------------------------------------------------------------------ * + * 时区计算 + * ------------------------------------------------------------------ */ + +const FORMATTERS = new Map(); + +function getFormatters(timeZone) { + let f = FORMATTERS.get(timeZone); + if (!f) { + f = { + numeric: new Intl.DateTimeFormat('en-US', { + timeZone, + hourCycle: 'h23', + weekday: 'short', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + }), + weekday: new Intl.DateTimeFormat('zh-CN', { timeZone, weekday: 'short' }), + offset: new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' }), + }; + FORMATTERS.set(timeZone, f); + } + return f; +} + +/** 把 "GMT-07:00" / "GMT+05:30" / "GMT" 解析成分钟偏移。 */ +function parseOffset(text) { + if (!text || text === 'GMT' || text === 'UTC') return 0; + const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(text); + if (!m) return 0; + const sign = m[1] === '-' ? -1 : 1; + return sign * (Number(m[2]) * 60 + Number(m[3] || 0)); +} + +/** + * 取某个时区在给定时刻的完整信息。 + * 时区 ID 先做一次解析兜底,绝不因为一个非法时区让整页渲染抛错。 + */ +function zonedInfo(timeZone, date) { + const zone = resolveZone(timeZone) || 'UTC'; + const f = getFormatters(zone); + const parts = {}; + for (const part of f.numeric.formatToParts(date)) { + if (part.type !== 'literal') parts[part.type] = part.value; + } + const offsetPart = f.offset.formatToParts(date).find((p) => p.type === 'timeZoneName'); + + const hour = Number(parts.hour); + const minute = Number(parts.minute); + const second = Number(parts.second); + const pad = (n) => String(n).padStart(2, '0'); + + return { + time24: `${pad(hour)}:${pad(minute)}:${pad(second)}`, + time12: `${pad(hour % 12 === 0 ? 12 : hour % 12)}:${pad(minute)}:${pad(second)}`, + period: periodOf(hour), + date: `${Number(parts.month)}月${Number(parts.day)}日 ${f.weekday.format(date)}`, + zoneLabel: parts.timeZoneName || '', + offsetMinutes: parseOffset(offsetPart ? offsetPart.value : ''), + hour, + minute, + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + }; +} + +/** 中文时段,替代 AM/PM:凌晨 / 上午 / 下午 / 晚上。 */ +function periodOf(hour) { + if (hour < 6) return '凌晨'; + if (hour < 12) return '上午'; + if (hour < 18) return '下午'; + return '晚上'; +} + +/** 固定阈值判定:绿 09–18、黄 18–22、红 22–06,清晨 06–09 归黄。 */ +function bandOf(hour, minute) { + const t = hour * 60 + minute; + if (t >= 22 * 60 || t < 6 * 60) return BAND_COPY.night; + if (t < 9 * 60) return BAND_COPY.dawn; + if (t < 18 * 60) return BAND_COPY.day; + return BAND_COPY.dusk; +} + +/** 绿 < 黄 < 红,用于比较两边的紧迫程度。 */ +const SEVERITY = { green: 0, yellow: 1, red: 2 }; + +/** + * 时差文案。 + * 整小时沿用「约 N 小时」;存在 45 分钟这类非整小时偏移时给出精确的 + * 小时 + 分钟,避免读者拿 5.8 小时去对旁边并排的两个时钟。 + */ +function formatDiff(diffMinutes) { + const abs = Math.abs(diffMinutes); + const hours = Math.floor(abs / 60); + const minutes = abs % 60; + if (minutes === 0) return `约 ${hours} 小时`; + // 非整小时时带一个前导空格,拼到「快/慢」后面才不会连成一团。 + return hours > 0 ? ` ${hours} 小时 ${minutes} 分钟` : ` ${minutes} 分钟`; +} + +/* ------------------------------------------------------------------ * + * 状态 + * ------------------------------------------------------------------ */ + +const STORAGE_KEY = 'timezone-guard.prefs.v1'; + +const DEFAULT_TARGET = { + zone: 'America/Los_Angeles', + title: '洛杉矶', + en: 'Los Angeles', + abbr: 'LA', + countryZh: '美国', + countryEn: 'United States', +}; +const DEFAULT_HOME = { + zone: 'Asia/Shanghai', + title: '上海', + en: 'Shanghai', + abbr: '', + countryZh: '中国', + countryEn: 'China', +}; + +let state = { + target: { ...DEFAULT_TARGET }, + home: { ...DEFAULT_HOME }, + theme: 'auto', + clockFormat: '12h', +}; + +/** + * 以 zone 为准重建展示字段。 + * + * 存档可能缺字段或字段类型不对(例如 title 为 null),直接展开合并会让 + * 页头显示「洛杉矶时间」而时钟其实是东京时间,同一屏自相矛盾。 + * 这里对每个展示字段做类型校验,并以 zone 反查补全。 + */ +function normalizeCity(raw, fallback) { + if (!raw || typeof raw !== 'object') return { ...fallback }; + const zone = resolveZone(raw.zone); + if (!zone) return { ...fallback }; + + const known = CITIES.find((c) => resolveZone(c.zone) === zone); + const fallbackTitle = known ? known.zh : zone.split('/').pop().replace(/_/g, ' '); + const text = (value, dflt) => (typeof value === 'string' && value.trim() ? value : dflt); + + return { + zone, + title: text(raw.title, fallbackTitle), + en: text(raw.en, known ? known.en : ''), + abbr: text(raw.abbr, known ? known.abbr || '' : ''), + countryZh: text(raw.countryZh, known ? known.countryZh : ''), + countryEn: text(raw.countryEn, known ? known.countryEn : ''), + }; +} + +function loadPrefs() { + let raw = null; + try { + raw = localStorage.getItem(STORAGE_KEY); + } catch { + return; + } + if (!raw) return; + try { + const saved = JSON.parse(raw); + if (saved && typeof saved === 'object') { + if (saved.target) state.target = normalizeCity(saved.target, DEFAULT_TARGET); + if (saved.home) state.home = normalizeCity(saved.home, DEFAULT_HOME); + if (saved.theme === 'light' || saved.theme === 'dark' || saved.theme === 'auto') state.theme = saved.theme; + if (saved.clockFormat === '12h' || saved.clockFormat === '24h') state.clockFormat = saved.clockFormat; + } + } catch { + /* 存档损坏时回落到默认值 */ + } +} + +function savePrefs() { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + target: state.target, + home: state.home, + theme: state.theme, + clockFormat: state.clockFormat, + }), + ); + } catch { + /* 存储不可用不影响使用 */ + } +} + +/* ------------------------------------------------------------------ * + * DOM + * ------------------------------------------------------------------ */ + +const el = { + pageTitle: document.getElementById('page-title'), + pageTitleTarget: document.getElementById('page-title-target'), + pageTitleHome: document.getElementById('page-title-home'), + pageTitleSwap: document.getElementById('page-title-swap'), + verdict: document.getElementById('verdict'), + verdictTitle: document.getElementById('verdict-title'), + verdictDetail: document.getElementById('verdict-detail'), + targetHeading: document.getElementById('target-heading'), + targetMeta: document.getElementById('target-meta'), + targetClock: document.getElementById('target-clock'), + targetDate: document.getElementById('target-date'), + targetIconUse: document.getElementById('target-icon-use'), + homeHeading: document.getElementById('home-heading'), + homeMeta: document.getElementById('home-meta'), + homeClock: document.getElementById('home-clock'), + homeDate: document.getElementById('home-date'), + homeIconUse: document.getElementById('home-icon-use'), + comboTarget: document.getElementById('combo-target'), + comboHome: document.getElementById('combo-home'), + factDiff: document.getElementById('fact-diff'), + verdictExtra: document.getElementById('verdict-extra'), + targetPeriod: document.getElementById('target-period'), + homePeriod: document.getElementById('home-period'), + daylineLabel: document.getElementById('dayline-label'), + hourBar: document.getElementById('hour-bar'), + fmt12: document.getElementById('fmt-12h'), + fmt24: document.getElementById('fmt-24h'), + themeToggle: document.getElementById('theme-toggle'), + themeToggleLabel: document.getElementById('theme-toggle-label'), +}; + +function entryToCity(entry) { + const c = entry.city; + if (c) { + return { + zone: c.zone, + title: c.zh, + en: c.en, + abbr: c.abbr || '', + countryZh: c.countryZh, + countryEn: c.countryEn || '', + }; + } + // 非精选条目(其余时区的中文名):标题即中文名,英文名取 IANA 尾段。 + return { + zone: entry.zone, + title: entry.title, + en: entry.en || entry.title, + abbr: '', + countryZh: entry.countryZh || '', + countryEn: '', + }; +} + +/** + * 只在文本真的变化时写入 DOM。 + * render() 每秒跑一次,直接赋值会反复触发 aria-live 区域的无谓播报。 + */ +function setText(node, value) { + if (node.textContent !== value) node.textContent = value; +} + +/** + * 只在属性值真的变化时写入。 + * render() 每秒跑一次;Blink 不会把同值 setAttribute 短路掉, + * 每帧重写会触发样式失效与 MutationObserver 噪音, + * 所以和 setText 一样先比再写。 + */ +function setAttr(node, name, value) { + if (node.getAttribute(name) !== value) node.setAttribute(name, value); +} + +/** hidden 同理:避免每帧写同一个布尔属性。 */ +function setHidden(node, flag) { + if (node.hidden !== flag) node.hidden = flag; +} + +/* ------------------------------------------------------------------ * + * 一天的图标带 + * ------------------------------------------------------------------ */ + +/** 一天 24 格,一格一小时。 */ +const HOUR_COUNT = 24; +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** 图标带里 24 格的 DOM 节点(每格一小时)。 */ +let hourCells = []; +/** 当前所在的小时格下标,用于避免每秒重刷同一格。 */ +let hourNowIndex = -1; + +/** + * 图标带画的是「对方那一整天」:每小时一格,格里一枚昼夜图标。 + * 太阳 = 白天,半日 = 清晨/傍晚,月亮 = 深夜——靠形状说明对方在睡还是醒着, + * 不再靠灰阶深浅去暗示:灰阶在深色主题下必须反着设,语义容易读反。 + * 阈值固定,所以格子只建一次;每帧只移动当前格。 + */ +function buildHourBar() { + if (!el.hourBar) return; + el.hourBar.textContent = ''; + hourCells = []; + for (let h = 0; h < HOUR_COUNT; h++) { + const band = bandOf(h, 0); + const cell = document.createElement('span'); + cell.className = 'hour-cell'; + // 整点起步,四个阈值(06/09/18/22)都落在格边界上。 + cell.dataset.tone = band.tone; + + const icon = document.createElementNS(SVG_NS, 'svg'); + icon.setAttribute('class', 'hour-icon'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + const use = document.createElementNS(SVG_NS, 'use'); + use.setAttribute('href', SKY_ICONS[band.sky]); + icon.appendChild(use); + cell.appendChild(icon); + + el.hourBar.appendChild(cell); + hourCells.push(cell); + } + hourNowIndex = -1; +} + +/** 把当前时刻对应的那一格标成状态色。 */ +function setHourNow(index) { + if (!hourCells.length) return; + if (hourNowIndex === index) return; + if (hourNowIndex >= 0 && hourCells[hourNowIndex]) { + hourCells[hourNowIndex].removeAttribute('data-now'); + } + if (hourCells[index]) hourCells[index].dataset.now = 'true'; + hourNowIndex = index; +} + +/** + * 图标带的文字替代:读屏用户拿不到图标,得靠这句话知道 + * 「对方那边一天里的哪一段是深夜、现在落在哪一段」。 + */ +function setHourBarLabel(cityTitle, band) { + if (!el.hourBar) return; + const label = `${cityTitle} 24 小时昼夜分布(每小时一格):深夜 22:00–06:00,清晨 06:00–09:00,白天 09:00–18:00,傍晚 18:00–22:00;当前在${band.label}段`; + // 值不变就不写:render() 每秒跑一次,无谓地改 aria-label 会让读屏反复重播。 + setAttr(el.hourBar, 'aria-label', label); +} + +/* ------------------------------------------------------------------ * + * 渲染 + * ------------------------------------------------------------------ */ + +function render() { + const now = new Date(); + const target = zonedInfo(state.target.zone, now); + const home = zonedInfo(state.home.zone, now); + + const band = bandOf(target.hour, target.minute); + const homeBand = bandOf(home.hour, home.minute); + + // 页头:两个城市并置。两侧是同一个城市时不重复显示,也不显示交换箭头。 + // 同城判定同时看中文名和时区 ID:只看名字的话, + // 将来出现两个同名不同区的城市时会误判成同城。 + const sameCity = + state.target.title === state.home.title && state.target.zone === state.home.zone; + setText(el.pageTitleTarget, state.target.title); + setText(el.pageTitleHome, sameCity ? '' : state.home.title); + // 隐藏而不是清空:清空会留下一个空的 flex 子项,尾部多出 7px 间隙。 + const pairDisplay = sameCity ? 'none' : ''; + if (el.pageTitleHome.style.display !== pairDisplay) { + el.pageTitleHome.style.display = pairDisplay; + } + if (el.pageTitleSwap.style.display !== pairDisplay) { + el.pageTitleSwap.style.display = pairDisplay; + } + // 标题的可访问名显式给:里面的对调按钮若参与 name-from-content, + // 标题会被读成「洛杉矶 对调两个城市 上海」。显式命名后标题只报两个城市名, + // 按钮本身仍是独立按钮,聚焦到它时会单独播报。 + setAttr(el.pageTitle, 'aria-label', sameCity ? state.target.title : `${state.target.title} ${state.home.title}`); + + // 判定区:整页唯一的状态色在这里,其余一律中性。 + // 「养号窗口」的内容并入这一块,不再单独占一行。 + // 状态色变量挂在 上,判定文字与色点共用同一套变量。 + // 每秒重写同一个属性会让样式每秒失效一次,所以先比再写。 + if (document.documentElement.dataset.band !== band.color) { + document.documentElement.dataset.band = band.color; + } + if (el.verdict.dataset.band !== band.color) el.verdict.dataset.band = band.color; + setText(el.verdictTitle, band.verdict); + setText(el.verdictDetail, `对方${band.label} ${band.range} · ${band.advice}`); + + // 只在「你这边比对方更晚」时补一句提醒——这是关于你自己而非对方的。 + const extra = extraNote(band, homeBand); + if (extra) { + setText(el.verdictExtra, extra); + setHidden(el.verdictExtra, false); + } else { + setHidden(el.verdictExtra, true); + setText(el.verdictExtra, ''); + } + + // 对方时钟 + setText(el.targetHeading, state.target.title); + setText(el.targetMeta, `${state.target.en || state.target.title} · ${target.zoneLabel}`); + setText(el.targetClock, state.clockFormat === '24h' ? target.time24 : target.time12); + setText(el.targetDate, target.date); + + // 我的时钟 + setText(el.homeHeading, state.home.title); + setText(el.homeMeta, `${state.home.en || state.home.title} · ${home.zoneLabel}`); + setText(el.homeClock, state.clockFormat === '24h' ? home.time24 : home.time12); + setText(el.homeDate, home.date); + + // 城市旁的昼夜图标:每城一个,太阳 / 半日 / 月亮。 + setAttr(el.targetIconUse, 'href', SKY_ICONS[band.sky]); + setAttr(el.homeIconUse, 'href', SKY_ICONS[homeBand.sky]); + + // 中文时段:12H 模式下它替代 AM/PM;24H 模式本身已含时段,故不重复显示。 + const showPeriod = state.clockFormat !== '24h'; + setHidden(el.targetPeriod, !showPeriod); + setHidden(el.homePeriod, !showPeriod); + if (showPeriod) { + setText(el.targetPeriod, target.period); + setText(el.homePeriod, home.period); + } + + // 一天的图标带:标出对方此刻所在的那一小时。 + setText(el.daylineLabel, `${state.target.title}的一天`); + setHourBarLabel(state.target.title, band); + setHourNow(target.hour); + + // 时差 + const diffMinutes = home.offsetMinutes - target.offsetMinutes; + if (state.target.zone === state.home.zone) { + setText(el.factDiff, '两边同一时区,时间完全一致'); + } else if (diffMinutes === 0) { + // 同一时刻偏移相同但并非同一时区(例如洛杉矶与凤凰城), + // 随夏令时切换会分开,此时不能声称「同一时区」。 + setText(el.factDiff, '当前时差 0 小时(此刻同步)'); + } else { + // ahead 永远是「时区靠前」的那一侧,所以动词恒为「快」。 + // 之前这里跟着 diff 的正负写「快/慢」,结果主语换了动词没换, + // 会出现「上海比洛杉矶慢约 15 小时」这种自相矛盾的说法。 + const homeAhead = diffMinutes > 0; + const ahead = homeAhead ? state.home.title : state.target.title; + const behind = homeAhead ? state.target.title : state.home.title; + const parts = [`${ahead}比${behind}快${formatDiff(diffMinutes)}`]; + const dayDiff = dayDelta(target, home); + if (dayDiff < 0) parts.push('对方还在昨天'); + else if (dayDiff > 0) parts.push('对方那边已是明天'); + setText(el.factDiff, parts.join(' · ')); + } +} + +/** 目标日期相对对照日期的日历天数差。 */ +function dayDelta(target, home) { + const t = Date.UTC(target.year, target.month - 1, target.day); + const h = Date.UTC(home.year, home.month - 1, home.day); + return Math.round((t - h) / 86400000); +} + +/** 当我方比对方更晚 / 都处于深夜时,补一条给用户自己的提醒。 */ +function extraNote(band, homeBand) { + if (SEVERITY[homeBand.color] > SEVERITY[band.color]) { + // 注意不能写「比对方更晚」:01:43 和 13:43 并排摆着, + // 说「更晚」会和上面的时钟自相矛盾。改成说「该休息」。 + return `你自己也是${homeBand.label},比对方更该休息,长对话先收着。`; + } + if (homeBand.color === 'red' && band.color === 'red') { + return '你俩都在深夜,同步作息反而最自然。'; + } + return ''; +} + +/* ------------------------------------------------------------------ * + * 城市选择器(combobox) + * ------------------------------------------------------------------ */ + +/** + * @param {object} options + * @param {HTMLInputElement} options.input + * @param {HTMLUListElement} options.list + * @param {HTMLElement} options.emptyEl 无结果时的提示(放在 listbox 之外) + * @param {() => object} options.getValue + * @param {(city: object) => void} options.onSelect + */ +function createCombo({ input, list, emptyEl, getValue, onSelect }) { + let options = []; + let activeIndex = -1; + let open = false; + // 当前候选列表是不是由输入框内容筛出来的。 + // 聚焦时展开的是「常用城市」列表,与输入框里的城市名对不上, + // 这时按 Enter 不能默认选首项,否则会把当前城市悄悄改掉。 + let listFromInput = false; + + function currentValue() { + return getValue(); + } + + function renderOptions() { + list.textContent = ''; + activeIndex = -1; + // 选项重建后旧的 aria-activedescendant 会指向已不存在的元素, + // 必须同步清掉,否则读屏拿到的是悬空引用。 + input.removeAttribute('aria-activedescendant'); + if (emptyEl) emptyEl.textContent = '没找到这个城市,换个关键词试试'; + + const current = currentValue(); + options.forEach((entry, index) => { + const item = document.createElement('li'); + item.className = 'combo-option'; + item.id = `${list.id}-opt-${index}`; + item.setAttribute('role', 'option'); + // 国家默认项在列表里叫「中国(默认)」,选中后落到城市名「中国」, + // 所以标题比较要去掉「(默认)」后缀,否则选中态永远不同步。 + const entryTitle = entry.title.replace('(默认)', ''); + const titleMatches = Boolean(current) && (current.title === entry.title || current.title === entryTitle); + const selected = Boolean(current) && current.zone === entry.zone && titleMatches; + item.setAttribute('aria-selected', String(selected)); + item.dataset.index = String(index); + + const title = document.createElement('span'); + title.className = 'combo-option-title'; + title.textContent = entry.title; + + const sub = document.createElement('span'); + sub.className = 'combo-option-sub'; + sub.textContent = entry.subtitle; + + item.append(title, sub); + list.appendChild(item); + }); + } + + function setOpen(next) { + open = next; + const hasOptions = options.length > 0; + // 没有候选时不要留一个空 listbox 盖在空态提示上。 + // 和 render() 一样先比再写:同值重写会白白触发一次属性变更与样式失效。 + setHidden(list, !next || !hasOptions); + if (emptyEl) setHidden(emptyEl, !next || hasOptions); + setAttr(input, 'aria-expanded', String(next && hasOptions)); + if (!next) { + activeIndex = -1; + input.removeAttribute('aria-activedescendant'); + } + } + + /** + * @param {string} [query] 覆盖输入框内容来筛选(聚焦时用空串展示常用城市) + */ + function refresh(query) { + const q = query === undefined ? input.value : query; + options = searchEntries(q); + // 只有「确实输入了非空关键词」时,候选才算来自输入。 + // 空查询得到的是常用城市列表,与输入框内容无关, + // 这时按 Enter 若回退选首项,会把当前城市悄悄改掉。 + listFromInput = q.trim() !== ''; + renderOptions(); + setOpen(true); + } + + function highlight(index) { + const items = list.querySelectorAll('.combo-option'); + items.forEach((item) => item.removeAttribute('data-active')); + if (items.length === 0) { + input.removeAttribute('aria-activedescendant'); + activeIndex = -1; + return; + } + // 越界回绕:列表只有一项时连按两次 ↓ 也不会把高亮清空导致 Enter 失效。 + const next = (((index % items.length) + items.length) % items.length); + activeIndex = next; + const item = items[next]; + item.setAttribute('data-active', 'true'); + input.setAttribute('aria-activedescendant', item.id); + item.scrollIntoView({ block: 'nearest' }); + } + + function commit(entry) { + if (!entry) return; + setOpen(false); + onSelect(entryToCity(entry)); + input.value = currentValue().title; + // 全选新值:用户接着打字时是替换,而不是在末尾追加。 + input.select(); + } + + function resetInput() { + input.value = currentValue().title; + } + + input.addEventListener('focus', () => { + // 全选当前城市名,直接输入即可替换。 + // 必须用 select() 而不是 setSelectionRange():前者会走一遍聚焦步骤, + // 能把 DOM 焦点真正落到输入框上,键盘操作才收得到事件。 + input.select(); + // 同时把候选展开成常用城市列表,方便不输入就直接浏览。 + refresh(''); + }); + + input.addEventListener('click', () => { + // 已经获得焦点时再点一下不会触发 focus,这里补一次展开。 + if (!open) { + refresh(''); + // 同样全选,让用户接着打字是替换当前城市名而不是追加。 + input.select(); + } + }); + + input.addEventListener('input', () => { + refresh(); + }); + + input.addEventListener('keydown', (event) => { + // 输入法组字过程中的按键不参与候选操作,否则选词回车会误选城市。 + if (event.isComposing || event.keyCode === 229) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!open) refresh(); + else highlight(activeIndex + 1); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!open) refresh(); + else highlight(activeIndex < 0 ? options.length - 1 : activeIndex - 1); + return; + } + if (event.key === 'Enter') { + if (!open) return; + // 有高亮项就用高亮项;没有高亮时,只有当候选确实是按输入内容筛出来的 + // 才回退选首项 —— 聚焦时的「常用城市」列表与输入内容无关, + // 直接回退会把当前城市悄悄改成列表首项。 + const pick = + activeIndex >= 0 && options[activeIndex] + ? options[activeIndex] + : listFromInput + ? options[0] + : null; + if (pick) { + event.preventDefault(); + commit(pick); + } + return; + } + if (event.key === 'Escape') { + if (open) { + event.preventDefault(); + setOpen(false); + resetInput(); + } + return; + } + if (event.key === 'Tab') { + setOpen(false); + resetInput(); + } + }); + + // mousedown 立刻提交:这是最早能拿到选择的时机,早于任何失焦。 + // 同时 preventDefault,避免输入框失焦导致列表收起。 + list.addEventListener('mousedown', (event) => { + const item = event.target.closest('.combo-option'); + if (!item) return; + event.preventDefault(); + commit(options[Number(item.dataset.index)]); + }); + + // 兜底:某些宿主只派发 click,那就走这里(列表已关说明上面已提交过)。 + list.addEventListener('click', (event) => { + if (!open) return; + const item = event.target.closest('.combo-option'); + if (!item) return; + commit(options[Number(item.dataset.index)]); + }); + + input.addEventListener('blur', () => { + // 延后一点再收起:部分宿主在点击候选之前会先让输入框失焦, + // 立刻收起会让用户点不中。 + // 这里用真实焦点判断而不是标志位——标志位会粘住(选中过一次之后 + // 再点空白就收不起来),真实焦点也不会在用户点回输入框时误收起。 + window.setTimeout(() => { + if (document.activeElement === input) return; + setOpen(false); + resetInput(); + }, 200); + }); + + return { + setValue(text) { + input.value = text; + }, + }; +} + +/* ------------------------------------------------------------------ * + * 主题 + * ------------------------------------------------------------------ */ + +const media = window.matchMedia('(prefers-color-scheme: dark)'); + +function resolvedTheme() { + if (state.theme === 'auto') return media.matches ? 'dark' : 'light'; + return state.theme; +} + +/** + * URL 参数 ?theme=light|dark 覆盖初始主题。 + * 用途:把同一个页面并排放两份(浅色 / 深色)做视觉对照, + * 预览页直接复用这份 index.html,不再维护一份手工同步的副本。 + */ +function themeFromUrl() { + try { + const t = new URLSearchParams(window.location.search).get('theme'); + return t === 'light' || t === 'dark' ? t : null; + } catch { + return null; + } +} + +function applyTheme() { + const theme = resolvedTheme(); + document.documentElement.dataset.theme = theme; + const isDark = theme === 'dark'; + // 主题按钮用固定的半填充圆图标,不跟昼夜图标混用: + // 太阳/月亮已经被"对方现在是白天还是夜里"占用了,页面上只应出现两枚。 + // 主题语义由 aria-pressed + 无障碍文本承担。 + el.themeToggle.setAttribute('aria-pressed', String(isDark)); + el.themeToggleLabel.textContent = isDark ? '切换到浅色主题' : '切换到深色主题'; +} + +function toggleTheme() { + state.theme = resolvedTheme() === 'dark' ? 'light' : 'dark'; + applyTheme(); + savePrefs(); +} + +/* ------------------------------------------------------------------ * + * 时间制式 + * ------------------------------------------------------------------ */ + +function applyClockFormat() { + const is24 = state.clockFormat === '24h'; + // data-fmt 是首帧可见的视觉依据(由 head 内联脚本预先写好), + // aria-pressed 负责无障碍语义,两者保持同步。 + document.documentElement.dataset.fmt = state.clockFormat; + el.fmt12.setAttribute('aria-pressed', String(!is24)); + el.fmt24.setAttribute('aria-pressed', String(is24)); +} + +function setClockFormat(format) { + if (format !== '12h' && format !== '24h') return; + state.clockFormat = format; + applyClockFormat(); + savePrefs(); + render(); +} + +/* ------------------------------------------------------------------ * + * 城市对调 + * ------------------------------------------------------------------ */ + +/** + * 一键对调两个城市。 + * + * 判定始终跟着「对方城市」走,所以对调等于换了一个提问视角: + * 原本在问「现在能不能找他聊」,对调之后变成「他那边的人想找我聊」。 + * 两个时钟、时差、图标带、判定全部由 render() 按新的 state 重算,不用额外处理。 + */ +function swapCities() { + const target = state.target; + state.target = state.home; + state.home = target; + savePrefs(); + render(); + // 两个城市输入框也要跟着翻。否则标题写「上海 ⇄ 洛杉矶」, + // 而标注「对方城市」的框里还是「洛杉矶」——判定会用错人, + // 同一个控件内部还自相矛盾(框里的文字与下拉列表里选中项对不上)。 + // 只能在这里写:render() 每秒跑一次,在那里回写会抹掉用户正在输入的城市名。 + el.comboTarget.value = state.target.title; + el.comboHome.value = state.home.title; +} + +/* ------------------------------------------------------------------ * + * 启动 + * ------------------------------------------------------------------ */ + +function init() { + INDEX = buildIndex(); + loadPrefs(); + // URL 里的 ?theme= 优先于存档(用于并排预览同一个页面的两种主题)。 + const urlTheme = themeFromUrl(); + if (urlTheme) state.theme = urlTheme; + applyTheme(); + applyClockFormat(); + buildHourBar(); + + el.themeToggle.addEventListener('click', toggleTheme); + el.fmt12.addEventListener('click', () => setClockFormat('12h')); + el.fmt24.addEventListener('click', () => setClockFormat('24h')); + // ⇄ 是真正的 + 上海 + +

实时对照 · 现在该不该开口

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+ +
+ +
+ +
+

正在读取时间…

+

+ +
+
+ +
+ +
+
+

对方

+

+ + 洛杉矶 +

+

+ --:--:-- + +

+

+

+
+
+

我的

+

+ + 上海 +

+

+ --:--:-- + +

+

+

+
+
+ +
+ +
+

对方的一天

+ + +
+ +
+ +
+
+
时差
+
+
+
+ + + + + + + diff --git a/plugins/liuchen19960902/timezone-guard/miniapp/client/styles.css b/plugins/liuchen19960902/timezone-guard/miniapp/client/styles.css new file mode 100644 index 00000000..e48b938d --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/miniapp/client/styles.css @@ -0,0 +1,746 @@ +/* A/时差保安 — 独立 Client 视觉层。 + 语义色全部收敛在变量声明里,组件样式只引用变量。 + 设计取向:一页一个主角(判定),整页只保留一个状态色(判定区), + 太阳/月亮只出现在城市旁。 */ + +:root { + --mcode-bg-secondary: #eef0f2; + --mcode-surface: #ffffff; + --mcode-surface-muted: #f0f1f3; + /* 卡片外壳外的底色:卡片浮在它上面,层次才分得开。 */ + --mcode-canvas: #e8eaec; + --mcode-text: #17191c; + --mcode-text-muted: #5c6268; + --mcode-text-subtle: #6b7178; + --mcode-border: #17191c21; + --mcode-border-strong: #17191c47; + --mcode-focus: #0077d9; + --mcode-success: #0f7a3c; + --mcode-warning: #a95b08; + --mcode-danger: #b42318; + --mcode-primary-action-bg: #17191c; + --mcode-primary-action-text: #ffffff; + + /* 状态色:fg 用于文字,dot 用于色点。 */ + --band-ok-fg: var(--mcode-success); + --band-ok-dot: var(--mcode-success); + --band-warn-fg: var(--mcode-warning); + --band-warn-dot: var(--mcode-warning); + --band-bad-fg: var(--mcode-danger); + --band-bad-dot: var(--mcode-danger); + + /* 窗口宽度默认 560,可拉宽到任意尺寸。 + 上限给到 680:窄窗口下它就是 100%(贴合), + 宽窗口下也不会让两列时钟被扯得太散。 */ + --page-max: 680px; + --radius-card: 12px; + --radius-control: 8px; + + --sans: ui-sans-serif, -apple-system, system-ui, "Segoe UI", "PingFang SC", + "Microsoft YaHei", Helvetica, Arial, sans-serif; + --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, + Consolas, monospace; +} + +:root[data-theme="dark"] { + --mcode-bg-secondary: #191c1f; + --mcode-surface: #1a1d20; + --mcode-surface-muted: #1f2225; + --mcode-text: #edeff1; + --mcode-text-muted: #9ba1a7; + --mcode-text-subtle: #838a90; + --mcode-border: #ffffff21; + --mcode-border-strong: #ffffff47; + /* 焦点色要同时压得住三种相邻底色:深色页面底、控件容器面, + 以及选中态分段按钮的浅色药丸(#edeff1)。 + 更亮的蓝在浅色药丸上只有 2.53:1,这档更暗的蓝对三处都在 3.9:1 以上。 */ + --mcode-focus: #0077d9; + --mcode-success: #5bd98a; + --mcode-warning: #f0a94a; + --mcode-danger: #f87171; + --mcode-primary-action-bg: #edeff1; + --mcode-primary-action-text: #101214; + --mcode-canvas: #0b0d0e; +} + +/* 状态色挂在 上,全页继承:判定文字与色点共用同一套变量。 */ +[data-band="green"] { + --band-fg: var(--band-ok-fg); + --band-dot: var(--band-ok-dot); +} + +[data-band="yellow"] { + --band-fg: var(--band-warn-fg); + --band-dot: var(--band-warn-dot); +} + +[data-band="red"] { + --band-fg: var(--band-bad-fg); + --band-dot: var(--band-bad-dot); +} + +/* 首帧占位:脚本还没跑时不该显示任何状态色, + 否则会出现「黄色图标配红色文案」这种自相矛盾的第一帧。 */ +[data-band="neutral"] { + --band-fg: var(--mcode-text); + --band-dot: var(--mcode-border-strong); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + /* 卡片浮在这层底色上,靠一道灰差建立前后关系,不用投影堆层次。 */ + background: var(--mcode-canvas); + color: var(--mcode-text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + padding: 16px; +} + +.icon-sprite { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.page { + max-width: var(--page-max); + /* 铺满一屏:内容不满时,把多出来的空间平均分给各分区之间的间隙, + 整张卡片从页头到时差行撑到底,而不是在底部堆一块死白。 */ + min-height: calc(100vh - 32px); + margin: 0 auto; + padding: 22px 24px 26px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--radius-card); + /* 投影只用来表达「这张卡片浮在画布上」,不做装饰。 */ + box-shadow: 0 1px 2px rgb(10 10 10 / 6%); + display: flex; + flex-direction: column; + /* 分区之间的最小间距交给 row-gap;条件允许时再多分一点空气, + 这样每一段间距都一致,发丝线正好落在每段间距的中间。 */ + row-gap: 20px; + justify-content: space-between; +} + +/* ---------- 页头 ---------- */ + +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.page-head-text { + min-width: 0; +} + +.page-title { + margin: 0; + display: flex; + align-items: center; + gap: 7px; + font-size: 17px; + line-height: 24px; + font-weight: 600; + letter-spacing: -0.01em; +} + +/* 城市对中间的 ⇄:真正可点的一键对调。 + 按钮撑出来的 3px 内边距用 -3px 外边距抵消,所以视觉间距和改之前一模一样, + 但多了一个 21×21 的点击区。 */ +.page-title .swap-btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + margin: -3px; + padding: 3px; + border: 0; + border-radius: 4px; + background: none; + color: var(--mcode-text-muted); + cursor: pointer; +} + +/* 悬停时把图标提到正文色:浅色主题是变深,深色主题是变亮, + 两个主题都是"更强调"的方向。 */ +.page-title .swap-btn:hover { + color: var(--mcode-text); +} + +/* 焦点语言与全页统一:2px 实色 --mcode-focus 环。 + offset 2px 时整道环正好落在标题 7px 的间隙里,不会压到城市名上。 */ +.page-title .swap-btn:focus-visible { + outline: 2px solid var(--mcode-focus); + outline-offset: 2px; +} + +.page-title .swap { + width: 15px; + height: 15px; +} + +.lede { + margin: 3px 0 0; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +.head-tools { + flex: none; + display: flex; + align-items: center; + gap: 8px; +} + +/* 12H / 24H 分段控件:选中段是实心药丸,靠底色差即可分辨。 */ +.segmented { + display: inline-flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--mcode-border); + border-radius: var(--radius-control); + background: var(--mcode-surface); +} + +.seg { + border: 0; + border-radius: 6px; + padding: 4px 9px; + background: transparent; + color: var(--mcode-text-muted); + font-family: var(--mono); + font-size: 11px; + line-height: 15px; + letter-spacing: 0.02em; + cursor: pointer; + transition: background 150ms ease-out, color 150ms ease-out; +} + +.seg:hover { + color: var(--mcode-text); +} + +/* 选中态由 驱动:首帧就能显示正确的制式, + 不会出现「先按 12H 画一帧再翻转」的闪烁。 */ +:root[data-fmt="12h"] #fmt-12h, +:root[data-fmt="24h"] #fmt-24h { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + font-weight: 600; +} + +.icon-button { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--mcode-text-muted); + cursor: pointer; + transition: background 150ms ease-out, color 150ms ease-out; +} + +.icon-button:hover { + background: var(--mcode-surface-muted); + color: var(--mcode-text); +} + +.icon { + width: 15px; + height: 15px; +} + +/* ---------- 城市切换 ---------- */ + +.switcher { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.field { + min-width: 0; +} + +.field-label { + display: block; + margin-bottom: 6px; + color: var(--mcode-text-muted); + font-size: 11px; + line-height: 15px; +} + +.combo { + position: relative; +} + +.combo-input { + width: 100%; + height: 36px; + padding: 0 30px 0 12px; + border: 1px solid var(--mcode-border); + border-radius: var(--radius-control); + background: var(--mcode-surface); + color: var(--mcode-text); + font: inherit; + font-size: 14px; + line-height: 20px; +} + +.combo-input::placeholder { + color: var(--mcode-text-subtle); +} + +.combo-input:hover { + border-color: var(--mcode-text-subtle); +} + +.combo-input:focus, +.combo-input:focus-visible { + /* offset 0:实色 outline 紧贴同色 border,合成一道 3px 的实边, + 不会出现「两条蓝线夹一道缝」的双线。 */ + outline: 2px solid var(--mcode-focus); + outline-offset: 0; + border-color: var(--mcode-focus); +} + +/* 焦点语言全页统一:一道 2px 实色 --mcode-focus 环。 + 之前用的是 25% 透明度的软环,合成到浅底上只有 1.4:1,达不到 3:1; + 而 .seg 与 .icon-button 没有边框变化,这道环是它们唯一的焦点提示。 */ +.seg:focus-visible { + outline: 2px solid var(--mcode-focus); + outline-offset: -2px; +} + +.icon-button:focus-visible { + outline: 2px solid var(--mcode-focus); + outline-offset: 2px; +} + +.combo-chevron { + position: absolute; + top: 50%; + right: 10px; + width: 14px; + height: 14px; + color: var(--mcode-text-subtle); + pointer-events: none; + transform: translateY(-50%); +} + +.combo-list { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: 264px; + margin: 0; + padding: 4px; + overflow-y: auto; + list-style: none; + border: 1px solid var(--mcode-border); + border-radius: var(--radius-card); + background: var(--mcode-surface); + box-shadow: 0 0 20px rgb(10 10 10 / 8%); +} + +.combo-list[hidden] { + display: none; +} + +.combo-option { + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px 10px; + border-radius: var(--radius-control); + cursor: pointer; +} + +.combo-option:hover, +.combo-option[data-active="true"] { + background: var(--mcode-surface-muted); +} + +.combo-option[aria-selected="true"] { + background: var(--mcode-bg-secondary); +} + +.combo-option-title { + font-size: 14px; + line-height: 20px; + color: var(--mcode-text); +} + +.combo-option-sub { + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); +} + +/* 空态提示放在 listbox 之外,避免 listbox 里混入非 option 子元素 */ +.combo-empty { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + margin: 0; + padding: 10px; + border: 1px solid var(--mcode-border); + border-radius: var(--radius-card); + background: var(--mcode-surface); + color: var(--mcode-text-muted); + font-size: 13px; + line-height: 20px; +} + +.combo-empty[hidden] { + display: none; +} + +/* ---------- 分区线:靠留白和发丝线组织版面,不用卡片框 ---------- */ + +/* 间距由 .page 的 row-gap 统一给,分区线自己不再带外边距, + 否则「固定 20px 外边距 + 均分空气」两套间距会互相打架。 */ +.rule { + height: 0; + margin: 0; + border: 0; + border-top: 1px solid var(--mcode-border); +} + +/* ---------- 判定区:整页唯一的状态色,也是唯一的焦点 ---------- */ + +.verdict { + display: flex; + align-items: flex-start; + gap: 11px; +} + +.verdict-dot { + flex: none; + width: 11px; + height: 11px; + margin-top: 9px; + border-radius: 50%; + background: var(--band-dot); +} + +.verdict-title { + margin: 0; + font-size: 24px; + line-height: 30px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--band-fg); +} + +.verdict-detail { + margin: 5px 0 0; + color: var(--mcode-text-muted); + font-size: 13px; + line-height: 20px; +} + +.verdict-extra { + margin: 2px 0 0; + color: var(--mcode-text-subtle); + font-size: 12px; + line-height: 18px; +} + +/* ---------- 双方时钟:并排对照,中间一条竖线 ---------- */ + +.clocks { + display: grid; + grid-template-columns: 1fr 1fr; +} + +.clock { + min-width: 0; + padding: 0 20px; +} + +.clock:first-child { + padding-left: 0; +} + +.clock:last-child { + padding-right: 0; + border-left: 1px solid var(--mcode-border); +} + +.clock-role { + margin: 0 0 4px; + color: var(--mcode-text-subtle); + font-size: 11px; + line-height: 15px; +} + +/* 太阳/月亮只在这里出现:每个城市一个,说明那边现在是白昼还是夜里。 */ +.clock-city { + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.clock-city .sky { + flex: none; + width: 20px; + height: 20px; + color: var(--mcode-text); +} + +.clock-city .name { + font-size: 15px; + line-height: 22px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.clock-time { + margin: 0; + display: flex; + align-items: baseline; + gap: 6px; + font-family: var(--mono); + font-size: 28px; + line-height: 34px; + font-weight: 400; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.clock-period { + color: var(--mcode-text-muted); + font-family: var(--sans); + font-size: 12px; + line-height: 18px; + letter-spacing: 0; +} + +.clock-date { + margin: 4px 0 0; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +.clock-zone { + margin: 1px 0 0; + color: var(--mcode-text-subtle); + font-size: 11px; + line-height: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---------- 一天的图标带:对方那一整天 ---------- */ + +/* 格子间隙由变量给出:刻度要按它算偏移,窄断点改间隙时两处一起变。 */ +.dayline { + --hour-gap: 2px; +} + +.dayline-label { + margin: 0 0 12px; + color: var(--mcode-text-muted); + font-size: 11px; + line-height: 15px; +} + +.hour-bar { + display: grid; + grid-template-columns: repeat(24, 1fr); + gap: var(--hour-gap); +} + +/* 一小时一格,格里一枚昼夜图标:太阳 / 半日 / 月亮。 + 形状本身就在说「那边是白天还是夜里」,不再靠灰阶深浅去暗示。 */ +.hour-cell { + aspect-ratio: 1; + display: grid; + place-items: center; + border-radius: 4px; +} + +.hour-icon { + width: 80%; + height: 80%; + color: var(--mcode-text-subtle); +} + +/* 深夜那几格的墨色再重一档:不靠颜色,只靠一点点重量让「对方在睡」那一段先跳出来。 */ +.hour-cell[data-tone="night"] .hour-icon { + color: var(--mcode-text-muted); +} + +/* 当前所在的那一小时:唯一带状态色的一格。 + 图标反白是为了在状态块上还看得清形状,不是给图标上状态色。 */ +.hour-cell[data-now="true"] { + background: var(--band-dot); +} + +.hour-cell[data-now="true"] .hour-icon { + color: var(--mcode-primary-action-text); +} + +.hour-ticks { + position: relative; + height: 16px; + margin-top: 11px; +} + +/* 刻度必须落在格子的左边缘上。网格的间隙会把每一格整体右推, + 第 i 格左边缘 = i/24 × 100% + i/24 × 间隙,所以这三档要补上间隙的相应份额, + 否则 6/12/18 会固定偏左 0.5/1.0/1.5px。 */ +.hour-ticks span { + position: absolute; + top: 0; + transform: translateX(-50%); + color: var(--mcode-text-subtle); + font-family: var(--mono); + font-size: 11px; + line-height: 14px; +} + +.hour-ticks span:nth-child(1) { + left: 0; + transform: none; +} + +.hour-ticks span:nth-child(2) { + left: calc(25% + var(--hour-gap) * 0.25); +} + +.hour-ticks span:nth-child(3) { + left: calc(50% + var(--hour-gap) * 0.5); +} + +.hour-ticks span:nth-child(4) { + left: calc(75% + var(--hour-gap) * 0.75); +} + +.hour-ticks span:nth-child(5) { + left: 100%; + transform: translateX(-100%); +} + +/* ---------- 信息行 ---------- */ + +.facts { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.fact { + display: flex; + align-items: baseline; + gap: 14px; +} + +.fact dt { + flex: none; + width: 34px; + color: var(--mcode-text-subtle); + font-size: 11px; + line-height: 18px; +} + +.fact dd { + margin: 0; + min-width: 0; + color: var(--mcode-text); + font-size: 13px; + line-height: 18px; +} + +/* ---------- 响应式 ---------- */ + +@media (max-width: 460px) { + body { + padding: 8px; + } + + .page { + padding: 16px 16px 20px; + min-height: calc(100vh - 16px); + } + + .switcher { + grid-template-columns: 1fr; + } + + /* 24 格摊在 ~350px 里时单格只有 12.7px:把间隙收窄、图标吃满格子。 + 否则半日图标的地平线会被抗锯齿吃掉,又读成一个小山包。 */ + .dayline { + --hour-gap: 1px; + } + + .hour-icon { + width: 100%; + height: 100%; + } + + .clocks { + grid-template-columns: 1fr; + } + + .clock { + padding: 0; + } + + .clock:last-child { + border-left: 0; + border-top: 1px solid var(--mcode-border); + padding-top: 16px; + margin-top: 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} diff --git a/plugins/liuchen19960902/timezone-guard/miniapp/miniapp.json b/plugins/liuchen19960902/timezone-guard/miniapp/miniapp.json new file mode 100644 index 00000000..f9415e52 --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/miniapp/miniapp.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard", + "window": { + "width": 560, + "height": 860, + "minWidth": 400, + "minHeight": 420, + "resizable": true + } + }, + "mcpEndpoints": [] +} diff --git a/plugins/liuchen19960902/timezone-guard/miniapp/node/miniapp-api.ts b/plugins/liuchen19960902/timezone-guard/miniapp/node/miniapp-api.ts new file mode 100644 index 00000000..f5d8b7a4 --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/miniapp/node/miniapp-api.ts @@ -0,0 +1,397 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: JsonObject; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a Node-created session starts with. */ + readonly default: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + */ +/** + * Every event except `run.queued` names its session, so `reply` can be called from `onEvent` even + * before the `run` that created the session has returned. `answer` cannot: the paused `run` still holds the + * session's single-run slot while `question.requested` is delivered, so call `answer` only after + * that `run` resolves (an earlier call rejects with `BUSY`). Ignore event types you do not know. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; decide it with `agent.reply`. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire; continue with `agent.answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunStatus; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export type MiniAppAgentErrorCode = + | 'NOT_PERMITTED' + | 'BUSY' + | 'SESSION_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'AGENT_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'AGENT_OUTCOME_UNKNOWN' + | 'REQUEST_NOT_FOUND'; + +export interface MiniAppAgentError extends Error { + readonly code: MiniAppAgentErrorCode; + /** + * True when nothing was started and the same call may be retried later. + * `AGENT_OUTCOME_UNKNOWN` is never retryable: the turn may have run. + */ + readonly retryable: boolean; + /** Set on every `BUSY`: the session already has a run in flight, or the wait queue is full. */ + readonly reason?: 'session_busy' | 'queue_full'; +} + +/** + * Present only when the manifest declares `agentAccess: ["session.run"]`. + * A new Node session uses the primary Agent unless `agent` names one from `agents()`, and works in + * this Mini App's own Agent directory and on the runtime default model; + * continuing a session keeps its Agent, directory and model (unless `model` switches it), including + * one a page created using its host chat's Agent, project directory and model; a session never + * changes its Agent. Runs one turn with that Agent's full capabilities + * (files, shell, Skills, MCP including this Mini App's own), and resolves when the turn ends. + * + * Interaction is yours to render: pass `onEvent` and handle `permission.requested` with + * `reply` and `question.requested` with `answer`. A permission request waits only while + * someone can answer it: this run passed `onEvent`, or a page of this Mini App currently has + * the session open in a chat side panel via `open`. Otherwise it is denied (the model is told + * why), and a questionnaire ends the turn with `result.pendingQuestion`. A run waiting on a + * permission is released by `cancel`/abort, process exit or republish. Up to `agentConcurrency` + * runs (default 4) per Mini App run at once; further `run` / `answer` calls wait in order (up to + * four times that many, see `run.queued`), and aborting a waiting call rejects it with + * `REQUEST_CANCELLED`. + */ +export interface MiniAppAgentClient { + run( + input: { + readonly text: string; + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a + * new session's model: it must be one `models()` lists; unusable is `INVALID_ARGUMENTS`, + * missing BYOK credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. Passing it + * with `sessionId` is `INVALID_ARGUMENTS`. + */ + readonly agent?: string; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Decide a pending permission request. Never writes the user's global rules. Node replies + * have 16 separate pending slots; `run` / `answer` / `models` / `agents` / `create` and Connector + * requests share another 16. Filling that ordinary budget still permits replies. A full reply + * budget rejects with retryable `AGENT_UNAVAILABLE`. + */ + reply(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: 'allowOnce' | 'deny'; + }): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `AGENT_OUTCOME_UNKNOWN` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `run`'s `model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Returns the model catalog and runtime `default`. Pass `default` directly as `model` to + * `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, add a selected `variant` + * if needed and preserve `variant: ''`. If thinking is supported, select an effort from + * `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session (agent, this Mini App's Agent directory, the runtime default model) without + * running a turn, and return its `sessionId`; continue it with `run({ sessionId })`. A page's + * `create` binds the session to its chat instead, and its `sessionId` can be continued here. + * Takes no Host run slot but uses the ordinary Node request budget. `agent` picks the session's + * agent from `agents()`; a session never changes it. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; + readonly agent?: MiniAppAgentClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/liuchen19960902/timezone-guard/miniapp/node/server.mjs b/plugins/liuchen19960902/timezone-guard/miniapp/node/server.mjs new file mode 100644 index 00000000..268e99cd --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/miniapp/node/server.mjs @@ -0,0 +1,95 @@ +// @ts-check + +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +/** 允许被访问的静态资源白名单。 */ +const ASSETS = { + 'index.html': 'text/html; charset=utf-8', + 'styles.css': 'text/css; charset=utf-8', + 'cities.js': 'text/javascript; charset=utf-8', + 'app.js': 'text/javascript; charset=utf-8', +}; + +/** + * 启动 Mini App 的 Node 入口。 + * + * 页面本身是纯静态 Client:所有时区计算都在浏览器里用 Intl 完成, + * 这里只负责把 surface.path 和它的静态资源挂到 Host 分配的端口上。 + * + * @param {MiniAppContext} context + * @returns {Promise} + */ +export async function start(context) { + const clientDir = join(context.pluginRoot, 'miniapp/client'); + + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { + 'content-type': 'application/json; charset=utf-8', + allow: 'GET, HEAD', + }); + response.end(JSON.stringify({ error: 'method_not_allowed' })); + return; + } + + // surface.path 与静态资源共用同一份 Client 目录。 + // 去掉尾部斜杠,宿主用 /dashboard 或 /dashboard/ 请求都能命中。 + const pathname = url.pathname.replace(/\/+$/, '') || '/'; + const name = pathname === '/' || pathname === '/dashboard' + ? 'index.html' + : pathname.split('/').filter(Boolean).pop() || ''; + + const contentType = ASSETS[name]; + if (!contentType) { + response.writeHead(404, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'not_found' })); + return; + } + + readFile(join(clientDir, name)).then( + (body) => { + response.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' }); + response.end(request.method === 'HEAD' ? undefined : body); + }, + () => { + response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'asset_unreadable' })); + }, + ); + }); + + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', onError); + resolve(); + }); + }); + + context.logger.info('miniapp.runtime.listening'); + + let disposal; + const dispose = () => { + if (disposal) return disposal; + context.signal.removeEventListener('abort', onAbort); + disposal = new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return disposal; + }; + const onAbort = () => { + void dispose().catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} diff --git a/plugins/liuchen19960902/timezone-guard/package.json b/plugins/liuchen19960902/timezone-guard/package.json new file mode 100644 index 00000000..aab6b8dc --- /dev/null +++ b/plugins/liuchen19960902/timezone-guard/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +}