@dsh-ft/dsh-fft-settings 0.5.0 → 0.6.0

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.
Files changed (34) hide show
  1. package/README.md +5 -2
  2. package/lib/client.d.ts +8 -1
  3. package/lib/client.js +342 -33
  4. package/lib/client.js.map +1 -1
  5. package/lib/features/api-key/card.js +2 -2
  6. package/lib/features/api-key/contract.d.ts +13 -0
  7. package/lib/features/api-key/contract.js +13 -0
  8. package/lib/features/api-key/probe.d.ts +7 -1
  9. package/lib/features/api-key/probe.js +14 -8
  10. package/lib/features/free-key-notice/card.d.ts +53 -0
  11. package/lib/features/free-key-notice/card.js +137 -0
  12. package/lib/features/free-key-notice/decision.d.ts +40 -0
  13. package/lib/features/free-key-notice/decision.js +37 -0
  14. package/lib/features/free-key-notice/index.d.ts +7 -0
  15. package/lib/features/free-key-notice/index.js +10 -0
  16. package/lib/features/free-key-notice/storage.d.ts +34 -0
  17. package/lib/features/free-key-notice/storage.js +58 -0
  18. package/lib/features/free-key-notice/styles.d.ts +11 -0
  19. package/lib/features/free-key-notice/styles.js +27 -0
  20. package/lib/guidance.d.ts +6 -1
  21. package/lib/guidance.js +6 -1
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +62 -38
  25. package/src/client.tsx +10 -1
  26. package/src/features/api-key/card.tsx +2 -2
  27. package/src/features/api-key/contract.ts +13 -0
  28. package/src/features/api-key/probe.ts +17 -8
  29. package/src/features/free-key-notice/card.tsx +172 -0
  30. package/src/features/free-key-notice/decision.ts +47 -0
  31. package/src/features/free-key-notice/index.ts +30 -0
  32. package/src/features/free-key-notice/storage.ts +62 -0
  33. package/src/features/free-key-notice/styles.ts +27 -0
  34. package/src/guidance.ts +6 -1
package/README.md CHANGED
@@ -39,7 +39,7 @@ const apiKey = ctx.ftshareSettings.apiKey
39
39
  | `set(value)` | `Promise<void>`:只保存,不联网测试;去首尾空白,拒绝空值、超过 512 字符和控制字符 |
40
40
  | `clear()` | `Promise<void>`:只清除 DSH 保存值;可能露出 `.env` 回退值 |
41
41
  | `configureAndTest(value)` | `Promise<ConfigureResult>`:先保存,再检查远端服务和 Key;保存和验证分别返回,测试失败不回滚 |
42
- | `testConnection()` | `Promise<ValidationResult>`:先匿名检查远端,再验证调用时生效的 Key,不修改配置;无 Key 仍检查远端 |
42
+ | `testConnection()` | `Promise<ValidationResult>`:先检查远端(配置了 Key 时携带),再验证调用时生效的 Key,不修改配置;无 Key 仍检查远端 |
43
43
  | `getValidationStatus()` | `Promise<ValidationResult>`:读取最近验证结果,不联网 |
44
44
  | `subscribe(listener)` | 返回取消订阅函数;失效通知,无参数、无密钥;回调重新读取状态 |
45
45
  | `createEnvironment(base?)` | 返回新环境变量对象,用当前 Key 替换 `FTSHARE_API_KEY`;无 Key 删除该字段,不修改 base |
@@ -73,9 +73,11 @@ const result = await apiKey.configureAndTest(userInput)
73
73
 
74
74
  | 结果字段 | 检测目的 | 固定请求 | 是否携带 Key |
75
75
  | --- | --- | --- | --- |
76
- | `validation.remote` | 远端服务是否正常 | `GET https://market.ft.tech/gateway/api/v1/market/data/stock-candlesticks` | 不携带 |
76
+ | `validation.remote` | 远端服务是否正常 | `GET https://market.ft.tech/gateway/api/v1/market/data/stock-candlesticks` | 配置了 Key 时携带;未配置时匿名 |
77
77
  | `validation.apiKey` | 当前 Key 是否通过认证 | `GET https://market.ft.tech/gateway/api/v2/market/data/stock_minutes` | 使用 `FTSHARE_API_KEY` 请求头 |
78
78
 
79
+ 远端检查也携带 Key:网关的匿名额度按出口 IP 计(500 次/24 小时),用尽后未携带 Key 的请求一律 429。若远端检查仍走匿名,用户在已经配好可用 Key 的情况下会看到「请求受限、Key 未验证」,把 Key 问题和服务额度问题混在一起。携带 Key 后远端检查照旧只看 HTTP/业务码与 `data` 数组,认证与权限失败按下面规则归到 Key 上。
80
+
79
81
  日 K 请求使用 `600519.SH`、day、不复权、最近 30 天、`limit=2`;历史分钟请求使用同一股票、1 分钟、不复权、最近 24 小时、`limit=1`。服务端实际限制为包含首尾的三个北京时间自然日,而非滚动 72 小时;24 小时最多覆盖两个自然日。`adjust_kind` 使用实测接受的小写 `none`;文档中的 `None` 会被实际接口以 HTTP 400 拒绝。参数固定,不查交易日历、不回溯补数据、不验证 OHLC 字段。
80
82
 
81
83
  **成功条件是 HTTP 成功、业务 `code=200` 且 `data` 为数组;数组为空同样通过。** 本插件检查服务和认证结果,不检查行情数据完整性。HTTP 200 中的业务失败、错误响应结构不能当作成功。响应中的行情不公开返回、不持久化。
@@ -83,6 +85,7 @@ const result = await apiKey.configureAndTest(userInput)
83
85
  - 远端正常、Key 通过:`status=passed`;两项均为 `available`。
84
86
  - 远端正常、Key 无效/未配置/受限:`status=partial`;`remote` 仍为 `available`,检查 `apiKey.status` 获取原因。不能把 `partial` 当作 Key 验证通过。
85
87
  - 远端检查失败:`status=failed`;有 Key 时跳过验证,`apiKey.status=not_run`,不把服务故障归咎于 Key。
88
+ - 远端检查携带 Key 时,其 401/403 按认证、权限、额度原因分类;400/422 仍提示参数被拒,不代表 Key 无效。未配置 Key 的匿名远端检查遇到 401/403 记 `upstream_error`(服务拒绝匿名访问),不据此指控 Key。
86
89
  - 未配置 Key:仍执行匿名远端检查,Key 项显示 `auth_required` / “未配置 API Key”,不发送分钟请求。
87
90
  - 明确的 401 认证错误显示“API Key 未通过认证”;权限、额度、限流等保留独立原因,不能一律判为 Key 无效,也不能据此宣称认证成功。
88
91
 
package/lib/client.d.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import { type ApiKeyCardContext } from './features/api-key/card.js';
2
2
  export * from './core/shell.js';
3
+ export { FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT, isViewIdle, shouldShowNotice, type FreeKeyNoticeSessionState } from './features/free-key-notice/decision.js';
4
+ export { readNoticeSeen, writeNoticeSeen, type NoticeFlagStorage } from './features/free-key-notice/storage.js';
3
5
  /** Browser context this plugin's own client half requires. */
4
6
  export type FtshareSettingsClientContext = ApiKeyCardContext;
5
7
  export declare const inject: string[];
6
8
  export declare function apply(ctx: FtshareSettingsClientContext): void;
7
- /** Contribute the section shell, then the built-in cards through the slot it declares. */
9
+ /**
10
+ * Contribute the settings section, its cards, and the frame-wide notice.
11
+ *
12
+ * The notice deliberately does not live in the settings page: it has to reach
13
+ * users who never open settings, so it takes a seat in `shell.overlay`.
14
+ */
8
15
  export declare function registerFtshareSettings(ctx: FtshareSettingsClientContext): void;
package/lib/client.js CHANGED
@@ -6,6 +6,19 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
+ /**
10
+ * 申请/购买 API Key 的页面。`utm_source=dsh-fft` 标记来源为本插件,站点据此归因;
11
+ * 卡片与一次性提示共用这一条地址,不要在别处另写一份。
12
+ */
13
+ const FTSHARE_API_KEY_APPLY_URL = "https://ftai.chat/me/profile?tab=api-key&utm_source=dsh-fft";
14
+ /**
15
+ * 设备级「已看过免费 Key 提示」标记的 localStorage 键。
16
+ *
17
+ * 带版本号:改一次文案就把版本加一,让所有用户重新看一次,不需要让用户清缓存。
18
+ * 设备级语义(重启、升级都保留,跨 workspace/窗口共享)由桌面版主窗口的
19
+ * `dsh-app://app/` origin 私有存储提供,不写宿主的配置文件。
20
+ */
21
+ const FREE_KEY_NOTICE_STORAGE_KEY = "dsh-fft.free-key-notice.v1";
9
22
  const FTSHARE_CREDENTIAL_EVENTS = ["credentials/reference-updated"];
10
23
  /** Local syntax validation, never an assertion of upstream validity. */
11
24
  function normalizeApiKey(input) {
@@ -14,8 +27,7 @@ window.__ModuleLoader__.load({
14
27
  if (value.length > 512 || /[\x00-\x1f\x7f]/.test(value)) throw new Error("API Key 长度不能超过 512 个字符,且不能包含控制字符");
15
28
  return value;
16
29
  }
17
- //#endregion
18
- //#region src/core/contract.ts
30
+ // src/core/contract.ts
19
31
  /**
20
32
  * Identity of the settings section that carries this plugin's cards.
21
33
  *
@@ -44,8 +56,7 @@ window.__ModuleLoader__.load({
44
56
  * the call itself.
45
57
  */
46
58
  const FTSHARE_SETTINGS_ROUTE = "/api/ftshare-settings";
47
- //#endregion
48
- //#region src/features/api-key/model.ts
59
+ // src/features/api-key/model.ts
49
60
  var FtshareSettingsModel = class {
50
61
  api;
51
62
  state = {
@@ -179,8 +190,7 @@ window.__ModuleLoader__.load({
179
190
  not_tested: "尚未测试"
180
191
  }[status] ?? "请查看分项结果";
181
192
  }
182
- //#endregion
183
- //#region src/core/rpc-core.ts
193
+ // src/core/rpc-core.ts
184
194
  /**
185
195
  * 把一次失败的信封翻译成调用方能展示的原因。
186
196
  *
@@ -200,8 +210,7 @@ window.__ModuleLoader__.load({
200
210
  if (!result.ok) throw new Error(describeSettingsFailure(endpoint, result.error));
201
211
  return result.value;
202
212
  }
203
- //#endregion
204
- //#region src/features/api-key/client-api.ts
213
+ // src/features/api-key/client-api.ts
205
214
  /** Browser adapter deliberately has no resolve/createEnvironment method. */
206
215
  function createApiKeyClient(transport) {
207
216
  async function call(method, payload = {}) {
@@ -216,8 +225,7 @@ window.__ModuleLoader__.load({
216
225
  getValidationStatus: () => call("getValidationStatus")
217
226
  };
218
227
  }
219
- //#endregion
220
- //#region src/core/client-transport.ts
228
+ // src/core/client-transport.ts
221
229
  /**
222
230
  * Browser half of the settings transport.
223
231
  *
@@ -245,8 +253,7 @@ window.__ModuleLoader__.load({
245
253
  return await response.json();
246
254
  } };
247
255
  }
248
- //#endregion
249
- //#region src/features/api-key/styles.ts
256
+ // src/features/api-key/styles.ts
250
257
  /**
251
258
  * The API Key card's own styles, rendered by the card component's `<style>`.
252
259
  *
@@ -271,8 +278,7 @@ window.__ModuleLoader__.load({
271
278
  .ftshare-settings .ftshare-error { color:var(--ftshare-error); }
272
279
  @media(max-width:540px) { .ftshare-settings .ftshare-card { padding:16px; } .ftshare-settings .ftshare-result { grid-template-columns:1fr; gap:4px; } }
273
280
  `;
274
- //#endregion
275
- //#region src/features/api-key/card.tsx
281
+ // src/features/api-key/card.tsx
276
282
  /**
277
283
  * Contribute the built-in API Key card through the same slot a third-party
278
284
  * card uses. Nothing about it is privileged: same declaration, same options.
@@ -370,7 +376,7 @@ window.__ModuleLoader__.load({
370
376
  children: "重新读取状态"
371
377
  }),
372
378
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
373
- href: "https://ftai.chat/me/profile?tab=api-key",
379
+ href: FTSHARE_API_KEY_APPLY_URL,
374
380
  target: "_blank",
375
381
  rel: "noreferrer",
376
382
  children: "获取 API Key ↗"
@@ -422,8 +428,307 @@ window.__ModuleLoader__.load({
422
428
  ]
423
429
  })] });
424
430
  }
425
- //#endregion
426
- //#region src/features/logs/contract.ts
431
+ // src/features/free-key-notice/storage.ts
432
+ /**
433
+ * 取浏览器存储;拿不到(没有 window、被禁用)时返回 undefined。
434
+ * @returns 可用存储,或 undefined。
435
+ */
436
+ function browserNoticeStorage() {
437
+ try {
438
+ const storage = globalThis.localStorage;
439
+ return storage === void 0 ? void 0 : {
440
+ getItem: (key) => storage.getItem(key),
441
+ setItem: (key, value) => {
442
+ storage.setItem(key, value);
443
+ }
444
+ };
445
+ } catch (_blocked) {
446
+ return;
447
+ }
448
+ }
449
+ /**
450
+ * 读标记。任何异常与空值都算「没看过」。
451
+ * @param storage - 目标存储;缺失视为没看过。
452
+ * @param key - 标记键。
453
+ * @returns 是否已看过。
454
+ */
455
+ function readNoticeSeen(storage, key) {
456
+ if (storage === void 0) return false;
457
+ try {
458
+ return storage.getItem(key) === "1";
459
+ } catch (_blocked) {
460
+ return false;
461
+ }
462
+ }
463
+ /**
464
+ * 写标记。写失败不抛:提示已经显示过了,这次没记下来只是下次会再弹一次。
465
+ * @param storage - 目标存储;缺失时什么都不做。
466
+ * @param key - 标记键。
467
+ * @returns 是否写入成功。
468
+ */
469
+ function writeNoticeSeen(storage, key) {
470
+ if (storage === void 0) return false;
471
+ try {
472
+ storage.setItem(key, "1");
473
+ return true;
474
+ } catch (_blocked) {
475
+ return false;
476
+ }
477
+ }
478
+ // src/features/free-key-notice/decision.ts
479
+ /**
480
+ * 「这条一次性提示该不该弹」的判定。
481
+ *
482
+ * 与渲染分开:判定是纯函数、零依赖,能在 Node 测试里直接钉住;渲染层只管把
483
+ * 模态框摆出去。三条输入里任何一条没准备好都按「不弹」处理——这是刻意的,
484
+ * 一次没弹只是少一次提醒,弹错一次就是打扰。
485
+ */
486
+ /** 宿主 `shell.overlay` 槽名;这个 feature 只往这一个座位贡献。 */
487
+ const FREE_KEY_NOTICE_SLOT = "shell.overlay";
488
+ /** 条目 id:必须自己的,占用宿主的会顶掉别人的浮层。 */
489
+ const FREE_KEY_NOTICE_ID = "ftshare.free-key-notice";
490
+ /**
491
+ * 主视图是否停在一个空会话上(没有内容会被弹窗挡住)。
492
+ * @param state - 会话列表快照。
493
+ * @returns 是否可以安全地弹出模态框。
494
+ */
495
+ function isViewIdle(state) {
496
+ if (state.phase !== "ready") return false;
497
+ const main = Object.values(state.byId).find((session) => (session.retainedBy?.mainView ?? 0) > 0);
498
+ return main === void 0 || main.blank;
499
+ }
500
+ /**
501
+ * 是否应当显示提示。
502
+ * - 已看过:不弹(设备级一次);
503
+ * - Key 已配置、或状态还读不到:不弹(读不到时宁可少弹);
504
+ * - 主视图被有内容的会话占着:不弹,等它空下来。
505
+ * @param facts - 三个输入。
506
+ * @returns 是否显示。
507
+ */
508
+ function shouldShowNotice(facts) {
509
+ if (facts.seen) return false;
510
+ if (facts.configured !== false) return false;
511
+ return facts.viewIdle;
512
+ }
513
+ // src/features/free-key-notice/styles.ts
514
+ /**
515
+ * 一次性提示的样式:固定在左下角的非阻塞卡片。
516
+ *
517
+ * 不盖遮罩、不抢焦点——这一层(宿主的 `shell.overlay`)本身可穿透点击,
518
+ * 条目只有在这张卡片范围内才接收指针事件,所以卡片以外的界面照常可用。
519
+ * 位置用 `fixed` 而不是依赖外层布局:浮层是 `inset:0` 的绝对定位层,
520
+ * 条目自己决定停在哪里。
521
+ *
522
+ * 颜色全走 `--dsw-alias-*` 与 `--ft-*` 变量,深色/浅色两套调色板共用一份。
523
+ */
524
+ const FREE_KEY_NOTICE_CSS = `
525
+ .ftshare-free-key-notice { position:fixed; left:24px; bottom:24px; z-index:20; box-sizing:border-box; width:min(320px, calc(100vw - 48px)); padding:16px; border:0.5px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1)); border-radius:var(--dsw-radius-xl,20px); background:var(--dsw-alias-bg-layer-2,#fff); color:var(--dsw-alias-label-primary,#0f1115); box-shadow:var(--dsw-elevation-prominent,0 3px 8px rgba(0,0,0,.04), 0 0 20px rgba(0,0,0,.05)); animation:ftshareNoticeIn .2s ease; }
526
+ .ftshare-free-key-notice * { box-sizing:border-box; }
527
+ .ftshare-free-key-notice .ftshare-free-key-head { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
528
+ .ftshare-free-key-notice strong { font-size:14px; font-weight:500; line-height:22px; }
529
+ .ftshare-free-key-notice .ftshare-free-key-close { flex:none; width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center; border:none; border-radius:var(--dsw-radius-sm,8px); background:transparent; color:var(--dsw-alias-label-secondary,#61666b); font:inherit; font-size:12px; line-height:1; cursor:pointer; }
530
+ .ftshare-free-key-notice .ftshare-free-key-close:hover { background:var(--dsw-alias-bg-layer-3,#f9fafb); }
531
+ .ftshare-free-key-notice p { margin:6px 0 14px; font-size:13px; line-height:1.6; color:var(--dsw-alias-label-secondary,#61666b); }
532
+ .ftshare-free-key-notice .ftshare-free-key-actions { display:flex; gap:8px; }
533
+ .ftshare-free-key-notice .ftshare-free-key-actions :is(button,a) { min-height:30px; display:inline-flex; align-items:center; justify-content:center; border-radius:var(--dsw-radius-md,12px); border:0.5px solid var(--dsw-alias-border-l4,rgba(0,0,0,.16)); padding:0 12px; font:inherit; font-size:13px; background:transparent; color:inherit; text-decoration:none; cursor:pointer; }
534
+ /* 主入口用品牌黄的半透明底:保留 FT 的色,又不至于在左下角变成一块醒目的亮黄。
535
+ 文字仍取正文主色(不是压在上面的深黑字),70% 在深浅两套调色板下都能读。 */
536
+ .ftshare-free-key-notice .ftshare-free-key-actions .primary { flex:1; background:color-mix(in srgb,var(--ft-accent,#f0c433) 70%,transparent); border-color:var(--ft-accent,#f0c433); color:var(--dsw-alias-label-primary,#0f1115); font-weight:500; }
537
+ .ftshare-free-key-notice .ftshare-free-key-actions .primary:hover { background:color-mix(in srgb,var(--ft-accent,#f0c433) 82%,transparent); }
538
+ @keyframes ftshareNoticeIn { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
539
+ @media (prefers-reduced-motion: reduce) { .ftshare-free-key-notice { animation:none; } }
540
+ `;
541
+ // src/features/free-key-notice/card.tsx
542
+ /**
543
+ * 一次性提示:还没配置 API Key 的用户,去获取免费的 FTShare Key。
544
+ *
545
+ * 三个设计决定:
546
+ *
547
+ * 1. **左下角、不阻塞**。它是提示,不是必须处理的阻断:不盖遮罩、不抢焦点、
548
+ * 不打断正在发生的事,12 秒后自己淡出(鼠标悬停时暂停计时)。用 `Modal`
549
+ * 会遮住整个界面,代价太大。
550
+ * 2. **只在确认「没配 Key」时出现**。凭据状态异步读来,没读到时一律不出现,
551
+ * 否则配了 Key 的用户每次启动都会被闪一下。
552
+ * 3. **设备级只出现一次**。首次出现就写 `localStorage` 标记(见 `./storage.ts`),
553
+ * 不等用户点按钮——否则自动消失后标记不落盘,下次启动又会打扰。
554
+ *
555
+ * 挂载在宿主框架级的 `shell.overlay` 槽上(`ui-layout` 的 AppFrame 常驻渲染),
556
+ * 不在设置页里:要触达的恰恰是从不打开设置的用户。该图层本身可穿透点击,
557
+ * 条目只在自己的卡片范围内接收指针事件。
558
+ */
559
+ /** 停留时长:足够读完一句话并决定要不要去获取,又不至于一直挂在界面上。 */
560
+ const FREE_KEY_NOTICE_AUTO_DISMISS_MS = 12e3;
561
+ /**
562
+ * 把异步的 `configured` 摊成 uSES 快照。只在有订阅者时读一次,订阅清理时停止。
563
+ * @param deps - 读函数与订阅函数。
564
+ * @returns uSES 快照源。
565
+ */
566
+ function useConfigured(deps) {
567
+ const source = (0, react.useMemo)(() => {
568
+ let value;
569
+ let stop;
570
+ const listeners = /* @__PURE__ */ new Set();
571
+ const publish = () => {
572
+ for (const listener of [...listeners]) listener();
573
+ };
574
+ const load = () => {
575
+ deps.readConfigured().then((next) => {
576
+ value = next;
577
+ publish();
578
+ }, () => {});
579
+ };
580
+ return {
581
+ getSnapshot: () => value,
582
+ subscribe: (listener) => {
583
+ listeners.add(listener);
584
+ if (stop === void 0) {
585
+ load();
586
+ const off = deps.subscribeConfigured(load);
587
+ stop = () => {
588
+ off();
589
+ };
590
+ }
591
+ return () => {
592
+ listeners.delete(listener);
593
+ if (listeners.size === 0) {
594
+ stop?.();
595
+ stop = void 0;
596
+ }
597
+ };
598
+ }
599
+ };
600
+ }, [deps]);
601
+ return (0, react.useSyncExternalStore)(source.subscribe, source.getSnapshot, source.getSnapshot);
602
+ }
603
+ /**
604
+ * 提示本体。
605
+ * @param props - 依赖注入,以及宿主通过槽位交给每个条目的标准份额。
606
+ * @returns 左下角卡片,或 null。
607
+ */
608
+ function FreeKeyNotice({ deps, useSessions }) {
609
+ const [seen, setSeen] = (0, react.useState)(() => readNoticeSeen(deps.storage, FREE_KEY_NOTICE_STORAGE_KEY));
610
+ const [visible, setVisible] = (0, react.useState)(true);
611
+ const configured = useConfigured(deps);
612
+ const viewIdle = useSessions(isViewIdle);
613
+ const show = visible && shouldShowNotice({
614
+ seen,
615
+ configured,
616
+ viewIdle
617
+ });
618
+ (0, react.useEffect)(() => {
619
+ if (!show || seen) return;
620
+ writeNoticeSeen(deps.storage, FREE_KEY_NOTICE_STORAGE_KEY);
621
+ setSeen(true);
622
+ }, [
623
+ show,
624
+ seen,
625
+ deps.storage
626
+ ]);
627
+ const deadline = (0, react.useRef)(void 0);
628
+ const timer = (0, react.useRef)(void 0);
629
+ const dismiss = (0, react.useCallback)(() => {
630
+ setVisible(false);
631
+ }, []);
632
+ const hold = (0, react.useCallback)(() => {
633
+ if (timer.current !== void 0) clearTimeout(timer.current);
634
+ timer.current = void 0;
635
+ deadline.current = void 0;
636
+ }, []);
637
+ const resume = (0, react.useCallback)(() => {
638
+ if (deadline.current === void 0) return;
639
+ const remaining = Math.max(0, deadline.current - Date.now());
640
+ timer.current = setTimeout(dismiss, remaining);
641
+ }, [dismiss]);
642
+ (0, react.useEffect)(() => {
643
+ if (!show) return;
644
+ deadline.current = Date.now() + FREE_KEY_NOTICE_AUTO_DISMISS_MS;
645
+ timer.current = setTimeout(dismiss, FREE_KEY_NOTICE_AUTO_DISMISS_MS);
646
+ return () => {
647
+ if (timer.current !== void 0) clearTimeout(timer.current);
648
+ timer.current = void 0;
649
+ deadline.current = void 0;
650
+ };
651
+ }, [show, dismiss]);
652
+ if (!show) return null;
653
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FREE_KEY_NOTICE_CSS }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("aside", {
654
+ className: "ftshare-free-key-notice",
655
+ role: "status",
656
+ "aria-live": "polite",
657
+ onMouseEnter: hold,
658
+ onMouseLeave: resume,
659
+ children: [
660
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
661
+ className: "ftshare-free-key-head",
662
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "免费获取 Key" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
663
+ type: "button",
664
+ className: "ftshare-free-key-close",
665
+ "aria-label": "关闭",
666
+ onClick: dismiss,
667
+ children: "✕"
668
+ })]
669
+ }),
670
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: "海量金融数据,一次配置长期使用" }),
671
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
672
+ className: "ftshare-free-key-actions",
673
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
674
+ className: "primary",
675
+ href: deps.applyUrl,
676
+ target: "_blank",
677
+ rel: "noreferrer",
678
+ onClick: dismiss,
679
+ children: "去获取"
680
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
681
+ type: "button",
682
+ onClick: dismiss,
683
+ children: "知道了"
684
+ })]
685
+ })
686
+ ]
687
+ })] });
688
+ }
689
+ /**
690
+ * 浏览器侧装配:把凭据通道、真实 localStorage 接到组件上。
691
+ * `useSessions` 是宿主交给 `shell.overlay` 条目的标准份额,从 props 透传而不是自造。
692
+ * @param ctx - 卡片那套上下文(slots + 凭据失效事件)。
693
+ * @returns 槽位组件工厂。
694
+ */
695
+ function createFreeKeyNotice(ctx) {
696
+ const api = createApiKeyClient(createSettingsTransport());
697
+ const subscribeConfigured = (listener) => {
698
+ const disposers = FTSHARE_CREDENTIAL_EVENTS.map((event) => ctx.remote.$on(event, (ref) => {
699
+ if (ref === "FTSHARE_API_KEY") listener();
700
+ }));
701
+ disposers.push(ctx.on("connection/reset", listener));
702
+ return () => {
703
+ for (const dispose of disposers) dispose();
704
+ };
705
+ };
706
+ const deps = {
707
+ readConfigured: async () => (await api.describe()).configured,
708
+ subscribeConfigured,
709
+ storage: browserNoticeStorage(),
710
+ applyUrl: FTSHARE_API_KEY_APPLY_URL
711
+ };
712
+ return ({ useSessions }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FreeKeyNotice, {
713
+ deps,
714
+ useSessions
715
+ });
716
+ }
717
+ // src/features/free-key-notice/index.ts
718
+ /**
719
+ * 注册一次性提示。
720
+ * @param ctx - 客户端上下文,需带 `slots` 服务。
721
+ * @returns 幂等 disposer。
722
+ */
723
+ function registerFreeKeyNotice(ctx) {
724
+ const component = createFreeKeyNotice(ctx);
725
+ return ctx.slots.inject(FREE_KEY_NOTICE_SLOT, () => ctx.slots.register({
726
+ name: FREE_KEY_NOTICE_SLOT,
727
+ id: FREE_KEY_NOTICE_ID,
728
+ order: 100
729
+ }, component));
730
+ }
731
+ // src/features/logs/contract.ts
427
732
  /**
428
733
  * `logs` 命名空间的共享契约:只有常量与类型,浏览器半边可以安全导入。
429
734
  *
@@ -444,8 +749,7 @@ window.__ModuleLoader__.load({
444
749
  * 它随 owner fiber 释放(`ctx.effect` + `register` 返回的 disposer)。
445
750
  */
446
751
  const LOGS_ARCHIVE_ROUTE = "/api/ftshare-settings/logs.zip";
447
- //#endregion
448
- //#region src/features/logs/client-api.ts
752
+ // src/features/logs/client-api.ts
449
753
  /** 浏览器适配器:只读,且不暴露本地路径以外的任何 Host 能力(刷盘与释放句柄)。 */
450
754
  function createLogsClient(transport) {
451
755
  const call = (method, payload = {}) => callSettingsRpc(transport, "logs", method, payload);
@@ -455,8 +759,7 @@ window.__ModuleLoader__.load({
455
759
  export: () => call("export")
456
760
  };
457
761
  }
458
- //#endregion
459
- //#region src/features/logs/styles.ts
762
+ // src/features/logs/styles.ts
460
763
  /**
461
764
  * The `logs` card's own styles, rendered by the card component's `<style>`.
462
765
  *
@@ -470,8 +773,7 @@ window.__ModuleLoader__.load({
470
773
  .ftshare-settings .ftshare-download { display:inline-flex; align-items:center; min-height:32px; padding:0 14px; border:0.5px solid var(--ftshare-control); border-radius:var(--dsw-radius-md); color:inherit; text-decoration:none; cursor:pointer; }
471
774
  .ftshare-settings .ftshare-log-preview { max-height:320px; overflow:auto; margin:0 0 12px; padding:12px; background:var(--ftshare-input); border:0.5px solid var(--ftshare-border); border-radius:var(--dsw-radius-lg); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; line-height:1.5; white-space:pre-wrap; overflow-wrap:anywhere; }
472
775
  `;
473
- //#endregion
474
- //#region src/features/logs/card.tsx
776
+ // src/features/logs/card.tsx
475
777
  /** 预览行数候选:上限由 Host 侧校验兜底。 */
476
778
  const PREVIEW_CHOICES = [
477
779
  50,
@@ -668,8 +970,7 @@ window.__ModuleLoader__.load({
668
970
  ]
669
971
  })] });
670
972
  }
671
- //#endregion
672
- //#region src/core/styles.ts
973
+ // src/core/styles.ts
673
974
  /**
674
975
  * The section shell's styles plus the theme tokens every card reads.
675
976
  *
@@ -713,8 +1014,7 @@ window.__ModuleLoader__.load({
713
1014
  .ftshare-settings a { color:var(--ftshare-link); }
714
1015
  @media(max-width:540px) { .ftshare-settings header { flex-direction:column; gap:10px; } }
715
1016
  `;
716
- //#endregion
717
- //#region src/core/shell.tsx
1017
+ // src/core/shell.tsx
718
1018
  /** Section slot this seam contributes into; declared by the settings shell. */
719
1019
  const FTSHARE_SETTINGS_SECTION_NAME = "settings.section";
720
1020
  /**
@@ -743,8 +1043,7 @@ window.__ModuleLoader__.load({
743
1043
  } }
744
1044
  }, FtshareSettingsSection));
745
1045
  }
746
- //#endregion
747
- //#region src/brand.ts
1046
+ // src/brand.ts
748
1047
  /** One value for both palettes: brand fills are scheme-invariant. */
749
1048
  const invariant = (value) => ({
750
1049
  light: value,
@@ -796,25 +1095,35 @@ window.__ModuleLoader__.load({
796
1095
  scope.effect(() => scope.theme.overrideTokens(FT_BRAND_SOURCE, FT_BRAND_TOKENS), "ft-settings: FT brand tokens");
797
1096
  });
798
1097
  }
799
- //#endregion
800
- //#region src/client.tsx
1098
+ // src/client.tsx
801
1099
  const inject = ["slots", "remote"];
802
1100
  function apply(ctx) {
803
1101
  registerFtshareSettings(ctx);
804
1102
  applyBrandTokens(ctx);
805
1103
  }
806
- /** Contribute the section shell, then the built-in cards through the slot it declares. */
1104
+ /**
1105
+ * Contribute the settings section, its cards, and the frame-wide notice.
1106
+ *
1107
+ * The notice deliberately does not live in the settings page: it has to reach
1108
+ * users who never open settings, so it takes a seat in `shell.overlay`.
1109
+ */
807
1110
  function registerFtshareSettings(ctx) {
808
1111
  registerSettingsSection(ctx);
809
1112
  registerApiKeyCard(ctx);
810
1113
  registerLogsCard(ctx);
1114
+ registerFreeKeyNotice(ctx);
811
1115
  }
812
- //#endregion
1116
+ exports.FREE_KEY_NOTICE_ID = FREE_KEY_NOTICE_ID;
1117
+ exports.FREE_KEY_NOTICE_SLOT = FREE_KEY_NOTICE_SLOT;
813
1118
  exports.FtshareSettingsSection = FtshareSettingsSection;
814
1119
  exports.apply = apply;
815
1120
  exports.inject = inject;
1121
+ exports.isViewIdle = isViewIdle;
1122
+ exports.readNoticeSeen = readNoticeSeen;
816
1123
  exports.registerFtshareSettings = registerFtshareSettings;
817
1124
  exports.registerSettingsSection = registerSettingsSection;
1125
+ exports.shouldShowNotice = shouldShowNotice;
1126
+ exports.writeNoticeSeen = writeNoticeSeen;
818
1127
  return module.exports;
819
1128
  }
820
1129
  });