dsh-sub2api-sync 0.2.3 → 0.2.5

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/lib/client.cjs CHANGED
@@ -132,6 +132,8 @@ const zh = {
132
132
  keysTitle: "Keys",
133
133
  createKey: "创建 key",
134
134
  keyGroupLabel: "所属分组",
135
+ keyGroupSearch: "搜索分组…",
136
+ keyGroupSearchEmpty: "没有匹配的分组",
135
137
  keyNameLabel: "Key 名称",
136
138
  keyNameHint: "默认与分组同名",
137
139
  keyGroupPlaceholder: "选择分组",
@@ -246,6 +248,8 @@ const en = {
246
248
  keysTitle: "Keys",
247
249
  createKey: "Create key",
248
250
  keyGroupLabel: "Group",
251
+ keyGroupSearch: "Search groups…",
252
+ keyGroupSearchEmpty: "No groups match",
249
253
  keyNameLabel: "Key name",
250
254
  keyNameHint: "Defaults to the group name",
251
255
  keyGroupPlaceholder: "Pick a group",
@@ -725,30 +729,135 @@ const CSS = `
725
729
  flex: 1 1 220px;
726
730
  }
727
731
 
728
- /* Menu anchor wrapper: give the picker trigger the same field-column
729
- footprint as the sibling Input rows. */
730
- .s2s-menuAnchor {
732
+ /* Group picker (create-key dialog): the trigger takes the same field-column
733
+ footprint as the sibling Input rows, painted like an input rather than a
734
+ button so both rows read as one form. */
735
+ .s2s-groupPicker {
731
736
  display: flex;
732
737
  flex: 1 1 220px;
733
738
  min-width: 0;
734
739
  }
735
740
 
736
- .s2s-menuAnchor > * {
737
- width: 100%;
738
- }
739
-
740
- /* Select-style trigger (group picker): label left, chevron right. */
741
- .s2s-selectTrigger {
741
+ .s2s-selectField {
742
742
  display: flex;
743
743
  align-items: center;
744
744
  justify-content: space-between;
745
745
  gap: 8px;
746
746
  width: 100%;
747
+ min-height: 34px;
748
+ padding: 6px 10px;
749
+ box-sizing: border-box;
750
+ border: 0.5px solid var(--dsw-alias-border-l2);
751
+ border-radius: var(--dsw-radius-md);
752
+ background: transparent;
753
+ color: inherit;
754
+ font: inherit;
755
+ text-align: left;
756
+ cursor: pointer;
757
+ user-select: none;
758
+ }
759
+
760
+ .s2s-selectField:disabled {
761
+ opacity: 0.55;
762
+ cursor: default;
763
+ }
764
+
765
+ .s2s-selectField:focus-visible {
766
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
767
+ outline-offset: 2px;
768
+ }
769
+
770
+ .s2s-selectPlaceholder {
771
+ color: var(--dsw-alias-label-tertiary);
772
+ overflow: hidden;
773
+ text-overflow: ellipsis;
774
+ white-space: nowrap;
775
+ }
776
+
777
+ .s2s-selectValue {
778
+ min-width: 0;
779
+ overflow: hidden;
780
+ text-overflow: ellipsis;
781
+ white-space: nowrap;
747
782
  }
748
783
 
749
784
  .s2s-selectChevron {
750
785
  flex: none;
751
786
  opacity: 0.55;
787
+ transition: transform var(--ds-transition-duration) var(--ds-ease-in-out);
788
+ }
789
+
790
+ .s2s-selectChevronOpen {
791
+ transform: rotate(180deg);
792
+ }
793
+
794
+ /* The options panel: fixed-positioned from the trigger (escapes the modal
795
+ card's overflow clip), width-matched to it, search pinned on top, options
796
+ scrolling below. */
797
+ .s2s-groupPanel {
798
+ display: flex;
799
+ flex-direction: column;
800
+ box-sizing: border-box;
801
+ z-index: 10;
802
+ overflow: hidden;
803
+ }
804
+
805
+ .s2s-groupSearch {
806
+ flex: none;
807
+ padding: 8px 8px 6px;
808
+ }
809
+
810
+ .s2s-groupSearch > * {
811
+ width: 100%;
812
+ }
813
+
814
+ .s2s-groupList {
815
+ flex: 1 1 auto;
816
+ min-height: 0;
817
+ overflow-y: auto;
818
+ overscroll-behavior: contain;
819
+ padding: 0 8px 8px;
820
+ }
821
+
822
+ .s2s-groupEmpty {
823
+ margin: 0;
824
+ padding: 10px 4px;
825
+ color: var(--dsw-alias-label-tertiary);
826
+ text-align: center;
827
+ }
828
+
829
+ .s2s-groupOption {
830
+ display: flex;
831
+ align-items: center;
832
+ gap: 8px;
833
+ width: 100%;
834
+ min-height: 32px;
835
+ padding: 6px 10px;
836
+ box-sizing: border-box;
837
+ border: 0;
838
+ border-radius: var(--dsw-radius-md);
839
+ background: transparent;
840
+ color: inherit;
841
+ font: inherit;
842
+ text-align: left;
843
+ cursor: pointer;
844
+ }
845
+
846
+ .s2s-groupOption[data-active='true'] {
847
+ background: var(--dsw-alias-interactive-bg-hover);
848
+ }
849
+
850
+ .s2s-groupOptionName {
851
+ flex: 1 1 auto;
852
+ min-width: 0;
853
+ overflow: hidden;
854
+ text-overflow: ellipsis;
855
+ white-space: nowrap;
856
+ }
857
+
858
+ .s2s-groupOptionCheck {
859
+ flex: none;
860
+ color: var(--dsw-alias-state-business-primary);
752
861
  }
753
862
 
754
863
  /* The keys-card search input: fixed-width slot between the title and the
@@ -1178,6 +1287,164 @@ function protocolForPlatform(platform) {
1178
1287
  }
1179
1288
  }
1180
1289
 
1290
+ //#endregion
1291
+ //#region src/client/section/GroupPicker.tsx
1292
+ /** Design cap for the panel; the viewport clamp never exceeds it. */
1293
+ const PANEL_MAX_HEIGHT = 280;
1294
+ /** Distance between the trigger's bottom edge and the panel. */
1295
+ const PANEL_GAP = 6;
1296
+ function GroupPicker({ groups, value, placeholder, searchPlaceholder, emptyLabel, disabled = false, onSelect }) {
1297
+ const [open, setOpen] = (0, react.useState)(false);
1298
+ const [filter, setFilter] = (0, react.useState)("");
1299
+ const [activeIndex, setActiveIndex] = (0, react.useState)(0);
1300
+ const [triggerWidth, setTriggerWidth] = (0, react.useState)(0);
1301
+ const rootRef = (0, react.useRef)(null);
1302
+ const triggerRef = (0, react.useRef)(null);
1303
+ const panelRef = (0, react.useRef)(null);
1304
+ const listRef = (0, react.useRef)(null);
1305
+ const selected = value === "" ? undefined : groups.find((group) => group.id === value);
1306
+ const filtered = (0, react.useMemo)(() => {
1307
+ const needle = filter.trim().toLowerCase();
1308
+ if (needle === "") return groups;
1309
+ return groups.filter((group) => group.name.toLowerCase().includes(needle));
1310
+ }, [groups, filter]);
1311
+ const position = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredPosition)({
1312
+ open,
1313
+ anchorRef: triggerRef,
1314
+ panelRef,
1315
+ side: "bottom",
1316
+ align: "start",
1317
+ gap: PANEL_GAP,
1318
+ margin: 24
1319
+ });
1320
+ const maxHeight = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredMaxHeight)(panelRef, PANEL_MAX_HEIGHT, open ? filtered.length : 0);
1321
+ (0, __deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, setOpen, panelRef);
1322
+ (0, react.useEffect)(() => {
1323
+ if (!open) return;
1324
+ setFilter("");
1325
+ setActiveIndex(0);
1326
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1327
+ const timer$1 = window.setTimeout(() => {
1328
+ panelRef.current?.querySelector("input")?.focus();
1329
+ }, 0);
1330
+ return () => window.clearTimeout(timer$1);
1331
+ }, [open]);
1332
+ (0, react.useEffect)(() => {
1333
+ if (!open) return;
1334
+ const onResize = () => {
1335
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1336
+ };
1337
+ window.addEventListener("resize", onResize);
1338
+ return () => window.removeEventListener("resize", onResize);
1339
+ }, [open]);
1340
+ (0, react.useEffect)(() => {
1341
+ const list = listRef.current;
1342
+ if (list === null) return;
1343
+ list.querySelector("[data-active='true']")?.scrollIntoView({ block: "nearest" });
1344
+ }, [activeIndex, filter]);
1345
+ const close = () => {
1346
+ setOpen(false);
1347
+ triggerRef.current?.focus();
1348
+ };
1349
+ const pick = (group) => {
1350
+ onSelect(group);
1351
+ close();
1352
+ };
1353
+ const onKeyDown = (event) => {
1354
+ if (event.key === "Escape") {
1355
+ event.stopPropagation();
1356
+ close();
1357
+ return;
1358
+ }
1359
+ if (event.key === "ArrowDown") {
1360
+ event.preventDefault();
1361
+ if (filtered.length > 0) setActiveIndex((index) => Math.min(index + 1, filtered.length - 1));
1362
+ return;
1363
+ }
1364
+ if (event.key === "ArrowUp") {
1365
+ event.preventDefault();
1366
+ setActiveIndex((index) => Math.max(index - 1, 0));
1367
+ return;
1368
+ }
1369
+ if (event.key === "Enter") {
1370
+ event.preventDefault();
1371
+ const group = filtered[activeIndex];
1372
+ if (group !== undefined) pick(group);
1373
+ }
1374
+ };
1375
+ return (0, react_jsx_runtime.jsxs)("div", {
1376
+ className: "s2s-groupPicker",
1377
+ ref: rootRef,
1378
+ children: [(0, react_jsx_runtime.jsxs)("button", {
1379
+ type: "button",
1380
+ ref: triggerRef,
1381
+ className: "s2s-selectField",
1382
+ disabled,
1383
+ "aria-haspopup": "listbox",
1384
+ "aria-expanded": open,
1385
+ onClick: () => {
1386
+ setOpen((next) => !next);
1387
+ },
1388
+ children: [(0, react_jsx_runtime.jsx)("span", {
1389
+ className: selected === undefined ? "s2s-selectPlaceholder" : "s2s-selectValue",
1390
+ children: selected === undefined ? placeholder : selected.name
1391
+ }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1392
+ size: 14,
1393
+ className: `s2s-selectChevron${open ? " s2s-selectChevronOpen" : ""}`
1394
+ })]
1395
+ }), open && (0, react_jsx_runtime.jsxs)(__deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
1396
+ ref: panelRef,
1397
+ className: "s2s-groupPanel",
1398
+ role: "listbox",
1399
+ style: position === null ? { visibility: "hidden" } : {
1400
+ ...position,
1401
+ ...triggerWidth > 0 ? { width: `${triggerWidth}px` } : {},
1402
+ maxHeight: `${maxHeight}px`
1403
+ },
1404
+ onKeyDown,
1405
+ children: [(0, react_jsx_runtime.jsx)("div", {
1406
+ className: "s2s-groupSearch",
1407
+ children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Input, {
1408
+ type: "text",
1409
+ value: filter,
1410
+ placeholder: searchPlaceholder,
1411
+ onChange: (event) => {
1412
+ setFilter(event.target.value);
1413
+ setActiveIndex(0);
1414
+ }
1415
+ })
1416
+ }), (0, react_jsx_runtime.jsxs)("div", {
1417
+ className: "s2s-groupList",
1418
+ ref: listRef,
1419
+ children: [filtered.length === 0 && (0, react_jsx_runtime.jsx)("p", {
1420
+ className: "s2s-groupEmpty",
1421
+ children: emptyLabel
1422
+ }), filtered.map((group, index) => (0, react_jsx_runtime.jsxs)("button", {
1423
+ type: "button",
1424
+ role: "option",
1425
+ "aria-selected": group.id === value,
1426
+ "data-active": index === activeIndex || undefined,
1427
+ className: "s2s-groupOption",
1428
+ onMouseEnter: () => {
1429
+ setActiveIndex(index);
1430
+ },
1431
+ onClick: () => {
1432
+ pick(group);
1433
+ },
1434
+ children: [(0, react_jsx_runtime.jsx)("span", {
1435
+ className: "s2s-groupOptionName",
1436
+ children: group.name
1437
+ }), group.id === value && (0, react_jsx_runtime.jsx)("span", {
1438
+ className: "s2s-groupOptionCheck",
1439
+ "aria-hidden": "true",
1440
+ children: "✓"
1441
+ })]
1442
+ }, group.id))]
1443
+ })]
1444
+ })]
1445
+ });
1446
+ }
1447
+
1181
1448
  //#endregion
1182
1449
  //#region src/client/section/KeysCard.tsx
1183
1450
  const PAGE_SIZE = 5;
@@ -1194,7 +1461,6 @@ function KeysCard({ api, config, groups, t }) {
1194
1461
  const [loading, setLoading] = (0, react.useState)(false);
1195
1462
  const [listError, setListError] = (0, react.useState)(undefined);
1196
1463
  const [creating, setCreating] = (0, react.useState)(false);
1197
- const [groupMenuOpen, setGroupMenuOpen] = (0, react.useState)(false);
1198
1464
  const [groupId, setGroupId] = (0, react.useState)("");
1199
1465
  const [name$1, setName] = (0, react.useState)("");
1200
1466
  const [nameTouched, setNameTouched] = (0, react.useState)(false);
@@ -1273,6 +1539,9 @@ else nextSet.delete(keyId);
1273
1539
  setGroupId(gid);
1274
1540
  if (!nameTouched) setName(groupNameOf(gid));
1275
1541
  };
1542
+ const selectGroup = (group) => {
1543
+ pickGroup(group.id);
1544
+ };
1276
1545
  const closeCreate = () => {
1277
1546
  setCreating(false);
1278
1547
  setGroupId("");
@@ -1461,36 +1730,14 @@ else nextSet.delete(keyId);
1461
1730
  children: [(0, react_jsx_runtime.jsx)("span", {
1462
1731
  className: "s2s-fieldLabel",
1463
1732
  children: t("keyGroupLabel")
1464
- }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
1465
- open: groupMenuOpen,
1466
- anchor: (0, react_jsx_runtime.jsx)("span", {
1467
- className: "s2s-menuAnchor",
1468
- children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1469
- variant: "outline",
1470
- onClick: () => {
1471
- setGroupMenuOpen(true);
1472
- },
1473
- children: (0, react_jsx_runtime.jsxs)("span", {
1474
- className: "s2s-selectTrigger",
1475
- children: [(0, react_jsx_runtime.jsx)("span", { children: groupId === "" ? t("keyGroupPlaceholder") : groupNameOf(groupId) }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1476
- size: 14,
1477
- className: "s2s-selectChevron"
1478
- })]
1479
- })
1480
- })
1481
- }),
1482
- items: groups.map((group) => ({
1483
- id: String(group.id),
1484
- label: group.name
1485
- })),
1486
- selectedId: groupId === "" ? undefined : String(groupId),
1487
- onSelect: (id) => {
1488
- pickGroup(Number(id));
1489
- setGroupMenuOpen(false);
1490
- },
1491
- onClose: () => {
1492
- setGroupMenuOpen(false);
1493
- }
1733
+ }), (0, react_jsx_runtime.jsx)(GroupPicker, {
1734
+ groups,
1735
+ value: groupId,
1736
+ placeholder: t("keyGroupPlaceholder"),
1737
+ searchPlaceholder: t("keyGroupSearch"),
1738
+ emptyLabel: t("keyGroupSearchEmpty"),
1739
+ disabled: busy,
1740
+ onSelect: selectGroup
1494
1741
  })]
1495
1742
  }), (0, react_jsx_runtime.jsxs)("div", {
1496
1743
  className: "s2s-inline",
package/lib/client.js CHANGED
@@ -138,6 +138,8 @@ const zh = {
138
138
  keysTitle: "Keys",
139
139
  createKey: "创建 key",
140
140
  keyGroupLabel: "所属分组",
141
+ keyGroupSearch: "搜索分组…",
142
+ keyGroupSearchEmpty: "没有匹配的分组",
141
143
  keyNameLabel: "Key 名称",
142
144
  keyNameHint: "默认与分组同名",
143
145
  keyGroupPlaceholder: "选择分组",
@@ -252,6 +254,8 @@ const en = {
252
254
  keysTitle: "Keys",
253
255
  createKey: "Create key",
254
256
  keyGroupLabel: "Group",
257
+ keyGroupSearch: "Search groups…",
258
+ keyGroupSearchEmpty: "No groups match",
255
259
  keyNameLabel: "Key name",
256
260
  keyNameHint: "Defaults to the group name",
257
261
  keyGroupPlaceholder: "Pick a group",
@@ -731,30 +735,135 @@ const CSS = `
731
735
  flex: 1 1 220px;
732
736
  }
733
737
 
734
- /* Menu anchor wrapper: give the picker trigger the same field-column
735
- footprint as the sibling Input rows. */
736
- .s2s-menuAnchor {
738
+ /* Group picker (create-key dialog): the trigger takes the same field-column
739
+ footprint as the sibling Input rows, painted like an input rather than a
740
+ button so both rows read as one form. */
741
+ .s2s-groupPicker {
737
742
  display: flex;
738
743
  flex: 1 1 220px;
739
744
  min-width: 0;
740
745
  }
741
746
 
742
- .s2s-menuAnchor > * {
743
- width: 100%;
744
- }
745
-
746
- /* Select-style trigger (group picker): label left, chevron right. */
747
- .s2s-selectTrigger {
747
+ .s2s-selectField {
748
748
  display: flex;
749
749
  align-items: center;
750
750
  justify-content: space-between;
751
751
  gap: 8px;
752
752
  width: 100%;
753
+ min-height: 34px;
754
+ padding: 6px 10px;
755
+ box-sizing: border-box;
756
+ border: 0.5px solid var(--dsw-alias-border-l2);
757
+ border-radius: var(--dsw-radius-md);
758
+ background: transparent;
759
+ color: inherit;
760
+ font: inherit;
761
+ text-align: left;
762
+ cursor: pointer;
763
+ user-select: none;
764
+ }
765
+
766
+ .s2s-selectField:disabled {
767
+ opacity: 0.55;
768
+ cursor: default;
769
+ }
770
+
771
+ .s2s-selectField:focus-visible {
772
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
773
+ outline-offset: 2px;
774
+ }
775
+
776
+ .s2s-selectPlaceholder {
777
+ color: var(--dsw-alias-label-tertiary);
778
+ overflow: hidden;
779
+ text-overflow: ellipsis;
780
+ white-space: nowrap;
781
+ }
782
+
783
+ .s2s-selectValue {
784
+ min-width: 0;
785
+ overflow: hidden;
786
+ text-overflow: ellipsis;
787
+ white-space: nowrap;
753
788
  }
754
789
 
755
790
  .s2s-selectChevron {
756
791
  flex: none;
757
792
  opacity: 0.55;
793
+ transition: transform var(--ds-transition-duration) var(--ds-ease-in-out);
794
+ }
795
+
796
+ .s2s-selectChevronOpen {
797
+ transform: rotate(180deg);
798
+ }
799
+
800
+ /* The options panel: fixed-positioned from the trigger (escapes the modal
801
+ card's overflow clip), width-matched to it, search pinned on top, options
802
+ scrolling below. */
803
+ .s2s-groupPanel {
804
+ display: flex;
805
+ flex-direction: column;
806
+ box-sizing: border-box;
807
+ z-index: 10;
808
+ overflow: hidden;
809
+ }
810
+
811
+ .s2s-groupSearch {
812
+ flex: none;
813
+ padding: 8px 8px 6px;
814
+ }
815
+
816
+ .s2s-groupSearch > * {
817
+ width: 100%;
818
+ }
819
+
820
+ .s2s-groupList {
821
+ flex: 1 1 auto;
822
+ min-height: 0;
823
+ overflow-y: auto;
824
+ overscroll-behavior: contain;
825
+ padding: 0 8px 8px;
826
+ }
827
+
828
+ .s2s-groupEmpty {
829
+ margin: 0;
830
+ padding: 10px 4px;
831
+ color: var(--dsw-alias-label-tertiary);
832
+ text-align: center;
833
+ }
834
+
835
+ .s2s-groupOption {
836
+ display: flex;
837
+ align-items: center;
838
+ gap: 8px;
839
+ width: 100%;
840
+ min-height: 32px;
841
+ padding: 6px 10px;
842
+ box-sizing: border-box;
843
+ border: 0;
844
+ border-radius: var(--dsw-radius-md);
845
+ background: transparent;
846
+ color: inherit;
847
+ font: inherit;
848
+ text-align: left;
849
+ cursor: pointer;
850
+ }
851
+
852
+ .s2s-groupOption[data-active='true'] {
853
+ background: var(--dsw-alias-interactive-bg-hover);
854
+ }
855
+
856
+ .s2s-groupOptionName {
857
+ flex: 1 1 auto;
858
+ min-width: 0;
859
+ overflow: hidden;
860
+ text-overflow: ellipsis;
861
+ white-space: nowrap;
862
+ }
863
+
864
+ .s2s-groupOptionCheck {
865
+ flex: none;
866
+ color: var(--dsw-alias-state-business-primary);
758
867
  }
759
868
 
760
869
  /* The keys-card search input: fixed-width slot between the title and the
@@ -1184,6 +1293,164 @@ function protocolForPlatform(platform) {
1184
1293
  }
1185
1294
  }
1186
1295
 
1296
+ //#endregion
1297
+ //#region src/client/section/GroupPicker.tsx
1298
+ /** Design cap for the panel; the viewport clamp never exceeds it. */
1299
+ const PANEL_MAX_HEIGHT = 280;
1300
+ /** Distance between the trigger's bottom edge and the panel. */
1301
+ const PANEL_GAP = 6;
1302
+ function GroupPicker({ groups, value, placeholder, searchPlaceholder, emptyLabel, disabled = false, onSelect }) {
1303
+ const [open, setOpen] = (0, react.useState)(false);
1304
+ const [filter, setFilter] = (0, react.useState)("");
1305
+ const [activeIndex, setActiveIndex] = (0, react.useState)(0);
1306
+ const [triggerWidth, setTriggerWidth] = (0, react.useState)(0);
1307
+ const rootRef = (0, react.useRef)(null);
1308
+ const triggerRef = (0, react.useRef)(null);
1309
+ const panelRef = (0, react.useRef)(null);
1310
+ const listRef = (0, react.useRef)(null);
1311
+ const selected = value === "" ? undefined : groups.find((group) => group.id === value);
1312
+ const filtered = (0, react.useMemo)(() => {
1313
+ const needle = filter.trim().toLowerCase();
1314
+ if (needle === "") return groups;
1315
+ return groups.filter((group) => group.name.toLowerCase().includes(needle));
1316
+ }, [groups, filter]);
1317
+ const position = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredPosition)({
1318
+ open,
1319
+ anchorRef: triggerRef,
1320
+ panelRef,
1321
+ side: "bottom",
1322
+ align: "start",
1323
+ gap: PANEL_GAP,
1324
+ margin: 24
1325
+ });
1326
+ const maxHeight = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredMaxHeight)(panelRef, PANEL_MAX_HEIGHT, open ? filtered.length : 0);
1327
+ (0, __deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, setOpen, panelRef);
1328
+ (0, react.useEffect)(() => {
1329
+ if (!open) return;
1330
+ setFilter("");
1331
+ setActiveIndex(0);
1332
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1333
+ const timer$1 = window.setTimeout(() => {
1334
+ panelRef.current?.querySelector("input")?.focus();
1335
+ }, 0);
1336
+ return () => window.clearTimeout(timer$1);
1337
+ }, [open]);
1338
+ (0, react.useEffect)(() => {
1339
+ if (!open) return;
1340
+ const onResize = () => {
1341
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1342
+ };
1343
+ window.addEventListener("resize", onResize);
1344
+ return () => window.removeEventListener("resize", onResize);
1345
+ }, [open]);
1346
+ (0, react.useEffect)(() => {
1347
+ const list = listRef.current;
1348
+ if (list === null) return;
1349
+ list.querySelector("[data-active='true']")?.scrollIntoView({ block: "nearest" });
1350
+ }, [activeIndex, filter]);
1351
+ const close = () => {
1352
+ setOpen(false);
1353
+ triggerRef.current?.focus();
1354
+ };
1355
+ const pick = (group) => {
1356
+ onSelect(group);
1357
+ close();
1358
+ };
1359
+ const onKeyDown = (event) => {
1360
+ if (event.key === "Escape") {
1361
+ event.stopPropagation();
1362
+ close();
1363
+ return;
1364
+ }
1365
+ if (event.key === "ArrowDown") {
1366
+ event.preventDefault();
1367
+ if (filtered.length > 0) setActiveIndex((index) => Math.min(index + 1, filtered.length - 1));
1368
+ return;
1369
+ }
1370
+ if (event.key === "ArrowUp") {
1371
+ event.preventDefault();
1372
+ setActiveIndex((index) => Math.max(index - 1, 0));
1373
+ return;
1374
+ }
1375
+ if (event.key === "Enter") {
1376
+ event.preventDefault();
1377
+ const group = filtered[activeIndex];
1378
+ if (group !== undefined) pick(group);
1379
+ }
1380
+ };
1381
+ return (0, react_jsx_runtime.jsxs)("div", {
1382
+ className: "s2s-groupPicker",
1383
+ ref: rootRef,
1384
+ children: [(0, react_jsx_runtime.jsxs)("button", {
1385
+ type: "button",
1386
+ ref: triggerRef,
1387
+ className: "s2s-selectField",
1388
+ disabled,
1389
+ "aria-haspopup": "listbox",
1390
+ "aria-expanded": open,
1391
+ onClick: () => {
1392
+ setOpen((next) => !next);
1393
+ },
1394
+ children: [(0, react_jsx_runtime.jsx)("span", {
1395
+ className: selected === undefined ? "s2s-selectPlaceholder" : "s2s-selectValue",
1396
+ children: selected === undefined ? placeholder : selected.name
1397
+ }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1398
+ size: 14,
1399
+ className: `s2s-selectChevron${open ? " s2s-selectChevronOpen" : ""}`
1400
+ })]
1401
+ }), open && (0, react_jsx_runtime.jsxs)(__deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
1402
+ ref: panelRef,
1403
+ className: "s2s-groupPanel",
1404
+ role: "listbox",
1405
+ style: position === null ? { visibility: "hidden" } : {
1406
+ ...position,
1407
+ ...triggerWidth > 0 ? { width: `${triggerWidth}px` } : {},
1408
+ maxHeight: `${maxHeight}px`
1409
+ },
1410
+ onKeyDown,
1411
+ children: [(0, react_jsx_runtime.jsx)("div", {
1412
+ className: "s2s-groupSearch",
1413
+ children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Input, {
1414
+ type: "text",
1415
+ value: filter,
1416
+ placeholder: searchPlaceholder,
1417
+ onChange: (event) => {
1418
+ setFilter(event.target.value);
1419
+ setActiveIndex(0);
1420
+ }
1421
+ })
1422
+ }), (0, react_jsx_runtime.jsxs)("div", {
1423
+ className: "s2s-groupList",
1424
+ ref: listRef,
1425
+ children: [filtered.length === 0 && (0, react_jsx_runtime.jsx)("p", {
1426
+ className: "s2s-groupEmpty",
1427
+ children: emptyLabel
1428
+ }), filtered.map((group, index) => (0, react_jsx_runtime.jsxs)("button", {
1429
+ type: "button",
1430
+ role: "option",
1431
+ "aria-selected": group.id === value,
1432
+ "data-active": index === activeIndex || undefined,
1433
+ className: "s2s-groupOption",
1434
+ onMouseEnter: () => {
1435
+ setActiveIndex(index);
1436
+ },
1437
+ onClick: () => {
1438
+ pick(group);
1439
+ },
1440
+ children: [(0, react_jsx_runtime.jsx)("span", {
1441
+ className: "s2s-groupOptionName",
1442
+ children: group.name
1443
+ }), group.id === value && (0, react_jsx_runtime.jsx)("span", {
1444
+ className: "s2s-groupOptionCheck",
1445
+ "aria-hidden": "true",
1446
+ children: "✓"
1447
+ })]
1448
+ }, group.id))]
1449
+ })]
1450
+ })]
1451
+ });
1452
+ }
1453
+
1187
1454
  //#endregion
1188
1455
  //#region src/client/section/KeysCard.tsx
1189
1456
  const PAGE_SIZE = 5;
@@ -1200,7 +1467,6 @@ function KeysCard({ api, config, groups, t }) {
1200
1467
  const [loading, setLoading] = (0, react.useState)(false);
1201
1468
  const [listError, setListError] = (0, react.useState)(undefined);
1202
1469
  const [creating, setCreating] = (0, react.useState)(false);
1203
- const [groupMenuOpen, setGroupMenuOpen] = (0, react.useState)(false);
1204
1470
  const [groupId, setGroupId] = (0, react.useState)("");
1205
1471
  const [name$1, setName] = (0, react.useState)("");
1206
1472
  const [nameTouched, setNameTouched] = (0, react.useState)(false);
@@ -1279,6 +1545,9 @@ else nextSet.delete(keyId);
1279
1545
  setGroupId(gid);
1280
1546
  if (!nameTouched) setName(groupNameOf(gid));
1281
1547
  };
1548
+ const selectGroup = (group) => {
1549
+ pickGroup(group.id);
1550
+ };
1282
1551
  const closeCreate = () => {
1283
1552
  setCreating(false);
1284
1553
  setGroupId("");
@@ -1467,36 +1736,14 @@ else nextSet.delete(keyId);
1467
1736
  children: [(0, react_jsx_runtime.jsx)("span", {
1468
1737
  className: "s2s-fieldLabel",
1469
1738
  children: t("keyGroupLabel")
1470
- }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
1471
- open: groupMenuOpen,
1472
- anchor: (0, react_jsx_runtime.jsx)("span", {
1473
- className: "s2s-menuAnchor",
1474
- children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1475
- variant: "outline",
1476
- onClick: () => {
1477
- setGroupMenuOpen(true);
1478
- },
1479
- children: (0, react_jsx_runtime.jsxs)("span", {
1480
- className: "s2s-selectTrigger",
1481
- children: [(0, react_jsx_runtime.jsx)("span", { children: groupId === "" ? t("keyGroupPlaceholder") : groupNameOf(groupId) }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1482
- size: 14,
1483
- className: "s2s-selectChevron"
1484
- })]
1485
- })
1486
- })
1487
- }),
1488
- items: groups.map((group) => ({
1489
- id: String(group.id),
1490
- label: group.name
1491
- })),
1492
- selectedId: groupId === "" ? undefined : String(groupId),
1493
- onSelect: (id) => {
1494
- pickGroup(Number(id));
1495
- setGroupMenuOpen(false);
1496
- },
1497
- onClose: () => {
1498
- setGroupMenuOpen(false);
1499
- }
1739
+ }), (0, react_jsx_runtime.jsx)(GroupPicker, {
1740
+ groups,
1741
+ value: groupId,
1742
+ placeholder: t("keyGroupPlaceholder"),
1743
+ searchPlaceholder: t("keyGroupSearch"),
1744
+ emptyLabel: t("keyGroupSearchEmpty"),
1745
+ disabled: busy,
1746
+ onSelect: selectGroup
1500
1747
  })]
1501
1748
  }), (0, react_jsx_runtime.jsxs)("div", {
1502
1749
  className: "s2s-inline",
package/lib/icon.d.ts CHANGED
@@ -41,9 +41,19 @@ export declare function parseSiteTitle(html: string): string | undefined;
41
41
  * the closure, so the icon and site-name consumers share one HTML read per
42
42
  * TTL window. Errors never throw — an unavailable answer resolves to
43
43
  * `undefined` and the client keeps its fallbacks.
44
+ *
45
+ * Title caching is two-tier: a RESOLVED title rides the shared 10-minute
46
+ * window, while a title-less result is only trusted for
47
+ * {@link NEGATIVE_TTL_MS} and one in-call retry backs the first read — the
48
+ * login-to-first-sync window is exactly when a cold connection would
49
+ * otherwise brand every provider with the `sub2api` fallback name.
44
50
  */
45
51
  export declare function createInstanceProbe(options?: {
46
52
  fetchImpl?: typeof fetch;
53
+ /** Test knob: the title-less retry wait (default {@link RETRY_DELAY_MS}). */
54
+ retryDelayMs?: number;
55
+ /** Test knob: the title-less negative-cache window (default {@link NEGATIVE_TTL_MS}). */
56
+ negativeTtlMs?: number;
47
57
  }): {
48
58
  icon: (instance: string) => Promise<IconPayload | undefined>;
49
59
  siteName: (instance: string) => Promise<string | undefined>;
package/lib/index.js CHANGED
@@ -895,6 +895,7 @@ var Sub2apiSyncEngine = class {
895
895
  const site = await this.siteName?.().catch(() => undefined);
896
896
  const syncWarnings = [];
897
897
  if (keyWalkTruncated) syncWarnings.push("面板 key 数量超过单次同步的读取上限(10 页 × 100 = 1000 把)——超出部分未参与同步,也不会被移除");
898
+ if (site === undefined && this.siteName !== undefined && (config.providerNameTemplate ?? "").includes("{siteName}")) syncWarnings.push("实例站点名探测失败(实例不可达或页面 <title> 缺失)——displayName 暂用回退名 sub2api,站点名可解析后的同步会自动更正");
898
899
  const effective = {
899
900
  ...config,
900
901
  selectedKeys: selection ?? config.selectedKeys
@@ -1168,6 +1169,14 @@ else if (isOwnedRoute(route)) {
1168
1169
  //#region src/icon.ts
1169
1170
  /** How long a resolved icon URL is trusted before re-reading the HTML. */
1170
1171
  const URL_TTL_MS = 6e5;
1172
+ /** How long a TITLE-LESS probe result is trusted before re-reading: the sync
1173
+ * that renders `{siteName}` must not pin a 10-minute negative on a cold
1174
+ * connection, or the first sync after login names every provider with the
1175
+ * `sub2api` fallback until the cache expires. */
1176
+ const NEGATIVE_TTL_MS = 15e3;
1177
+ /** One in-call retry wait when a fresh probe still answers without a title
1178
+ * (cold DNS/TLS or a momentary gateway hiccup — not proof the title is absent). */
1179
+ const RETRY_DELAY_MS = 1200;
1171
1180
  /** Discovery + byte fetches cap, so an offline instance cannot stall callers. */
1172
1181
  const TIMEOUT_MS = 2500;
1173
1182
  /** Refuse to proxy absurd payloads. */
@@ -1252,6 +1261,8 @@ function guessContentType(url) {
1252
1261
  }
1253
1262
  function createInstanceProbe(options = {}) {
1254
1263
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1264
+ const retryDelayMs = options.retryDelayMs ?? RETRY_DELAY_MS;
1265
+ const negativeTtlMs = options.negativeTtlMs ?? NEGATIVE_TTL_MS;
1255
1266
  const cache = new Map();
1256
1267
  const metaOf = async (instance) => {
1257
1268
  const base = instance.trim().replace(/\/+$/, "");
@@ -1266,6 +1277,9 @@ function createInstanceProbe(options = {}) {
1266
1277
  });
1267
1278
  return meta;
1268
1279
  };
1280
+ const wait = (ms) => new Promise((resolve) => {
1281
+ setTimeout(resolve, ms);
1282
+ });
1269
1283
  return {
1270
1284
  icon: async (instance) => {
1271
1285
  const meta = await metaOf(instance);
@@ -1284,7 +1298,27 @@ function createInstanceProbe(options = {}) {
1284
1298
  return undefined;
1285
1299
  }
1286
1300
  },
1287
- siteName: async (instance) => (await metaOf(instance))?.title
1301
+ siteName: async (instance) => {
1302
+ const base = instance.trim().replace(/\/+$/, "");
1303
+ if (!/^https?:\/\//i.test(base)) return undefined;
1304
+ const now = Date.now();
1305
+ const hit = cache.get(base);
1306
+ if (hit !== undefined && hit.meta.title !== undefined && now - hit.at < URL_TTL_MS) return hit.meta.title;
1307
+ if (hit !== undefined && hit.meta.title === undefined && now - hit.at < negativeTtlMs) return undefined;
1308
+ const first = await probeInstance(base, fetchImpl);
1309
+ cache.set(base, {
1310
+ meta: first,
1311
+ at: Date.now()
1312
+ });
1313
+ if (first.title !== undefined) return first.title;
1314
+ await wait(retryDelayMs);
1315
+ const second = await probeInstance(base, fetchImpl);
1316
+ cache.set(base, {
1317
+ meta: second,
1318
+ at: Date.now()
1319
+ });
1320
+ return second.title;
1321
+ }
1288
1322
  };
1289
1323
  }
1290
1324
 
@@ -2636,6 +2670,14 @@ function apply(ctx, config) {
2636
2670
  const mutateOwn = (ops) => mutateWithRetry(ctx.settings, OWN_NS, () => ops, { missingError: "sub2api-sync: 自身 settings 命名空间未注册" });
2637
2671
  const instance = () => source().baseURL?.trim() ?? "";
2638
2672
  const instanceProbe = createInstanceProbe();
2673
+ /** Warm the site-name cache at auth events. The first sync right after a
2674
+ * login/authorization renders `{siteName}` — a cold probe there would
2675
+ * brand the providers with the `sub2api` fallback name. Fire-and-forget:
2676
+ * the probe's own negative-TTL window bounds any retry fetching. */
2677
+ const warmProbe = () => {
2678
+ const base = instance();
2679
+ if (/^https?:\/\//i.test(base)) void instanceProbe.siteName(base).catch(() => undefined);
2680
+ };
2639
2681
  const oauthAttempts = new OAuthAttemptStore();
2640
2682
  const deps = {
2641
2683
  instance,
@@ -2650,6 +2692,7 @@ function apply(ctx, config) {
2650
2692
  value: email
2651
2693
  }]).catch(() => undefined);
2652
2694
  const outcome = await gateway.login(email, pwd);
2695
+ warmProbe();
2653
2696
  if (outcome.kind === "2fa") {
2654
2697
  pendingTypedPassword = typed;
2655
2698
  return {
@@ -2667,6 +2710,7 @@ function apply(ctx, config) {
2667
2710
  },
2668
2711
  login2FA: async (code) => {
2669
2712
  await gateway.login2FA(code);
2713
+ warmProbe();
2670
2714
  if (pendingTypedPassword !== undefined) {
2671
2715
  await credentials.set(panelPasswordRef(instance()), pendingTypedPassword).catch(() => undefined);
2672
2716
  pendingTypedPassword = undefined;
@@ -2825,6 +2869,7 @@ function apply(ctx, config) {
2825
2869
  });
2826
2870
  await gateway.storeOAuthTokens(pair);
2827
2871
  oauthAttempts.settle(attempt.id, "completed");
2872
+ warmProbe();
2828
2873
  const successMessage = lang === "en" ? "You can close this page and return to the desktop app to continue." : "你可以关闭此页面,返回桌面端继续工作。";
2829
2874
  return { html: callbackPageHtml("success", successMessage, lang) };
2830
2875
  } catch (cause) {
@@ -2834,6 +2879,7 @@ function apply(ctx, config) {
2834
2879
  }
2835
2880
  },
2836
2881
  oauthStatus: async (attemptId) => {
2882
+ warmProbe();
2837
2883
  const attempt = attemptId !== undefined ? oauthAttempts.peek(attemptId) : undefined;
2838
2884
  const email = source().email;
2839
2885
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sub2api-sync",
3
3
  "description": "Sync one sub2api instance's groups, keys, and models into dsh-llm-pi-ai provider routes: panel-driven login (password / OAuth), one relay key one provider.",
4
- "version": "0.2.3",
4
+ "version": "0.2.5",
5
5
  "license": "MIT",
6
6
  "author": "Shuai-S",
7
7
  "homepage": "https://github.com/Shuai-S/dsh-sub2api-sync#readme",