dsh-market-watching 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -3,6 +3,8 @@
3
3
  A market card for the DSH sidebar: crypto, A-shares / HK / US equities, global indices, CME futures continuous contracts, FX and spot metals — live prices, daily change, plus position P&L against your own cost basis. Pick the symbols, their order and the refresh cadence in **DSH Settings**. **No login, no API key.** The UI copy follows the DSH interface language (English / Chinese).
4
4
 
5
5
  <p align="center">
6
+ <img src="icon.svg" alt="dsh-market-watching" width="72" height="72">
7
+ <br>
6
8
  <a href="README.md"><img src="docs/badges/lang-zh-switch.svg" alt="简体中文" height="20"></a>
7
9
  &nbsp;
8
10
  <img src="docs/badges/lang-en-current.svg" alt="English" height="20">
@@ -65,29 +67,23 @@ All of these are public quote endpoints — no account, no token, no auth. Every
65
67
 
66
68
  ## Install
67
69
 
68
- The plugin is wired in through the profile, in two edits:
70
+ Use the DSH plugin command. It forwards its arguments verbatim to pnpm inside the profile directory, so one line does the whole install.
69
71
 
70
- 1. Add to `dependencies` in `%USERPROFILE%\.dsh\profiles\desktop\package.json`:
72
+ Desktop (`desktop` profile):
71
73
 
72
- ```json
73
- "dsh-market-watching": "file:E:/LLm/Project/dsh-market-watching"
74
- ```
74
+ ```powershell
75
+ dsh plugin --profile desktop add dsh-market-watching
76
+ ```
75
77
 
76
- 2. Append to the `dsh.profile.bundles` array in the same `package.json`:
78
+ Web (`web` profile):
77
79
 
78
- ```json
79
- "dsh-market-watching"
80
- ```
80
+ ```powershell
81
+ dsh plugin --profile web add dsh-market-watching
82
+ ```
81
83
 
82
- 3. Link the package into the profile's `node_modules` (a Windows junction is enough):
84
+ Restart the process afterwards: close and reopen the DeepSeek Harness desktop app (the host half `index.js` is only assembled at process start), or restart `dsh web` for the web profile. The client half `client.js` is injected when the page loads, so a refresh picks it up.
83
85
 
84
- ```powershell
85
- New-Item -ItemType Junction `
86
- -Path "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-market-watching" `
87
- -Target "E:\LLm\Project\dsh-market-watching"
88
- ```
89
86
 
90
- 4. **Restart the DeepSeek Harness desktop app.** The host half (`index.js`) is only assembled at process start; the client half (`client.js`) is injected when the page loads.
91
87
 
92
88
  ## Usage
93
89
 
@@ -142,12 +138,15 @@ The `config` block in `cordis.patch.yml` overrides these defaults (a profile pat
142
138
  | `/api/market.ticker` | GET / POST | Quote snapshot for the current watchlist; `?refresh=1` bypasses the cache |
143
139
  | `/api/market.settings` | GET / POST | Read and write settings |
144
140
  | `/api/market.resolve` | POST | Code resolution and validation (returns candidates and official names) |
141
+ | `/api/market.notice` | GET / POST | Whether the thank-you card is due (GET) and what the operator chose (POST `thanks` / `issue` / `later`) |
145
142
 
146
143
  Settings persist to `$DSH_HOME/dsh-market-watching.json` (default `%USERPROFILE%\.dsh\dsh-market-watching.json`). The plugin used to be called `dsh-plugin-market-ticker`; its old file `dsh-market-ticker.json` is adopted automatically on first start and left in place.
147
144
 
145
+ The card keeps its own counter in `$DSH_HOME/dsh-market-watching.notice.json` rather than inside the settings document: settings are normalized on every read and write, so an unknown field would be dropped the moment the watchlist is saved.
146
+
148
147
  ## How it works
149
148
 
150
- - **Host side** (`index.js`): `inject = ['connection']`, and `ctx.connection.fetch.register` installs three exact routes. Inside, instruments are grouped by market: equities / indices / FX merge into one East Money `ulist` batch (40 secids per batch), crypto goes to Gate pair by pair, spot metals merge into one Sina batch. A `cacheMs` cache and `inflight` de-duplication sit in front, and when every upstream fails the response is `degraded: true` with the previous quotes from `lastGood` (`stale: true`), so the card never goes blank. Position maths runs on the host: `positionPnl(entry, price)` yields the percentage (long `(price - cost) / cost`, inverted for shorts) and `positionAmount(entry, qty, price)` yields the cash amount (price delta × quantity, inverted for shorts), shipped as `pnlPercent` / `pnlAmount`; the client only renders them. Error responses carry a `code` (`badJson` / `writeFailed` / `unknownCode` / `noQuote`) next to `message`, so the client can re-render the text in the current language.
149
+ - **Host side** (`index.js`): `inject = ['connection']`, and `ctx.connection.fetch.register` installs four exact routes. Inside, instruments are grouped by market: equities / indices / FX merge into one East Money `ulist` batch (40 secids per batch), crypto goes to Gate pair by pair, spot metals merge into one Sina batch. A `cacheMs` cache and `inflight` de-duplication sit in front, and when every upstream fails the response is `degraded: true` with the previous quotes from `lastGood` (`stale: true`), so the card never goes blank. Position maths runs on the host: `positionPnl(entry, price)` yields the percentage (long `(price - cost) / cost`, inverted for shorts) and `positionAmount(entry, qty, price)` yields the cash amount (price delta × quantity, inverted for shorts), shipped as `pnlPercent` / `pnlAmount`; the client only renders them. Error responses carry a `code` (`badJson` / `writeFailed` / `unknownCode` / `noQuote`) next to `message`, so the client can re-render the text in the current language.
151
150
  - **Client side** (`client.js`): `inject = ['slots', 'locale']`, registering `sidebar.footer.action` (the card), `settings.section` (the settings page) and `settings.section.icon` (the nav icon). Styling uses `--dsw-alias-*` theme tokens only, so it follows light and dark themes.
152
151
 
153
152
  ## Language
@@ -175,7 +174,7 @@ node scripts/probe-ticker.mjs # hit the ticker route once, print real quotes and
175
174
 
176
175
  ## Troubleshooting
177
176
 
178
- - **No card in the sidebar**: first check whether the master switch in settings is off; then that at least one instrument exists; then that `%USERPROFILE%\.dsh\profiles\desktop\node_modules\dsh-market-watching` exists as a junction pointing at the source directory; finally restart the desktop app.
177
+ - **No card in the sidebar**: first check whether the master switch in settings is off; then that at least one instrument exists; then that the package is installed in the current profile (`dsh plugin --profile desktop why dsh-market-watching`); finally restart the process.
179
178
  - **Card shows `--`**: upstreams are unreachable. Check that the machine can reach `push2.eastmoney.com`, `api.gateio.ws` and `hq.sinajs.cn` directly; behind a proxy, put the proxy address in `config.proxy` (the Sina path then uses the same client, so if it is blocked, drop the proxy or go direct).
180
179
  - **A symbol is rejected**: the host could not get a quote for that code. Try another form (market prefix for A-shares, a full secid for indices).
181
180
  - **All copy is English**: interface language follows DSH — switch it to Chinese in DSH's own language setting. Takes effect immediately, no restart.
@@ -183,7 +182,11 @@ node scripts/probe-ticker.mjs # hit the ticker route once, print real quotes and
183
182
 
184
183
  ## Uninstall
185
184
 
186
- Remove the two edits from the profile `package.json`, delete the junction, restart the desktop app. The settings file `%USERPROFILE%\.dsh\dsh-market-watching.json` can go too.
185
+ ```powershell
186
+ dsh plugin --profile desktop remove dsh-market-watching
187
+ ```
188
+
189
+ For the web profile, swap `--profile desktop` for `--profile web`. Restart the process afterwards. The settings file `%USERPROFILE%\.dsh\dsh-market-watching.json` can stay (it is reused on reinstall) or be deleted.
187
190
 
188
191
  ## License
189
192
 
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  DSH 侧栏行情卡片:加密货币、A股 / 港股 / 美股、国际指数、CME 期货连续合约、外汇、现货金银,实时价格与涨跌幅一览,还能按自己填的成本价算持仓盈亏。标的、顺序、刷新节奏全在 **DSH 设置页**里定,**无需登录、无需 API Key**,界面文案跟随 DSH 的界面语言(中文 / 英文)。
4
4
 
5
5
  <p align="center">
6
+ <img src="icon.svg" alt="dsh-market-watching" width="72" height="72">
7
+ <br>
6
8
  <img src="docs/badges/lang-zh-current.svg" alt="简体中文" height="20">
7
9
  &nbsp;
8
10
  <a href="README.en.md"><img src="docs/badges/lang-en-switch.svg" alt="English" height="20"></a>
@@ -78,29 +80,21 @@ English:
78
80
 
79
81
  ## 安装
80
82
 
81
- 插件通过 profile 装配,两步:
83
+ 用 DSH 自带的插件命令,一条就够——它把参数原样转发给 profile 目录里的 pnpm。
82
84
 
83
- 1. 在 `%USERPROFILE%\.dsh\profiles\desktop\package.json` 的 `dependencies` 增加:
85
+ 桌面版(`desktop` profile):
84
86
 
85
- ```json
86
- "dsh-market-watching": "file:E:/LLm/Project/dsh-market-watching"
87
- ```
88
-
89
- 2. 同一个 `package.json` 的 `dsh.profile.bundles` 数组末尾增加:
90
-
91
- ```json
92
- "dsh-market-watching"
93
- ```
87
+ ```powershell
88
+ dsh plugin --profile desktop add dsh-market-watching
89
+ ```
94
90
 
95
- 3. 在 profile 目录下建立包链接(Windows 用 junction 即可):
91
+ Web 版(`web` profile):
96
92
 
97
- ```powershell
98
- New-Item -ItemType Junction `
99
- -Path "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-market-watching" `
100
- -Target "E:\LLm\Project\dsh-market-watching"
101
- ```
93
+ ```powershell
94
+ dsh plugin --profile web add dsh-market-watching
95
+ ```
102
96
 
103
- 4. **重启 DeepSeek Harness 桌面应用**。宿主插件(`index.js`)只在进程启动时装配;客户端 `client.js` 由页面加载时注入。
97
+ 装完重启对应进程才算数:桌面版关掉重开 DeepSeek Harness(宿主 `index.js` 只在进程启动时装配),Web 版重启 `dsh web`。客户端 `client.js` 是页面加载时注入的,刷新一次就换。
104
98
 
105
99
  ## 使用
106
100
 
@@ -155,12 +149,15 @@ English:
155
149
  | `/api/market.ticker` | GET / POST | 当前标的的报价快照;`?refresh=1` 强制绕过缓存 |
156
150
  | `/api/market.settings` | GET / POST | 读写设置 |
157
151
  | `/api/market.resolve` | POST | 代码解析与校验(返回候选与官方名称) |
152
+ | `/api/market.notice` | GET / POST | 是否该弹出道谢卡片(GET),以及记录用户的选择(POST `thanks` / `issue` / `later`) |
158
153
 
159
154
  设置落盘在 `$DSH_HOME/dsh-market-watching.json`(默认 `%USERPROFILE%\.dsh\dsh-market-watching.json`)。插件改名前叫 `dsh-plugin-market-ticker`,旧文件 `dsh-market-ticker.json` 会在首次启动时被自动接管,原文件保留不删。
160
155
 
156
+ 道谢卡片的计数单独落在 `$DSH_HOME/dsh-market-watching.notice.json`,不写进设置文件:设置每次读写都会被规范化,未知字段留不住,用户一存自选就会把计数洗掉。
157
+
161
158
  ## 工作方式
162
159
 
163
- - **宿主侧**(`index.js`):`inject = ['connection']`,用 `ctx.connection.fetch.register` 注册三条精确路由。内部把标的按市场分组,股票/指数/外汇合并成一次东财 `ulist` 批量请求(每批 40 个 secid),加密逐个走 Gate,现货金属合并成一次新浪批次请求。带 `cacheMs` 缓存与 `inflight` 去重,上游整体失败时返回 `degraded: true` 并沿用 `lastGood` 里的上一次报价(`stale: true`),不会把卡片打空。持仓盈亏由宿主算:`positionPnl(entry, price)` 出百分比(多头 `(现价-成本)/成本`,空头取反),`positionAmount(entry, qty, price)` 出金额(价格差 × 数量,空头取反),分别以 `pnlPercent` / `pnlAmount` 下发,客户端只负责显示。错误响应除 `message` 外还带 `code`(`badJson` / `writeFailed` / `unknownCode` / `noQuote`),供客户端按当前语言重新渲染。
160
+ - **宿主侧**(`index.js`):`inject = ['connection']`,用 `ctx.connection.fetch.register` 注册四条精确路由。内部把标的按市场分组,股票/指数/外汇合并成一次东财 `ulist` 批量请求(每批 40 个 secid),加密逐个走 Gate,现货金属合并成一次新浪批次请求。带 `cacheMs` 缓存与 `inflight` 去重,上游整体失败时返回 `degraded: true` 并沿用 `lastGood` 里的上一次报价(`stale: true`),不会把卡片打空。持仓盈亏由宿主算:`positionPnl(entry, price)` 出百分比(多头 `(现价-成本)/成本`,空头取反),`positionAmount(entry, qty, price)` 出金额(价格差 × 数量,空头取反),分别以 `pnlPercent` / `pnlAmount` 下发,客户端只负责显示。错误响应除 `message` 外还带 `code`(`badJson` / `writeFailed` / `unknownCode` / `noQuote`),供客户端按当前语言重新渲染。
164
161
  - **客户端侧**(`client.js`):`inject = ['slots', 'locale']`,注册 `sidebar.footer.action`(卡片)、`settings.section`(设置页)、`settings.section.icon`(导航图标)。样式只用 `--dsw-alias-*` 主题 token,跟随深浅色主题。
165
162
 
166
163
  ## 语言
@@ -188,7 +185,7 @@ node scripts/probe-ticker.mjs # 打一次 ticker 路由,打印真实报价与
188
185
 
189
186
  ## 故障排查
190
187
 
191
- - **侧栏没有卡片**:先看设置里的总开关是不是被关了;再确认至少有一个标的;再确认 `%USERPROFILE%\.dsh\profiles\desktop\node_modules\dsh-market-watching` 这个 junction 存在且指向源码目录;最后重启桌面应用。
188
+ - **侧栏没有卡片**:先看设置里的总开关是不是被关了;再确认至少有一个标的;再确认包装进了当前 profile(`dsh plugin --profile desktop why dsh-market-watching`);最后重启对应进程。
192
189
  - **卡片显示 `--`**:上游不通。检查网络能否直连 `push2.eastmoney.com`、`api.gateio.ws` 与 `hq.sinajs.cn`;若走代理,在 `config.proxy` 填上代理地址(新浪那一路在配置代理后也走同一客户端,若被拦就把代理关掉或改用直连)。
193
190
  - **添加标的被拒**:说明宿主用报价接口探测不到该代码,换个写法(A 股带市场前缀、指数写完整 secid)。
194
191
  - **文案全是英文**:界面语言跟着 DSH 走,去 DSH 自己的语言设置里切成中文即可,改完即时生效,不用重启。
@@ -196,7 +193,11 @@ node scripts/probe-ticker.mjs # 打一次 ticker 路由,打印真实报价与
196
193
 
197
194
  ## 卸载
198
195
 
199
- 删掉 profile `package.json` 里加的两处、删除 junction、重启桌面应用。设置文件 `%USERPROFILE%\.dsh\dsh-market-watching.json` 可一并删除。
196
+ ```powershell
197
+ dsh plugin --profile desktop remove dsh-market-watching
198
+ ```
199
+
200
+ Web 版就把 `--profile desktop` 换成 `--profile web`。卸完重启对应进程。设置文件 `%USERPROFILE%\.dsh\dsh-market-watching.json` 可以留着(重装后接着用),也可以直接删。
200
201
 
201
202
  ## License
202
203
 
package/client.js CHANGED
@@ -27,6 +27,9 @@ window.__ModuleLoader__.load({
27
27
  const SETTINGS_ENDPOINT = '/api/market.settings'
28
28
  const RESOLVE_ENDPOINT = '/api/market.resolve'
29
29
 
30
+ /** Route that decides whether the post-install thank-you card is due. */
31
+ const NOTICE_ENDPOINT = '/api/market.notice'
32
+
30
33
  /** Slot ids, spelled exactly as the UI packages declare them. */
31
34
  const SIDEBAR_SLOT = 'sidebar.footer.action'
32
35
  const SETTINGS_SLOT = 'settings.section'
@@ -35,6 +38,22 @@ window.__ModuleLoader__.load({
35
38
  const CARD_ID = 'market-watching'
36
39
  const SETTINGS_ID = 'market-watching-settings'
37
40
  const STYLE_ID = 'dsh-market-watching-style'
41
+ const THANKS_ID = 'market-watching-thanks'
42
+
43
+ /** Where a reader can complain about a price or ask for a market. */
44
+ const REPO_URL = 'https://github.com/Leni-More/dsh-market-watching'
45
+ const ISSUES_URL = `${REPO_URL}/issues`
46
+
47
+ /**
48
+ * The card waits for the shell to settle before it appears: long enough that
49
+ * it never races the sidebar's first paint, short enough to still feel like
50
+ * a greeting rather than a delayed pop-up.
51
+ */
52
+ const THANKS_DELAY_MS = 2400
53
+
54
+ /** While the operator is typing, the card keeps its distance and tries again. */
55
+ const THANKS_IDLE_MS = 800
56
+ const THANKS_IDLE_TRIES = 12
38
57
 
39
58
  /** Locale namespace this plugin owns; `apply` registers the dictionaries below. */
40
59
  const LOCALE_NAMESPACE = 'marketTicker'
@@ -80,7 +99,7 @@ window.__ModuleLoader__.load({
80
99
  'page.subtitle': '侧边栏底部显示的价格、涨跌幅与盈亏。标的、顺序与刷新间隔都在这里决定,改动立即生效。',
81
100
  'page.loading': '正在读取自选列表…',
82
101
  'page.failed': '读取自选列表失败,请重开设置页。',
83
- 'page.footer': '数据来自东方财富与 Gate.io 的公开接口,无需账号与密钥;网络不通时卡片保留上一次报价并淡化显示。',
102
+ 'page.footer': '数据来自东方财富与 Gate.io 的公开接口,无需账号与密钥;不同平台可能存在一定价差,可以根据实际的价差来调整成本价。',
84
103
  'panel.master': '总开关',
85
104
  'panel.masterHint': '关闭后侧边栏不再显示卡片,也不再请求行情',
86
105
  'panel.add': '添加标的',
@@ -135,6 +154,14 @@ window.__ModuleLoader__.load({
135
154
  'error.unknownCode': '认不出这个代码,换成 600519 / hk00700 / AAPL / USTEC / EURUSD / XAUUSD / BTC 试试。',
136
155
  'error.noQuote': '上游这会儿没有这个标的的报价。',
137
156
  'hint.setPriceFirst': '先在输入框填成本价,再点确定,然后就能切换买入/做空',
157
+ 'thanks.title': '谢谢你把它留在侧边栏',
158
+ 'thanks.body': '很开心它可以帮上你。功能优化,或者你想增加新的标的,可以随时提出来。要是它确实帮上了忙,去仓库点个 Star 就是最好的谢礼。',
159
+ 'thanks.wish': '祝你持仓大赚,住上大平层,开上帕拉梅拉,享受苹果人生。',
160
+ 'thanks.star': '点个 Star',
161
+ 'thanks.ack': '知道了',
162
+ 'thanks.close': '关闭',
163
+ 'thanks.note': '有更好的建议?',
164
+ 'thanks.repo': '去提一条',
138
165
  },
139
166
  en: {
140
167
  'market.crypto': 'Crypto',
@@ -168,7 +195,7 @@ window.__ModuleLoader__.load({
168
195
  'page.subtitle': 'Price, change and P&L at the bottom of the sidebar. Symbols, order and refresh rate are all decided here, and every change applies at once.',
169
196
  'page.loading': 'Reading the watchlist…',
170
197
  'page.failed': 'Could not read the watchlist. Reopen Settings.',
171
- 'page.footer': 'Quotes come from the public East Money and Gate.io endpoints — no account, no API key. When a network call fails the card keeps the last quote and fades it.',
198
+ 'page.footer': 'The data comes from the public endpoints of East Money and Gate.io, with no account or API key required. Price differences may exist across platforms, so the cost price can be adjusted based on the actual price difference.',
172
199
  'panel.master': 'Master switch',
173
200
  'panel.masterHint': 'When off the sidebar card disappears and no quotes are fetched',
174
201
  'panel.add': 'Add a symbol',
@@ -223,6 +250,14 @@ window.__ModuleLoader__.load({
223
250
  'error.unknownCode': 'Unrecognised code. Try 600519 / hk00700 / AAPL / USTEC / EURUSD / XAUUSD / BTC.',
224
251
  'error.noQuote': 'The upstream has no quote for this symbol right now.',
225
252
  'hint.setPriceFirst': 'Type a cost and press Save first, then the long/short switch works',
253
+ 'thanks.title': 'Thank you for keeping it in the sidebar',
254
+ 'thanks.body': 'Glad it could help. If you’d like feature improvements or want to add new tickers, feel free to reach out anytime. If it really did help, dropping a Star on the repo is the best thank-you.',
255
+ 'thanks.wish': 'Wishing you big gains on your holdings, a fancy penthouse, a Panamera, and the Apple life.',
256
+ 'thanks.star': 'Drop a Star',
257
+ 'thanks.ack': 'Got it',
258
+ 'thanks.close': 'Close',
259
+ 'thanks.note': 'Any better suggestions?',
260
+ 'thanks.repo': 'Open an issue',
226
261
  },
227
262
  }
228
263
 
@@ -485,6 +520,17 @@ window.__ModuleLoader__.load({
485
520
  letter-spacing: 0.02em;
486
521
  }
487
522
 
523
+ /* Brand mark: gives the card a face of its own before the title, and stays put when the
524
+ rail collapses the header to bare numbers and strips the written title away. */
525
+ .dmt-brand {
526
+ display: inline-flex;
527
+ align-items: center;
528
+ flex: none;
529
+ color: #22c55e;
530
+ }
531
+
532
+ .dmt-brand > svg { display: block; }
533
+
488
534
  .dmt-mark {
489
535
  display: inline-block;
490
536
  width: 5px;
@@ -890,6 +936,162 @@ window.__ModuleLoader__.load({
890
936
 
891
937
  .dmt-toggle[data-on="1"] { background: var(--dsw-alias-brand-primary, #4d7cfe); border-color: transparent; }
892
938
  .dmt-toggle[data-on="1"]::after { transform: translateX(18px); background: #fff; }
939
+
940
+ /* ------------------------------------------------------------------ *
941
+ * thank-you card
942
+ * ------------------------------------------------------------------ */
943
+
944
+ .dmt-thanks-layer {
945
+ position: fixed;
946
+ inset: 0;
947
+ z-index: 200;
948
+ display: flex;
949
+ align-items: center;
950
+ justify-content: center;
951
+ padding: 20px;
952
+ background: rgba(16, 16, 20, 0.4);
953
+ animation: dmt-thanks-fade 200ms ease-out;
954
+ }
955
+
956
+ .dmt-thanks {
957
+ position: relative;
958
+ box-sizing: border-box;
959
+ width: min(384px, 100%);
960
+ padding: 24px 24px 18px;
961
+ border: 1px solid var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.22));
962
+ border-radius: 14px;
963
+ background: var(--dsw-alias-bg-layer-2, #1c1c20);
964
+ color: var(--dsw-alias-label-primary, #f2f2f5);
965
+ box-shadow: 0 20px 52px rgba(0, 0, 0, 0.32);
966
+ font-size: 13px;
967
+ line-height: 1.62;
968
+ animation: dmt-thanks-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
969
+ }
970
+
971
+ .dmt-thanks:focus { outline: none; }
972
+
973
+ .dmt-thanks-close {
974
+ position: absolute;
975
+ top: 8px;
976
+ right: 8px;
977
+ display: flex;
978
+ align-items: center;
979
+ justify-content: center;
980
+ width: 26px;
981
+ height: 26px;
982
+ padding: 0;
983
+ border: none;
984
+ border-radius: 7px;
985
+ background: transparent;
986
+ color: var(--dsw-alias-label-tertiary, rgba(160, 160, 170, 0.9));
987
+ font-size: 17px;
988
+ line-height: 1;
989
+ cursor: pointer;
990
+ }
991
+
992
+ .dmt-thanks-close:hover {
993
+ background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12));
994
+ color: var(--dsw-alias-label-primary, #f2f2f5);
995
+ }
996
+
997
+ .dmt-thanks-title {
998
+ margin: 0 22px 8px 0;
999
+ font-size: 15px;
1000
+ font-weight: 600;
1001
+ letter-spacing: 0.01em;
1002
+ }
1003
+
1004
+ .dmt-thanks-body {
1005
+ margin: 0;
1006
+ color: var(--dsw-alias-label-secondary, rgba(210, 210, 218, 0.92));
1007
+ }
1008
+
1009
+ .dmt-thanks-wish {
1010
+ margin: 10px 0 0;
1011
+ color: var(--dsw-alias-state-success-primary, #30a46c);
1012
+ font-weight: 600;
1013
+ }
1014
+
1015
+ .dmt-thanks-actions {
1016
+ display: flex;
1017
+ flex-wrap: wrap;
1018
+ gap: 8px;
1019
+ margin-top: 18px;
1020
+ }
1021
+
1022
+ .dmt-thanks-cta,
1023
+ .dmt-thanks-ack {
1024
+ appearance: none;
1025
+ box-sizing: border-box;
1026
+ padding: 7px 14px;
1027
+ border-radius: 8px;
1028
+ font: inherit;
1029
+ font-weight: 600;
1030
+ text-decoration: none;
1031
+ cursor: pointer;
1032
+ transition: background 120ms ease, border-color 120ms ease;
1033
+ }
1034
+
1035
+ /* The ask is the one bright thing in the card: amber rather than the shell's blue, so it
1036
+ reads as an invitation instead of another system button. Dark ink keeps it legible. */
1037
+ .dmt-thanks-cta {
1038
+ border: 1px solid #F0B429;
1039
+ background: #FFC93C;
1040
+ color: #2B2000;
1041
+ box-shadow: 0 6px 16px -10px rgba(255, 201, 60, 0.9);
1042
+ }
1043
+
1044
+ .dmt-thanks-cta:hover {
1045
+ background: #FFD75E;
1046
+ border-color: #F7C948;
1047
+ color: #2B2000;
1048
+ }
1049
+
1050
+ .dmt-thanks-ack {
1051
+ border: 1px solid var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.28));
1052
+ background: transparent;
1053
+ color: var(--dsw-alias-label-primary, #f2f2f5);
1054
+ }
1055
+
1056
+ .dmt-thanks-ack:hover { background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12)); }
1057
+
1058
+ .dmt-thanks-cta:focus-visible,
1059
+ .dmt-thanks-ack:focus-visible,
1060
+ .dmt-thanks-close:focus-visible,
1061
+ .dmt-thanks-link:focus-visible {
1062
+ outline: 2px solid var(--dsw-alias-brand-primary, #4d7cfe);
1063
+ outline-offset: 2px;
1064
+ }
1065
+
1066
+ .dmt-thanks-foot {
1067
+ margin: 14px 0 0;
1068
+ padding-top: 10px;
1069
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.16));
1070
+ color: var(--dsw-alias-label-tertiary, rgba(160, 160, 170, 0.9));
1071
+ font-size: 12px;
1072
+ }
1073
+
1074
+ .dmt-thanks-link {
1075
+ color: var(--dsw-alias-brand-primary, #4d7cfe);
1076
+ text-decoration: none;
1077
+ }
1078
+
1079
+ .dmt-thanks-link:hover { text-decoration: underline; }
1080
+
1081
+ @keyframes dmt-thanks-fade {
1082
+ from { opacity: 0; }
1083
+ to { opacity: 1; }
1084
+ }
1085
+
1086
+ @keyframes dmt-thanks-rise {
1087
+ from { opacity: 0; transform: translateY(10px) scale(0.985); }
1088
+ to { opacity: 1; transform: translateY(0) scale(1); }
1089
+ }
1090
+
1091
+ @media (prefers-reduced-motion: reduce) {
1092
+ .dmt-thanks-layer,
1093
+ .dmt-thanks { animation: none; }
1094
+ }
893
1095
  `
894
1096
 
895
1097
  /** Inject the stylesheet once per document. */
@@ -1053,6 +1255,7 @@ window.__ModuleLoader__.load({
1053
1255
  h(
1054
1256
  'div',
1055
1257
  { className: 'dmt-head' },
1258
+ h('span', { className: 'dmt-brand', 'aria-hidden': 'true' }, h(BrandMark, { size: 14 })),
1056
1259
  h('span', {
1057
1260
  className: 'dmt-mark',
1058
1261
  'data-stale': stale ? '1' : '0',
@@ -1698,6 +1901,248 @@ window.__ModuleLoader__.load({
1698
1901
  )
1699
1902
  }
1700
1903
 
1904
+ /**
1905
+ * Brand mark for the card header: the same rising line as the settings glyph, but
1906
+ * filled so it still reads at 14px against the sidebar. Colour comes from CSS, so
1907
+ * the mark can be darkened without editing the shape.
1908
+ */
1909
+ function BrandMark(props) {
1910
+ const size = typeof props?.size === 'number' ? props.size : 14
1911
+ return h(
1912
+ 'svg',
1913
+ { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true' },
1914
+ h('rect', { width: 24, height: 24, rx: 6, fill: 'currentColor' }),
1915
+ h('path', {
1916
+ d: 'M5.4 15.6 9.5 11.5l2.9 2.9 6-6',
1917
+ stroke: '#FFFFFF',
1918
+ strokeWidth: 2.1,
1919
+ strokeLinecap: 'round',
1920
+ strokeLinejoin: 'round',
1921
+ }),
1922
+ h('path', {
1923
+ d: 'M14.7 8.4h3.7v3.7',
1924
+ stroke: '#FFFFFF',
1925
+ strokeWidth: 2.1,
1926
+ strokeLinecap: 'round',
1927
+ strokeLinejoin: 'round',
1928
+ }),
1929
+ )
1930
+ }
1931
+
1932
+ /* ---------------------------------------------------------------------- *
1933
+ * thank-you card
1934
+ * ---------------------------------------------------------------------- */
1935
+
1936
+ /**
1937
+ * True while the operator is typing somewhere, so the card never takes the
1938
+ * caret out of a cost box that is half filled.
1939
+ * @returns whether an editable element has focus.
1940
+ */
1941
+ function isEditing() {
1942
+ const active = document.activeElement
1943
+ if (active === null || active === undefined) return false
1944
+ const tag = String(active.tagName ?? '').toLowerCase()
1945
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
1946
+ return active.isContentEditable === true
1947
+ }
1948
+
1949
+ /**
1950
+ * Ask the host whether the card is due. A host that cannot answer is a host
1951
+ * that shows nothing: this surface must never get in the way of the card.
1952
+ * @returns the decision payload, or undefined when the route did not answer.
1953
+ */
1954
+ async function fetchThanks() {
1955
+ try {
1956
+ const body = await requestJson(NOTICE_ENDPOINT)
1957
+ return typeof body?.show === 'boolean' ? body : undefined
1958
+ } catch {
1959
+ return undefined
1960
+ }
1961
+ }
1962
+
1963
+ /**
1964
+ * Record one answer. `keepalive` matters for the issue link: the click also
1965
+ * opens a new tab, and the request has to survive that navigation.
1966
+ * @param action - `thanks`, `issue` or `later`.
1967
+ */
1968
+ function answerThanks(action) {
1969
+ void fetch(apiUrl(NOTICE_ENDPOINT), {
1970
+ method: 'POST',
1971
+ headers: { 'content-type': 'application/json' },
1972
+ body: JSON.stringify({ action }),
1973
+ keepalive: true,
1974
+ }).catch(() => {})
1975
+ }
1976
+
1977
+ /**
1978
+ * Build the card as plain DOM instead of a React tree: it lives directly on
1979
+ * `document.body`, outlives every panel switch, and must not depend on which
1980
+ * renderer version the shell happens to ship.
1981
+ * @param onClose - called with the action the operator chose.
1982
+ * @returns the layer plus the two hooks `mountThanks` needs.
1983
+ */
1984
+ function buildThanks(onClose) {
1985
+ const titleId = `${THANKS_ID}-title`
1986
+ const bodyId = `${THANKS_ID}-body`
1987
+
1988
+ const layer = document.createElement('div')
1989
+ layer.className = 'dmt-thanks-layer'
1990
+
1991
+ const card = document.createElement('div')
1992
+ card.className = 'dmt-thanks'
1993
+ card.id = THANKS_ID
1994
+ card.tabIndex = -1
1995
+ card.setAttribute('role', 'dialog')
1996
+ card.setAttribute('aria-modal', 'true')
1997
+ card.setAttribute('aria-labelledby', titleId)
1998
+ card.setAttribute('aria-describedby', bodyId)
1999
+
2000
+ const close = document.createElement('button')
2001
+ close.type = 'button'
2002
+ close.className = 'dmt-thanks-close'
2003
+ close.textContent = '×'
2004
+ close.addEventListener('click', () => onClose('later'))
2005
+
2006
+ const title = document.createElement('h2')
2007
+ title.className = 'dmt-thanks-title'
2008
+ title.id = titleId
2009
+
2010
+ const body = document.createElement('p')
2011
+ body.className = 'dmt-thanks-body'
2012
+ body.id = bodyId
2013
+
2014
+ const wish = document.createElement('p')
2015
+ wish.className = 'dmt-thanks-wish'
2016
+
2017
+ const actions = document.createElement('div')
2018
+ actions.className = 'dmt-thanks-actions'
2019
+
2020
+ const cta = document.createElement('a')
2021
+ cta.className = 'dmt-thanks-cta'
2022
+ // The star lives on the repo landing page; no deep link into the widget needed.
2023
+ cta.href = REPO_URL
2024
+ cta.target = '_blank'
2025
+ cta.rel = 'noopener noreferrer'
2026
+ // Starring or saying thanks both mean "this landed" — neither should be asked twice.
2027
+ cta.addEventListener('click', () => onClose('thanks'))
2028
+
2029
+ const ack = document.createElement('button')
2030
+ ack.type = 'button'
2031
+ ack.className = 'dmt-thanks-ack'
2032
+ ack.addEventListener('click', () => onClose('thanks'))
2033
+
2034
+ const foot = document.createElement('p')
2035
+ foot.className = 'dmt-thanks-foot'
2036
+
2037
+ const note = document.createTextNode('')
2038
+ const feedback = document.createElement('a')
2039
+ feedback.className = 'dmt-thanks-link'
2040
+ feedback.href = ISSUES_URL
2041
+ feedback.target = '_blank'
2042
+ feedback.rel = 'noopener noreferrer'
2043
+
2044
+ actions.appendChild(cta)
2045
+ actions.appendChild(ack)
2046
+ foot.appendChild(note)
2047
+ foot.appendChild(document.createTextNode(' '))
2048
+ foot.appendChild(feedback)
2049
+ card.appendChild(close)
2050
+ card.appendChild(title)
2051
+ card.appendChild(body)
2052
+ card.appendChild(wish)
2053
+ card.appendChild(actions)
2054
+ card.appendChild(foot)
2055
+ layer.appendChild(card)
2056
+
2057
+ // Clicking the dimmed area, and only it, counts as "later".
2058
+ layer.addEventListener('click', (event) => { if (event.target === layer) onClose('later') })
2059
+ layer.addEventListener('keydown', (event) => {
2060
+ if (event.key !== 'Escape') return
2061
+ event.preventDefault()
2062
+ onClose('later')
2063
+ })
2064
+
2065
+ /** Repaint every label, so a language switch reaches the open card too. */
2066
+ const copy = () => {
2067
+ close.setAttribute('aria-label', t('thanks.close'))
2068
+ title.textContent = t('thanks.title')
2069
+ body.textContent = t('thanks.body')
2070
+ wish.textContent = t('thanks.wish')
2071
+ cta.textContent = t('thanks.star')
2072
+ ack.textContent = t('thanks.ack')
2073
+ note.textContent = t('thanks.note')
2074
+ feedback.textContent = t('thanks.repo')
2075
+ }
2076
+ copy()
2077
+
2078
+ return { layer, card, copy }
2079
+ }
2080
+
2081
+ /**
2082
+ * Show the thank-you card once, when the host says it is due.
2083
+ *
2084
+ * Everything that decides whether it appears lives on the host: the show
2085
+ * budget, the cooldown and the final answer all survive a restart, which a
2086
+ * browser flag could not, because the desktop shell binds a new port each
2087
+ * launch and takes `localStorage` with it.
2088
+ */
2089
+ function mountThanks() {
2090
+ const host = typeof document === 'undefined' ? undefined : document.body
2091
+ // Test harnesses load this module without a DOM; there is nothing to
2092
+ // attach a floating card to, and nothing to prove by trying.
2093
+ if (host === undefined || host === null || typeof host.appendChild !== 'function') return
2094
+ if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return
2095
+
2096
+ /** The layer on screen, or null. */
2097
+ let layer = null
2098
+ /** Element that held focus before the card took it. */
2099
+ let previous = null
2100
+ let unsubscribe = null
2101
+ let tries = 0
2102
+
2103
+ const dismiss = (action) => {
2104
+ if (layer === null) return
2105
+ const node = layer
2106
+ layer = null
2107
+ node.remove()
2108
+ if (unsubscribe !== null) {
2109
+ unsubscribe()
2110
+ unsubscribe = null
2111
+ }
2112
+ answerThanks(action)
2113
+ if (previous !== null && typeof previous.focus === 'function') previous.focus()
2114
+ previous = null
2115
+ }
2116
+
2117
+ const reveal = () => {
2118
+ const built = buildThanks(dismiss)
2119
+ layer = built.layer
2120
+ previous = document.activeElement
2121
+ document.body.appendChild(layer)
2122
+ built.card.focus()
2123
+ if (localeService !== null && typeof localeService.subscribe === 'function') {
2124
+ unsubscribe = localeService.subscribe(built.copy)
2125
+ }
2126
+ }
2127
+
2128
+ const attempt = async () => {
2129
+ if (layer !== null || document.hidden === true) return
2130
+ if (isEditing()) {
2131
+ // Give the operator room to finish the sentence; past the budget the
2132
+ // card is simply skipped for this page load.
2133
+ if (tries >= THANKS_IDLE_TRIES) return
2134
+ tries += 1
2135
+ window.setTimeout(() => { void attempt() }, THANKS_IDLE_MS)
2136
+ return
2137
+ }
2138
+ const decision = await fetchThanks()
2139
+ if (decision?.show !== true || layer !== null || document.hidden === true) return
2140
+ reveal()
2141
+ }
2142
+
2143
+ window.setTimeout(() => { void attempt() }, THANKS_DELAY_MS)
2144
+ }
2145
+
1701
2146
  /**
1702
2147
  * Register the dictionaries, then both surfaces. `slots` and `locale` are the two
1703
2148
  * services these components need, so they are the only ones injected — injecting
@@ -1733,6 +2178,14 @@ window.__ModuleLoader__.load({
1733
2178
  { name: SETTINGS_ICON_SLOT, id: SETTINGS_ID },
1734
2179
  MarketIcon,
1735
2180
  ))
2181
+
2182
+ try {
2183
+ mountThanks()
2184
+ } catch (error) {
2185
+ // A greeting is not worth a broken sidebar: the card may be absent, the
2186
+ // quote card and the editor must still mount.
2187
+ console.warn('[market-watching] thank-you card skipped', error)
2188
+ }
1736
2189
  }
1737
2190
 
1738
2191
  const inject = ['slots', 'locale']
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="dsh-market-watching">
2
+ <defs>
3
+ <linearGradient id="dmt-icon-fill" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#0E9F6E"/>
5
+ <stop offset="1" stop-color="#34D399"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="24" height="24" rx="6" fill="url(#dmt-icon-fill)"/>
9
+ <path d="M5.4 15.6 9.5 11.5l2.9 2.9 6-6" fill="none" stroke="#FFFFFF" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
10
+ <path d="M14.7 8.4h3.7v3.7" fill="none" stroke="#FFFFFF" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
11
+ </svg>
package/index.js CHANGED
@@ -37,6 +37,9 @@ export const SETTINGS_PATH = '/api/market.settings'
37
37
  /** Route that validates one operator-typed code before it joins the watchlist. */
38
38
  export const RESOLVE_PATH = '/api/market.resolve'
39
39
 
40
+ /** Route that decides whether the post-install thank-you card is due. */
41
+ export const NOTICE_PATH = '/api/market.notice'
42
+
40
43
  /** Connection owns the `/api` channel; its exact Fetch registry carries these routes. */
41
44
  export const inject = ['connection']
42
45
 
@@ -54,6 +57,39 @@ const LEGACY_SETTINGS_FILENAME = 'dsh-market-ticker.json'
54
57
  /** Bumped only when the stored shape changes incompatibly. */
55
58
  const SETTINGS_VERSION = 1
56
59
 
60
+ /**
61
+ * Thank-you card bookkeeping.
62
+ *
63
+ * This lives beside the settings document rather than inside it: settings are
64
+ * normalized on every read and write, so an unknown field would be dropped the
65
+ * moment the operator saved the watchlist.
66
+ */
67
+ const NOTICE_FILENAME = 'dsh-market-watching.notice.json'
68
+
69
+ /** Bumped only when the stored notice shape changes incompatibly. */
70
+ const NOTICE_VERSION = 1
71
+
72
+ /** How many times one installation may ever see the card. */
73
+ const NOTICE_MAX_SHOWS = 3
74
+
75
+ /** Quiet window after a "later", so the card never becomes a nag. */
76
+ const NOTICE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000
77
+
78
+ /**
79
+ * The browser asks once per page load; a reload storm must not burn through the
80
+ * show budget, so two answers inside this window are served from memory.
81
+ */
82
+ const NOTICE_THROTTLE_MS = 30 * 1000
83
+
84
+ /** An answer body is a single short action, never a payload. */
85
+ const NOTICE_MAX_BODY = 2048
86
+
87
+ /** Answers that mean "do not ask again", as opposed to "not now". */
88
+ const NOTICE_FINAL = new Set(['thanks', 'issue'])
89
+
90
+ /** Every answer the card can send back. */
91
+ const NOTICE_ACTIONS = new Set(['thanks', 'issue', 'later'])
92
+
57
93
  /**
58
94
  * Upper bound on the watchlist. The sidebar card is the constraint, not the
59
95
  * upstreams: sixteen rows already overflow a short window.
@@ -531,6 +567,78 @@ function writeSettings(path, settings, log) {
531
567
  }
532
568
  }
533
569
 
570
+ /* -------------------------------------------------------------------------- *
571
+ * thank-you card
572
+ * -------------------------------------------------------------------------- */
573
+
574
+ /**
575
+ * Shape written before anything has been shown, so the first decision is a
576
+ * plain `shows === 0` read instead of a special case.
577
+ * @returns a fresh notice document.
578
+ */
579
+ function emptyNotice() {
580
+ return { version: NOTICE_VERSION, shows: 0, lastAt: 0, closed: null }
581
+ }
582
+
583
+ /**
584
+ * Read the notice document, tolerating anything on disk.
585
+ *
586
+ * The card is decoration: a corrupt or hand-edited file must degrade to "ask
587
+ * once more", never to a crash on startup.
588
+ * @param path - absolute path of the document.
589
+ * @param log - warning sink for an unreadable file.
590
+ * @returns always a complete notice document.
591
+ */
592
+ function readNotice(path, log) {
593
+ try {
594
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
595
+ const shows = Number.isInteger(parsed?.shows) && parsed.shows >= 0 ? Math.min(parsed.shows, NOTICE_MAX_SHOWS) : 0
596
+ const lastAt = Number.isFinite(parsed?.lastAt) && parsed.lastAt >= 0 ? parsed.lastAt : 0
597
+ const closed = NOTICE_FINAL.has(parsed?.closed) ? parsed.closed : null
598
+ return { version: NOTICE_VERSION, shows, lastAt, closed }
599
+ } catch (error) {
600
+ if (error.code !== 'ENOENT') log.warn(`${LOG_PREFIX} ignoring ${path}: ${String(error.message ?? error)}`)
601
+ return emptyNotice()
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Persist the notice document atomically, matching the settings writer.
607
+ * @param path - absolute path of the document.
608
+ * @param notice - already-normalized notice document.
609
+ * @param log - warning sink for a failed write.
610
+ * @returns true when the document reached disk.
611
+ */
612
+ function writeNotice(path, notice, log) {
613
+ try {
614
+ mkdirSync(dirname(path), { recursive: true })
615
+ const staged = `${path}.tmp`
616
+ writeFileSync(staged, `${JSON.stringify(notice, null, 2)}\n`)
617
+ renameSync(staged, path)
618
+ return true
619
+ } catch (error) {
620
+ log.warn(`${LOG_PREFIX} cannot write ${path}: ${String(error.message ?? error)}`)
621
+ return false
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Decide whether the card is due, and say why.
627
+ *
628
+ * The order matters: a closed card stays closed whatever the counters say, and
629
+ * a spent show budget outranks the cooldown so the reason reported to the
630
+ * browser is the one that will not change on the next reload.
631
+ * @param notice - stored notice document.
632
+ * @param now - clock, injected for tests.
633
+ * @returns `{ show, reason }` with a stable reason string.
634
+ */
635
+ export function noticeDecision(notice, now = Date.now()) {
636
+ if (notice?.closed === 'thanks' || notice?.closed === 'issue') return { show: false, reason: 'answered' }
637
+ if ((notice?.shows ?? 0) >= NOTICE_MAX_SHOWS) return { show: false, reason: 'capped' }
638
+ if (notice?.lastAt > 0 && now - notice.lastAt < NOTICE_COOLDOWN_MS) return { show: false, reason: 'cooling' }
639
+ return { show: true, reason: (notice?.shows ?? 0) === 0 ? 'first' : 'again' }
640
+ }
641
+
534
642
  /* -------------------------------------------------------------------------- *
535
643
  * upstream
536
644
  * -------------------------------------------------------------------------- */
@@ -742,6 +850,11 @@ export function apply(ctx, config = {}) {
742
850
  const cacheMs = Number.isFinite(config.cacheMs) && config.cacheMs >= 0 ? config.cacheMs : DEFAULTS.cacheMs
743
851
  const settingsPath = join(harnessHome(), SETTINGS_FILENAME)
744
852
  const legacySettingsPath = join(harnessHome(), LEGACY_SETTINGS_FILENAME)
853
+ const noticePath = join(harnessHome(), NOTICE_FILENAME)
854
+
855
+ let notice = readNotice(noticePath, log)
856
+ /** Last decision served, so reloads inside the throttle window cannot spend two shows. */
857
+ let noticeAnswer
745
858
 
746
859
  let settings = readSettings(settingsPath, log)
747
860
  if (!existsSync(settingsPath) && existsSync(legacySettingsPath)) {
@@ -981,4 +1094,69 @@ export function apply(ctx, config = {}) {
981
1094
  return jsonResponse({ ok: false, code: 'noQuote', message: '上游没有这个标的的报价' }, 404)
982
1095
  },
983
1096
  })
1097
+
1098
+ /**
1099
+ * The thank-you card. GET answers whether it is due and counts the showing;
1100
+ * POST records what the operator did with it.
1101
+ *
1102
+ * The host owns both halves on purpose: `localStorage` is origin-scoped, and
1103
+ * the desktop shell binds a fresh loopback port on every launch, so a browser
1104
+ * flag would bring the card back on each restart.
1105
+ */
1106
+ ctx.connection.fetch.register({
1107
+ path: NOTICE_PATH,
1108
+ methods: ['GET', 'POST'],
1109
+ requestBody: 'buffered',
1110
+ fetch: async (request) => {
1111
+ if (request.method !== 'POST') {
1112
+ const now = Date.now()
1113
+ if (noticeAnswer !== undefined && now - noticeAnswer.at < NOTICE_THROTTLE_MS) {
1114
+ return jsonResponse(noticeAnswer.body)
1115
+ }
1116
+ const decision = noticeDecision(notice, now)
1117
+ let body
1118
+ if (decision.show) {
1119
+ notice = { ...notice, shows: notice.shows + 1, lastAt: now }
1120
+ writeNotice(noticePath, notice, log)
1121
+ body = { ok: true, show: true, reason: decision.reason, shows: notice.shows }
1122
+ } else {
1123
+ body = { ok: true, show: false, reason: decision.reason, shows: notice.shows }
1124
+ }
1125
+ noticeAnswer = { at: now, body }
1126
+ return jsonResponse(body)
1127
+ }
1128
+
1129
+ // The body is one short action, so it is read as text and bounded before parsing.
1130
+ let text
1131
+ try {
1132
+ text = await request.text()
1133
+ } catch {
1134
+ return jsonResponse({ ok: false, code: 'badJson', message: '请求体不是合法的 JSON' }, 400)
1135
+ }
1136
+ if (text.length > NOTICE_MAX_BODY) {
1137
+ return jsonResponse({ ok: false, code: 'tooLarge', message: '请求体过大' }, 413)
1138
+ }
1139
+ let body
1140
+ try {
1141
+ body = JSON.parse(text)
1142
+ } catch {
1143
+ return jsonResponse({ ok: false, code: 'badJson', message: '请求体不是合法的 JSON' }, 400)
1144
+ }
1145
+ const action = typeof body?.action === 'string' ? body.action : ''
1146
+ if (!NOTICE_ACTIONS.has(action)) {
1147
+ return jsonResponse({ ok: false, code: 'badAction', message: 'action 必须是 thanks / issue / later' }, 400)
1148
+ }
1149
+
1150
+ // "thanks" and "issue" close the card for good; "later" only restarts the cooldown.
1151
+ notice = action === 'later'
1152
+ ? { ...notice, lastAt: Date.now() }
1153
+ : { ...notice, closed: action }
1154
+ if (!writeNotice(noticePath, notice, log)) {
1155
+ return jsonResponse({ ok: false, code: 'writeFailed', message: '状态写入失败,请检查 DSH_HOME 权限' }, 500)
1156
+ }
1157
+ // The cached answer would still say `show: true`, so it is dropped here.
1158
+ noticeAnswer = undefined
1159
+ return jsonResponse({ ok: true, closed: notice.closed })
1160
+ },
1161
+ })
984
1162
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-market-watching",
3
- "version": "0.1.0",
4
- "description": "DSH 侧栏行情卡片:加密货币、A股/港股/美股、外汇的实时价格与涨幅,可设置买入/做空价格与数量,实时显示持仓盈亏金额;标的在 DSH 设置页里自选,无需登录。",
3
+ "version": "0.2.2",
4
+ "description": "DSH 侧栏行情卡片:加密货币、A股/港股/美股、黄金、外汇、指数的实时价格与涨幅,可设置买入/做空价格与数量,实时显示持仓盈亏金额;标的在 DSH 设置页里自选,无需登录。",
5
+ "icon": "./icon.svg",
5
6
  "keywords": [
6
7
  "dsh",
7
8
  "dsh-plugin",
@@ -34,6 +35,7 @@
34
35
  "index.js",
35
36
  "http.mjs",
36
37
  "client.js",
38
+ "icon.svg",
37
39
  "cordis.patch.yml",
38
40
  "README.md",
39
41
  "README.en.md",