@mangoc/dsh-sub2api-sync 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.cjs CHANGED
@@ -125,8 +125,8 @@ const zh = {
125
125
  detailSectionEmpty: "还没有同步过——在设置 → Sub2API 同步 完成首次同步",
126
126
  activationTitle: "开始使用 Sub2API 同步",
127
127
  activationStep1: "1. 在设置中连接面板(账号密码或网页授权)",
128
- activationStep2: "2. 勾选要同步的分组(key 自动准备)",
129
- activationStep3: "3. 点击「立即同步」,模型即可使用",
128
+ activationStep2: "2. 在 Keys 卡勾选要同步的 key(可跨分组)",
129
+ activationStep3: "3. 点击「同步所选」,模型即可使用",
130
130
  activationOpenConfig: "打开配置页",
131
131
  activationDismiss: "知道了",
132
132
  keysTitle: "Keys",
@@ -138,9 +138,13 @@ const zh = {
138
138
  keyCreated: "已创建并绑定:{name}",
139
139
  keysListEmpty: "还没有 key",
140
140
  keysListLoading: "正在读取 keys…",
141
+ keysSearchPlaceholder: "搜索 key…",
142
+ keysSearchEmpty: "没有匹配的 key",
141
143
  syncSelected: "同步所选({count})",
142
144
  syncSelectedDone: "同步完成:{ok}/{total} 条成功",
143
- keysLoadMore: "加载更多(还有 {count} 个)"
145
+ keysPagePrev: "上一页",
146
+ keysPageNext: "下一页",
147
+ keysPageStatus: "第 {page} / {pages} 页 · 共 {total} 个"
144
148
  };
145
149
  const en = {
146
150
  nav: "Sub2API Sync",
@@ -235,8 +239,8 @@ const en = {
235
239
  detailSectionEmpty: "Never synced — finish the first sync in Settings → Sub2API Sync",
236
240
  activationTitle: "Get started with Sub2API Sync",
237
241
  activationStep1: "1. Connect the panel in Settings (account password or web authorization)",
238
- activationStep2: "2. Check the groups to sync (keys are prepared automatically)",
239
- activationStep3: "3. Click \"Sync now\" and the models are ready",
242
+ activationStep2: "2. Tick the keys to sync on the Keys card (cross-group)",
243
+ activationStep3: "3. Click \"Sync selected\" and the models are ready",
240
244
  activationOpenConfig: "Open configuration",
241
245
  activationDismiss: "Got it",
242
246
  keysTitle: "Keys",
@@ -248,9 +252,13 @@ const en = {
248
252
  keyCreated: "Created and bound: {name}",
249
253
  keysListEmpty: "No keys yet",
250
254
  keysListLoading: "Loading keys…",
255
+ keysSearchPlaceholder: "Search keys…",
256
+ keysSearchEmpty: "No keys match",
251
257
  syncSelected: "Sync selected ({count})",
252
258
  syncSelectedDone: "Synced: {ok}/{total} succeeded",
253
- keysLoadMore: "Load more ({count} left)"
259
+ keysPagePrev: "Previous page",
260
+ keysPageNext: "Next page",
261
+ keysPageStatus: "Page {page} / {pages} · {total} keys"
254
262
  };
255
263
  const dictionaries = {
256
264
  zh,
@@ -404,7 +412,7 @@ function createPluginApi() {
404
412
  login2fa: (code) => callJson(ROUTES.login2fa, post({ code })),
405
413
  logout: () => callJson(ROUTES.logout, post()),
406
414
  groups: () => callJson(ROUTES.groups),
407
- listKeys: (page, pageSize) => callJson(`${ROUTES.keys}?page=${page}&pageSize=${pageSize}`),
415
+ listKeys: (page, pageSize, search) => callJson(`${ROUTES.keys}?page=${page}&pageSize=${pageSize}${search !== undefined && search.length > 0 ? `&search=${encodeURIComponent(search)}` : ""}`),
408
416
  createKey: (input) => callJson(ROUTES.keys, post(input)),
409
417
  site: () => callJson(ROUTES.site),
410
418
  account: () => callJson(ROUTES.account),
@@ -743,6 +751,31 @@ const CSS = `
743
751
  opacity: 0.55;
744
752
  }
745
753
 
754
+ /* The keys-card search input: fixed-width slot between the title and the
755
+ action buttons (see the s2s-actions flex layout). */
756
+ .s2s-searchInput {
757
+ flex: none;
758
+ width: 220px;
759
+ }
760
+
761
+ /* The keys-card pager: centered prev / status / next strip. */
762
+ .s2s-pager {
763
+ display: flex;
764
+ align-items: center;
765
+ justify-content: flex-end;
766
+ gap: 10px;
767
+ flex-wrap: wrap;
768
+ min-height: 32px;
769
+ }
770
+
771
+ .s2s-pageStatus {
772
+ color: var(--dsw-alias-label-secondary);
773
+ font-size: 12px;
774
+ line-height: 18px;
775
+ font-variant-numeric: tabular-nums;
776
+ white-space: nowrap;
777
+ }
778
+
746
779
  /* U2 placements: the provider-card badge, the Models-page footer strip, and
747
780
  the Plugins-page activation steps. */
748
781
  .s2s-badgeRow {
@@ -1148,12 +1181,16 @@ function protocolForPlatform(platform) {
1148
1181
  //#endregion
1149
1182
  //#region src/client/section/KeysCard.tsx
1150
1183
  const PAGE_SIZE = 5;
1184
+ const SEARCH_DEBOUNCE_MS = 300;
1151
1185
  function KeysCard({ api, config, groups, t }) {
1152
1186
  const snapshot$1 = useConfigForm(config);
1153
1187
  const [keys, setKeys] = (0, react.useState)([]);
1154
- const [page, setPage] = (0, react.useState)(0);
1188
+ const [search, setSearch] = (0, react.useState)("");
1189
+ const [query, setQuery] = (0, react.useState)("");
1190
+ const [page, setPage] = (0, react.useState)(1);
1155
1191
  const [pages, setPages] = (0, react.useState)(1);
1156
1192
  const [total, setTotal] = (0, react.useState)(0);
1193
+ const [reloadTick, setReloadTick] = (0, react.useState)(0);
1157
1194
  const [loading, setLoading] = (0, react.useState)(false);
1158
1195
  const [listError, setListError] = (0, react.useState)(undefined);
1159
1196
  const [creating, setCreating] = (0, react.useState)(false);
@@ -1164,23 +1201,41 @@ function KeysCard({ api, config, groups, t }) {
1164
1201
  const [busy, setBusy] = (0, react.useState)(false);
1165
1202
  const [syncing, setSyncing] = (0, react.useState)(false);
1166
1203
  const [feedback, setFeedback] = (0, react.useState)(undefined);
1167
- const loadPage = (next) => {
1204
+ const appliedQueryRef = (0, react.useRef)(query);
1205
+ (0, react.useEffect)(() => {
1206
+ const timer$1 = window.setTimeout(() => {
1207
+ const next = search.trim();
1208
+ if (appliedQueryRef.current !== next) {
1209
+ appliedQueryRef.current = next;
1210
+ setQuery(next);
1211
+ setPage(1);
1212
+ }
1213
+ }, SEARCH_DEBOUNCE_MS);
1214
+ return () => window.clearTimeout(timer$1);
1215
+ }, [search]);
1216
+ const requestSeqRef = (0, react.useRef)(0);
1217
+ (0, react.useEffect)(() => {
1218
+ const requestId = ++requestSeqRef.current;
1168
1219
  setLoading(true);
1169
1220
  setListError(undefined);
1170
- api.listKeys(next, PAGE_SIZE).then((response) => {
1171
- setKeys((previous) => next === 1 ? response.keys : [...previous, ...response.keys]);
1221
+ api.listKeys(page, PAGE_SIZE, query === "" ? undefined : query).then((response) => {
1222
+ if (requestSeqRef.current !== requestId) return;
1223
+ setKeys(response.keys);
1172
1224
  setPages(response.pages);
1173
1225
  setTotal(response.total);
1174
- setPage(next);
1226
+ if (response.pages > 0 && page > response.pages) setPage(response.pages);
1175
1227
  }).catch((cause) => {
1228
+ if (requestSeqRef.current !== requestId) return;
1176
1229
  setListError(cause instanceof Error ? cause.message : String(cause));
1177
1230
  }).finally(() => {
1178
- setLoading(false);
1231
+ if (requestSeqRef.current === requestId) setLoading(false);
1179
1232
  });
1180
- };
1181
- (0, react.useEffect)(() => {
1182
- loadPage(1);
1183
- }, [api]);
1233
+ }, [
1234
+ api,
1235
+ page,
1236
+ query,
1237
+ reloadTick
1238
+ ]);
1184
1239
  const selectedIds = snapshot$1.value?.selectedKeys ?? [];
1185
1240
  const toggle = (keyId, next) => {
1186
1241
  const nextSet = new Set(selectedIds);
@@ -1241,7 +1296,8 @@ else nextSet.delete(keyId);
1241
1296
  text: t("keyCreated", { name: response.key.name })
1242
1297
  });
1243
1298
  closeCreate();
1244
- loadPage(1);
1299
+ setPage(1);
1300
+ setReloadTick((tick) => tick + 1);
1245
1301
  }).catch((cause) => {
1246
1302
  setFeedback({
1247
1303
  tone: "error",
@@ -1276,10 +1332,19 @@ else nextSet.delete(keyId);
1276
1332
  }), (0, react_jsx_runtime.jsxs)("div", {
1277
1333
  className: "s2s-actions",
1278
1334
  children: [
1335
+ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Input, {
1336
+ className: "s2s-searchInput",
1337
+ type: "text",
1338
+ value: search,
1339
+ placeholder: t("keysSearchPlaceholder"),
1340
+ onChange: (event) => {
1341
+ setSearch(event.target.value);
1342
+ }
1343
+ }),
1279
1344
  (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1280
1345
  variant: "outline",
1281
1346
  onClick: () => {
1282
- loadPage(1);
1347
+ setReloadTick((tick) => tick + 1);
1283
1348
  },
1284
1349
  children: t("groupsRefresh")
1285
1350
  }),
@@ -1309,7 +1374,7 @@ else nextSet.delete(keyId);
1309
1374
  }),
1310
1375
  !loading && keys.length === 0 && listError === undefined && (0, react_jsx_runtime.jsx)("p", {
1311
1376
  className: "s2s-feedback s2s-hint",
1312
- children: t("keysListEmpty")
1377
+ children: query !== "" ? t("keysSearchEmpty") : t("keysListEmpty")
1313
1378
  }),
1314
1379
  loading && keys.length === 0 && (0, react_jsx_runtime.jsx)("p", {
1315
1380
  className: "s2s-feedback s2s-hint",
@@ -1354,16 +1419,34 @@ else nextSet.delete(keyId);
1354
1419
  }, key.id);
1355
1420
  })
1356
1421
  }),
1357
- keys.length > 0 && page < pages && (0, react_jsx_runtime.jsx)("div", {
1358
- className: "s2s-actions",
1359
- children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1360
- variant: "outline",
1361
- disabled: loading,
1362
- onClick: () => {
1363
- loadPage(page + 1);
1364
- },
1365
- children: t("keysLoadMore", { count: Math.max(0, Math.min(PAGE_SIZE, total - keys.length)) })
1366
- })
1422
+ keys.length > 0 && (pages > 1 || query !== "") && (0, react_jsx_runtime.jsxs)("div", {
1423
+ className: "s2s-pager",
1424
+ children: [
1425
+ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1426
+ variant: "outline",
1427
+ disabled: loading || page <= 1,
1428
+ onClick: () => {
1429
+ setPage(page - 1);
1430
+ },
1431
+ children: t("keysPagePrev")
1432
+ }),
1433
+ (0, react_jsx_runtime.jsx)("span", {
1434
+ className: "s2s-pageStatus",
1435
+ children: t("keysPageStatus", {
1436
+ page,
1437
+ pages,
1438
+ total
1439
+ })
1440
+ }),
1441
+ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1442
+ variant: "outline",
1443
+ disabled: loading || page >= pages,
1444
+ onClick: () => {
1445
+ setPage(page + 1);
1446
+ },
1447
+ children: t("keysPageNext")
1448
+ })
1449
+ ]
1367
1450
  }),
1368
1451
  (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Modal, {
1369
1452
  open: creating,
@@ -2313,7 +2396,7 @@ function DetailSectionBody({ t }) {
2313
2396
  //#endregion
2314
2397
  //#region src/client/plugins/ActivationGuide.tsx
2315
2398
  function ActivationGuide(props) {
2316
- const { t, onDismiss, onOpenDetails } = props;
2399
+ const { t, onDismiss, onOpenDetails, openBundleDetails } = props;
2317
2400
  return (0, react_jsx_runtime.jsxs)("div", {
2318
2401
  className: "s2s-card",
2319
2402
  children: [
@@ -2333,7 +2416,9 @@ function ActivationGuide(props) {
2333
2416
  className: "s2s-actions",
2334
2417
  children: [(0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2335
2418
  variant: "primary",
2336
- onClick: onOpenDetails,
2419
+ onClick: () => {
2420
+ if (!(openBundleDetails?.() ?? false)) onOpenDetails();
2421
+ },
2337
2422
  children: t("activationOpenConfig")
2338
2423
  }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2339
2424
  variant: "ghost",
@@ -2391,10 +2476,17 @@ function mount(ctx) {
2391
2476
  id: "sub2api-sync",
2392
2477
  locale: NS
2393
2478
  }, DetailSection));
2479
+ const activationOps = { openBundleDetails: () => {
2480
+ const nav = ctx.pluginNavigation;
2481
+ if (nav === undefined) return false;
2482
+ nav.openBundle(PKG_NAME);
2483
+ return true;
2484
+ } };
2394
2485
  ctx.slots.inject("plugins.bundle.activation", () => ctx.slots.register({
2395
2486
  name: "plugins.bundle.activation",
2396
2487
  key: PKG_NAME,
2397
- locale: NS
2488
+ locale: NS,
2489
+ inject: () => activationOps
2398
2490
  }, ActivationGuide));
2399
2491
  }
2400
2492
 
@@ -2404,7 +2496,8 @@ const name = "dsh-sub2api-sync-client";
2404
2496
  const inject = [
2405
2497
  "slots",
2406
2498
  "locale",
2407
- "configForms"
2499
+ "configForms",
2500
+ "pluginNavigation"
2408
2501
  ];
2409
2502
  function apply(ctx) {
2410
2503
  mount(ctx);
package/lib/client.js CHANGED
@@ -131,8 +131,8 @@ const zh = {
131
131
  detailSectionEmpty: "还没有同步过——在设置 → Sub2API 同步 完成首次同步",
132
132
  activationTitle: "开始使用 Sub2API 同步",
133
133
  activationStep1: "1. 在设置中连接面板(账号密码或网页授权)",
134
- activationStep2: "2. 勾选要同步的分组(key 自动准备)",
135
- activationStep3: "3. 点击「立即同步」,模型即可使用",
134
+ activationStep2: "2. 在 Keys 卡勾选要同步的 key(可跨分组)",
135
+ activationStep3: "3. 点击「同步所选」,模型即可使用",
136
136
  activationOpenConfig: "打开配置页",
137
137
  activationDismiss: "知道了",
138
138
  keysTitle: "Keys",
@@ -144,9 +144,13 @@ const zh = {
144
144
  keyCreated: "已创建并绑定:{name}",
145
145
  keysListEmpty: "还没有 key",
146
146
  keysListLoading: "正在读取 keys…",
147
+ keysSearchPlaceholder: "搜索 key…",
148
+ keysSearchEmpty: "没有匹配的 key",
147
149
  syncSelected: "同步所选({count})",
148
150
  syncSelectedDone: "同步完成:{ok}/{total} 条成功",
149
- keysLoadMore: "加载更多(还有 {count} 个)"
151
+ keysPagePrev: "上一页",
152
+ keysPageNext: "下一页",
153
+ keysPageStatus: "第 {page} / {pages} 页 · 共 {total} 个"
150
154
  };
151
155
  const en = {
152
156
  nav: "Sub2API Sync",
@@ -241,8 +245,8 @@ const en = {
241
245
  detailSectionEmpty: "Never synced — finish the first sync in Settings → Sub2API Sync",
242
246
  activationTitle: "Get started with Sub2API Sync",
243
247
  activationStep1: "1. Connect the panel in Settings (account password or web authorization)",
244
- activationStep2: "2. Check the groups to sync (keys are prepared automatically)",
245
- activationStep3: "3. Click \"Sync now\" and the models are ready",
248
+ activationStep2: "2. Tick the keys to sync on the Keys card (cross-group)",
249
+ activationStep3: "3. Click \"Sync selected\" and the models are ready",
246
250
  activationOpenConfig: "Open configuration",
247
251
  activationDismiss: "Got it",
248
252
  keysTitle: "Keys",
@@ -254,9 +258,13 @@ const en = {
254
258
  keyCreated: "Created and bound: {name}",
255
259
  keysListEmpty: "No keys yet",
256
260
  keysListLoading: "Loading keys…",
261
+ keysSearchPlaceholder: "Search keys…",
262
+ keysSearchEmpty: "No keys match",
257
263
  syncSelected: "Sync selected ({count})",
258
264
  syncSelectedDone: "Synced: {ok}/{total} succeeded",
259
- keysLoadMore: "Load more ({count} left)"
265
+ keysPagePrev: "Previous page",
266
+ keysPageNext: "Next page",
267
+ keysPageStatus: "Page {page} / {pages} · {total} keys"
260
268
  };
261
269
  const dictionaries = {
262
270
  zh,
@@ -410,7 +418,7 @@ function createPluginApi() {
410
418
  login2fa: (code) => callJson(ROUTES.login2fa, post({ code })),
411
419
  logout: () => callJson(ROUTES.logout, post()),
412
420
  groups: () => callJson(ROUTES.groups),
413
- listKeys: (page, pageSize) => callJson(`${ROUTES.keys}?page=${page}&pageSize=${pageSize}`),
421
+ listKeys: (page, pageSize, search) => callJson(`${ROUTES.keys}?page=${page}&pageSize=${pageSize}${search !== undefined && search.length > 0 ? `&search=${encodeURIComponent(search)}` : ""}`),
414
422
  createKey: (input) => callJson(ROUTES.keys, post(input)),
415
423
  site: () => callJson(ROUTES.site),
416
424
  account: () => callJson(ROUTES.account),
@@ -749,6 +757,31 @@ const CSS = `
749
757
  opacity: 0.55;
750
758
  }
751
759
 
760
+ /* The keys-card search input: fixed-width slot between the title and the
761
+ action buttons (see the s2s-actions flex layout). */
762
+ .s2s-searchInput {
763
+ flex: none;
764
+ width: 220px;
765
+ }
766
+
767
+ /* The keys-card pager: centered prev / status / next strip. */
768
+ .s2s-pager {
769
+ display: flex;
770
+ align-items: center;
771
+ justify-content: flex-end;
772
+ gap: 10px;
773
+ flex-wrap: wrap;
774
+ min-height: 32px;
775
+ }
776
+
777
+ .s2s-pageStatus {
778
+ color: var(--dsw-alias-label-secondary);
779
+ font-size: 12px;
780
+ line-height: 18px;
781
+ font-variant-numeric: tabular-nums;
782
+ white-space: nowrap;
783
+ }
784
+
752
785
  /* U2 placements: the provider-card badge, the Models-page footer strip, and
753
786
  the Plugins-page activation steps. */
754
787
  .s2s-badgeRow {
@@ -1154,12 +1187,16 @@ function protocolForPlatform(platform) {
1154
1187
  //#endregion
1155
1188
  //#region src/client/section/KeysCard.tsx
1156
1189
  const PAGE_SIZE = 5;
1190
+ const SEARCH_DEBOUNCE_MS = 300;
1157
1191
  function KeysCard({ api, config, groups, t }) {
1158
1192
  const snapshot$1 = useConfigForm(config);
1159
1193
  const [keys, setKeys] = (0, react.useState)([]);
1160
- const [page, setPage] = (0, react.useState)(0);
1194
+ const [search, setSearch] = (0, react.useState)("");
1195
+ const [query, setQuery] = (0, react.useState)("");
1196
+ const [page, setPage] = (0, react.useState)(1);
1161
1197
  const [pages, setPages] = (0, react.useState)(1);
1162
1198
  const [total, setTotal] = (0, react.useState)(0);
1199
+ const [reloadTick, setReloadTick] = (0, react.useState)(0);
1163
1200
  const [loading, setLoading] = (0, react.useState)(false);
1164
1201
  const [listError, setListError] = (0, react.useState)(undefined);
1165
1202
  const [creating, setCreating] = (0, react.useState)(false);
@@ -1170,23 +1207,41 @@ function KeysCard({ api, config, groups, t }) {
1170
1207
  const [busy, setBusy] = (0, react.useState)(false);
1171
1208
  const [syncing, setSyncing] = (0, react.useState)(false);
1172
1209
  const [feedback, setFeedback] = (0, react.useState)(undefined);
1173
- const loadPage = (next) => {
1210
+ const appliedQueryRef = (0, react.useRef)(query);
1211
+ (0, react.useEffect)(() => {
1212
+ const timer$1 = window.setTimeout(() => {
1213
+ const next = search.trim();
1214
+ if (appliedQueryRef.current !== next) {
1215
+ appliedQueryRef.current = next;
1216
+ setQuery(next);
1217
+ setPage(1);
1218
+ }
1219
+ }, SEARCH_DEBOUNCE_MS);
1220
+ return () => window.clearTimeout(timer$1);
1221
+ }, [search]);
1222
+ const requestSeqRef = (0, react.useRef)(0);
1223
+ (0, react.useEffect)(() => {
1224
+ const requestId = ++requestSeqRef.current;
1174
1225
  setLoading(true);
1175
1226
  setListError(undefined);
1176
- api.listKeys(next, PAGE_SIZE).then((response) => {
1177
- setKeys((previous) => next === 1 ? response.keys : [...previous, ...response.keys]);
1227
+ api.listKeys(page, PAGE_SIZE, query === "" ? undefined : query).then((response) => {
1228
+ if (requestSeqRef.current !== requestId) return;
1229
+ setKeys(response.keys);
1178
1230
  setPages(response.pages);
1179
1231
  setTotal(response.total);
1180
- setPage(next);
1232
+ if (response.pages > 0 && page > response.pages) setPage(response.pages);
1181
1233
  }).catch((cause) => {
1234
+ if (requestSeqRef.current !== requestId) return;
1182
1235
  setListError(cause instanceof Error ? cause.message : String(cause));
1183
1236
  }).finally(() => {
1184
- setLoading(false);
1237
+ if (requestSeqRef.current === requestId) setLoading(false);
1185
1238
  });
1186
- };
1187
- (0, react.useEffect)(() => {
1188
- loadPage(1);
1189
- }, [api]);
1239
+ }, [
1240
+ api,
1241
+ page,
1242
+ query,
1243
+ reloadTick
1244
+ ]);
1190
1245
  const selectedIds = snapshot$1.value?.selectedKeys ?? [];
1191
1246
  const toggle = (keyId, next) => {
1192
1247
  const nextSet = new Set(selectedIds);
@@ -1247,7 +1302,8 @@ else nextSet.delete(keyId);
1247
1302
  text: t("keyCreated", { name: response.key.name })
1248
1303
  });
1249
1304
  closeCreate();
1250
- loadPage(1);
1305
+ setPage(1);
1306
+ setReloadTick((tick) => tick + 1);
1251
1307
  }).catch((cause) => {
1252
1308
  setFeedback({
1253
1309
  tone: "error",
@@ -1282,10 +1338,19 @@ else nextSet.delete(keyId);
1282
1338
  }), (0, react_jsx_runtime.jsxs)("div", {
1283
1339
  className: "s2s-actions",
1284
1340
  children: [
1341
+ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Input, {
1342
+ className: "s2s-searchInput",
1343
+ type: "text",
1344
+ value: search,
1345
+ placeholder: t("keysSearchPlaceholder"),
1346
+ onChange: (event) => {
1347
+ setSearch(event.target.value);
1348
+ }
1349
+ }),
1285
1350
  (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1286
1351
  variant: "outline",
1287
1352
  onClick: () => {
1288
- loadPage(1);
1353
+ setReloadTick((tick) => tick + 1);
1289
1354
  },
1290
1355
  children: t("groupsRefresh")
1291
1356
  }),
@@ -1315,7 +1380,7 @@ else nextSet.delete(keyId);
1315
1380
  }),
1316
1381
  !loading && keys.length === 0 && listError === undefined && (0, react_jsx_runtime.jsx)("p", {
1317
1382
  className: "s2s-feedback s2s-hint",
1318
- children: t("keysListEmpty")
1383
+ children: query !== "" ? t("keysSearchEmpty") : t("keysListEmpty")
1319
1384
  }),
1320
1385
  loading && keys.length === 0 && (0, react_jsx_runtime.jsx)("p", {
1321
1386
  className: "s2s-feedback s2s-hint",
@@ -1360,16 +1425,34 @@ else nextSet.delete(keyId);
1360
1425
  }, key.id);
1361
1426
  })
1362
1427
  }),
1363
- keys.length > 0 && page < pages && (0, react_jsx_runtime.jsx)("div", {
1364
- className: "s2s-actions",
1365
- children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1366
- variant: "outline",
1367
- disabled: loading,
1368
- onClick: () => {
1369
- loadPage(page + 1);
1370
- },
1371
- children: t("keysLoadMore", { count: Math.max(0, Math.min(PAGE_SIZE, total - keys.length)) })
1372
- })
1428
+ keys.length > 0 && (pages > 1 || query !== "") && (0, react_jsx_runtime.jsxs)("div", {
1429
+ className: "s2s-pager",
1430
+ children: [
1431
+ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1432
+ variant: "outline",
1433
+ disabled: loading || page <= 1,
1434
+ onClick: () => {
1435
+ setPage(page - 1);
1436
+ },
1437
+ children: t("keysPagePrev")
1438
+ }),
1439
+ (0, react_jsx_runtime.jsx)("span", {
1440
+ className: "s2s-pageStatus",
1441
+ children: t("keysPageStatus", {
1442
+ page,
1443
+ pages,
1444
+ total
1445
+ })
1446
+ }),
1447
+ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1448
+ variant: "outline",
1449
+ disabled: loading || page >= pages,
1450
+ onClick: () => {
1451
+ setPage(page + 1);
1452
+ },
1453
+ children: t("keysPageNext")
1454
+ })
1455
+ ]
1373
1456
  }),
1374
1457
  (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Modal, {
1375
1458
  open: creating,
@@ -2319,7 +2402,7 @@ function DetailSectionBody({ t }) {
2319
2402
  //#endregion
2320
2403
  //#region src/client/plugins/ActivationGuide.tsx
2321
2404
  function ActivationGuide(props) {
2322
- const { t, onDismiss, onOpenDetails } = props;
2405
+ const { t, onDismiss, onOpenDetails, openBundleDetails } = props;
2323
2406
  return (0, react_jsx_runtime.jsxs)("div", {
2324
2407
  className: "s2s-card",
2325
2408
  children: [
@@ -2339,7 +2422,9 @@ function ActivationGuide(props) {
2339
2422
  className: "s2s-actions",
2340
2423
  children: [(0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2341
2424
  variant: "primary",
2342
- onClick: onOpenDetails,
2425
+ onClick: () => {
2426
+ if (!(openBundleDetails?.() ?? false)) onOpenDetails();
2427
+ },
2343
2428
  children: t("activationOpenConfig")
2344
2429
  }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2345
2430
  variant: "ghost",
@@ -2397,10 +2482,17 @@ function mount(ctx) {
2397
2482
  id: "sub2api-sync",
2398
2483
  locale: NS
2399
2484
  }, DetailSection));
2485
+ const activationOps = { openBundleDetails: () => {
2486
+ const nav = ctx.pluginNavigation;
2487
+ if (nav === undefined) return false;
2488
+ nav.openBundle(PKG_NAME);
2489
+ return true;
2490
+ } };
2400
2491
  ctx.slots.inject("plugins.bundle.activation", () => ctx.slots.register({
2401
2492
  name: "plugins.bundle.activation",
2402
2493
  key: PKG_NAME,
2403
- locale: NS
2494
+ locale: NS,
2495
+ inject: () => activationOps
2404
2496
  }, ActivationGuide));
2405
2497
  }
2406
2498
 
@@ -2410,7 +2502,8 @@ const name = "dsh-sub2api-sync-client";
2410
2502
  const inject = [
2411
2503
  "slots",
2412
2504
  "locale",
2413
- "configForms"
2505
+ "configForms",
2506
+ "pluginNavigation"
2414
2507
  ];
2415
2508
  function apply(ctx) {
2416
2509
  mount(ctx);
package/lib/index.js CHANGED
@@ -1829,6 +1829,8 @@ var PanelClient = class {
1829
1829
  timezone: timeZoneOf()
1830
1830
  });
1831
1831
  if (options?.groupId !== undefined) query.set("group_id", String(options.groupId));
1832
+ const search = options?.search?.trim();
1833
+ if (search !== undefined && search.length > 0) query.set("search", search);
1832
1834
  const data = await this.call(token, "GET", `/keys?${query.toString()}`);
1833
1835
  const record = data;
1834
1836
  const items = record?.items;
@@ -2206,11 +2208,11 @@ function conflictStatus(error) {
2206
2208
  function registerRoutes(ctx, deps) {
2207
2209
  ctx.inject(["webServer"], (webCtx) => {
2208
2210
  const route = (path, handler) => {
2209
- webCtx.webServer.register({
2211
+ webCtx.effect(() => webCtx.webServer.register({
2210
2212
  kind: "exact",
2211
2213
  path,
2212
2214
  handler
2213
- });
2215
+ }));
2214
2216
  };
2215
2217
  const guard = (req, res) => {
2216
2218
  if (!trustedRequest(req, deps.allowRemote())) {
@@ -2342,7 +2344,9 @@ function registerRoutes(ctx, deps) {
2342
2344
  const rawSize = Number(url.searchParams.get("pageSize") ?? "5");
2343
2345
  const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1;
2344
2346
  const pageSize = Number.isSafeInteger(rawSize) && rawSize > 0 ? Math.min(rawSize, 50) : 5;
2345
- const result = await deps.listKeys(page, pageSize);
2347
+ const rawSearch = (url.searchParams.get("search") ?? "").trim();
2348
+ const search = rawSearch.length > 0 ? rawSearch : undefined;
2349
+ const result = await deps.listKeys(page, pageSize, search);
2346
2350
  json(res, 200, {
2347
2351
  ok: true,
2348
2352
  keys: result.keys,
@@ -2528,6 +2532,8 @@ function registerRoutes(ctx, deps) {
2528
2532
  });
2529
2533
  }
2530
2534
  });
2535
+ }).then(undefined, (error) => {
2536
+ ctx.logger.error(error);
2531
2537
  });
2532
2538
  }
2533
2539
  /** Standalone failure page when the callback handler itself throws. Inline on
@@ -2688,10 +2694,11 @@ function apply(ctx, config) {
2688
2694
  },
2689
2695
  lastReport: () => reports.report(),
2690
2696
  lastReportAt: () => reports.at(),
2691
- listKeys: async (page, pageSize) => {
2697
+ listKeys: async (page, pageSize, search) => {
2692
2698
  const result = await gateway.use((panel, token) => panel.listKeys(token, {
2693
2699
  page,
2694
- pageSize
2700
+ pageSize,
2701
+ ...search !== undefined && search.length > 0 ? { search } : {}
2695
2702
  }));
2696
2703
  return {
2697
2704
  keys: result.items.map((key) => ({
package/lib/panel.d.ts CHANGED
@@ -100,6 +100,7 @@ export declare class PanelClient {
100
100
  groupId?: number;
101
101
  page?: number;
102
102
  pageSize?: number;
103
+ search?: string;
103
104
  sortBy?: string;
104
105
  sortOrder?: string;
105
106
  }): Promise<PanelKeyPage>;
package/lib/routes.d.ts CHANGED
@@ -193,8 +193,9 @@ export interface RouteDeps {
193
193
  sync(keyIds?: number[]): Promise<SyncReport>;
194
194
  lastReport(): SyncReport | undefined;
195
195
  lastReportAt(): number | undefined;
196
- /** One page of the panel's keys (newest first; page is 1-based). */
197
- listKeys(page: number, pageSize: number): Promise<KeysListPage>;
196
+ /** One page of the panel's keys (newest first; page is 1-based), optional
197
+ * server-side `search` over key names. */
198
+ listKeys(page: number, pageSize: number, search?: string): Promise<KeysListPage>;
198
199
  /** Create one relay key (name defaults to the group name upstream of this seam). */
199
200
  createKey(input: {
200
201
  groupId: number;
@@ -247,7 +248,16 @@ export interface RouteDeps {
247
248
  /** Drop a pending attempt (user cancelled the dialog). */
248
249
  oauthCancel(attemptId: string): Promise<void>;
249
250
  }
250
- /** Register all card routes on the web server (no-op without one). */
251
+ /** Register all card routes on the web server (no-op without one).
252
+ *
253
+ * Lifecycle: every registration's disposer rides the registering inject fiber
254
+ * (`webCtx.effect`). A plugin reload must remove its routes BEFORE the next
255
+ * apply re-registers them — the webserver rejects duplicate (kind, path)
256
+ * entries, and a dropped disposer would orphan the old entries: the new apply
257
+ * fails silently (an inject child failure does not fail the plugin fiber) and
258
+ * the table keeps serving the disposed fiber's closures, whose every service
259
+ * access then throws `cannot get required service … in inactive context`
260
+ * (the OAuth callback's 授权失败). */
251
261
  export declare function registerRoutes(ctx: Context, deps: RouteDeps): void;
252
262
  /**
253
263
  * Assemble the `ownedRoutes` view for the status endpoint from the live
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Sub2API Sync",
4
- "description": "Sync one sub2api instance's groups, keys, and models into dsh-llm-pi-ai provider routes: panel-driven login (PAT / password / OAuth) first, one group one provider."
4
+ "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."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Sub2API 同步",
4
- "description": "把一个 sub2api 实例的分组、key 与模型同步为 dsh-llm-pi-ai 供应商路由:先连接面板(PAT / 密码 / OAuth),一个分组一个供应商。"
4
+ "description": "把一个 sub2api 实例的分组、key 与模型同步为 dsh-llm-pi-ai 供应商路由:面板登录(密码 / OAuth),一把 key 一个供应商。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mangoc/dsh-sub2api-sync",
3
- "description": "Sync one sub2api instance's groups, keys, and models into dsh-llm-pi-ai provider routes: panel-driven login (password / OAuth) first, one group one provider.",
4
- "version": "0.2.0",
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.2",
5
5
  "license": "MIT",
6
6
  "author": "Shuai-S",
7
7
  "homepage": "https://github.com/Shuai-S/dsh-sub2api-sync#readme",