@v587d/capital-generation 2.6.2 → 2.6.4

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.
@@ -65,7 +65,7 @@ const zh = {
65
65
  'command.description': '添加 / 查看 / 删除自选股',
66
66
  title: '自选股',
67
67
  close: '关闭',
68
- searchPlaceholder: '输入证券代码或中文名,支持A股/指数/ETF',
68
+ searchPlaceholder: '输入证券代码或名称,支持A股/港股/美股',
69
69
  add: '添加',
70
70
  added: '已添加',
71
71
  refreshing: '刷新中…',
@@ -79,11 +79,33 @@ const zh = {
79
79
  notRefreshed: '未刷新',
80
80
  /** 陈旧值的 tooltip:「……限流,请稍后再试 · 上一次快照 09-29 19:04」。 */
81
81
  stale: '上一次快照',
82
+ /**
83
+ * 港美股回包带的是**交易所当地时间**(HKT / 美东),与页脚那个"我们几点取的数"是两件事。
84
+ * 只写进 tooltip,不做时区换算(换算要用到我们没有的偏移表与夏令时口径)。
85
+ */
86
+ exchangeTime: '交易所时间',
82
87
  /** 有最新价、但没有涨跌(停牌 / 未开盘)时 tooltip 说的那件事。 */
83
88
  noChangePct: '暂无涨跌数据',
84
- typeStock: '股票',
85
- typeIndex: '指数',
86
- typeEtf: 'ETF',
89
+ /**
90
+ * 徽标说**市场**(2026-10-07 用户点名:「股票」这一档要拆成 A股 / 港股 / 美股,指数三档跟着拆)。
91
+ * 六档一字典,场内 ETF 不单列——它的报价同样是二级市场行情,归 A股那一档("这是 ETF"那一层
92
+ * 留在递进输入框那句话里,见下面 `kindStock` 一族)。
93
+ */
94
+ typeAShare: 'A股',
95
+ typeHkStock: '港股',
96
+ typeUsStock: '美股',
97
+ typeAIndex: 'A指',
98
+ typeHkIndex: '港指',
99
+ typeUsIndex: '美指',
100
+ /**
101
+ * ⛔ 这一族**不是徽标**:它只进「预测」「复盘」递进输入框的那句话,说的是**取数端点那一族**。
102
+ * 徽标按市场配色之后就不说"股票 / 指数 / ETF"了,而 Agent 恰恰靠这三档选端点
103
+ * (A 股股票、A 股指数、场内基金是三个不同上游端点,港美股又各两档)。
104
+ * 市场不在这儿重复:中间那段 thscode 自己带(`.SZ` / `.HK` / `.US`)。
105
+ */
106
+ kindStock: '股票',
107
+ kindIndex: '指数',
108
+ kindEtf: 'ETF',
87
109
  remove: '移除',
88
110
  more: '更多',
89
111
  pin: '置顶',
@@ -109,8 +131,22 @@ const zh = {
109
131
  confirmDescSuffix: ' 从自选中移除吗?',
110
132
  confirmCancel: '取消',
111
133
  confirmOk: '确认移除',
112
- truncated: '候选超过 10 条,请输入准确证券代码或名称。',
113
- noHit: '没有匹配的标的(支持沪深 A 股 / 指数 / 场内 ETF)。',
134
+ /**
135
+ * `truncated`:候选已经**一行不落地全画出来了**(服务端不切片),所以这句里没有"候选不止这些"
136
+ * ——那半句说的是"还有没显示的",此刻是假话。2026-10-08 用户改写成"没有更多":它讲的是
137
+ * **上游那一路回满了自己那一档**(A 股 50 / 港 10 / 美 10),而它自己就住在滚动区末尾,
138
+ * 滚到底才看得见(不做成钉在底部的常驻条)。也**故意不写 N**:数字的上游是宿主常量,
139
+ * 抄进两套字典就迟早只改红一边(`error.list_full` 那条教训)。
140
+ */
141
+ truncated: '没有更多,请输入准确的证券代码或名称。',
142
+ /**
143
+ * 候选下拉的筛选头(全部 / CN / HK / US,默认全部):**只在客户端筛已经拿全的候选**,
144
+ * 不重新出网、也不改变上游入参(`asset_type` 白名单那条纪律讲的是入参收窄,不是这里的显示筛选)。
145
+ */
146
+ filterAll: '全部',
147
+ filterLabel: '按市场筛选候选',
148
+ filterNoHit: '这个市场下没有匹配的标的。',
149
+ noHit: '没有匹配的标的(A股 / 港股 / 美股,含指数与场内基金)。',
114
150
  'error.invalid_query': '输入不被接受',
115
151
  'error.not_found': '没有找到该标的',
116
152
  'error.ambiguous': '命中多条,请从候选里选一条',
@@ -122,14 +158,22 @@ const zh = {
122
158
  */
123
159
  'error.list_full': '自选股最多 {max} 条,先删一条再加',
124
160
  'error.list_full_noMax': '自选股已达上限,先删一条再加',
125
- 'error.credential_missing': '未配置 Fuyao 密钥:在「插件」页的 Capital 模式卡片里填 API Key',
161
+ 'error.credential_missing': '未配置 Fuyao 密钥:在「插件」页的 Capital 模式卡片里填 API Key(只影响 A 股那一路,港美股照常用)',
126
162
  'error.rate_limited': '行情服务限流,请稍后再试',
127
- 'error.fuyao_unavailable': '行情服务暂时不可用,请稍后再试',
163
+ 'error.fuyao_unavailable': 'A 股行情暂时取不到,请稍后再试',
164
+ 'error.tencent_unavailable': '港美股行情暂时取不到,请稍后再试',
128
165
  'error.quote_unavailable': '该标的没有可用报价',
129
166
  'error.refresh_timeout': '刷新超时,请稍后再试',
130
167
  'error.store_unavailable': '自选股存储不可用',
131
168
  'error.unauthorized': '登录状态已失效:请先回到宿主窗口重新登录',
132
169
  errorSuffix: '(其他标的仍是上一次成功快照)',
170
+ /**
171
+ * 某一路检索挂了:候选照常给其余路,这一句说**挂掉的那一路**(R12 / 设计文档 §5.4)。
172
+ * 值自带冒号,中英文各按各的标点;接的是同一份 `error.*` 文案,不另起一套说法。
173
+ */
174
+ 'partial.a-share': 'A 股检索不可用:',
175
+ 'partial.hk': '港股检索不可用:',
176
+ 'partial.us': '美股检索不可用:',
133
177
  }
134
178
 
135
179
  const en = {
@@ -137,7 +181,7 @@ const en = {
137
181
  'command.description': 'Add / view / remove watched symbols',
138
182
  title: 'Watchlist',
139
183
  close: 'Close',
140
- searchPlaceholder: 'Symbol code or Chinese name — A-share / index / ETF',
184
+ searchPlaceholder: 'Symbol code or name — A-share / HK / US',
141
185
  add: 'Add',
142
186
  added: 'Added',
143
187
  refreshing: 'Refreshing…',
@@ -150,10 +194,17 @@ const en = {
150
194
  updated: 'Updated',
151
195
  notRefreshed: 'Not refreshed',
152
196
  stale: 'last snapshot',
197
+ exchangeTime: 'exchange time',
153
198
  noChangePct: 'No change data yet',
154
- typeStock: 'Stock',
155
- typeIndex: 'Index',
156
- typeEtf: 'ETF',
199
+ typeAShare: 'A-Share',
200
+ typeHkStock: 'HK',
201
+ typeUsStock: 'US',
202
+ typeAIndex: 'A-Index',
203
+ typeHkIndex: 'HK-Index',
204
+ typeUsIndex: 'US-Index',
205
+ kindStock: 'stock',
206
+ kindIndex: 'index',
207
+ kindEtf: 'ETF',
157
208
  remove: 'Remove',
158
209
  more: 'More',
159
210
  pin: 'Pin to top',
@@ -175,34 +226,78 @@ const en = {
175
226
  confirmDescSuffix: ' from your watchlist?',
176
227
  confirmCancel: 'Cancel',
177
228
  confirmOk: 'Remove',
178
- truncated: 'More than 10 candidates — enter the exact code or name.',
179
- noHit: 'No matching symbol (A-share / index / on-exchange ETF).',
229
+ truncated: 'No more candidates — enter an accurate code or name.',
230
+ filterAll: 'All',
231
+ filterLabel: 'Filter candidates by market',
232
+ filterNoHit: 'No matching symbol in this market.',
233
+ noHit: 'No matching symbol (A-share / HK / US, including indices and listed funds).',
180
234
  'error.invalid_query': 'Input not accepted',
181
235
  'error.not_found': 'Symbol not found',
182
236
  'error.ambiguous': 'Multiple hits — pick one candidate',
183
237
  'error.list_full': 'Watchlist holds at most {max} symbols — remove one first',
184
238
  'error.list_full_noMax': 'Watchlist is full — remove one first',
185
- 'error.credential_missing': 'No Fuyao API key: set it on the Capital card in the Plugins page',
239
+ 'error.credential_missing': 'No Fuyao API key: set it on the Capital card in the Plugins page (A-share quotes only — HK and US keep working)',
186
240
  'error.rate_limited': 'Quote service is rate limited — try again later',
187
- 'error.fuyao_unavailable': 'Quote service unavailable — try again later',
241
+ 'error.fuyao_unavailable': 'A-share quotes unavailable — try again later',
242
+ 'error.tencent_unavailable': 'HK / US quotes unavailable — try again later',
188
243
  'error.quote_unavailable': 'No quote for this symbol',
189
244
  'error.refresh_timeout': 'Refresh timed out — try again later',
190
245
  'error.store_unavailable': 'Watchlist storage unavailable',
191
246
  'error.unauthorized': 'Session expired — sign back in to the host first',
192
247
  errorSuffix: ' (other symbols still show their last good snapshot)',
248
+ 'partial.a-share': 'A-share search unavailable: ',
249
+ 'partial.hk': 'HK search unavailable: ',
250
+ 'partial.us': 'US search unavailable: ',
193
251
  }
194
252
 
195
253
  /**
196
254
  * `asset_type` → 类型徽标文案的**字典键**。文案本身住两套字典里(2.5.2 是在这里写死中文,
197
- * 英文界面于是画着「股票 / 指数」,还绕过了"两套语言 key 必须齐平"那道闸门);
198
- * 「预测」「复盘」递进输入框的那句话也读同一份,英文句子中间不再夹中文类型词。
255
+ * 英文界面于是画着「股票 / 指数」,还绕过了"两套语言 key 必须齐平"那道闸门)。
256
+ *
257
+ * 六档只说**市场**(2026-10-07 用户点名:「股票」拆成 A股 / 港股 / 美股,指数跟着拆三档,
258
+ * 三市三种底色)。两件事因此从徽标上退场,各自另有落点:
259
+ * ① "这是 ETF / 这是股票还是指数"进递进输入框那句话(`KIND_LABEL_KEYS`,Agent 按它选端点);
260
+ * ② 港股 ETF 在检索阶段本来就分不出与个股的差别(实测 `03037` 搜索给 `GP`、快照给 `GP-FUND`),
261
+ * 所以 `港股` 那一档同时盖住两者是**如实**,不是合并出了错。
199
262
  */
200
- const TYPE_LABEL_KEYS = { 'a-share': 'typeStock', 'a-share-index': 'typeIndex', 'fund-etf': 'typeEtf' }
263
+ const TYPE_LABEL_KEYS = {
264
+ 'a-share': 'typeAShare',
265
+ 'hk-stock': 'typeHkStock',
266
+ 'us-stock': 'typeUsStock',
267
+ 'a-share-index': 'typeAIndex',
268
+ 'hk-index': 'typeHkIndex',
269
+ 'us-index': 'typeUsIndex',
270
+ // 场内 ETF 归 A股那一档:徽标说市场,而它交易的就是 A 股二级市场的价格。
271
+ 'fund-etf': 'typeAShare',
272
+ }
273
+ /** 递进输入框那句话里的"这一族走哪个端点",与徽标各管各的(见字典里 `kindStock` 一族的注释)。 */
274
+ const KIND_LABEL_KEYS = {
275
+ 'a-share': 'kindStock',
276
+ 'hk-stock': 'kindStock',
277
+ 'us-stock': 'kindStock',
278
+ 'a-share-index': 'kindIndex',
279
+ 'hk-index': 'kindIndex',
280
+ 'us-index': 'kindIndex',
281
+ 'fund-etf': 'kindEtf',
282
+ }
201
283
  /** 未知类型(上游哪天多一档)照原样画出来,比硬套一个错的中文标签诚实。 */
202
284
  function typeLabel(assetType, t) {
203
285
  const key = TYPE_LABEL_KEYS[assetType]
204
286
  return key === undefined ? String(assetType ?? '') : t(key)
205
287
  }
288
+ function kindLabel(assetType, t) {
289
+ const key = KIND_LABEL_KEYS[assetType]
290
+ return key === undefined ? String(assetType ?? '') : t(key)
291
+ }
292
+
293
+ /**
294
+ * 面板**不画币种**(2026-10-07 用户两次点名:先不要 ISO 码、再不要 `¥` / `$`——"正常人都 get
295
+ * 货币单元的问题")。落点只有这一处:`quote.currency` 照旧取、照旧入库、照旧过 schema,
296
+ * 只是不进 DOM。要恢复显示,只需要在这一处重新读它,上游与宿主都不必动。
297
+ * ⚠️ 已知代价(记录在 `docs/design/watchlist-hk-us.md` R9 与 P10):`80700` 这类 `-R` 柜台是
298
+ * **港币市场、人民币计价**,同一屏里它与 `00700`(HKD) 现在长得一样。用户按市场读币种,
299
+ * 在这一类标的上会读错——这是这一版接受了的取舍,不是没看见。
300
+ */
206
301
 
207
302
  /**
208
303
  * 上游(同花顺搜索结果)的标的名在进面板、进 tooltip、进输入框之前,先剥掉不可见与双向控制字符。
@@ -222,8 +317,29 @@ function scrubInvisible(value) {
222
317
  function cleanRows(rows) {
223
318
  return Array.isArray(rows) ? rows.map((row) => (row === null || typeof row !== 'object' ? row : { ...row, name: scrubInvisible(row.name) })) : rows
224
319
  }
225
- /** 类型徽标的配色:股票=橙、指数=蓝、ETF=绿,与候选行共用同一组类。 */
226
- const TYPE_TONES = { 'a-share': 'capital-watchlist-tag-stock', 'a-share-index': 'capital-watchlist-tag-index', 'fund-etf': 'capital-watchlist-tag-etf' }
320
+ /**
321
+ * 类型徽标的配色:**一色只说一件事 = 市场**(2026-10-07 用户点名:A 股 / 港股 / 美股三种底色;
322
+ * 此前 A 股与港股共用同一支琥珀色,三市在色上分不开)。同一市场里股票与指数同色,
323
+ * 品种那一层只由文字给(`A股` / `A指`)——颜色不再同时表达市场与品种两件事。
324
+ */const TYPE_TONES = {
325
+ 'a-share': 'capital-watchlist-tag-cn',
326
+ 'a-share-index': 'capital-watchlist-tag-cn',
327
+ 'fund-etf': 'capital-watchlist-tag-cn',
328
+ 'hk-stock': 'capital-watchlist-tag-hk',
329
+ 'hk-index': 'capital-watchlist-tag-hk',
330
+ 'us-stock': 'capital-watchlist-tag-us',
331
+ 'us-index': 'capital-watchlist-tag-us',
332
+ }
333
+
334
+ /**
335
+ * 候选筛选头的四档(2026-10-08 用户点名:**默认「全部」**,三市都摆出来让用户自己筛)。
336
+ * 只在客户端筛**已经拿全的候选**:A 股 50 + 港 10 + 美 10 一次回包,切换不重新出网,
337
+ * 也不碰上游入参(`asset_type` 白名单那条纪律讲的是入参收窄,与这里的显示筛选是两件事)。
338
+ * `marketIdOf` 是这一层唯一的判据:候选的 `exchange` 存的是**市场**(`SH`/`SZ`/`HK`/`US`,
339
+ * 美股的具体交易所留在 canonical 代码里),所以 A 股那一档由"不是 HK 也不是 US"给出。
340
+ */
341
+ const MARKET_FILTERS = [{ id: 'all' }, { id: 'cn', label: 'CN' }, { id: 'hk', label: 'HK' }, { id: 'us', label: 'US' }]
342
+ const marketIdOf = (candidate) => (candidate.exchange === 'HK' ? 'hk' : candidate.exchange === 'US' ? 'us' : 'cn')
227
343
 
228
344
  /**
229
345
  * 面板几何。
@@ -289,30 +405,67 @@ const CSS = `
289
405
  直接透视上来(官方滚动浮层 .media 面板与 MenuSurface .material 都是这一对)。border: 0 +
290
406
  elevation-prominent 分离;描边浅色重绑 l1,深色由宿主 [data-menu-material] 规则翻成 l3。 */
291
407
  /* 候选下拉是**卡片内**的浮层,而卡片 overflow: hidden(圆角与清单滚动都靠它):浮层一旦比
292
- 卡片剩余的空间高,多出来的部分就被无声裁掉——实测默认播种 4 行裁掉约 50px,1 行时只剩两三条
293
- 可见,用户既看不到"下面还有",也够不着最后一条。所以最大高度收到 246px,并由下面
294
- capital-watchlist-list-open 在打开下拉时把清单那一格撑到同一高度:卡片因此总是够高,
295
- 超出 246px 的候选在这层自己的盒子里滚(SEARCH_LIMIT 是 10 条,滚得到底)。 */
296
- .capital-watchlist-dropdown { position: absolute; top: calc(100% - 8px); left: 20px; right: 20px; z-index: 20; max-height: 246px; overflow-y: auto; background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); border: 0; border-radius: 12px; --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); animation: capital-watchlist-drop .18s var(--ds-ease-in-out); }
408
+ 卡片剩余的空间高,多出来的部分就被无声裁掉。所以它的 max-height 必须与下面
409
+ capital-watchlist-list-open 那格的 min-height **同值**(下拉正好铺满清单那一格:够高,又压不到
410
+ 页脚)——246px → **320px**(2026-10-08 用户点名"一屏只能显示 3 个标的":行高压到约 47px、
411
+ 浮层抬到 320,一屏 5~6 行;这两个数一改必须一起改,测试里有同值断言)。
412
+ 这一层自己不滚:里面 .capital-watchlist-options 才是滚动区——筛选头钉在顶上("锁定这个头"),
413
+ "没有更多"那句住在**滚动区末尾**(滚到底才看得见,不冻结在底部),其余 hint 贴在选项之后。
414
+ 候选池是 50+10+10=70 行(三路上游硬顶之和,服务端不切片)。 */
415
+ .capital-watchlist-dropdown { position: absolute; top: calc(100% - 8px); left: 20px; right: 20px; z-index: 20; display: flex; flex-direction: column; max-height: 320px; overflow: hidden; background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); border: 0; border-radius: 12px; --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); animation: capital-watchlist-drop .18s var(--ds-ease-in-out); }
416
+ /* 筛选头与那几句"此刻怎么了"的 hint 都是 flex: none,只有选项层 flex: 0 1 auto 可收缩、可滚:
417
+ 头永远在顶,hint 在选项之后(内容短时紧贴最后一行,内容长时也不会被挤没)。 */
418
+ .capital-watchlist-filter { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: .5px solid var(--dsw-alias-border-l2); }
419
+ .capital-watchlist-options { flex: 0 1 auto; min-height: 0; overflow-y: auto; }
420
+ .capital-watchlist-filterbtn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border: none; border-radius: 999px; corner-shape: round; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); font-family: inherit; font-size: 12px; line-height: 16px; cursor: pointer; }
421
+ .capital-watchlist-filterbtn:hover { background: var(--dsw-alias-interactive-bg-hover-accent); color: var(--dsw-alias-label-primary); }
422
+ /* 选中态读 aria-pressed:状态住在属性里,样式只是它的投影(读屏也听得见"已选中")。
423
+ 压在半透明菜单材质上用 color-mix 的透明淡染——它不写背景实底,底下的菜单模糊照常透上来。 */
424
+ .capital-watchlist-filterbtn[aria-pressed="true"] { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); color: var(--dsw-alias-label-primary); }
425
+ .capital-watchlist-filtercount { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
297
426
  @keyframes capital-watchlist-drop { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
298
427
  @keyframes capital-watchlist-breathe { 0% { opacity: 1; } 40% { opacity: .6; } 80%, 100% { opacity: 1; } }
299
428
  @media (prefers-reduced-motion: reduce) { .capital-watchlist-dropdown { animation: none; } .capital-watchlist-skeleton { animation: none; } }
300
- .capital-watchlist-option { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border: none; border-bottom: .5px solid var(--dsw-alias-border-l2); background: transparent; color: inherit; font-family: inherit; text-align: left; cursor: pointer; }
429
+ /* 候选行高度(2026-10-08 用户点名"减少每个标的展示高度"):padding 6 + 名称 18 + 间隙 1 + 代码行 15
430
+ ≈ 47px。与上面那条"浮层 320px"一起算:(320 − 筛选头约 32) ÷ 47 ≈ 6 行一屏(改前是 3 行)。
431
+ 两个数是一对——只抬浮层不压行高,多出来的空间会被行高吃掉;只压行高不抬浮层,一屏还是 3 行。 */
432
+ .capital-watchlist-option { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 12px; border: none; border-bottom: .5px solid var(--dsw-alias-border-l2); background: transparent; color: inherit; font-family: inherit; text-align: left; cursor: pointer; }
301
433
  .capital-watchlist-option:last-of-type { border-bottom: none; }
302
- .capital-watchlist-option:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); }
303
- .capital-watchlist-option:disabled { opacity: .45; cursor: not-allowed; }
434
+ /* 「已添加」与"在途"这一档读的是 **aria-disabled 而不是 :disabled**(真 disabled 会把焦点踢到
435
+ body,搜索段的 onBlur 就判"点到框外"收起下拉 —— 见渲染处那段),所以视觉降档也跟着读它:
436
+ 整行 opacity .45、指针 not-allowed,焦点照旧留在这一行。 */
437
+ .capital-watchlist-option:hover:not([aria-disabled="true"]) { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); }
438
+ .capital-watchlist-option[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
304
439
  /* 键盘焦点走官方表达式:环色由宿主按主题与输入模态管好(指针模态自动透明),不自己造环。 */
305
- .capital-watchlist-close:focus-visible, .capital-watchlist-clear:focus-visible, .capital-watchlist-option:focus-visible, .capital-watchlist-more:focus-visible, .capital-watchlist-predict:focus-visible, .capital-watchlist-review:focus-visible, .capital-watchlist-refresh:focus-visible, .capital-watchlist-confirmbtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
306
- .capital-watchlist-optionmain { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
307
- .capital-watchlist-optionname { font-size: 14px; font-weight: 500; color: var(--dsw-alias-label-primary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
308
- .capital-watchlist-optioncode { font-size: 12px; color: var(--dsw-alias-label-tertiary); }
440
+ .capital-watchlist-close:focus-visible, .capital-watchlist-clear:focus-visible, .capital-watchlist-option:focus-visible, .capital-watchlist-filterbtn:focus-visible, .capital-watchlist-more:focus-visible, .capital-watchlist-predict:focus-visible, .capital-watchlist-review:focus-visible, .capital-watchlist-refresh:focus-visible, .capital-watchlist-confirmbtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
441
+ .capital-watchlist-optionmain { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
442
+ .capital-watchlist-optionname { font-size: 14px; line-height: 18px; font-weight: 500; color: var(--dsw-alias-label-primary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
443
+ .capital-watchlist-optioncode { font-size: 12px; line-height: 15px; color: var(--dsw-alias-label-tertiary); }
444
+ /* 右侧那颗「添加」是**整行这个 button 的可见动词**,不是第二个可聚焦控件(button 里套 button
445
+ 是非法 HTML):所以它只是一颗胶囊——常态灰底、hover 跟着整行加深;「已添加」那档由整行 disabled
446
+ 的 opacity 统一降下去,不再往代码行里塞「· 已添加」(代码 + 徽标才是第二行要读的两样)。 */
447
+ /* 右侧那颗「添加」是**整行这个 button 的可见动词**,不是第二个可聚焦控件(button 里套 button
448
+ 是非法 HTML)。两态(2026-10-08 第三轮用户点名):
449
+ - **添加 = 醒目**:沿用「刷新报价」那颗 verb 按钮的配方(state 色 12% 淡底 + 25% 描边 + 同色字),
450
+ 整行里唯一带色的元素就是它,一眼知道点哪儿;
451
+ - **已添加 = 暗**:透明底 + l1 描边 + tertiary 字(data-state="added"),跟整行的 aria-disabled
452
+ 一起降下去,并且点了也不再出网(guard 在渲染处)。 */
453
+ .capital-watchlist-addbtn { flex: none; padding: 3px 9px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 25%, transparent); border-radius: 999px; corner-shape: round; background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); color: var(--dsw-alias-state-business-primary); font-size: 12px; line-height: 15px; font-weight: 500; }
454
+ .capital-watchlist-option:hover:not([aria-disabled="true"]) .capital-watchlist-addbtn { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); }
455
+ .capital-watchlist-addbtn[data-state="added"] { border-color: var(--dsw-alias-border-l1); background: transparent; color: var(--dsw-alias-label-tertiary); font-weight: 400; }
309
456
  .capital-watchlist-tag { flex: none; display: inline-block; padding: 2px 7px; border-radius: 6px; font-size: 11px; line-height: 14px; font-weight: 500; }
310
457
  /* 类型徽标 = 官方状态徽标那对写法:底色用 state-*-tertiary(主题包为"状态色的底"准备的实心档,
311
458
  自己 color-mix 出来的透明档压在白卡片上等于没混),文字用同色系 primary;
312
459
  amber 例外走 warn-label——warn-primary 压白底只有 2:1,官方"状态色当文字"另有 label 一档。 */
313
- .capital-watchlist-tag-index { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-state-business-tertiary); }
314
- .capital-watchlist-tag-stock { color: var(--dsw-alias-state-warn-label); background: var(--dsw-alias-state-warn-tertiary); }
315
- .capital-watchlist-tag-etf { color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-tertiary); }
460
+ /* 三档徽标按**市场**配色,取的是"钞票的主色"这把直觉:人民币=红、港币=金琥珀、美元=绿
461
+ (2026-10-07 用户点名,并明确绿那一档"和美元很像")。股票与指数同市同色,品种只由文字分。
462
+ ⚠️ 红档与"红涨"那颗红是两种东西:徽标是整行固定的底,价格只在涨跌时上色。
463
+ state-*-tertiary 是主题包给状态色准备的**实心底**;error 那一族的 tertiary 本仓从未用过、
464
+ 无法在此确认存在,于是给一条 color-mix 兜底——猜错 token 的症状是"底色没了",不会报错。
465
+ ⚠️ 这段 CSS 住在模板字符串里:注释里不许出现反引号,出现就把整段样式截断(本轮踩过)。 */
466
+ .capital-watchlist-tag-cn { color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-tertiary, color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)); }
467
+ .capital-watchlist-tag-hk { color: var(--dsw-alias-state-warn-label); background: var(--dsw-alias-state-warn-tertiary); }
468
+ .capital-watchlist-tag-us { color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-tertiary); }
316
469
  /* 表头与数据行共用同一个 grid 定义:宽度只在这一处,两处各自排版就会串行(实机踩过)。
317
470
  三列:名称(第二行是「代码 · 徽标」)/ 报价 / 「操作」那一簇(「预测」「复盘」「更多」三枚)。
318
471
  92px 与 84px 必须是**定值**:表头与数据行是两个独立的 grid 容器,只有定值才让两行的列边线对齐;
@@ -327,15 +480,19 @@ const CSS = `
327
480
  这一格装着这一行的三个动作:「预测」「复盘」与「更多」,表头贴的就是它们共同那条右缘。 */
328
481
  .capital-watchlist-headaction { text-align: right; }
329
482
  .capital-watchlist-list { max-height: 300px; padding: 0 12px 8px; overflow-y: auto; }
330
- /* 下拉打开时把这一格撑到浮层的最大高度(见上面 capital-watchlist-dropdown 那一段的理由)。
331
- 只在打开时撑:平时 1-2 行的短清单不该平白多出一截空白。 */
332
- .capital-watchlist-list-open { min-height: 246px; }
483
+ /* 下拉打开时把这一格撑到浮层的最大高度(见上面 capital-watchlist-dropdown 那一段的理由):
484
+ **这两个数必须同值**——一个改了另一个不改,下拉就会被卡片裁掉一截(或者清单被顶出卡片外)。
485
+ 2026-10-08 一起从 246 抬到 320(用户点名"一屏只能显示 3 个标的"),测试里有同值断言。
486
+ 只在打开时撑:平时 1-2 行的短清单不该平白多出一截空白。
487
+ ⚠️ 打开时 min-height(320) > 上面那条 max-height(300),而 CSS 里 min 恒胜 max ⇒ 这一格此时按
488
+ 320 高、内部照旧 overflow-y: auto 自己滚;收起后 max-height 300 恢复生效。 */
489
+ .capital-watchlist-list-open { min-height: 320px; }
333
490
  .capital-watchlist-row { padding: 11px 8px; border-radius: 10px; transition: background .15s var(--ds-ease-in-out); }
334
491
  .capital-watchlist-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
335
492
  .capital-watchlist-namewrap { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
336
493
  .capital-watchlist-name { font-size: 14px; font-weight: 500; color: var(--dsw-alias-label-primary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
337
- /* 代码与类型徽标同处第二行:gap 是这一对唯一的间距来源,徽标自带内边距,别再各写 margin。 */
338
- .capital-watchlist-codeline { display: flex; align-items: center; gap: 6px; min-width: 0; }
494
+ /* 代码与类型徽标同处第二行(2026-10-07 起这一行就这两样,币种不进面板):gap 是这一对唯一的间距来源,徽标自带内边距,别再各写 margin。10px 与候选行 capital-watchlist-option 那条 gap 同值——同一件事的两种排法,不该一处紧一处松;6px 在实机上读起来是"贴着"(2026-10-07 用户点名)。 */
495
+ .capital-watchlist-codeline { display: flex; align-items: center; gap: 10px; min-width: 0; }
339
496
  .capital-watchlist-code { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
340
497
  /* 最新价在上、涨跌幅在下,右对齐:两行都同一列里排,列宽才不需要跟着涨跌位数抖。
341
498
  padding-right 是**数字与动作区之间那口气的唯一来源**(2026-10-01 用户点名:报价和预测/复盘太近,
@@ -393,7 +550,9 @@ const CSS = `
393
550
  .capital-watchlist-refresh:disabled { opacity: .45; cursor: not-allowed; }
394
551
  /* 34px = 刷新按钮的高度:与它同一行的时间按按钮高度行高居中,左列多出失败行也不受影响。 */
395
552
  .capital-watchlist-updated { font-size: 12px; line-height: 34px; color: var(--dsw-alias-label-tertiary); }
396
- .capital-watchlist-hint { padding: 8px 14px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
553
+ /* flex: none:2026-10-08 起 hint 是下拉这个 flex 列的直接子元素(滚动区只剩选项那一层),
554
+ 不钉住的话空间一紧它就会被压扁——hint 没有 overflow,压扁就是文字被裁。 */
555
+ .capital-watchlist-hint { flex: none; padding: 8px 14px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
397
556
  /* 黄 = 动作没成。两条黄字都用 warn-label:官方"状态色用作文字"给的是 label 那一档,
398
557
  warn-primary 压在浅底上只有 2:1,报错文字反而是最不该看不见的一行。 */
399
558
  /* 搜索失败:占下拉的 hint 位(与「搜索中…」「没有匹配的标的」同一个槽,替换而非并列)。
@@ -484,6 +643,12 @@ const blank = () => ({
484
643
  items: [],
485
644
  candidates: [],
486
645
  truncated: false,
646
+ /**
647
+ * 候选筛选头(`all` / `cn` / `hk` / `us`,**默认 `all`**)。住 store 而不是组件 `useState`:
648
+ * 两条复位路径各归各的——面板重开(`open()` 的 `...blank()`)回到「全部」;换词**不**掰回
649
+ * (用户筛了港,下一个词他还想看港),清空输入也留着(下拉收起时它本来看不见)。
650
+ */
651
+ marketFilter: 'all',
487
652
  query: '',
488
653
  /** 下拉是否盖在清单上:由输入打开、由选中/点到框外收起(Esc 也收,见组件的 onKeyDown)。 */
489
654
  dropdownOpen: false,
@@ -508,6 +673,8 @@ const blank = () => ({
508
673
  * 与 `addError` **分开两个字段**是刻意的——两者清空的时机不同(见 `onQueryChange`)。
509
674
  */
510
675
  searchError: null,
676
+ /** 某一路检索挂了:候选照常给其余路,这一句只说挂掉的那一路(R12)。 */
677
+ searchPartial: null,
511
678
  /** 添加动作的失败(list_full / ambiguous):与输入无关,显示在搜索区下方,改字不清它。 */
512
679
  addError: null,
513
680
  /**
@@ -671,6 +838,16 @@ class WatchlistSurface {
671
838
  this.patch({ dropdownOpen: false })
672
839
  }
673
840
 
841
+ /**
842
+ * 筛选头(全部 / CN / HK / US):只换**显示**哪几行,不出网——候选是一次拿全的,
843
+ * 切档也不该把下拉收起(它就在下拉顶上)。
844
+ * 未知档名直接忽略:这一格的取值只有 `MARKET_FILTERS` 那四个,拼错的值不许让下拉变空白。
845
+ */
846
+ setMarketFilter(filter) {
847
+ if (!MARKET_FILTERS.some((entry) => entry.id === filter)) return
848
+ this.patch({ marketFilter: filter })
849
+ }
850
+
674
851
  /** 行内「更多」键:再点一次就收起(同一个键既开又关,不用去别处找关闭入口)。 */
675
852
  toggleRowMenu(thscode) {
676
853
  this.patch({ rowMenu: this.snapshot.rowMenu === thscode ? null : thscode })
@@ -691,7 +868,7 @@ class WatchlistSurface {
691
868
  if (this.searchController !== null) this.searchController.abort()
692
869
  const controller = new AbortController()
693
870
  this.searchController = controller
694
- this.patch({ searching: true, searchError: null })
871
+ this.patch({ searching: true, searchError: null, searchPartial: null })
695
872
  // 回包落地前再看一眼输入框:从发出到返回这段时间里用户可能已经改字(新词只排了个防抖
696
873
  // 定时器,还没出网)。那一串候选属于**上一个词**,画出来就是能被点错的那一行——丢掉,
697
874
  // 让新词的定时器自己去查。
@@ -703,7 +880,7 @@ class WatchlistSurface {
703
880
  this.patch({ searching: false })
704
881
  return
705
882
  }
706
- this.patch({ candidates: cleanRows(payload.items) ?? [], truncated: payload.truncated === true, searching: false, pendingSearch: false })
883
+ this.patch({ candidates: cleanRows(payload.items) ?? [], truncated: payload.truncated === true, searching: false, pendingSearch: false, searchPartial: Array.isArray(payload.partial) && payload.partial.length > 0 ? payload.partial : null })
707
884
  } catch (error) {
708
885
  if (error.name === 'AbortError') return
709
886
  // 失败不记在"这个词查过了"的账上:同样几个字重打一遍还得能再出网。
@@ -711,7 +888,7 @@ class WatchlistSurface {
711
888
  if (!stillCurrent()) return
712
889
  // 走 `searchError` 而不是 `addError`:它占下拉的 hint 位,替换掉「没有匹配的标的」——
713
890
  // 同一屏不许一边说"没有这个票"、一边说"服务限流"(候选清空了,noHit 本来就会亮起来)。
714
- this.patch({ candidates: [], truncated: false, searching: false, pendingSearch: false, searchError: { code: error.code ?? 'fuyao_unavailable' } })
891
+ this.patch({ candidates: [], truncated: false, searching: false, pendingSearch: false, searchError: { code: error.code ?? 'fuyao_unavailable' }, searchPartial: null })
715
892
  }
716
893
  }
717
894
 
@@ -728,8 +905,16 @@ class WatchlistSurface {
728
905
  // try 里:`/add` 已经落库、紧接着清单读回来 503,面板就报"自选股已达上限"式的黄字,
729
906
  // 用户照着再点一次——同一个标的被加了第二遍)。读清单的失败归 `loadError`(列表那
730
907
  // 一片),刷报价的失败归 `error`(页脚那条红字),各自有位置,都不占添加这句话。
731
- this.patch({ addError: null, candidates: [], query: '', dropdownOpen: false, pendingSearch: false })
732
- this.lastQuery = null
908
+ // ⛔ 成功之后**不再收下拉、不清输入**(2026-10-08 用户点名"能在下拉框里连续添加"):
909
+ // 收起 = 每加一条都得重敲一遍词,"连续添加"根本无从谈起。这一行当场标 `已添加` 并随行禁用
910
+ // (幂等,点不进第二次),其余候选、筛选档与输入框里的词都原样留着。`lastQuery` 因此保持原值
911
+ // ⇒ 同一个词不会被重发(三条闸里"同一个词不重发"那条正是靠它)。
912
+ // ⛔ `searchError` / `searchPartial` 也**不清**:它们说的是"这次搜索有一路没参与",下拉还开着,
913
+ // 把真话擦掉就是让它失效。
914
+ this.patch({
915
+ addError: null,
916
+ candidates: this.snapshot.candidates.map((row) => (row.thscode === candidate.thscode ? { ...row, in_list: true } : row)),
917
+ })
733
918
  await this.load().catch(() => {})
734
919
  // 添加是一次完整动作:读完清单顺手刷一次报价,新行当场就有价。停在「未刷新」
735
920
  // 等于把用户刚加的东西留成半成品——开面板那一步本来就是同一套 load→refresh。
@@ -809,18 +994,19 @@ function formatTime(value) {
809
994
 
810
995
  /** 类型徽标:候选行与数据行共用同一支笔(文案与配色都在这两处一次对齐)。 */
811
996
  function TypeTag({ assetType, t }) {
812
- return h('span', { className: `capital-watchlist-tag ${TYPE_TONES[assetType] ?? 'capital-watchlist-tag-stock'}` }, typeLabel(assetType, t))
997
+ return h('span', { className: `capital-watchlist-tag ${TYPE_TONES[assetType] ?? 'capital-watchlist-tag-cn'}` }, typeLabel(assetType, t))
813
998
  }
814
999
 
815
1000
  /**
816
- * 「预测」与「复盘」共用的**形状**:`<前缀>名称 (thscode · 类型)<后缀>`。
1001
+ * 「预测」与「复盘」共用的**形状**:`<前缀>名称 (thscode · 品种)<后缀>`。
817
1002
  * 括号、空格与中点是结构,写死在这里(中英文同一形状);句子两半住字典的
818
1003
  * `<kind>PromptPrefix` / `<kind>PromptSuffix`(kind 是 `predict` / `review`),改文案去那儿改。
819
- * 类型词读字典(2.5.3 起):英文句子中间不许再夹「股票 / 指数」这种中文标签。
1004
+ * 中间那个品种词读 `KIND_LABEL_KEYS`,**不是徽标那套市场词**:Agent 靠它选端点
1005
+ * (股票 / 指数 / 场内基金是三个不同上游),市场由 thscode 自己带。
820
1006
  */
821
1007
  function promptOf(item, t, kind) {
822
1008
  const name = scrubInvisible(item.name)
823
- return `${t(`${kind}PromptPrefix`)}${name} (${item.thscode} · ${typeLabel(item.asset_type, t)})${t(`${kind}PromptSuffix`)}`
1009
+ return `${t(`${kind}PromptPrefix`)}${name} (${item.thscode} · ${kindLabel(item.asset_type, t)})${t(`${kind}PromptSuffix`)}`
824
1010
  }
825
1011
 
826
1012
  /**
@@ -856,11 +1042,20 @@ function QuoteCell({ quote, failureCode, refreshing, t }) {
856
1042
  : !hasPct
857
1043
  ? 'capital-watchlist-nodata'
858
1044
  : quote.change_pct > 0 ? 'capital-watchlist-up' : quote.change_pct < 0 ? 'capital-watchlist-down' : 'capital-watchlist-flat'
1045
+ /**
1046
+ * tooltip 的三句话按本次状态拼:失败原因 + 上一次快照时刻(陈旧行)、"暂无涨跌数据"(价有涨跌没有)、
1047
+ * 交易所当地时间(**只有港美股那一路带 `source_time`**,A 股那一路没有这格就不画、不编一句)。
1048
+ */
1049
+ const exchange = typeof quote.source_time === 'string' && quote.source_time.length > 0
1050
+ ? `${t('exchangeTime')} ${quote.source_time}`
1051
+ : ''
1052
+ const tips = []
1053
+ if (stale) tips.push(`${t(`error.${failureCode}`)} · ${t('stale')} ${formatTime(quote.captured_at)}`)
1054
+ else if (!hasPct) tips.push(t('noChangePct'))
1055
+ if (exchange.length > 0) tips.push(exchange)
859
1056
  return h('div', {
860
1057
  className: 'capital-watchlist-quote',
861
- title: stale
862
- ? `${t(`error.${failureCode}`)} · ${t('stale')} ${formatTime(quote.captured_at)}`
863
- : hasPct ? undefined : t('noChangePct'),
1058
+ title: tips.length === 0 ? undefined : tips.join(' · '),
864
1059
  },
865
1060
  h('div', { className: stale ? 'capital-watchlist-price capital-watchlist-stale' : 'capital-watchlist-price' }, quote.price.toFixed(2)),
866
1061
  h('div', { className: `capital-watchlist-change ${tone}` }, pct))
@@ -881,6 +1076,12 @@ function addErrorText(t, addError) {
881
1076
  function WatchlistDialog({ useDialog, surface, t, inputActions }) {
882
1077
  const state = useDialog((value) => value)
883
1078
  const [busy, setBusy] = useState(false)
1079
+ /**
1080
+ * 在途的那一次「添加」落在哪一行(null = 没有)。⛔ 与 `busy` 分开:`busy` 关的是**清单那半边**
1081
+ * (预测 / 复盘 / 更多 / 刷新按钮),候选行只许禁它自己那一条——全禁的话,"连续添加"会被每一次
1082
+ * 在途请求打断(第二条正灰着、点不进去)。并发添加是安全的:服务端把清单读写串在一条队列上。
1083
+ */
1084
+ const [addingThscode, setAddingThscode] = useState(null)
884
1085
  /**
885
1086
  * 待移除的那一行,走面板内的二次确认层——不是 `window.confirm`:那是宿主窗口级别的系统弹窗,
886
1087
  * 样式与语言都不归插件管,而且会把"从自选中移除"这件事从面板里搬出去。
@@ -937,25 +1138,84 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
937
1138
  surface.close()
938
1139
  }
939
1140
 
940
- const dropdown = !showDropdown
1141
+ /**
1142
+ * 筛选头只筛**已经拿全**的候选(A 股 50 + 港 10 + 美 10 一次回包):切档不出网、也不收下拉。
1143
+ * 计数按市场算,「全部」那一档是三市之和——用户那句"怎么全是 A 股,港股美股呢",答案就写在
1144
+ * 这三个数字里(港 10 / 美 10,不是 0),不用他逐档点过去找。档名 `CN` / `HK` / `US` 中英文同一套。
1145
+ */
1146
+ const visibleCandidates = state.marketFilter === 'all'
1147
+ ? state.candidates
1148
+ : state.candidates.filter((candidate) => marketIdOf(candidate) === state.marketFilter)
1149
+ const filterCounts = { all: state.candidates.length, cn: 0, hk: 0, us: 0 }
1150
+ for (const candidate of state.candidates) filterCounts[marketIdOf(candidate)] += 1
1151
+ const filterBar = state.candidates.length === 0
941
1152
  ? null
942
- : h('div', { className: 'capital-watchlist-dropdown', role: 'listbox', 'aria-label': t('title'), 'data-menu-material': 'translucent' },
943
- state.candidates.map((candidate) => h('button', {
944
- key: candidate.thscode,
1153
+ : h('div', { className: 'capital-watchlist-filter', role: 'group', 'aria-label': t('filterLabel') },
1154
+ MARKET_FILTERS.map((entry) => h('button', {
1155
+ key: entry.id,
945
1156
  type: 'button',
946
- role: 'option',
947
- className: 'capital-watchlist-option',
948
- // 整行就是"添加"这个动词(点行选中),title 把这个 affordance 说出来;
949
- // 已在清单里的候选标 `已添加`(幂等)并整行禁用。
950
- title: candidate.in_list === true ? t('added') : t('add'),
951
- disabled: candidate.in_list === true || busy,
952
- onClick: () => { setBusy(true); surface.add(candidate).finally(() => setBusy(false)) },
1157
+ className: 'capital-watchlist-filterbtn',
1158
+ // 选中态只写在 `aria-pressed` 上、样式读它:状态先进属性,读屏也听得见"已选中"。
1159
+ 'aria-pressed': state.marketFilter === entry.id,
1160
+ onClick: () => surface.setMarketFilter(entry.id),
953
1161
  },
954
- h('span', { className: 'capital-watchlist-optionmain' },
955
- h('span', { className: 'capital-watchlist-optionname' }, candidate.name),
956
- h('span', { className: 'capital-watchlist-optioncode' },
957
- candidate.in_list === true ? `${candidate.ticker} · ${t('added')}` : candidate.ticker)),
958
- h(TypeTag, { assetType: candidate.asset_type, t }))),
1162
+ h('span', { className: 'capital-watchlist-filterlabel' }, entry.id === 'all' ? t('filterAll') : entry.label),
1163
+ h('span', { className: 'capital-watchlist-filtercount' }, String(filterCounts[entry.id])))))
1164
+
1165
+ const dropdown = !showDropdown
1166
+ ? null
1167
+ : h('div', { className: 'capital-watchlist-dropdown', 'aria-label': t('title'), 'data-menu-material': 'translucent' },
1168
+ // 筛选头**不进滚动区**:`role="listbox"` 因此跟着选项搬到里面那层(列表框里只许摆 option,
1169
+ // 头自己是 group),滚动的也只有选项那一层——头由 CSS 钉在下拉顶部,即用户要的"锁定这个头"。
1170
+ filterBar,
1171
+ h('div', { className: 'capital-watchlist-options', role: 'listbox', 'aria-label': t('title') },
1172
+ visibleCandidates.map((candidate) => h('button', {
1173
+ key: candidate.thscode,
1174
+ type: 'button',
1175
+ role: 'option',
1176
+ className: 'capital-watchlist-option',
1177
+ // hover 出来的是**证券全称**(2026-10-08 用户点名):旧版这一格写的是 `添加`,那句
1178
+ // "这个按钮能干嘛"已经由右侧那颗「添加」说了;美股长名会被 CSS 截断,全称只有这个
1179
+ // 原生 tooltip 给得全。
1180
+ title: candidate.name,
1181
+ // ⛔ 这一格用 `aria-disabled` 而不是真 `disabled`(2026-10-08 第三轮用户点名"点一下添加、
1182
+ // 下拉就没了"):真 `disabled` 会让浏览器**立刻把焦点从这一行踢到 body**,搜索段的 onBlur
1183
+ // 于是拿到 `relatedTarget: null` → 判"点到框外" → 收起下拉,连续添加被这一下打断。
1184
+ // `aria-disabled` 不动焦点(行照样可聚焦,读屏也听得见"不可用"),"禁止点击"由下面的 guard 承担。
1185
+ 'aria-disabled': candidate.in_list === true || addingThscode === candidate.thscode,
1186
+ onClick: () => {
1187
+ // 幂等 + 防在途双击:已添加的那条点多少次都不再出网,在途那条也拦掉。
1188
+ if (candidate.in_list === true || addingThscode === candidate.thscode) return
1189
+ setBusy(true)
1190
+ setAddingThscode(candidate.thscode)
1191
+ surface.add(candidate).finally(() => { setBusy(false); setAddingThscode(null) })
1192
+ },
1193
+ },
1194
+ h('span', { className: 'capital-watchlist-optionmain' },
1195
+ h('span', { className: 'capital-watchlist-optionname' }, candidate.name),
1196
+ // ⛔ 第二行画**完整 canonical 代码**,不是裸码:`00700` 一次同时命中港个股与
1197
+ // `000700.SZ` 模塑科技,搜「恒生指数」还会同时回 `HSI` / `HSIGTR` / `HSINTR` 三条真指数——
1198
+ // 裸码并列时用户没有可核对的信息,点错就是加错票。
1199
+ // 类型徽标挪到代码右边(`codeline` 直接复用清单第一列那个类 ⇒ 排法与清单同款,用户
1200
+ // 2026-10-08 点名"类似自选股列表第一列的样式"),右侧因此腾出来放「添加」。
1201
+ h('span', { className: 'capital-watchlist-codeline' },
1202
+ h('span', { className: 'capital-watchlist-optioncode' }, candidate.thscode),
1203
+ h(TypeTag, { assetType: candidate.asset_type, t }))),
1204
+ // 右侧那颗「添加」是**整行这个 button 的可见动词**,不是第二个可聚焦控件(button 里再
1205
+ // 套 button 是非法 HTML);已在清单里就换 `已添加`,随行一起禁用(点了也不会重复写库)。
1206
+ // 两态(2026-10-08 第三轮用户点名):**添加 = 醒目**(沿用「刷新报价」那颗 verb 按钮的配方,
1207
+ // 整行里唯一带色的就是它 ⇒ 一眼知道点哪儿)、**已添加 = 暗**(透明底 + l1 描边 + tertiary 字,
1208
+ // 配合整行 aria-disabled 的 opacity 一起降)。状态只写在 `data-state` 上,样式读它。
1209
+ h('span', { className: 'capital-watchlist-addbtn', 'data-state': candidate.in_list === true ? 'added' : 'idle' },
1210
+ candidate.in_list === true ? t('added') : t('add')))),
1211
+ // ⛔ "没有更多"那句住在**滚动区末尾**(2026-10-08 用户点名"不要冻结"):它跟着内容一起滚,
1212
+ // 滚到底才看得见——钉在浮层底部就成了一条常驻横幅,还挡着最后一行候选。
1213
+ // 筛选档里一行都不剩时不说它(那一格由下面的 `filterNoHit` 负责)。
1214
+ state.truncated === true && visibleCandidates.length > 0
1215
+ ? h('div', { className: 'capital-watchlist-hint capital-watchlist-endhint' }, t('truncated'))
1216
+ : null),
1217
+ // 下面这几句 hint **不进滚动区**:它们说的是"这一屏此刻怎么了"(正在查 / 查不到 / 有路挂了),
1218
+ // 被内容滚走就等于没人看见——所以留在浮层里、贴在选项之后。
959
1219
  state.composing === true
960
1220
  // ⛔ 合成期(拼音还没上屏)不许报「没有匹配的标的」:那是把"还没查"说成"查了,没有",
961
1221
  // 用户会以为自己的输入法打错了字。这一格说的是下一步会发生什么。
@@ -970,8 +1230,20 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
970
1230
  ? h('div', { className: 'capital-watchlist-hint capital-watchlist-searcherror', role: 'alert', 'data-code': state.searchError.code }, t(`error.${state.searchError.code}`))
971
1231
  : state.candidates.length === 0
972
1232
  ? h('div', { className: 'capital-watchlist-hint' }, t('noHit'))
973
- : null,
974
- state.truncated ? h('div', { className: 'capital-watchlist-hint' }, t('truncated')) : null)
1233
+ : visibleCandidates.length === 0
1234
+ // 筛选把候选滤空了(总数 > 0):这句与 noHit 说的是两件事——票是有的,只是不在
1235
+ // 这一档里。说成"没有匹配"就是把用户的筛选说成他查错了。
1236
+ ? h('div', { className: 'capital-watchlist-hint', 'data-filter': state.marketFilter }, t('filterNoHit'))
1237
+ : null,
1238
+ // 某一路挂了而其余路照常给候选:这一行说的是**那一路**(R12)。它与上面的 hint 各说
1239
+ // 各的事——上面那句讲"这一屏有没有匹到",这句讲"有一路今天没参与",不互相顶替。
1240
+ ...(state.searchPartial ?? []).map((branch) => h('div', {
1241
+ key: `partial-${branch.market}`,
1242
+ className: 'capital-watchlist-hint capital-watchlist-searcherror',
1243
+ role: 'status',
1244
+ 'data-market': branch.market,
1245
+ 'data-code': branch.code,
1246
+ }, `${t(`partial.${branch.market}`)}${t(`error.${branch.code}`)}`)))
975
1247
 
976
1248
  const head = state.items.length === 0
977
1249
  ? null
@@ -1002,7 +1274,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1002
1274
  },
1003
1275
  h('span', { className: 'capital-watchlist-namewrap' },
1004
1276
  h('span', { className: 'capital-watchlist-name', title: `${item.name} ${item.ticker}` }, item.name),
1005
- // 代码与类型徽标同处第二行(用户反馈:类型单占一列太累赘)。
1277
+ // 代码与市场徽标同处第二行(2026-10-07 起这一行只有这两样:币种不进面板,见上面那一族注释)。
1006
1278
  h('span', { className: 'capital-watchlist-codeline' },
1007
1279
  h('span', { className: 'capital-watchlist-code' }, item.ticker),
1008
1280
  h(TypeTag, { assetType: item.asset_type, t }))),