Skip to content

ServiceProvidersPage 新增「刷新」和「新建提供商」按钮方案

问题背景

当前 ServiceProvidersPage 页面缺少两个常用操作入口:

  1. 刷新按钮:当后端配置变更(如手动编辑了配置文件、其他终端操作等)后,用户需要手动刷新页面才能看到最新数据。
  2. 新建提供商按钮:当前只能通过编辑已有提供商来管理配置,无法直接在页面上新建一个全新的 OpenAI 兼容提供商。

ProvidersWorkbenchPage(ai-providers 页面)已有这两个功能,其实现可作为参考。


现有实现分析

ProvidersWorkbenchPage 的刷新实现

tsx
// ProvidersWorkbenchPage.tsx:91-96
const handleRefresh = useCallback(async () => {
  await Promise.allSettled([
    workbench.refetch(),                    // 重新拉取提供商配置
    refreshRecentRequests().catch(() => undefined),  // 刷新最近请求数据
  ]);
}, [refreshRecentRequests, workbench]);

刷新按钮位于 ProviderHeaderCard 中,点击后调用 onRefresh 回调,有 loading 旋转动画。

ProvidersWorkbenchPage 的新建实现

tsx
// ProvidersWorkbenchPage.tsx:225-234
const openCreate = useCallback(() => {
  setSheetState({ open: true, brand: 'openaiCompatibility', mode: 'create', resource: null });
}, [activeBrand, groups]);

新建按钮点击后打开 ProviderSheet(mode='create'),表单使用 BaseProviderForm 渲染,提交后调用 workbench.createProvider()

ServiceProvidersPage 现有机制


实现方案

一、新增刷新功能

1.1 新增 refreshing 状态

ServiceProvidersPage.tsx:292 附近添加:

tsx
const [refreshing, setRefreshing] = useState(false);

1.2 新增 handleRefresh 函数

fetchProviders 之后(约第 321 行)添加:

tsx
const handleRefresh = useCallback(async () => {
  if (refreshing) return;
  setRefreshing(true);
  try {
    const data = await providersApi.getOpenAIProviders();
    setProviders(data);
    // 保留当前选中,如果当前选中的 provider 已不存在则自动选第一个
    setSelectedName((prev) => {
      if (prev && data.some((p) => p.name === prev)) return prev;
      return data.length > 0 ? data[0].name : null;
    });
    showNotification(t('serviceProviders.toast.refreshed', 'Data refreshed'), 'success');
  } catch {
    showNotification(t('serviceProviders.toast.refreshFailed', 'Refresh failed'), 'error');
  } finally {
    setRefreshing(false);
  }
}, [refreshing, showNotification, t]);

fetchProviders 的区别

  • 不设置 setLoading(true)(不会显示全屏加载态,避免闪烁)
  • 不清空选中的 selectedName,而是验证后保留
  • 刷新完成后显示 toast 通知

1.3 UI 中添加刷新按钮

在页面标题区域(第 1071 行 <h2> 附近)改为:

tsx
<div className={styles.headerRow}>
  <h2 className={styles.title}>{t('serviceProviders.header.title')}</h2>
  <div className={styles.headerActions}>
    <button
      type="button"
      className={styles.headerBtn}
      onClick={() => void handleRefresh()}
      disabled={refreshing}
    >
      {refreshing ? (
        <IconLoader2 size={16} className={styles.spin} />
      ) : (
        <IconRefreshCw size={16} />
      )}
      <span>{refreshing
        ? t('serviceProviders.actions.syncing', 'Syncing...')
        : t('serviceProviders.actions.refresh', 'Refresh')
      }</span>
    </button>
    <button
      type="button"
      className={styles.headerBtn}
      onClick={openCreateSheet}
    >
      <IconPlus size={16} />
      <span>{t('serviceProviders.actions.newProvider', 'New Provider')}</span>
    </button>
  </div>
</div>

二、新增新建提供商功能

2.1 将 Sheet 状态改为支持 create/edit 两种模式

当前的 editTarget + sheetOpen 只支持编辑模式。需要引入 sheetMode

tsx
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('edit');

2.2 新增 openCreateSheet 函数

tsx
const openCreateSheet = useCallback(() => {
  setSheetMode('create');
  setEditTarget(null);
  setSheetOpen(true);
}, []);

2.3 修改 openEditSheet 设置 mode

tsx
const openEditSheet = useCallback(
  (provider: OpenAIProviderConfig) => {
    setSheetMode('edit');
    setProviders((prev) => {
      const idx = prev.indexOf(provider);
      setEditTarget({ provider, providerIdx: idx });
      return prev;
    });
    setSheetOpen(true);
  },
  []
);

2.4 新增新建提交处理函数 handleCreateSubmit

tsx
const handleCreateSubmit = useCallback(
  async (input: ProviderEntryFormInput) => {
    try {
      const apiKeyEntries: ApiKeyEntry[] = (input.apiKeyEntries ?? [])
        .map((e) => ({
          apiKey: (e.apiKey ?? '').trim() || (e.existingApiKey ?? '').trim(),
          proxyUrl: e.proxyUrl || undefined,
          authIndex: e.authIndex || undefined,
        }))
        .filter((e) => !!e.apiKey);

      const models = (input.models ?? [])
        .filter((m) => m.name.trim())
        .map((m) => ({
          name: m.name.trim(),
          ...(m.alias?.trim() ? { alias: m.alias.trim() } : {}),
        }));

      const headers: Record<string, string> = {};
      (input.headers ?? []).forEach((h) => {
        if (h.key.trim()) headers[h.key.trim()] = h.value;
      });

      const newProvider: OpenAIProviderConfig = {
        name: input.name?.trim() || 'new-provider',
        baseUrl: (input.baseUrl?.trim() || '').replace(/\/+$/, ''),
        apiKeyEntries,
        models: models.length ? models : undefined,
        headers: Object.keys(headers).length ? headers : undefined,
        testModel: input.testModel?.trim() || undefined,
        prefix: input.prefix?.trim() || undefined,
        disabled: input.disabled ?? false,
      };

      // 追加到列表末尾并保存
      const nextList = [...providers, newProvider];
      await providersApi.saveOpenAIProviders(nextList);
      setProviders(nextList);
      setSelectedName(newProvider.name);
      setSheetOpen(false);
      showNotification(t('serviceProviders.toast.createSuccess', 'Provider created'), 'success');
    } catch {
      showNotification(t('serviceProviders.toast.createFailed', 'Creation failed'), 'error');
    }
  },
  [providers, showNotification, t]
);

2.5 修改 Sheet 渲染逻辑,支持 create 模式

将原来的 {editTarget && (...)} 改为:

tsx
{(sheetMode === 'edit' ? editTarget : true) && (
  <Sheet
    open={sheetOpen}
    onClose={() => setSheetOpen(false)}
    title={sheetMode === 'create'
      ? t('providersPage.form.createEyebrow')
      : t('providersPage.form.editEyebrow')
    }
    footer={
      <>
        <button ... onClick={() => setSheetOpen(false)}>
          {t('providersPage.actions.cancel')}
        </button>
        <button type="submit" form={editFormId} ...>
          {t('providersPage.actions.save')}
        </button>
      </>
    }
  >
    <BaseProviderForm
      brand="openaiCompatibility"
      resource={sheetMode === 'edit' && editTarget
        ? buildEditResource(editTarget.provider, editTarget.providerIdx)
        : null
      }
      mode={sheetMode}
      mutating={editSubmitting}
      formId={editFormId}
      onSubmit={async (input) => {
        setEditSubmitting(true);
        try {
          if (sheetMode === 'create') {
            await handleCreateSubmit(input);
          } else {
            await handleFormSubmit(input);
          }
        } finally {
          setEditSubmitting(false);
        }
      }}
    />
  </Sheet>
)}

三、i18n 翻译键

需要在所有语言文件中添加以下翻译键:

en.json

json
"serviceProviders": {
  "actions": {
    "refresh": "Refresh",
    "syncing": "Syncing...",
    "newProvider": "New Provider"
  },
  "toast": {
    "refreshed": "Data refreshed",
    "refreshFailed": "Refresh failed",
    "createSuccess": "Provider created",
    "createFailed": "Creation failed"
  }
}

zh-CN.json

json
"serviceProviders": {
  "actions": {
    "refresh": "刷新",
    "syncing": "同步中...",
    "newProvider": "新建提供商"
  },
  "toast": {
    "refreshed": "数据已刷新",
    "refreshFailed": "刷新失败",
    "createSuccess": "提供商已创建",
    "createFailed": "创建失败"
  }
}

同样需要在 ru.jsonzh-TW.json 中添加对应翻译。


四、样式

ServiceProvidersPage.module.scss 中添加:

scss
.headerRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.headerActions {
  display: flex;
  gap: 8px;
}

.headerBtn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px;
  font-size: 13px;
  font-weight: 500;
  border-radius: 6px;
  border: 1px solid var(--border-color, #ddd);
  background: var(--bg-primary);
  color: var(--text-primary);
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;

  &:hover:not(:disabled) {
    background: var(--hover-bg, #f5f5f5);
    border-color: var(--primary-color);
  }

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  span.spin {
    animation: spin 1s linear infinite;
  }
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

五、实施步骤清单

步骤操作涉及文件
1新增 IconPlusIconRefreshCw 导入ServiceProvidersPage.tsx
2新增 refreshingsheetMode 状态ServiceProvidersPage.tsx
3新增 handleRefresh 回调ServiceProvidersPage.tsx
4新增 openCreateSheethandleCreateSubmit 回调ServiceProvidersPage.tsx
5修改 openEditSheet 添加 setSheetMode('edit')ServiceProvidersPage.tsx
6修改页面标题区域为 flex 布局并添加两个按钮ServiceProvidersPage.tsx
7修改 Sheet 渲染逻辑支持 create 模式ServiceProvidersPage.tsx
8添加 i18n 翻译键(4 个语言文件)en.jsonzh-CN.jsonzh-TW.jsonru.json
9添加 SCSS 样式ServiceProvidersPage.module.scss
10测试刷新和新建功能-