diff --git a/AGENTS.md b/AGENTS.md index 748b876..034d754 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ src/ │ ├── foundation/ # theme(设计令牌) / glass(玻璃) / base / dark │ ├── desktop/ # surface / folder-panel / home │ ├── components/ # dock / dock-layer / folder-tree / folder-popup -│ └── widgets/ # clock / weather / activity +│ └── widgets/ # clock / weather / activity / rss ├── components/ │ ├── ui/ # shadcn 基础组件 │ ├── ai/ # AI 助手 UI(AIAssistant / AIChatPanel / 子组件) @@ -51,7 +51,8 @@ src/ │ │ ├── timer/ # TimerWidget.tsx │ │ ├── note/ # NoteWidget.tsx │ │ ├── weather/ # WeatherWidget.tsx + weather.ts(数据与纯逻辑) -│ │ └── activity/ # ActivityWidget.tsx + activity.ts(数据与纯逻辑) +│ │ ├── activity/ # ActivityWidget.tsx + activity.ts(数据与纯逻辑) +│ │ └── rss/ # RssWidget.tsx + RssFeedPopup.tsx + rss.ts(数据与纯逻辑) │ ├── shell/ # 页面外壳(NewTab / SidePanel / SidePanelHome) │ ├── desktop/ # 桌面系统(DesktopWorkspace / HomeDesktop / DesktopGridView / DesktopItemViews) │ ├── dock/ # Dock 栏与文件夹浮层(Dock / DockFolderLayer) diff --git a/README.md b/README.md index ab0276d..c34e4c2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **时钟** — 显示在搜索框上方。 - **天气** — 实时天气与逐时/逐天预报(展开弹窗)。 - **米哈游活动** — 游戏活动日历与甘特图,支持自定义外显游戏与紧急提醒。 +- **RSS 订阅** — 订阅 RSS/Atom/JSON Feed 并按时间列出更新,默认订阅 DeepSeek 服务状态。 - **计数器** / **秒表** / **笔记** — 通用小工具。 ### AI 助手 diff --git a/package.json b/package.json index 53966a8..15ebe99 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "ai": "^7.0.109", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "feedsmith": "^3.0.1", "lucide-react": "^1.17.0", "marked": "^18.0.5", "react": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0caee6e..54b7465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: date-fns: specifier: ^4.4.0 version: 4.4.0 + feedsmith: + specifier: ^3.0.1 + version: 3.0.1 lucide-react: specifier: ^1.17.0 version: 1.33.0(react@19.2.8) @@ -515,6 +518,9 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nodable/entities@3.1.0': + resolution: {integrity: sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1848,6 +1854,9 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2434,6 +2443,13 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.2: + resolution: {integrity: sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==} + hasBin: true + fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -2446,6 +2462,10 @@ packages: picomatch: optional: true + feedsmith@3.0.1: + resolution: {integrity: sha512-P4puSPGXvIcA5wYvJJCap+nbO2tVemZ39fpkx68ZtkbAGtzw4eNJ0BDm8tLb25UWD8yL8j47eDG2uwfzprTiZA==} + engines: {node: '>=14'} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -2789,6 +2809,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -3681,6 +3704,10 @@ packages: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4176,6 +4203,9 @@ packages: strip-literal@4.0.0: resolution: {integrity: sha512-PaqAvfUZKBwc/SLmNZtHmzK+v19Z4O4eS3cKPeGvbIv/U3pnyEq4Tuw3/4v/FwfM8VQaEawsyCcOQ0P+kpwWWw==} + strnum@2.5.1: + resolution: {integrity: sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==} + stubborn-fs@2.0.0: resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} @@ -4727,6 +4757,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -5093,6 +5127,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@nodable/entities@3.1.0': {} + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.7': @@ -6143,6 +6179,8 @@ snapshots: any-promise@1.3.0: {} + anynum@1.0.1: {} + argparse@2.0.1: {} argv-formatter@1.0.0: {} @@ -6722,6 +6760,20 @@ snapshots: fast-redact@3.5.0: {} + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.2: + dependencies: + '@nodable/entities': 3.1.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.5.1 + xml-naming: 0.3.0 + fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -6730,6 +6782,11 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + feedsmith@3.0.1: + dependencies: + entities: 7.0.1 + fast-xml-parser: 5.11.2 + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -7031,6 +7088,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-unsafe@2.0.2: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -8106,6 +8165,8 @@ snapshots: path-exists@3.0.0: {} + path-expression-matcher@1.6.2: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -8652,6 +8713,10 @@ snapshots: dependencies: js-tokens: 10.0.0 + strnum@2.5.1: + dependencies: + anynum: 1.0.1 + stubborn-fs@2.0.0: dependencies: stubborn-utils: 1.0.2 @@ -9204,6 +9269,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} + xml2js@0.6.2: dependencies: sax: 1.6.1 diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 5889709..591433f 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -1886,5 +1886,73 @@ "migrationRelease": { "message": "Release to home desktop", "description": "迁移选择文件夹确认按钮" + }, + "rssWidgetName": { + "message": "RSS Feed", + "description": "RSS 小部件名称" + }, + "rssWidgetDesc": { + "message": "Subscribe to RSS/Atom feeds and list updates by time; DeepSeek status by default", + "description": "RSS 小部件描述" + }, + "rssFeedTitle": { + "message": "Feed entries", + "description": "RSS 订阅弹窗标题" + }, + "rssLoading": { + "message": "Loading...", + "description": "订阅加载中" + }, + "rssLoadFailed": { + "message": "Failed to load feed", + "description": "订阅获取失败" + }, + "rssEmpty": { + "message": "No updates", + "description": "订阅暂无条目" + }, + "rssRefresh": { + "message": "Refresh", + "description": "刷新订阅" + }, + "rssUrlPlaceholder": { + "message": "Feed URL (RSS/Atom/JSON Feed)", + "description": "订阅地址输入占位符" + }, + "rssApplyUrl": { + "message": "Subscribe", + "description": "应用订阅地址按钮" + }, + "rssResetUrl": { + "message": "Default", + "description": "恢复默认订阅地址按钮" + }, + "rssEntryCount": { + "message": "{n} entries", + "description": "订阅条目总数" + }, + "rssStaleContent": { + "message": "Refresh failed, content may be out of date", + "description": "展示兜底旧内容时的提示" + }, + "rssFeedKind": { + "message": "Feed type", + "description": "订阅类型选择标签" + }, + "rssFeedKindStatus": { + "message": "Status", + "description": "订阅类型:状态页" + }, + "rssFeedKindList": { + "message": "List", + "description": "订阅类型:列表" + }, + "rssFeedKindStatusHint": { + "message": "Tile shows the latest entry only", + "description": "状态类说明" + }, + "rssFeedKindListHint": { + "message": "Tile lists entries by time", + "description": "列表类说明" } } diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index c9833cb..33a62a8 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -942,5 +942,73 @@ "migrationRelease": { "message": "释放到主桌面", "description": "迁移选择文件夹确认按钮" + }, + "rssWidgetName": { + "message": "RSS 订阅", + "description": "RSS 小部件名称" + }, + "rssWidgetDesc": { + "message": "订阅 RSS/Atom 源并按时间列出更新,默认订阅 DeepSeek 服务状态", + "description": "RSS 小部件描述" + }, + "rssFeedTitle": { + "message": "订阅内容", + "description": "RSS 订阅弹窗标题" + }, + "rssLoading": { + "message": "加载中...", + "description": "订阅加载中" + }, + "rssLoadFailed": { + "message": "订阅获取失败", + "description": "订阅获取失败" + }, + "rssEmpty": { + "message": "暂无更新", + "description": "订阅暂无条目" + }, + "rssRefresh": { + "message": "刷新", + "description": "刷新订阅" + }, + "rssUrlPlaceholder": { + "message": "订阅地址(RSS/Atom/JSON Feed)", + "description": "订阅地址输入占位符" + }, + "rssApplyUrl": { + "message": "订阅", + "description": "应用订阅地址按钮" + }, + "rssResetUrl": { + "message": "默认", + "description": "恢复默认订阅地址按钮" + }, + "rssEntryCount": { + "message": "共 {n} 条", + "description": "订阅条目总数" + }, + "rssStaleContent": { + "message": "刷新失败,内容可能不是最新", + "description": "展示兜底旧内容时的提示" + }, + "rssFeedKind": { + "message": "订阅类型", + "description": "订阅类型选择标签" + }, + "rssFeedKindStatus": { + "message": "状态", + "description": "订阅类型:状态页" + }, + "rssFeedKindList": { + "message": "列表", + "description": "订阅类型:列表" + }, + "rssFeedKindStatusHint": { + "message": "磁贴只显示最新一条", + "description": "状态类说明" + }, + "rssFeedKindListHint": { + "message": "磁贴按时间列出多条", + "description": "列表类说明" } } diff --git a/src/assets/styles/components/context-menu.css b/src/assets/styles/components/context-menu.css new file mode 100644 index 0000000..e638167 --- /dev/null +++ b/src/assets/styles/components/context-menu.css @@ -0,0 +1,27 @@ +/* + * 右键菜单(通用浮层) + * + * 背景与边框由 `.xb-glass-popover` 提供(令牌混色 + backdrop-filter), + * 本文件只补菜单自身需要的交互态:悬停/按下时的浅色叠层。 + * + * 悬停刻意不用 Tailwind 的 `bg-accent`:那是实色,在玻璃背景上会盖出一块 + * 不透明白底,与周围的通透感割裂。这里改用与玻璃同源的"前景色低透明度"叠层, + * 亮/暗主题下都能自然融入。 + */ +.ctx-menu-item { + /* 菜单项是可点击控件,恢复文本选择语义下的光标 */ + cursor: pointer; + transition: background 0.12s; +} + +.ctx-menu-item:hover:not(:disabled) { + background: color-mix(in oklch, var(--color-foreground) 10%, transparent); +} + +.ctx-menu-item:active:not(:disabled) { + background: color-mix(in oklch, var(--color-foreground) 16%, transparent); +} + +.ctx-menu-item:disabled { + cursor: default; +} diff --git a/src/assets/styles/components/search-box.css b/src/assets/styles/components/search-box.css new file mode 100644 index 0000000..fdd627e --- /dev/null +++ b/src/assets/styles/components/search-box.css @@ -0,0 +1,375 @@ +/* + * 搜索框 + * + * 接入玻璃系统:背景/边框走 `--color-card` 与 `--color-border` 的令牌混色, + * 模糊量由 `--xb-glass-blur`、透明度由 `--xb-glass-opacity` 控制, + * 因此设置面板里的玻璃滑块对它同样生效。 + * + * 与玻璃系统其他浮层(xb-glass-popover / xb-glass-card)的差异: + * 搜索框是首屏主控件,需要**略重一点**——底色比标准玻璃更实(见 --search-veil)、 + * 边框更清晰、并额外带一层内高光与投影,避免在大面积壁纸上"飘"起来看不清。 + * + * 聚焦时向两侧拉长:用**宽度**动画而不是 `scaleX`—— + * 横向缩放会把文字与圆角一起拉扁变形,而宽度变化只让两端向外扩展, + * 内容字号与形状保持原样。只动这一个元素的 width/margin-left, + * 每帧布局成本可以忽略。 + */ + +/* + * 搜索框宿主:允许拉长后的溢出可见,并**统一提供共用的玻璃参数**。 + * + * `--search-veil` 必须定义在这里而不是 `.search-box` 上: + * 下拉菜单与搜索框是**兄弟**节点,兄弟之间不继承自定义属性, + * 定义在 `.search-box` 上的话下拉菜单取到的是空值、背景直接失效。 + * + * 刻意**不用** `container-type`:它会为后代建立新的包含块, + * 进而改变 `backdrop-filter` 的采样范围(本项目壁纸层是刻意放在 + * 玻璃元素同级背后供其采样的)。用固定像素 + clamp 已足够控制幅度。 + */ +.search-box-host { + /* + * 搜索框相对标准玻璃的"加重"量:混入更高比例的底色, + * 使其在大面积壁纸上比普通玻璃更实、更易读。 + * 下拉菜单同样取用,保持两者观感一致。 + * + * 用**百分比**而不是无单位数字:它要和 `--xb-glass-opacity`(0.8) + * 相加后一起进 color-mix,单位必须一致,否则 calc() 非法、整条声明失效。 + */ + --search-veil: 20%; + + /* flex 居中:子元素宽度变化时两端对称外扩,不依赖 margin 反算 */ + display: flex; + justify-content: center; + overflow: visible; +} + +/* + * 搜索框的"加重"底色。 + * + * `--xb-glass-opacity` 是**无单位数字**(0.8),而 `--search-veil` 是百分比, + * 两者不能直接相加:`calc(0.8 + 20%)` 单位不兼容,会让整条 + * background-color 被浏览器丢弃、只剩回退的实色,玻璃透明度滑块因此失效。 + * 正确做法是先把 opacity 转成百分比(`* 100%`)再相加。 + * + * 用 `min()` 钳到 100%:玻璃透明度调到 100% 时相加会越界。 + */ +.search-box { + --search-grow: clamp(48px, 8vw, 110px); + + /* + * 文字色与投影。 + * + * 壁纸可随时更换,玻璃底色又半透明,单靠字色无法保证可读性。 + * 但**不用硬描边**:多向 1px 阴影会叠出显眼的白边/黑边,观感廉价。 + * 这里只用一层极淡的柔和投影,仅在字形下方形成轻微暗角, + * 主要对比由上面更实的底色保证。 + */ + --search-ink: var(--color-foreground); + --search-ink-shadow: 0 1px 2px color-mix(in srgb, var(--color-background) 35%, transparent); + --search-placeholder-ink: color-mix(in srgb, var(--color-foreground) 62%, transparent); + + /* + * 单侧外扩量。 + * + * 搜索框被外层 `max-w-lg` 限制在约 512px,因此用固定区间而不是视口单位: + * 下限 48px 保证小容器(侧边栏)也有可见幅度, + * 上限 110px 避免宽屏下把两端顶到 header 边缘。 + * 中间随视口平滑变化。 + */ + --search-grow: clamp(48px, 8vw, 110px); + + position: relative; + /* + * 居中拉长:宿主用 flex 居中,这里只改宽度,两端自然对称外扩。 + * 用 flex 而不是 left/transform/margin 反算,避免钳制后偏心。 + * `min()` 以视口宽度兜底:极窄窗口下若不钳制, + * 总宽会超出屏幕、两端被裁掉。 + */ + width: min(calc(100% + var(--search-grow) * 2), calc(100vw - 16px)); + /* + * 必须禁止收缩:宿主是 flex 容器,外扩后的宽度已超过容器, + * 默认的 `flex-shrink: 1` 会把它压回原宽,聚焦时的拉长动画等于被吃掉。 + */ + flex-shrink: 0; + background-color: color-mix( + in srgb, + var(--color-card) min(100%, calc(var(--xb-glass-opacity) * 100% + var(--search-veil))), + transparent + ); + backdrop-filter: blur(calc(var(--xb-glass-blur) + 2px)); + border: 1px solid + color-mix(in srgb, var(--color-border) calc(var(--xb-glass-opacity) * 100%), transparent); + border-radius: 9999px; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.1), + inset 0 1px 0 color-mix(in srgb, white 35%, transparent); + + transition: + width 0.42s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.42s ease, + border-color 0.42s ease, + background-color 0.42s ease; +} + +/* 未聚焦:外扩量为 0,与常规宽度一致 */ +.search-box:not(:focus-within) { + --search-grow: 0px; +} + +/* 聚焦:向两侧拉长,并加强边框与光晕 */ +.search-box:focus-within { + border-color: color-mix(in srgb, var(--color-ring) 70%, transparent); + box-shadow: + 0 10px 34px rgba(0, 0, 0, 0.18), + 0 0 0 3px color-mix(in srgb, var(--color-ring) 18%, transparent), + inset 0 1px 0 color-mix(in srgb, white 45%, transparent); +} + +/* 输入框:透明底,聚焦环由外层统一表达,避免出现两套焦点样式 */ +/* + * 输入框。 + * + * 背景是可随时更换的壁纸,玻璃底色又半透明,因此不能只靠前景色保证可读性。 + * 这里用**一层极淡的柔和投影**托住文字,而不是多向硬描边—— + * 多层 1px 阴影会叠出明显的白边/黑边,看起来像劣质描边字; + * 单层大半径低透明度只在字形下方形成柔和暗角,几乎无感却足以拉开与背景的层次。 + */ +.search-box-input { + flex: 1; + min-width: 0; + height: 100%; + padding: 0 10px; + border: none; + outline: none; + background: transparent; + color: var(--search-ink); + font-size: 16px; + text-shadow: var(--search-ink-shadow); +} + +/* 占位符是次要信息,沿用同一层投影即可,不额外加重 */ +.search-box-input::placeholder { + color: var(--search-placeholder-ink); + opacity: 1; + text-shadow: var(--search-ink-shadow); +} + +/* 搜索引擎按钮:悬停用前景色叠层,与玻璃背景同源 */ +.search-box-engine { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-left: 6px; + flex-shrink: 0; + border: none; + border-radius: 9999px; + background: transparent; + cursor: pointer; + transition: background 0.2s ease; +} + +.search-box-engine:hover { + background: color-mix(in oklch, var(--color-foreground) 8%, transparent); +} + +/* + * 引擎图标是各站点的彩色 favicon,颜色不可控。 + * 深色壁纸下深色 logo 会消失,因此给按钮加一层极淡的白色衬底 + + * 投影,让图标始终落在可预期的背景上。 + */ +/* + * 引擎图标是各站点的彩色 favicon,颜色不可控。 + * 只加一层柔和投影托底,不铺白色衬底——衬底会在圆角图标外露出方角, + * 与玻璃质感割裂。 + */ +.search-box-engine-icon { + width: 20px; + height: 20px; + object-fit: contain; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.28)); + transition: transform 0.3s ease; +} + +.search-box-engine:hover .search-box-engine-icon { + transform: rotate(12deg); +} + +/* + * AI 入口图标。 + * + * 用 `--search-ink` 而不是 `--color-muted-foreground`: + * 后者在半透明玻璃上、深色壁纸背景下对比不足(这正是搜索框整体的问题)。 + * 同时加一层与文字一致的描边,保证图标在任何壁纸上都能辨认。 + */ +.search-box-ai { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex-shrink: 0; + border: none; + border-radius: 9999px; + background: transparent; + color: var(--search-ink); + opacity: 0.75; + cursor: pointer; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)); + transition: + background 0.2s ease, + color 0.2s ease, + opacity 0.2s ease; +} + +.search-box-ai:hover { + background: color-mix(in oklch, var(--color-foreground) 10%, transparent); + color: var(--search-ink); + opacity: 1; +} + +/* 提交按钮:唯一保留品牌色的元素,作为首屏视觉落点 */ +.search-box-submit { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-right: 6px; + flex-shrink: 0; + border: none; + border-radius: 9999px; + background: var(--color-primary); + color: var(--color-primary-foreground); + cursor: pointer; + box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 35%, transparent); + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background 0.2s ease; +} + +.search-box-submit:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px color-mix(in srgb, var(--color-primary) 45%, transparent); +} + +.search-box-submit:active { + transform: scale(0.95); +} + +/* ===== 下拉:搜索建议与引擎切换,同样走玻璃系统 ===== */ +.search-dropdown { + position: absolute; + left: 0; + right: 0; + top: 100%; + margin-top: 8px; + z-index: 40; + padding: 6px 0; + border-radius: 12px; + border: 1px solid + color-mix(in srgb, var(--color-border) calc(var(--xb-glass-opacity) * 100%), transparent); + background-color: color-mix( + in srgb, + var(--color-popover) min(100%, calc(var(--xb-glass-opacity) * 100% + var(--search-veil))), + transparent + ); + backdrop-filter: blur(calc(var(--xb-glass-blur) + 2px)); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28); + overflow: hidden; +} + +/* 引擎切换菜单靠左且更窄 */ +.search-dropdown.is-engines { + right: auto; + width: 240px; +} + +.search-dropdown-scroll { + max-height: 384px; + overflow-y: auto; +} + +.search-dropdown-heading { + padding: 6px 12px; + font-size: 12px; + color: var(--color-muted-foreground); +} + +/* 下拉项:悬停用前景色叠层,与玻璃背景同源 */ +.search-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + border: none; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + transition: background 0.15s ease; +} + +.search-option:hover { + background: color-mix(in oklch, var(--color-foreground) 8%, transparent); +} + +.search-option.is-active { + background: color-mix(in oklch, var(--color-foreground) 12%, transparent); +} + +.search-option-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + object-fit: contain; +} + +.search-option-main { + flex: 1; + min-width: 0; +} + +.search-option-title { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-option-sub { + font-size: 12px; + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-option-tail { + flex-shrink: 0; + width: 16px; + height: 16px; + opacity: 0.4; +} + +/* 引擎菜单的分隔线与底部操作区 */ +.search-dropdown-divider { + height: 1px; + margin: 6px 0; + background: color-mix(in srgb, var(--color-border) 70%, transparent); +} + +/* + * 暗色主题:字色提亮,投影改为深色。 + * + * 亮色主题的投影取 `--color-background`(浅色),暗色主题下它会自动变深; + * 但这里显式给出深色投影,让浅字在深色壁纸上也有稳定的层次。 + */ +html.dark .search-box { + --search-ink: oklch(0.99 0 0); + --search-ink-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + --search-placeholder-ink: rgba(255, 255, 255, 0.66); +} diff --git a/src/assets/styles/index.css b/src/assets/styles/index.css index 389dfc0..74805db 100644 --- a/src/assets/styles/index.css +++ b/src/assets/styles/index.css @@ -27,3 +27,6 @@ @import "./desktop/home.css"; /* 12 原第 1443-1447 行 · 主桌面容器微调 */ @import "./widgets/weather.css"; /* 13 原第 1448-1899 行 · 天气小部件 + 展开弹窗(逐小时 / 逐天) */ @import "./widgets/activity.css"; /* 14 原第 1900-2713 行 · 米哈游活动小部件 + 展开弹窗(筛选 / 甘特图 / 日程列表) */ +@import "./widgets/rss.css"; /* 15 新增 · RSS 订阅小部件 + 展开弹窗(订阅设置 / 完整条目列表) */ +@import "./components/context-menu.css"; /* 16 新增 · 右键菜单(玻璃背景沿用 xb-glass-popover,此处只补交互态) */ +@import "./components/search-box.css"; /* 17 新增 · 搜索框(玻璃系统 + 聚焦向两侧拉长) */ diff --git a/src/assets/styles/widgets/rss.css b/src/assets/styles/widgets/rss.css new file mode 100644 index 0000000..6c5af53 --- /dev/null +++ b/src/assets/styles/widgets/rss.css @@ -0,0 +1,519 @@ +/* + * RSS 订阅小部件 + 展开弹窗(订阅设置 / 完整条目列表) + * + * 追加在样式清单末尾:RSS 是新增组件,不存在需要覆盖的历史规则, + * 放在最后可保证自身规则不被同名旧规则影响。 + */ +/* ===== RSS 订阅小部件(磁贴) ===== */ +.rss-widget { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + box-sizing: border-box; + gap: 6px; + color: var(--color-foreground); + /* 磁贴不滚动:纵向滚动只由内部列表承担,避免内容溢出圆角 */ + overflow: hidden; + /* 整块可点击展开;内部控件各自覆盖光标 */ + cursor: pointer; +} + +.rss-widget-top { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + min-width: 0; +} + +.rss-widget-source-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + opacity: 0.7; +} + +.rss-widget-source { + flex: 1; + min-width: 0; + font-size: 12px; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* + * 旧内容标记:刷新失败但仍展示兜底快照时出现。 + * 静态而非闪烁——它表达的是"这份内容可能不新",不是"正在加载"。 + */ +.rss-widget-stale { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: #f87171; +} + +.rss-widget-stale-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + opacity: 0.9; +} + +/* + * 状态徽标:回答"现在怎么样了",因此比标题更靠前。 + * + * 刻意**只有一种中性配色**:状态值来自远端(`resolved` / `investigating` …), + * 给它们分"好/坏"色等于替远端做语义判断,而各状态页的取值集合并不固定。 + */ +.rss-status { + flex-shrink: 0; + padding: 0 5px; + border-radius: 4px; + font-size: 10px; + line-height: 1.6; + white-space: nowrap; + background: color-mix(in oklch, var(--color-foreground) 14%, transparent); + opacity: 0.9; +} + +.rss-feed-stale { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + font-size: 11px; + color: #f87171; +} + +.rss-feed-stale-icon { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.rss-widget-refresh { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: inherit; + opacity: 0.7; + cursor: pointer; + transition: + opacity 0.15s, + background 0.15s; +} + +.rss-widget-refresh:hover { + opacity: 1; + background: var(--widget-btn-bg, rgba(128, 128, 128, 0.2)); +} + +.rss-widget-refresh-icon { + width: 12px; + height: 12px; +} + +.rss-widget-refresh-icon.is-spinning, +.rss-feed-refresh-icon.is-spinning, +.rss-feed-state-icon.is-spinning { + animation: rss-spin 1s linear infinite; +} + +@keyframes rss-spin { + to { + transform: rotate(360deg); + } +} + +/* ===== 状态类磁贴:正文为主体 ===== */ + +/* + * 状态类把磁贴内空间让给正文:表头(来源 + 刷新)保持不变, + * 其下依次是状态行、次要的标题、以及**占满剩余高度的正文**。 + * 四层结构下 6px 间距过于松散,收紧以让正文拿到更多高度。 + */ +.rss-widget-status { + gap: 3px; +} + +.rss-status-head { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + min-width: 0; +} + +/* + * 标题退为次要信息:它是"发生了什么故障",而正文才说明"现在是否已解决"。 + * 单行截断,不与正文争空间。 + */ +.rss-status-title { + margin: 0; + flex-shrink: 0; + min-width: 0; + font-size: 11px; + line-height: 1.35; + opacity: 0.65; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* + * 正文主体:`flex: 1` 占满表头之外的剩余空间。 + * 字号比列表类明显放大——它是这块磁贴要传达的主要内容。 + * 放不下时内部滚动(`overscroll-behavior: contain` 防止滚动链传给桌面), + * 而不是把内容截断成省略号。 + */ +.rss-status-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + font-size: 13px; + line-height: 1.5; + /* 中文正文按词换行,避免长英文串撑破容器 */ + overflow-wrap: anywhere; +} + +/* 紧凑模式:空间不足,正文回到常规字号并收紧行高 */ +.rss-widget.is-compact .rss-status-body { + font-size: 11px; + line-height: 1.4; +} + +/* + * 条目列表:内容全部渲染,放不下时内部纵向滚动。 + * `overscroll-behavior: contain` 阻止滚到边界后把整个桌面一起滚走; + * `scrollbar-gutter: stable` 让滚动条出现/消失时行宽不跳变。 + */ +.rss-widget-list { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +/* + * 条目:两行结构——上行「状态 + 标题 + 时间」,下行正文摘要。 + * 正文在这里不是附属信息,它往往比标题更能说明当前状况(见 .rss-widget-item-summary)。 + */ +.rss-widget-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 3px 5px; + border-radius: 6px; + font-size: 11px; + line-height: 1.35; + min-width: 0; + /* 条目多时不允许被 flex 压扁,行高保持恒定才能滚动阅读 */ + flex-shrink: 0; + background: color-mix(in oklch, var(--color-foreground) 5%, transparent); +} + +.rss-widget-item-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.rss-widget-item-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* + * 正文摘要:**比标题更重要**——标题只说"发生了什么故障", + * 正文才说明"现在是否已解决"。因此它在磁贴里占一整行, + * 用两行截断保证每条高度可预期,完整内容靠 title 与弹窗查看。 + */ +.rss-widget-item-summary { + margin: 0; + min-width: 0; + font-size: 10px; + line-height: 1.4; + opacity: 0.7; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.rss-widget-item-time { + flex-shrink: 0; + font-size: 10px; + opacity: 0.6; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.rss-widget-state { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + flex: 1; + min-height: 0; + font-size: 11px; + line-height: 1.4; + text-align: center; + opacity: 0.7; +} + +.rss-widget-state.is-error { + color: #f87171; + opacity: 0.9; +} + +.rss-widget-state-icon { + width: 13px; + height: 13px; + flex-shrink: 0; +} + +/* ===== RSS 展开弹窗(订阅设置 / 完整条目列表) ===== */ +.rss-feed { + display: flex; + flex-direction: column; + gap: 10px; + color: var(--color-foreground); + /* + * 关键:min-width: 0 允许本列在弹窗内收窄。 + * 缺了它,flex 子项会被长标题/长地址撑到内容宽度而横向溢出被裁掉。 + */ + min-width: 0; +} + +.rss-feed-bar { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.rss-feed-url { + flex: 1; + min-width: 0; + font-size: 12px; +} + +.rss-feed-refresh { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + flex-shrink: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.rss-feed-refresh:hover:not(:disabled) { + background: color-mix(in oklch, var(--color-foreground) 12%, transparent); + color: var(--color-foreground); +} + +.rss-feed-refresh:disabled { + cursor: default; + opacity: 0.5; +} + +.rss-feed-refresh-icon { + width: 14px; + height: 14px; +} + +.rss-feed-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.rss-feed-title { + flex: 1; + min-width: 0; + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rss-feed-count { + flex-shrink: 0; + font-size: 11px; + color: var(--color-muted-foreground); + font-variant-numeric: tabular-nums; +} + +.rss-feed-state { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 24px 0; + font-size: 12px; + color: var(--color-muted-foreground); +} + +/* 订阅类型选择:决定磁贴外显条数,属低频设置 */ +.rss-feed-kinds { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + color: var(--color-muted-foreground); +} + +.rss-feed-kinds-label { + flex-shrink: 0; +} + +.rss-feed-kind { + flex-shrink: 0; + padding: 2px 10px; + border: 1px solid color-mix(in oklch, var(--color-foreground) 20%, transparent); + border-radius: 999px; + background: transparent; + color: inherit; + font-size: 12px; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; +} + +.rss-feed-kind:hover { + background: color-mix(in oklch, var(--color-foreground) 10%, transparent); +} + +.rss-feed-kind.is-active { + border-color: transparent; + background: color-mix(in oklch, var(--color-foreground) 18%, transparent); + color: var(--color-foreground); +} + +.rss-feed-kinds-hint { + font-size: 11px; + opacity: 0.75; +} + +.rss-feed-state.is-error { + color: #f87171; +} + +.rss-feed-state-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* + * 条目列表:独立纵向滚动容器,限高用视口比例而不是硬编码像素, + * 避免内容被弹窗裁掉;滚动到边界不把滚动传给外层弹窗。 + */ +.rss-feed-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 52vh; + overflow-y: auto; + overscroll-behavior: contain; + min-width: 0; +} + +.rss-feed-item { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px; + border-radius: 8px; + min-width: 0; +} + +.rss-feed-item:hover { + background: color-mix(in oklch, var(--color-foreground) 8%, transparent); +} + +.rss-feed-item-head { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.rss-feed-item-title { + flex: 1; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + text-align: left; + cursor: pointer; + /* 长标题折两行以内,超出截断,保证条目高度可预期 */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.rss-feed-item-title:hover { + color: var(--color-primary, inherit); + text-decoration: underline; +} + +span.rss-feed-item-title { + cursor: default; +} + +.rss-feed-item-time { + flex-shrink: 0; + font-size: 10px; + color: var(--color-muted-foreground); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.rss-feed-item-summary { + margin: 0; + font-size: 11px; + line-height: 1.5; + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} diff --git a/src/components/desktop/HomeDesktop.tsx b/src/components/desktop/HomeDesktop.tsx index 545de5b..d9cf899 100644 --- a/src/components/desktop/HomeDesktop.tsx +++ b/src/components/desktop/HomeDesktop.tsx @@ -13,6 +13,7 @@ import { DesktopGridView } from "@/components/desktop/DesktopGridView"; import { WidgetAddDialog } from "@/components/widget-system/WidgetSystem"; import { FolderPopup } from "@/components/folder/FolderPopup"; import { WidgetPopupHost } from "@/components/widget-system/WidgetPopupHost"; +import { ContextMenu } from "@/components/ui/context-menu"; import { MigrationFolderDialog } from "@/components/desktop/MigrationFolderDialog"; import { BOOKMARK_DRAG_TYPE } from "@/components/dock/DockFolderLayer"; import { useContextMenu, type ContextMenuItem } from "@/hooks/useContextMenu"; @@ -23,7 +24,7 @@ import { Input } from "@/components/ui/input"; import { normalizeBrowserUrl } from "@/lib/browser"; import { isInSidePanel, openUrl } from "@/lib/open-url"; import { createDesktopItemId, useHomeDesktop } from "@/lib/home-desktop-store"; -import { hasWidgetPopup } from "@/lib/widget-registry"; +import { hasWidgetPopup, getWidgetDefaultData } from "@/lib/widget-registry"; import { DEFAULT_ITEM_NAME, DEFAULT_SHORTCUT_COLOR, @@ -339,29 +340,13 @@ export const HomeDesktop = forwardRef(funct ); const ctxMenuEl: ReactNode = ctxMenuState.isOpen && ( -
- {ctxMenuState.items.map((item, i) => - item.divider ? ( -
- ) : ( - - ), - )} -
+ ); const emptyState = ( @@ -456,7 +441,8 @@ export const HomeDesktop = forwardRef(funct type: "widget", widgetType: type, title: type, - data: {}, + // 带默认配置的小部件(如 RSS 的默认订阅)在新建时即写入 + data: getWidgetDefaultData(type), w: 2, h: 2, }); diff --git a/src/components/dock/DockFolderLayer.tsx b/src/components/dock/DockFolderLayer.tsx index b5f4573..c08be6a 100644 --- a/src/components/dock/DockFolderLayer.tsx +++ b/src/components/dock/DockFolderLayer.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Folder, Pin, PinOff, X } from "lucide-react"; import { FolderTreeView, type FolderTreeViewProps } from "@/components/folder/FolderTreeView"; import { ShortcutIcon } from "@/components/desktop/DesktopItemViews"; +import { ContextMenu } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/useContextMenu"; import { useFolderBookmarks } from "@/hooks/useFolderBookmarks"; import { useHomeDesktop } from "@/lib/home-desktop-store"; @@ -223,29 +224,13 @@ export function DockFolderLayer({ ); const ctxMenuEl = ctxMenuState.isOpen && ( -
- {ctxMenuState.items.map((item, i) => - item.divider ? ( -
- ) : ( - - ), - )} -
+ ); return ( diff --git a/src/components/search/SearchBox.tsx b/src/components/search/SearchBox.tsx index 397adc0..98fa43b 100644 --- a/src/components/search/SearchBox.tsx +++ b/src/components/search/SearchBox.tsx @@ -207,57 +207,42 @@ export function SearchBox() { 'data:image/svg+xml,'; return ( -
-
+
+ -
- { - const value = e.target.value; - setQuery(value); - debouncedFetchSuggestions(value); - }} - onFocus={() => { - if (query.trim() && suggestions.length > 0) { - setShowSuggestions(true); - } - }} - placeholder={getMessage("searchPlaceholder", "搜索...")} - className="flex-1 bg-transparent border-none outline-none shadow-none px-2.5 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 text-base h-full min-w-0 focus:ring-0" - /> -
+ { + const value = e.target.value; + setQuery(value); + debouncedFetchSuggestions(value); + }} + onFocus={() => { + if (query.trim() && suggestions.length > 0) { + setShowSuggestions(true); + } + }} + placeholder={getMessage("searchPlaceholder", "搜索...")} + className="search-box-input" + /> {aiEnabled && !(window as any).__IN_SIDEPANEL__ && ( )} - {/* 搜索建议下拉菜单 */} {showSuggestions && suggestions.length > 0 && ( -
-
+
+
{suggestions.map((suggestion) => ( ))}
@@ -336,11 +310,8 @@ export function SearchBox() { )} {menuOpen && ( -
-
+
+
{getMessage("switchEngine", "切换搜索引擎")}
{searchEngines.map((engine, index) => ( @@ -351,40 +322,34 @@ export function SearchBox() { setCurrentEngineIndex(index); setMenuOpen(false); }} - className={cn( - "w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors", - index === currentEngineIndex - ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300" - : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50", - )} + className={cn("search-option", index === currentEngineIndex && "is-active")} > {engine.name} { (e.target as HTMLImageElement).src = ""; }} /> - {engine.name} + {engine.name} {index === currentEngineIndex && ( - + )} ))} -
- -
+
+
)} diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..20bf714 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,80 @@ +import { createPortal } from "react-dom"; +import { useLayoutEffect, useState, type RefObject } from "react"; +import type { ContextMenuItem } from "@/hooks/useContextMenu"; + +interface ContextMenuProps { + x: number; + y: number; + items: ContextMenuItem[]; + menuRef: RefObject; + onSelect: () => void; +} + +/** 菜单与视口边缘的最小间距。 */ +const EDGE_GAP = 8; + +/** + * 右键菜单。 + * + * **必须渲染到 `document.body`(Portal)**,不能作为调用方的普通子节点: + * `position: fixed` 的定位基准是"最近的、会形成包含块的祖先",而 + * `backdrop-filter` 非 none 的元素(本项目的玻璃系统大量使用)正是这样的祖先。 + * 菜单若留在桌面容器或 Dock 浮层内部,`left/top` 就会相对那个容器计算, + * 而不是相对视口——表现为菜单不出现在鼠标位置、且随容器偏移。 + * + * 另外按视口做翻转:鼠标靠近右下角时菜单向外展开会溢出屏幕, + * 此时改为向左/向上展开,保证整块菜单始终可见。 + */ +export function ContextMenu({ x, y, items, menuRef, onSelect }: ContextMenuProps) { + /** 实测尺寸前的初始位置:先按鼠标点放置,避免首帧闪到别处。 */ + const [pos, setPos] = useState({ left: x, top: y }); + + useLayoutEffect(() => { + const el = menuRef.current; + if (!el) return; + const { width, height } = el.getBoundingClientRect(); + const maxLeft = window.innerWidth - width - EDGE_GAP; + const maxTop = window.innerHeight - height - EDGE_GAP; + // 放不下就向左/向上翻转;翻转后仍越界则贴边钳制 + const left = x + width > window.innerWidth ? Math.max(EDGE_GAP, x - width) : x; + const top = y + height > window.innerHeight ? Math.max(EDGE_GAP, y - height) : y; + setPos({ + left: Math.min(left, Math.max(EDGE_GAP, maxLeft)), + top: Math.min(top, Math.max(EDGE_GAP, maxTop)), + }); + }, [x, y, items, menuRef]); + + if (typeof document === "undefined") return null; + return createPortal( +
+ {items.map((item, i) => + item.divider ? ( +
+ ) : ( + + ), + )} +
, + document.body, + ); +} diff --git a/src/components/widget-system/WidgetSystem.tsx b/src/components/widget-system/WidgetSystem.tsx index 77dd51d..53017d8 100644 --- a/src/components/widget-system/WidgetSystem.tsx +++ b/src/components/widget-system/WidgetSystem.tsx @@ -8,6 +8,9 @@ import { WeatherWidget } from "@/components/widgets/weather/WeatherWidget"; import { WeatherForecastPopup } from "@/components/widgets/weather/WeatherForecastPopup"; import { ActivityWidget } from "@/components/widgets/activity/ActivityWidget"; import { ActivityCalendarPopup } from "@/components/widgets/activity/ActivityCalendarPopup"; +import { RssWidget } from "@/components/widgets/rss/RssWidget"; +import { RssFeedPopup } from "@/components/widgets/rss/RssFeedPopup"; +import { DEFAULT_FEED_KIND, DEFAULT_FEED_URL } from "@/components/widgets/rss/rss"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; @@ -102,6 +105,34 @@ export function useWidgetRegistration() { contentClassName: "max-w-2xl max-h-[80vh]", }, }); + registerWidget("rss", { + meta: { + type: "rss", + name: getMessage("rssWidgetName", "RSS 订阅"), + description: getMessage( + "rssWidgetDesc", + "订阅 RSS/Atom 源并按时间列出更新,默认订阅 DeepSeek 服务状态", + ), + icon: "rss", + }, + config: { + defaultWidth: 240, + defaultHeight: 150, + minWidth: 160, + minHeight: 110, + maxWidth: 480, + maxHeight: 300, + }, + component: RssWidget, + // 新建实例即带默认订阅,开箱即可用;内置的 DeepSeek 是状态页,故用状态类 + defaultData: { url: DEFAULT_FEED_URL, kind: DEFAULT_FEED_KIND }, + // 完整条目与订阅地址设置放在弹窗内,磁贴只做摘要 + popup: { + title: getMessage("rssFeedTitle", "订阅内容"), + content: RssFeedPopup, + contentClassName: "max-w-2xl max-h-[80vh]", + }, + }); }, []); } diff --git a/src/components/widgets/rss/RssFeedPopup.tsx b/src/components/widgets/rss/RssFeedPopup.tsx new file mode 100644 index 0000000..67413bb --- /dev/null +++ b/src/components/widgets/rss/RssFeedPopup.tsx @@ -0,0 +1,241 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AlertTriangle, Loader2, RefreshCw } from "lucide-react"; +import { getMessage } from "@/lib/i18n"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { openUrl } from "@/lib/open-url"; +import { + DEFAULT_FEED_URL, + SUMMARY_MAX_LENGTH, + feedTimeLabel, + fetchFeed, + normalizeFeedUrl, + readCachedSnapshot, + readFeedKind, + readFeedUrl, + truncateText, + type RssFeedKind, + type RssSnapshot, +} from "@/components/widgets/rss/rss"; + +/** 订阅类型选项(顺序即展示顺序)。 */ +const FEED_KINDS: RssFeedKind[] = ["status", "list"]; + +interface RssFeedPopupProps { + data?: Record; + onDataChange?: (data: Record) => void; +} + +/** + * RSS 小部件的展开内容:订阅地址设置 + 完整条目列表。 + * + * 该组件只在弹窗打开时挂载,因此展开才取数;结果与磁贴共享同一份兜底快照。 + * 条目点击交给 `openUrl`(侧边栏内留在侧边栏,新标签页中新开标签)。 + */ +export function RssFeedPopup({ data, onDataChange }: RssFeedPopupProps) { + const storedUrl = useMemo(() => readFeedUrl(data), [data]); + /** + * 弹窗内改地址时先用本地状态接管,而不是等父级把新地址回灌回来。 + * + * 覆盖值记录"它是在哪个 props 值之上做出的选择"(`from`): + * props 仍是 `from` → 父级还没跟上,用本地值 `to`;props 已变成别的值 → + * 父级已接手,丢弃本地值。这样在渲染期即可判定,无需在 effect 里 setState。 + */ + const [override, setOverride] = useState<{ from: string; to: string } | null>(null); + const url = override && override.from === storedUrl ? override.to : storedUrl; + /** 订阅类型:状态类磁贴只外显最新 1 条,列表类多条。 */ + const kind = readFeedKind(data); + /** 输入框草稿:只由用户编辑与提交改写,避免外部回灌覆盖正在输入的内容。 */ + const [draft, setDraft] = useState(storedUrl); + /** 拉取结果连同它所属的地址一起存,切换订阅时渲染期即可判定是否过期。 */ + const [fetched, setFetched] = useState<{ url: string; snapshot: RssSnapshot } | null>(null); + const cached = useMemo(() => readCachedSnapshot(url), [url]); + const snapshot = fetched?.url === url ? fetched.snapshot : cached; + const [loading, setLoading] = useState(() => cached === null); + const [error, setError] = useState(""); + const [now, setNow] = useState(() => Date.now()); + /** 当前展示的是回落的旧内容而非本次联网结果。 */ + const [stale, setStale] = useState(false); + /** 每次请求的序号:丢弃迟到的旧响应,避免覆盖新订阅的数据。 */ + const requestRef = useRef(0); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, []); + + const load = useCallback(async (target: string) => { + const token = ++requestRef.current; + setLoading(true); + setError(""); + try { + const { snapshot: next, fresh } = await fetchFeed(target); + if (requestRef.current !== token) return; + setFetched({ url: target, snapshot: next }); + // 回落到了旧内容:列表中常展示,但标记"可能不新" + setStale(!fresh); + } catch { + if (requestRef.current === token) setError(getMessage("rssLoadFailed", "订阅获取失败")); + } finally { + if (requestRef.current === token) setLoading(false); + } + }, []); + + // 挂载、切换订阅与切换语言/开关时取数:每次都会联网 + useEffect(() => { + void (async () => { + await load(url); + })(); + }, [url, load]); + + /** 提交订阅地址:非法输入回落到默认订阅;同一地址则视为"再刷新一次"。 */ + const applyUrl = useCallback(() => { + const normalized = normalizeFeedUrl(draft) ?? DEFAULT_FEED_URL; + setDraft(normalized); + if (normalized === url) { + void load(normalized); + return; + } + setOverride({ from: storedUrl, to: normalized }); + onDataChange?.({ url: normalized, updatedAt: Date.now() }); + }, [draft, url, storedUrl, load, onDataChange]); + + const resetUrl = useCallback(() => { + setDraft(DEFAULT_FEED_URL); + if (DEFAULT_FEED_URL === url) { + void load(DEFAULT_FEED_URL); + return; + } + setOverride({ from: storedUrl, to: DEFAULT_FEED_URL }); + onDataChange?.({ url: DEFAULT_FEED_URL, updatedAt: Date.now() }); + }, [url, storedUrl, load, onDataChange]); + + const entries = snapshot?.entries ?? []; + + return ( +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") applyUrl(); + if (e.key === "Escape") setDraft(url); + }} + /> + + + +
+ +
+ {snapshot?.title || url} + {/* + 回落到旧内容时给出标记。列表照常展示(比空列表有用), + 但用户必须知道它不是本次刷新的结果。 + */} + {stale && ( + + + {getMessage("rssStaleContent", "刷新失败,内容可能不是最新")} + + )} + {entries.length > 0 && ( + + {getMessage("rssEntryCount", "共 {n} 条").replace("{n}", String(entries.length))} + + )} +
+ + {/* + 订阅类型:决定磁贴外显多少条。 + 状态类(状态页)只看最新 1 条——最新一条即当前状况; + 列表类按时间列出多条。两类由用户自行选择。 + */} +
+ {getMessage("rssFeedKind", "订阅类型")} + {FEED_KINDS.map((value) => ( + + ))} + + {kind === "status" + ? getMessage("rssFeedKindStatusHint", "磁贴只显示最新一条") + : getMessage("rssFeedKindListHint", "磁贴按时间列出多条")} + +
+ + {entries.length === 0 ? ( + error ? ( +
+ + {error} +
+ ) : ( +
+ {loading ? ( + <> + + {getMessage("rssLoading", "加载中...")} + + ) : ( + {getMessage("rssEmpty", "暂无更新")} + )} +
+ ) + ) : ( +
+ {entries.map((entry) => ( +
+
+ {/* 状态徽标前置:它比标题更能回答"现在怎么样了";值原样展示 */} + {entry.status && {entry.status}} + {entry.link ? ( + + ) : ( + {entry.title} + )} + {entry.publishedAt !== null && ( + + {feedTimeLabel(entry.publishedAt, now)} + + )} +
+ {entry.summary && ( +

+ {truncateText(entry.summary, SUMMARY_MAX_LENGTH)} +

+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/widgets/rss/RssWidget.tsx b/src/components/widgets/rss/RssWidget.tsx new file mode 100644 index 0000000..ff48e14 --- /dev/null +++ b/src/components/widgets/rss/RssWidget.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AlertTriangle, RefreshCw, Rss } from "lucide-react"; +import { getMessage } from "@/lib/i18n"; +import { resolveWidgetSizeMode, widgetPadding } from "@/lib/widget-layout"; +import { + SUMMARY_TILE_COMPACT_LENGTH, + SUMMARY_TILE_LENGTH, + feedTimeLabel, + fetchFeed, + readCachedSnapshot, + readFeedKind, + readFeedUrl, + tileEntryLimit, + truncateText, + type RssEntry, + type RssSnapshot, +} from "@/components/widgets/rss/rss"; + +interface RssWidgetProps { + data?: Record; + onDataChange?: (data: Record) => void; + containerWidth?: number; + containerHeight?: number; +} + +/** + * RSS 小部件(磁贴)。 + * + * 两类订阅的**信息层级不同,因此布局也不同**: + * - 状态类(内置的 DeepSeek 即此类):只取最新 1 条,**正文是主体**—— + * 标题只说"发生了什么故障",正文才说明"现在是否已解决", + * 因此正文占据表头之外的剩余空间并放大字号,标题退为次要的一行; + * - 列表类:多条并列,每条「标题 + 正文摘要 + 时间」。 + * + * 完整条目放在展开弹窗里(点非控件区域展开)。 + * 订阅地址存在 `data.url`,缺省为 `DEFAULT_FEED_URL`(DeepSeek 服务状态)。 + * 每次挂载都联网;联网失败时回落到上次成功的内容并标记"可能不新"。 + */ +export function RssWidget({ + data, + onDataChange, + containerWidth = 200, + containerHeight = 150, +}: RssWidgetProps) { + void onDataChange; // 磁贴在弹窗里改订阅,这里只读展示 + const url = useMemo(() => readFeedUrl(data), [data]); + /** 订阅类型:状态类只取最新 1 条,列表类多条。 */ + const kind = readFeedKind(data); + + const cached = useMemo(() => readCachedSnapshot(url), [url]); + /** 拉取结果连同它所属的地址一起存,切换订阅时渲染期即可判定是否过期。 */ + const [fetched, setFetched] = useState<{ url: string; snapshot: RssSnapshot } | null>(null); + const snapshot = fetched?.url === url ? fetched.snapshot : cached; + + const [loading, setLoading] = useState(() => cached === null); + const [error, setError] = useState(""); + /** 当前展示的是回落的旧内容而非本次联网结果。 */ + const [stale, setStale] = useState(false); + /** 每次请求的序号:丢弃迟到的旧响应,避免覆盖切换订阅后的新状态。 */ + const requestRef = useRef(0); + /** + * 当前时间作为 state(而非渲染期直接调用 Date.now())。 + * 每 60 秒刷新一次,让「x 分钟前」这类相对时间自行走动。 + */ + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, []); + + const load = useCallback(async () => { + /* + * 请求序号:切换订阅地址或连点刷新时,可能有多份请求在飞。 + * 迟到的旧响应不能覆盖当前状态——尤其 `stale`/`error`/`loading` + * 不与地址绑定,若被旧请求改写,界面会显示错误的"可能不新"或"加载中"。 + * (`fetched` 自带 url,渲染期已能判定,但其余三个状态需要这里把关。) + */ + const token = ++requestRef.current; + setLoading(true); + setError(""); + // 新一轮请求开始即清掉上一轮的过期标记,避免旧标记残留到新结果 + setStale(false); + try { + const { snapshot: next, fresh } = await fetchFeed(url); + if (requestRef.current !== token) return; + setFetched({ url, snapshot: next }); + // 回落到了旧内容:列表照常展示(比空磁贴有用),但要标记"可能不新" + setStale(!fresh); + } catch { + if (requestRef.current === token) { + setError(getMessage("rssLoadFailed", "订阅获取失败")); + } + } finally { + if (requestRef.current === token) setLoading(false); + } + }, [url]); + + // 挂载与切换订阅时取数:每次都会联网(订阅是轻量纯文本,不做新鲜窗口) + useEffect(() => { + void (async () => { + await load(); + })(); + }, [load]); + + const refresh = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (loading) return; + void load(); + }, + [load, loading], + ); + + const mode = resolveWidgetSizeMode({ width: containerWidth, height: containerHeight }); + const compact = mode === "compact"; + /** 外显条目数由订阅类型决定(状态类恒为 1,列表类按上限并随紧凑模式递减)。 */ + const entries = snapshot ? snapshot.entries.slice(0, tileEntryLimit(kind, compact)) : []; + + return ( +
+
+ + {snapshot?.title || url} + {/* + 展示的是回落的旧内容:列表照常展示(总比空磁贴有用), + 但必须给出标记——否则用户会把一份过期的状态当成当前状态。 + */} + {stale && ( + + + + )} + +
+ + {error && entries.length === 0 ? ( +
+ + {error} +
+ ) : entries.length === 0 ? ( +
+ {loading ? getMessage("rssLoading", "加载中...") : getMessage("rssEmpty", "暂无更新")} +
+ ) : kind === "status" ? ( + + ) : ( + + )} +
+ ); +} + +/** + * 状态类磁贴:正文为主体。 + * + * 层级:状态徽标(是否已解决)> 正文(详情)> 标题(是什么故障)。 + * 正文**不截断**,而是占满剩余高度并在放不下时内部滚动—— + * 它才是这条订阅真正要传达的内容。 + */ +function StatusTile({ entry, now }: { entry: RssEntry; now: number }) { + const time = feedTimeLabel(entry.publishedAt, now); + // 少数条目没有正文:此时标题就是唯一内容,直接作为主体展示 + const body = entry.summary || entry.title; + + return ( + <> +
+ {/* 状态值原样展示,不做翻译——它是远端给出的机器值 */} + {entry.status && {entry.status}} + {time && {time}} +
+ {/* + 标题始终保留但退为次要的一行:它交代"是什么故障", + 与正文("现在怎么样了")共同构成完整信息,紧凑模式下也不该丢。 + 没有正文时标题本身就是主体,由下面的 body 承担。 + */} + {entry.summary && ( +

+ {entry.title} +

+ )} + {/* + `data-no-drag` 只关闭磁贴拖拽,点击仍冒泡到磁贴以展开弹窗; + 正文长时由它自己滚动,不把滚动链传给桌面。 + */} +
+ {body} +
+ + ); +} + +/** 列表类磁贴:多条并列,每条「标题 + 正文摘要 + 时间」。 */ +function ListTile({ + entries, + compact, + now, +}: { + entries: RssEntry[]; + compact: boolean; + now: number; +}) { + return ( + + ); +} diff --git a/src/components/widgets/rss/rss.ts b/src/components/widgets/rss/rss.ts new file mode 100644 index 0000000..41798ca --- /dev/null +++ b/src/components/widgets/rss/rss.ts @@ -0,0 +1,571 @@ +/** + * RSS 小部件的纯逻辑与数据访问。 + * + * 解析一律交给 feedsmith(RSS 2.0 / Atom / RDF / JSON Feed 通用解析器), + * 本模块只承担:订阅地址与类型、解析结果归一化、正文取纯文本、 + * 时间的相对展示,以及"每次联网 + 失败兜底"的取数。 + * + * 除 `fetchFeed` 外全部是纯函数(不依赖 React 与浏览器存储),便于单元测试。 + */ + +import { format, formatDistanceToNow } from "date-fns"; +import { enUS, zhCN } from "date-fns/locale"; +import type { AtomFeed, JsonFeed, RdfFeed, RssFeed } from "feedsmith"; +import { readStaleCache, writeCache } from "@/lib/cache-store"; +import { getCurrentLanguage, getMessage } from "@/lib/i18n"; + +/** + * 默认订阅地址:DeepSeek 服务状态 RSS。 + * 小部件数据里没有订阅地址时使用它。 + */ +export const DEFAULT_FEED_URL = "https://status.deepseek.com/feed.rss"; + +/** 单次请求超时(毫秒)。 */ +const FEED_TIMEOUT_MS = 12 * 1000; + +/** 单次最多归一化的条目数(解析与渲染的上限)。 */ +export const MAX_ENTRIES = 50; + +/** + * 列表类磁贴内展示的条目数上限。 + * + * 取 3 而不是更多:每条都带一段正文,磁贴高度固定, + * 塞太多条会让每条都只剩标题可读,反而丢掉关键信息。 + * 状态类不看这个值——它只外显最新 1 条。 + */ +const TILE_ENTRY_LIMIT = 3; + +/** 弹窗内摘要的截断长度。 */ +export const SUMMARY_MAX_LENGTH = 240; + +/** + * 磁贴内摘要的截断长度。 + * + * 磁贴很窄,因此比弹窗短得多;但**必须给出这段正文**—— + * 状态页的标题只说明故障现象,正文才说明"现在是否已解决"。 + */ +export const SUMMARY_TILE_LENGTH = 90; +/** 紧凑模式下的磁贴摘要长度。 */ +export const SUMMARY_TILE_COMPACT_LENGTH = 50; + +/** 缓存键前缀(与 cache-store 的命名空间拼接)。 */ +const CACHE_PREFIX = "rss:"; + +/** + * 订阅类型。 + * + * - `status`:状态页(Statuspage / FlashDuty 之类),每条是一次事件进展, + * 标题只说故障现象、正文才说明"是否已解决",因此磁贴只外显**最新 1 条** + * 并突出状态徽标; + * - `list`:普通资讯/博客类订阅,按时间列出多条。 + */ +export type RssFeedKind = "status" | "list"; + +/** 内置默认订阅的类型:DeepSeek 服务状态页。 */ +export const DEFAULT_FEED_KIND: RssFeedKind = "status"; + +/** 读取小部件里的订阅类型;未设置时按默认(状态页)。 */ +export function readFeedKind(data?: Record): RssFeedKind { + return data?.kind === "list" ? "list" : DEFAULT_FEED_KIND; +} + +/** + * 磁贴外显的条目数。 + * + * - 状态类恒为 **1**:最新一条即当前状况,历史进展没有外显价值; + * - 列表类按上限列出,紧凑模式再少一条(每条都带正文,宁可少而可读)。 + */ +export function tileEntryLimit(kind: RssFeedKind, compact = false): number { + if (kind === "status") return 1; + return Math.max(1, TILE_ENTRY_LIMIT - (compact ? 1 : 0)); +} + +/** 归一化后的订阅条目。 */ +export interface RssEntry { + /** 稳定 id:优先源里的 guid/id,退化为链接,最后退化为下标。 */ + id: string; + title: string; + link: string; + /** 正文纯文本(已去标签,且已摘掉开头的状态标记)。 */ + summary: string; + /** + * 正文开头的状态值(如 `resolved` / `identified`)。 + * + * 状态页(Statuspage / FlashDuty)把"当前是否已解决"放在正文里, + * 标题只说明故障现象,因此这个字段往往比标题更能回答"现在怎么样了"。 + * 源里没有该约定时为 undefined。 + */ + status?: string; + /** 发布时间戳(毫秒);源里没有或不可解析时为 null。 */ + publishedAt: number | null; +} + +/** 归一化后的订阅快照。 */ +export interface RssSnapshot { + title: string; + link: string; + description: string; + entries: RssEntry[]; + /** 抓取完成时刻(毫秒)。 */ + fetchedAt: number; +} + +/** 取数失败的类别。 */ +export type RssErrorKind = "invalid-url" | "http-error" | "empty-body" | "parse-error"; + +/** 携带类别信息的订阅异常。 */ +export class RssError extends Error { + constructor( + readonly kind: RssErrorKind, + readonly status = 0, + ) { + super(status > 0 ? `RSS_${kind}_${status}` : `RSS_${kind}`); + this.name = "RssError"; + } +} + +/** + * 规范化订阅地址:只接受 http/https 绝对地址。 + * 非法输入返回 null。 + */ +export function normalizeFeedUrl(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const text = raw.trim(); + if (!text) return null; + try { + const parsed = new URL(text); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.href; + } catch { + return null; + } +} + +/** 读取小部件里的订阅地址;缺失或非法时回落到默认订阅。 */ +export function readFeedUrl(data?: Record): string { + return normalizeFeedUrl(data?.url) ?? DEFAULT_FEED_URL; +} + +/** 快照是否可用(防御旧版本或损坏的缓存)。 */ +function isUsableSnapshot(value: unknown): value is RssSnapshot { + if (!value || typeof value !== "object") return false; + const snapshot = value as Partial; + return typeof snapshot.title === "string" && Array.isArray(snapshot.entries); +} + +/** 块级元素选择器:这些元素的后面是段落边界。 */ +const BLOCK_SELECTOR = "p, div, li, tr, blockquote, section, h1, h2, h3, h4, h5, h6"; + +/** + * 把 HTML 片段切成段落数组。 + * + * 段落边界有两种:块级元素之间,以及同一块内的空行——后者是状态页的常见写法, + * 同一个 `

` 里用空行分隔两段说明文字。 + * + * 必须**保留段落结构**而不是压成一行:状态标记只在**首段**开头, + * 摘除它需要知道首段的边界;压平后标记会与后文粘连,状态值随之被污染。 + */ +export function htmlToParagraphs(value: unknown): string[] { + if (typeof value !== "string") return []; + const raw = value.trim(); + if (!raw) return []; + let text = raw; + if (typeof DOMParser !== "undefined") { + try { + const doc = new DOMParser().parseFromString(raw, "text/html"); + // `
` 只是折行,按单换行处理(下面会被归一化成空格) + doc.body?.querySelectorAll("br").forEach((el) => { + el.insertAdjacentText("afterend", "\n"); + }); + // 块级边界补空行,与块内空行走同一套切分规则 + doc.body?.querySelectorAll(BLOCK_SELECTOR).forEach((el) => { + el.insertAdjacentText("afterend", "\n\n"); + }); + text = doc.body?.textContent ?? raw; + } catch { + text = raw; + } + } + return text + .split(/\n{2,}/) + .map((part) => part.replace(/\s+/g, " ").trim()) + .filter(Boolean); +} + +/** 把 HTML 片段取成单行纯文本;非字符串返回空串。 */ +export function stripHtmlToText(value: unknown): string { + return htmlToParagraphs(value).join(" "); +} + +/** 按长度截断文本,超出时补省略号。 */ +export function truncateText(text: string, maxLength: number): string { + if (maxLength <= 0 || text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trimEnd()}…`; +} + +/** + * 状态页正文开头的状态标记,如 `Status: resolved`。 + * + * 只认**正文开头**(允许前置空白):正文中段出现 "status:" 的往往是 + * 描述性文字而非状态字段,一并匹配会把普通内容误当成状态。 + */ +const STATUS_PREFIX_RE = /^\s*status\s*[::]\s*([^\s。.,,;;))]+)/i; + +/** + * 从正文里摘出状态值,并返回去掉状态标记后的正文。 + * + * 状态值**原样返回、不做翻译**:它是远端给出的机器值, + * 替它另造一套文案既无必要也不可靠。 + * + * 摘掉标记是因为它已由状态徽标单独表达,留在正文里会在弹窗中重复一遍。 + */ +export function extractStatus(summary: string): { status?: string; summary: string } { + const match = STATUS_PREFIX_RE.exec(summary); + if (!match) return { summary }; + return { + // 去掉尾随标点(`resolved.` / `resolved,`),保留远端原始大小写 + status: match[1]!.replace(/[.,;)]+$/, ""), + summary: summary.slice(match[0].length).trim(), + }; +} + +/** 把任意日期表示换算成毫秒时间戳;不可用时返回 null。 */ +function toTimestamp(value: unknown): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (value instanceof Date) { + const ms = value.getTime(); + return Number.isNaN(ms) ? null : ms; + } + if (typeof value === "string") { + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : ms; + } + return null; +} + +/** feedsmith 的日期解析回调:无法解析时返回 null 而不是抛错。 */ +function parseFeedDate(raw: string): number | null { + const ms = Date.parse(raw); + return Number.isNaN(ms) ? null : ms; +} + +/** 取第一个非空字符串。 */ +function firstText(...values: Array): string { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +/** 从 Atom 的链接集合里取站点/条目链接:优先 rel="alternate" 或未声明 rel 的项。 */ +function pickLink(links?: Array<{ href?: string; rel?: string }>): string { + if (!links || links.length === 0) return ""; + const alternate = links.find((link) => !link.rel || link.rel === "alternate"); + return firstText(alternate?.href, links[0]?.href); +} + +/** + * 把条目链接归一化为可直接打开的绝对地址。 + * + * - 相对链接(`/post/1`、`post/1`)以**订阅地址**为基准解析—— + * 订阅源里出现相对链接并不罕见,直接用会被 `chrome.tabs.create` 拒绝; + * - 只保留 http/https:`javascript:` / `data:` 这类协议一律丢弃, + * 因为链接会交给 `openUrl` 直接打开,此处是唯一的把关点 + * (`BrowserView` 的校验只作用于其自身的输入,不覆盖这里)。 + * - 解析失败或协议不合法时返回空串,由调用方回退。 + */ +export function resolveEntryLink(raw: string | undefined, feedUrl: string): string { + const text = raw?.trim() ?? ""; + if (!text) return ""; + try { + const base = normalizeFeedUrl(feedUrl) ?? DEFAULT_FEED_URL; + const url = new URL(text, base); + return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; + } catch { + return ""; + } +} + +/** + * 组装条目;标题缺失时用链接或正文兜底。 + * + * 正文按段落取出并**摘掉开头的状态标记**:该标记已由状态徽标单独表达, + * 留在正文里会重复一遍。 + * + * `feedUrl` 用于把相对链接解析成绝对地址,并过滤掉非 http(s) 协议。 + */ +function buildEntry( + index: number, + input: { + id?: string; + title?: string; + link?: string; + summary?: string; + publishedAt: number | null; + }, + feedUrl: string, +): RssEntry { + const link = resolveEntryLink(input.link, feedUrl); + const paragraphs = htmlToParagraphs(input.summary); + const first = paragraphs[0]; + const extracted = first === undefined ? { summary: "" } : extractStatus(first); + const rest = extracted.summary + ? [extracted.summary, ...paragraphs.slice(1)] + : paragraphs.slice(1); + const summary = rest.join(" "); + // 标题缺失时优先用去掉了状态标记的正文,避免标题变成 "Status: resolved" + return { + id: firstText(input.id, link, `${index}`), + title: firstText(input.title, summary, link) || `${index + 1}`, + link, + summary, + status: extracted.status, + publishedAt: input.publishedAt, + }; +} + +/** 归一化 RSS 2.0 解析结果。 */ +function normalizeRss(feed: RssFeed.Feed, feedUrl: string): RssSnapshot { + const items = feed.items ?? []; + return { + title: firstText(feed.title, feed.link, DEFAULT_FEED_URL), + link: firstText(feed.link), + description: stripHtmlToText(feed.description), + entries: items.slice(0, MAX_ENTRIES).map((item, index) => + buildEntry( + index, + { + id: item.guid?.value, + title: item.title, + link: item.link, + summary: item.description ?? item.content?.encoded ?? item.dc?.descriptions?.[0], + publishedAt: toTimestamp(item.pubDate) ?? toTimestamp(item.dc?.dates?.[0]), + }, + feedUrl, + ), + ), + fetchedAt: Date.now(), + }; +} + +/** 归一化 Atom 解析结果。 */ +function normalizeAtom(feed: AtomFeed.Feed, feedUrl: string): RssSnapshot { + const entries = feed.entries ?? []; + return { + title: firstText(feed.title?.value, pickLink(feed.links), DEFAULT_FEED_URL), + link: pickLink(feed.links), + description: stripHtmlToText(feed.subtitle?.value), + entries: entries.slice(0, MAX_ENTRIES).map((entry, index) => + buildEntry( + index, + { + id: entry.id, + title: entry.title?.value, + link: pickLink(entry.links), + summary: entry.summary?.value ?? entry.content?.value, + publishedAt: toTimestamp(entry.published) ?? toTimestamp(entry.updated), + }, + feedUrl, + ), + ), + fetchedAt: Date.now(), + }; +} + +/** 归一化 RDF(RSS 1.0)解析结果。 */ +function normalizeRdf(feed: RdfFeed.Feed, feedUrl: string): RssSnapshot { + const items = feed.items ?? []; + return { + title: firstText(feed.title, feed.link, DEFAULT_FEED_URL), + link: firstText(feed.link), + description: stripHtmlToText(feed.description), + entries: items.slice(0, MAX_ENTRIES).map((item, index) => + buildEntry( + index, + { + id: item.rdf?.about, + title: item.title, + link: item.link, + summary: item.description ?? item.dc?.descriptions?.[0] ?? item.content?.encoded, + publishedAt: toTimestamp(item.dc?.dates?.[0]), + }, + feedUrl, + ), + ), + fetchedAt: Date.now(), + }; +} + +/** 归一化 JSON Feed 解析结果。 */ +function normalizeJson(feed: JsonFeed.Feed, feedUrl: string): RssSnapshot { + const items = feed.items ?? []; + return { + title: firstText(feed.title, feed.home_page_url, DEFAULT_FEED_URL), + link: firstText(feed.home_page_url, feed.feed_url), + description: stripHtmlToText(feed.description), + entries: items.slice(0, MAX_ENTRIES).map((item, index) => + buildEntry( + index, + { + id: item.id, + title: item.title, + link: item.url ?? item.external_url, + summary: item.content_text ?? item.content_html ?? item.summary, + publishedAt: toTimestamp(item.date_published) ?? toTimestamp(item.date_modified), + }, + feedUrl, + ), + ), + fetchedAt: Date.now(), + }; +} + +/** + * 解析订阅内容,返回归一化快照。 + * + * `feedUrl` 是这份内容的来源地址,用于把条目里的相对链接解析成绝对地址 + * (订阅源写相对链接并不罕见),并过滤掉非 http(s) 协议。 + * + * feedsmith 以动态导入加载:订阅解析只在真正取数时发生, + * 不进入新标签页首屏依赖。 + * + * @throws 内容不符合任何订阅格式时抛 `RssError("parse-error")`。 + */ +export async function parseFeedDocument( + content: string, + feedUrl: string = DEFAULT_FEED_URL, +): Promise { + const { parseFeed } = await import("feedsmith"); + try { + const parsed = parseFeed(content, { + maxItems: MAX_ENTRIES, + parseDateFn: parseFeedDate, + }); + switch (parsed.format) { + case "rss": + return normalizeRss(parsed.feed, feedUrl); + case "atom": + return normalizeAtom(parsed.feed, feedUrl); + case "rdf": + return normalizeRdf(parsed.feed, feedUrl); + case "json": + return normalizeJson(parsed.feed, feedUrl); + default: + throw new RssError("parse-error"); + } + } catch (error) { + if (error instanceof RssError) throw error; + throw new RssError("parse-error"); + } +} + +/** 请求订阅内容。 */ +async function requestFeed(url: string): Promise { + const response = await fetch(url, { + credentials: "omit", + signal: AbortSignal.timeout(FEED_TIMEOUT_MS), + headers: { + accept: + "application/rss+xml, application/atom+xml, application/xml, text/xml, application/json;q=0.8, */*;q=0.5", + }, + }); + if (!response.ok) throw new RssError("http-error", response.status); + const content = await response.text(); + if (!content.trim()) throw new RssError("empty-body"); + return content; +} + +/** 取数结果。 */ +export interface RssFetchResult { + snapshot: RssSnapshot; + /** 是否为本次联网取得;false 表示联网失败、回落到了上次成功的内容。 */ + fresh: boolean; +} + +/** 缓存键(按订阅地址区分)。 */ +function rssCacheKey(target: string): string { + return `${CACHE_PREFIX}${target}`; +} + +/** + * 进行中的请求。仅用于避免同一地址的并发重复取数, + * 不承担"新鲜度"语义。 + */ +const inflight = new Map>(); + +/** + * 取订阅数据。 + * + * **每次都联网**:订阅是轻量纯文本,不设新鲜窗口,避免把上一轮内容 + * 当成当前内容展示(状态页尤其忌讳看到过期的"当前状态")。 + * + * 缓存唯一的用途是**远程故障时兜底**:联网失败则返回上次成功的内容并标记 + * `fresh: false`,让界面能提示"可能不新",而不是空磁贴。 + * + * 刻意不复用 `readThroughCache`:它在失败时静默返回旧值,调用方无从区分 + * "刚拿到的"与"回落到的",也就无法给出过期提示。 + * + * @throws 地址非法,或取数失败且从未成功过时抛出。 + */ +export function fetchFeed(url: string): Promise { + const target = normalizeFeedUrl(url); + if (!target) return Promise.reject(new RssError("invalid-url")); + const key = rssCacheKey(target); + + const existing = inflight.get(key); + if (existing) return existing; + + const task = (async (): Promise => { + try { + // 订阅地址同时作为相对链接的解析基准 + const snapshot = await parseFeedDocument(await requestFeed(target), target); + writeCache(key, snapshot); + return { snapshot, fresh: true }; + } catch (error) { + // 兜底不设年龄上限:过期的状态页内容仍比空磁贴有用 + const fallback = readStaleSnapshot(key); + if (fallback) return { snapshot: fallback, fresh: false }; + throw error; + } + })(); + + inflight.set(key, task); + return task.finally(() => { + inflight.delete(key); + }); +} + +/** 读取已落盘的上次成功快照(不发请求)。 */ +function readStaleSnapshot(key: string): RssSnapshot | null { + const stale = readStaleCache(key); + return isUsableSnapshot(stale) ? stale : null; +} + +/** + * 读取**上次成功**的快照(不发请求)。 + * + * 供磁贴首屏先渲染已有内容,随即被联网结果替换。 + */ +export function readCachedSnapshot(url: string): RssSnapshot | null { + const target = normalizeFeedUrl(url); + return target ? readStaleSnapshot(rssCacheKey(target)) : null; +} + +/** date-fns 的展示语言:中文用 zhCN,其余用 enUS。 */ +function dateLocale() { + return getCurrentLanguage() === "en" ? enUS : zhCN; +} + +/** + * 条目时间的展示文案: + * 1 分钟内「刚刚」,30 天内相对时间(date-fns 本地化),更早显示具体日期。 + */ +export function feedTimeLabel(publishedAt: number | null, now = Date.now()): string { + if (publishedAt === null) return ""; + const diff = now - publishedAt; + if (diff < 60 * 1000) return getMessage("timeJustNow", "刚刚"); + if (diff < 30 * 24 * 60 * 60 * 1000) { + return formatDistanceToNow(new Date(publishedAt), { addSuffix: true, locale: dateLocale() }); + } + return format(new Date(publishedAt), "yyyy-MM-dd"); +} diff --git a/src/lib/widget-registry.ts b/src/lib/widget-registry.ts index 85b32ee..8171465 100644 --- a/src/lib/widget-registry.ts +++ b/src/lib/widget-registry.ts @@ -45,6 +45,11 @@ export interface WidgetDefinition { meta: WidgetMeta; config: WidgetConfig; component: ComponentType; + /** + * 新建实例时的初始 `data`(缺省为空对象)。 + * 用于带"默认配置"的小部件(如 RSS 的默认订阅地址)。 + */ + defaultData?: Record; /** * 提供后,小部件磁贴会出现"展开"入口,点击弹出该内容。 * 未提供即不支持展开。 @@ -82,6 +87,17 @@ export function getWidgetPopup(type: string): WidgetPopup | undefined { return registry.get(type)?.popup; } +/** + * 取新建小部件实例的初始 `data`。 + * + * 每次调用返回**新的浅拷贝**,避免多个实例共享同一份初始数据对象 + * 而在落盘时互相串改。 + */ +export function getWidgetDefaultData(type: string): Record { + const defaults = registry.get(type)?.defaultData; + return defaults ? { ...defaults } : {}; +} + /** 小部件是否支持展开弹窗。 */ export function hasWidgetPopup(type: string): boolean { return Boolean(registry.get(type)?.popup); diff --git a/src/test/components/widgets/rss/RssWidget.test.tsx b/src/test/components/widgets/rss/RssWidget.test.tsx new file mode 100644 index 0000000..fdd0593 --- /dev/null +++ b/src/test/components/widgets/rss/RssWidget.test.tsx @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { RssWidget } from "@/components/widgets/rss/RssWidget"; +import { RssFeedPopup } from "@/components/widgets/rss/RssFeedPopup"; +import { DEFAULT_FEED_URL } from "@/components/widgets/rss/rss"; + +/** + * RSS 小部件的渲染测试。 + * + * 覆盖:默认订阅地址生效、磁贴摘要渲染、弹窗完整列表渲染, + * 以及接口失败时的错误提示。 + */ + +const FEED_XML = ` + + + DeepSeek + https://status.deepseek.com + Status updates for DeepSeek + + DeepSeek 网页/API 部分中断 + https://status.deepseek.com/incidents/1 + <p><strong>Status:</strong> identified</p><p>已定位原因,正在实施修复。</p> + urn:flashduty:change:1 + Thu, 15 Oct 2026 08:11:35 +0800 + + + DeepSeek API 性能下降 + https://status.deepseek.com/incidents/2 + <p><strong>Status:</strong> resolved</p><p>本次问题已解决。</p> + urn:flashduty:change:2 + Mon, 12 Oct 2026 14:42:23 +0800 + + +`; + +function textResponse(body: string, ok = true, status = ok ? 200 : 500) { + return { ok, status, text: async () => body } as unknown as Response; +} + +describe("RssWidget", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("subscribes to the DeepSeek status feed by default", async () => { + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + + render(); + + expect(String(fetchMock.mock.calls[0]![0])).toBe(DEFAULT_FEED_URL); + await waitFor(() => { + expect(screen.queryByText("DeepSeek 网页/API 部分中断")).not.toBeNull(); + }); + expect(screen.queryByText("DeepSeek")).not.toBeNull(); + }); + + it("makes the body the main content for the status kind", async () => { + // 状态页的标题只说"发生了什么故障",正文才说明"现在是否已解决", + // 因此状态类把正文作为主体(占满剩余空间),标题退为次要一行。 + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + + const { container } = render(); + + await waitFor(() => { + expect(screen.queryByText("已定位原因,正在实施修复。")).not.toBeNull(); + }); + + // 状态值原样展示,不做翻译 + expect(screen.queryByText("identified")).not.toBeNull(); + // 正文在专用的主体容器里(不是列表条目) + const body = container.querySelector(".rss-status-body"); + expect(body).not.toBeNull(); + expect(body!.textContent).toContain("已定位原因,正在实施修复。"); + // 状态标记已从正文中摘出,不在正文里重复出现 + expect(body!.textContent).not.toContain("Status:"); + // 标题作为次要信息保留 + expect(container.querySelector(".rss-status-title")).not.toBeNull(); + // 状态类不渲染列表结构 + expect(container.querySelector(".rss-widget-list")).toBeNull(); + }); + + it("shows only the latest entry for the status kind", async () => { + // 状态类:最新一条即当前状况,历史进展没有外显价值 + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + + render(); + + await waitFor(() => { + expect(screen.queryByText("DeepSeek 网页/API 部分中断")).not.toBeNull(); + }); + // 第二条(较旧)不出现在磁贴里 + expect(screen.queryByText("DeepSeek API 性能下降")).toBeNull(); + }); + + it("keeps the list layout for the list kind", async () => { + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + + const { container } = render(); + + await waitFor(() => { + expect(screen.queryByText("DeepSeek 网页/API 部分中断")).not.toBeNull(); + }); + await waitFor(() => { + expect(container.querySelectorAll(".rss-widget-item").length).toBeGreaterThan(1); + }); + // 列表类不套用状态类的正文主体布局 + expect(container.querySelector(".rss-status-body")).toBeNull(); + expect(container.querySelector(".rss-widget-list")).not.toBeNull(); + }); + + it("uses the configured URL from the widget data", async () => { + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + + render(); + + expect(String(fetchMock.mock.calls[0]![0])).toBe("https://example.com/feed"); + }); + + it("requests the network on every mount instead of trusting a fresh window", async () => { + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + render(); + await waitFor(() => { + expect(screen.queryByText("DeepSeek")).not.toBeNull(); + }); + + fetchMock.mockClear(); + render(); + + // 上一次成功的内容已在本地,但挂载仍必须联网,避免展示上一轮的"当前状态" + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + expect(String(fetchMock.mock.calls[0]![0])).toBe(DEFAULT_FEED_URL); + }); + + it("shows the fallback snapshot with a stale marker when the refresh fails", async () => { + // 缓存存在的唯一意义:远程故障时降级为"内容可能不新",而不是空磁贴 + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + render(); + await waitFor(() => { + expect(screen.queryByText("DeepSeek")).not.toBeNull(); + }); + + fetchMock.mockRejectedValue(new Error("network down")); + const { container } = render(); + + // 旧条目仍在展示(状态类只显最新 1 条),而不是被清空成错误态 + await waitFor(() => { + expect(screen.queryAllByText("DeepSeek 网页/API 部分中断").length).toBeGreaterThan(0); + }); + // 同时给出"可能不新"的标记,避免用户误当成当前状态 + await waitFor(() => { + expect(container.querySelector(".rss-widget-stale")).not.toBeNull(); + }); + // 不再展示"订阅获取失败"(有内容可降级时不该挡在错误态上) + expect(screen.queryAllByText("订阅获取失败").length).toBe(0); + }); + + it("marks the popup entries as possibly out of date", async () => { + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + const first = render(); + await waitFor(() => { + expect(screen.queryByText("DeepSeek API 性能下降")).not.toBeNull(); + }); + first.unmount(); + + fetchMock.mockRejectedValue(new Error("network down")); + render(); + + await waitFor(() => { + expect(screen.queryByText("刷新失败,内容可能不是最新")).not.toBeNull(); + }); + // 条目仍在 + expect(screen.queryByText("DeepSeek API 性能下降")).not.toBeNull(); + }); + + it("shows an error state when the request fails", async () => { + fetchMock.mockRejectedValue(new Error("network down")); + + render(); + + await waitFor(() => { + expect(screen.queryByText("订阅获取失败")).not.toBeNull(); + }); + }); + + it("shows the empty state for a feed without entries", async () => { + fetchMock.mockResolvedValue( + textResponse( + `空https://e.comd`, + ), + ); + + render(); + + await waitFor(() => { + expect(screen.queryByText("暂无更新")).not.toBeNull(); + }); + }); +}); + +describe("RssFeedPopup", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("lists every entry with its summary", async () => { + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + + render(); + + await waitFor(() => { + expect(screen.queryByText("DeepSeek API 性能下降")).not.toBeNull(); + }); + expect(screen.queryByText("已定位原因,正在实施修复。")).not.toBeNull(); + expect(screen.queryByText("共 2 条")).not.toBeNull(); + // 订阅地址回填到输入框 + expect((screen.getByDisplayValue(DEFAULT_FEED_URL) as HTMLInputElement).value).toBe( + DEFAULT_FEED_URL, + ); + }); + + it("persists a new subscription URL", async () => { + fetchMock.mockResolvedValue(textResponse(FEED_XML)); + const onDataChange = vi.fn(); + + render(); + + await waitFor(() => { + expect(screen.queryByText("DeepSeek API 性能下降")).not.toBeNull(); + }); + + const input = screen.getByDisplayValue(DEFAULT_FEED_URL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "https://example.com/feed" } }); + fireEvent.click(screen.getByText("订阅")); + + await waitFor(() => { + expect(onDataChange).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://example.com/feed" }), + ); + }); + }); +}); diff --git a/src/test/components/widgets/rss/rss.test.ts b/src/test/components/widgets/rss/rss.test.ts new file mode 100644 index 0000000..9c5b116 --- /dev/null +++ b/src/test/components/widgets/rss/rss.test.ts @@ -0,0 +1,470 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { format } from "date-fns"; +import { + DEFAULT_FEED_URL, + MAX_ENTRIES, + RssError, + extractStatus, + feedTimeLabel, + fetchFeed, + htmlToParagraphs, + normalizeFeedUrl, + parseFeedDocument, + readCachedSnapshot, + readFeedKind, + readFeedUrl, + resolveEntryLink, + stripHtmlToText, + tileEntryLimit, +} from "@/components/widgets/rss/rss"; + +/** + * RSS 小部件的解析与取数测试。 + * + * 覆盖:feedsmith 解析后的归一化(RSS / Atom / JSON Feed)、HTML 转纯文本、 + * 默认订阅地址、按地址缓存与 TTL 取数、以及错误分类。 + */ + +const RSS_XML = ` + + + DeepSeek + https://status.deepseek.com + Status updates for DeepSeek + + DeepSeek 网页/API 部分中断 + https://status.deepseek.com/incidents/7116348214287 + <p><strong>Status:</strong> identified</p><p>已定位原因,正在实施修复。 The cause has been identified and a fix is being implemented.</p> + urn:flashduty:change:7116348214287 + Thu, 15 Oct 2026 08:11:35 +0800 + + + DeepSeek API 性能下降 + https://status.deepseek.com/incidents/7104277696287 + <p><strong>Status:</strong> resolved</p><p>本次问题已解决,服务已恢复。</p> + urn:flashduty:change:7104277696287 + Mon, 12 Oct 2026 14:42:23 +0800 + + +`; + +const ATOM_XML = ` + + Atom 示例 + + 示例订阅 + 2026-10-15T01:00:00Z + + 第一条 + + tag:example.com,2026:1 + 2026-10-15T00:00:00Z +

正文 <b>加粗</b> + +`; + +const JSON_FEED = JSON.stringify({ + version: "https://jsonfeed.org/version/1.1", + title: "JSON 示例", + home_page_url: "https://example.com/", + items: [ + { + id: "1", + url: "https://example.com/j1", + title: "JSON 第一条", + content_text: "正文内容", + date_published: "2026-10-14T00:00:00Z", + }, + ], +}); + +function textResponse(body: string, ok = true, status = ok ? 200 : 500) { + return { + ok, + status, + text: async () => body, + } as unknown as Response; +} + +describe("normalizeFeedUrl", () => { + it("accepts http(s) URLs and trims whitespace", () => { + expect(normalizeFeedUrl(" https://status.deepseek.com/feed.rss ")).toBe( + "https://status.deepseek.com/feed.rss", + ); + expect(normalizeFeedUrl("http://example.com/feed")).toBe("http://example.com/feed"); + }); + + it("rejects non-http protocols and non-strings", () => { + expect(normalizeFeedUrl("ftp://example.com/feed")).toBeNull(); + expect(normalizeFeedUrl("javascript:alert(1)")).toBeNull(); + expect(normalizeFeedUrl("not a url")).toBeNull(); + expect(normalizeFeedUrl("")).toBeNull(); + expect(normalizeFeedUrl(undefined)).toBeNull(); + expect(normalizeFeedUrl(42)).toBeNull(); + }); +}); + +describe("readFeedUrl", () => { + it("falls back to the DeepSeek status feed", () => { + expect(readFeedUrl(undefined)).toBe(DEFAULT_FEED_URL); + expect(readFeedUrl({})).toBe(DEFAULT_FEED_URL); + expect(readFeedUrl({ url: "" })).toBe(DEFAULT_FEED_URL); + expect(readFeedUrl({ url: "javascript:x" })).toBe(DEFAULT_FEED_URL); + }); + + it("returns a configured URL", () => { + expect(readFeedUrl({ url: "https://example.com/feed" })).toBe("https://example.com/feed"); + }); +}); + +describe("resolveEntryLink", () => { + it("resolves relative links against the feed URL", () => { + // 订阅源里出现相对链接并不罕见;直接用会被 chrome.tabs.create 拒绝 + expect(resolveEntryLink("/post/1", "https://example.com/feed.rss")).toBe( + "https://example.com/post/1", + ); + expect(resolveEntryLink("post/1", "https://example.com/blog/feed.rss")).toBe( + "https://example.com/blog/post/1", + ); + }); + + it("keeps absolute http(s) links as-is", () => { + expect(resolveEntryLink("https://example.com/a", "https://example.com/feed")).toBe( + "https://example.com/a", + ); + expect(resolveEntryLink("http://example.com/a", "https://example.com/feed")).toBe( + "http://example.com/a", + ); + }); + + it("drops non-http(s) protocols", () => { + // 链接会交给 openUrl 直接打开,这里是唯一的协议把关点 + expect(resolveEntryLink("javascript:alert(1)", "https://example.com/feed")).toBe(""); + expect(resolveEntryLink("data:text/html,hi", "https://example.com/feed")).toBe(""); + expect(resolveEntryLink("file:///etc/passwd", "https://example.com/feed")).toBe(""); + }); + + it("returns an empty string for empty or unparsable input", () => { + expect(resolveEntryLink(undefined, "https://example.com/feed")).toBe(""); + expect(resolveEntryLink(" ", "https://example.com/feed")).toBe(""); + }); +}); + +describe("stripHtmlToText", () => { + it("removes tags and collapses whitespace", () => { + expect(stripHtmlToText("

Status: identified

")).toBe( + "Status: identified", + ); + expect(stripHtmlToText("第一行\n\n第二行")).toBe("第一行 第二行"); + }); + + it("keeps block boundaries so adjacent paragraphs do not merge", () => { + // 不插入分隔时 textContent 会把两段粘成 "resolved本次问题已解决" + expect(stripHtmlToText("

Status: resolved

本次问题已解决

")).toBe( + "Status: resolved 本次问题已解决", + ); + }); + + it("returns an empty string for non-strings and empty markup", () => { + expect(stripHtmlToText(undefined)).toBe(""); + expect(stripHtmlToText(null)).toBe(""); + expect(stripHtmlToText(" ")).toBe(""); + expect(stripHtmlToText(123)).toBe(""); + }); +}); + +describe("htmlToParagraphs", () => { + it("splits on block boundaries and on blank lines inside a block", () => { + expect(htmlToParagraphs("

甲

乙

")).toEqual(["甲", "乙"]); + // 双语源的常见写法:同一

内用空行分隔中英 + expect(htmlToParagraphs("

中文一段\n\nEnglish paragraph

")).toEqual([ + "中文一段", + "English paragraph", + ]); + }); + + it("treats
as a line break, not a paragraph break", () => { + expect(htmlToParagraphs("

第一行
第二行

")).toEqual(["第一行 第二行"]); + }); +}); + +describe("readFeedKind", () => { + it("defaults to the status kind", () => { + // 内置的 DeepSeek 订阅是状态页,未设置时按状态类处理 + expect(readFeedKind(undefined)).toBe("status"); + expect(readFeedKind({})).toBe("status"); + expect(readFeedKind({ kind: "status" })).toBe("status"); + }); + + it("accepts the list kind and ignores unknown values", () => { + expect(readFeedKind({ kind: "list" })).toBe("list"); + expect(readFeedKind({ kind: "whatever" })).toBe("status"); + }); +}); + +describe("tileEntryLimit", () => { + it("shows a single entry for the status kind", () => { + // 状态类的最新一条即当前状况,历史进展没有外显价值 + expect(tileEntryLimit("status")).toBe(1); + }); + + it("shows several entries for the list kind", () => { + expect(tileEntryLimit("list")).toBeGreaterThan(1); + }); +}); + +describe("extractStatus", () => { + it("pulls the status out of the body and strips the marker", () => { + expect(extractStatus("Status: resolved 本次问题已解决")).toEqual({ + status: "resolved", + summary: "本次问题已解决", + }); + }); + + it("keeps the remote value as-is instead of translating it", () => { + // 状态值是远端机器值,且源本身已是双语;不替它另造文案 + expect(extractStatus("Status: investigating 正在排查").status).toBe("investigating"); + expect(extractStatus("Status: IDENTIFIED 已定位").status).toBe("IDENTIFIED"); + }); + + it("tolerates trailing punctuation on the status value", () => { + expect(extractStatus("Status: resolved.").status).toBe("resolved"); + expect(extractStatus("Status: resolved,").status).toBe("resolved"); + }); + + it("leaves text without a leading status marker untouched", () => { + expect(extractStatus("本次问题已解决")).toEqual({ summary: "本次问题已解决" }); + // 中段的 status: 不是状态字段,不能误判 + expect(extractStatus("说明 status: resolved 的含义")).toEqual({ + summary: "说明 status: resolved 的含义", + }); + }); +}); + +describe("parseFeedDocument", () => { + it("normalizes an RSS 2.0 feed", async () => { + const snapshot = await parseFeedDocument(RSS_XML); + expect(snapshot.title).toBe("DeepSeek"); + expect(snapshot.link).toBe("https://status.deepseek.com"); + expect(snapshot.description).toBe("Status updates for DeepSeek"); + expect(snapshot.entries).toHaveLength(2); + // 状态标记被摘出为 status,正文不再重复它;正文完整保留(不再按语言取段) + expect(snapshot.entries[0]).toMatchObject({ + id: "urn:flashduty:change:7116348214287", + title: "DeepSeek 网页/API 部分中断", + link: "https://status.deepseek.com/incidents/7116348214287", + status: "identified", + summary: + "已定位原因,正在实施修复。 The cause has been identified and a fix is being implemented.", + }); + // 第二条的状态与正文各自独立提取 + expect(snapshot.entries[1]).toMatchObject({ + status: "resolved", + summary: "本次问题已解决,服务已恢复。", + }); + // pubDate 换算成毫秒时间戳 + expect(snapshot.entries[0]!.publishedAt).toBe(Date.parse("Thu, 15 Oct 2026 08:11:35 +0800")); + }); + + it("normalizes an Atom feed", async () => { + const snapshot = await parseFeedDocument(ATOM_XML); + expect(snapshot.title).toBe("Atom 示例"); + expect(snapshot.link).toBe("https://example.com/"); + expect(snapshot.entries).toHaveLength(1); + expect(snapshot.entries[0]).toMatchObject({ + id: "tag:example.com,2026:1", + title: "第一条", + link: "https://example.com/1", + summary: "正文 加粗", + }); + expect(snapshot.entries[0]!.publishedAt).toBe(Date.parse("2026-10-15T00:00:00Z")); + }); + + it("normalizes a JSON feed", async () => { + const snapshot = await parseFeedDocument(JSON_FEED); + expect(snapshot.title).toBe("JSON 示例"); + expect(snapshot.entries).toHaveLength(1); + expect(snapshot.entries[0]).toMatchObject({ + id: "1", + title: "JSON 第一条", + link: "https://example.com/j1", + summary: "正文内容", + }); + }); + + it("keeps the status badge and the whole body", async () => { + // 不再按语言取段:状态与正文都完整保留(双语源会中英各一段) + const xml = `Thttps://e.comd网页中断<p><strong>Status:</strong> resolved</p><p>本次问题已解决。 The incident has been resolved.</p>`; + const snapshot = await parseFeedDocument(xml); + expect(snapshot.entries[0]!.status).toBe("resolved"); + expect(snapshot.entries[0]!.summary).toBe("本次问题已解决。 The incident has been resolved."); + expect(snapshot.entries[0]!.title).toBe("网页中断"); + }); + + it("falls back to a link or index when a title is missing", async () => { + const snapshot = await parseFeedDocument( + `Thttps://e.comdhttps://e.com/1`, + ); + expect(snapshot.entries[0]!.title).toBe("https://e.com/1"); + }); + + it("drops unparsable dates instead of failing", async () => { + const snapshot = await parseFeedDocument( + `Thttps://e.comdXnot a date`, + ); + expect(snapshot.entries[0]!.publishedAt).toBeNull(); + }); + + it("caps the number of entries", async () => { + const items = Array.from( + { length: MAX_ENTRIES + 10 }, + (_, i) => `#${i}g${i}`, + ).join(""); + const snapshot = await parseFeedDocument( + `Thttps://e.comd${items}`, + ); + expect(snapshot.entries).toHaveLength(MAX_ENTRIES); + }); + + it("rejects content that is not a feed", async () => { + await expect(parseFeedDocument("nope")).rejects.toBeInstanceOf( + RssError, + ); + }); +}); + +describe("fetchFeed", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests the network on every call instead of reusing a fresh window", async () => { + // 订阅是轻量纯文本,不设新鲜窗口:连续两次取数必须各发一次请求, + // 否则状态页会展示上一轮的内容。 + fetchMock.mockResolvedValue(textResponse(RSS_XML)); + + const first = await fetchFeed(DEFAULT_FEED_URL); + const second = await fetchFeed(DEFAULT_FEED_URL); + + expect(first.snapshot.title).toBe("DeepSeek"); + expect(first.fresh).toBe(true); + expect(second.fresh).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]![0])).toBe(DEFAULT_FEED_URL); + // 上次成功的内容仍留在本地,作为失败兜底 + expect(readCachedSnapshot(DEFAULT_FEED_URL)?.title).toBe("DeepSeek"); + }); + + it("collapses concurrent requests for the same URL", async () => { + fetchMock.mockResolvedValue(textResponse(RSS_XML)); + + const [a, b] = await Promise.all([fetchFeed(DEFAULT_FEED_URL), fetchFeed(DEFAULT_FEED_URL)]); + + expect(a.fresh).toBe(true); + expect(b.fresh).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("separates the fallback snapshot by URL", async () => { + fetchMock.mockResolvedValue(textResponse(ATOM_XML)); + await fetchFeed("https://a.com/feed"); + await fetchFeed("https://b.com/feed"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(readCachedSnapshot("https://a.com/feed")?.title).toBe("Atom 示例"); + expect(readCachedSnapshot("https://b.com/feed")?.title).toBe("Atom 示例"); + }); + + it("falls back to the last successful snapshot and reports it as not fresh", async () => { + // 缓存存在的唯一意义:远程故障时降级为"内容可能不新",而不是空磁贴 + fetchMock.mockResolvedValueOnce(textResponse(RSS_XML)); + await fetchFeed(DEFAULT_FEED_URL); + + fetchMock.mockRejectedValueOnce(new Error("network down")); + await expect(fetchFeed(DEFAULT_FEED_URL)).resolves.toMatchObject({ + snapshot: { title: "DeepSeek" }, + fresh: false, + }); + + // 503 同样回落到旧内容 + fetchMock.mockResolvedValueOnce(textResponse("", false, 503)); + await expect(fetchFeed(DEFAULT_FEED_URL)).resolves.toMatchObject({ fresh: false }); + }); + + it("keeps serving the old snapshot even long after it was written", async () => { + // 兜底刻意不设年龄上限:过期的状态页内容仍比空磁贴有用。 + // 直接把缓存记录的时间戳改到很久以前来模拟。 + fetchMock.mockResolvedValueOnce(textResponse(RSS_XML)); + await fetchFeed(DEFAULT_FEED_URL); + + const key = Object.keys(localStorage).find((k) => k.includes("rss:"))!; + const record = JSON.parse(localStorage.getItem(key)!); + localStorage.setItem(key, JSON.stringify({ ...record, savedAt: Date.now() - 30 * 86400_000 })); + + fetchMock.mockRejectedValueOnce(new Error("network down")); + await expect(fetchFeed(DEFAULT_FEED_URL)).resolves.toMatchObject({ + snapshot: { title: "DeepSeek" }, + fresh: false, + }); + }); + + it("rejects malformed content with no cached fallback", async () => { + fetchMock.mockResolvedValue(textResponse("not xml at all")); + await expect(fetchFeed("https://bad.example/feed")).rejects.toBeInstanceOf(RssError); + }); + + it("rejects invalid URLs without any request", async () => { + await expect(fetchFeed("javascript:alert(1)")).rejects.toMatchObject({ kind: "invalid-url" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects an empty body", async () => { + fetchMock.mockResolvedValue(textResponse(" ")); + await expect(fetchFeed("https://empty.example/feed")).rejects.toMatchObject({ + kind: "empty-body", + }); + }); + + it("falls back when the body is empty but a snapshot exists", async () => { + fetchMock.mockResolvedValueOnce(textResponse(RSS_XML)); + await fetchFeed(DEFAULT_FEED_URL); + + fetchMock.mockResolvedValueOnce(textResponse("")); + await expect(fetchFeed(DEFAULT_FEED_URL)).resolves.toMatchObject({ fresh: false }); + }); +}); + +describe("feedTimeLabel", () => { + it("shows 刚刚 within a minute", () => { + const now = Date.parse("2026-10-15T12:00:00+08:00"); + expect(feedTimeLabel(now - 30 * 1000, now)).toBe("刚刚"); + }); + + it("shows a localized relative time within 30 days", () => { + const now = Date.parse("2026-10-15T12:00:00+08:00"); + expect(feedTimeLabel(now - 3 * 60 * 60 * 1000, now)).toContain("前"); + }); + + it("shows an absolute date beyond 30 days", () => { + /* + * 绝对日期按**浏览器本地时区**渲染(用户熟悉的口径),因此断言不能写死 + * "某个带时区的时间戳 → 某个固定日期串":CI 跑在 UTC、开发机常在 UTC+8, + * 同一时间戳会落在不同日期上(2026-01-02T00:00+08:00 在 UTC 下是 01-01)。 + * 这里改为用同一套本地时区自行算出期望值,从而与运行环境无关。 + */ + const publishedAt = Date.parse("2026-01-02T00:00:00+08:00"); + const now = Date.parse("2026-10-15T12:00:00+08:00"); + expect(feedTimeLabel(publishedAt, now)).toBe(format(new Date(publishedAt), "yyyy-MM-dd")); + }); + + it("returns an empty string when the date is unknown", () => { + expect(feedTimeLabel(null)).toBe(""); + }); +});