Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Key components:
- **Path alias**: `@/` maps to `./src/`
- **Formatting**: Biome (double quotes, trailing commas, 2-space indent, 100 char width)
- **Exports**: Prefer named exports over default exports
- **i18n**: Use `next-intl` for internationalization (5 languages: zh-CN, en, ja, ko, de)
- **i18n**: Use `next-intl` for internationalization (5 languages: zh-CN, zh-TW, en, ja, ru)
- **Testing**: Unit tests in `tests/unit/`, integration in `tests/integration/`, source-adjacent tests in `src/**/*.test.ts`

## Environment Variables
Expand Down
5 changes: 5 additions & 0 deletions messages/en/dashboard.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"actions": {
"copy": "Copy",
"download": "Download",
"copied": "Copied"
},
"title": {
"costRanking": "Cost Leaderboard",
"costRankingDescription": "View user cost rankings, data updates every 5 minutes",
Expand Down
16 changes: 16 additions & 0 deletions messages/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,22 @@
"addFailed": "Failed to add provider",
"addProvider": "Add Provider",
"addSuccess": "Provider added successfully",
"autoSort": {
"button": "Auto Sort Priority",
"dialogTitle": "Auto Sort Provider Priority",
"dialogDescription": "Automatically assign priority based on cost multiplier (lower cost = higher priority)",
"changeCount": "{count} providers will be updated",
"noChanges": "No changes needed (already sorted)",
"costMultiplierHeader": "Cost Multiplier",
"priorityHeader": "Priority",
"providersHeader": "Providers",
"changesTitle": "Change Details",
"providerHeader": "Provider",
"priorityChangeHeader": "Priority Change",
"confirm": "Apply Changes",
"success": "Updated priority for {count} providers",
"error": "Failed to update priorities"
},
"types": {
"claude": {
"label": "Claude",
Expand Down
5 changes: 5 additions & 0 deletions messages/ja/dashboard.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"actions": {
"copy": "コピー",
"download": "ダウンロード",
"copied": "コピーしました"
},
"title": {
"costRanking": "コスト ランキング",
"costRankingDescription": "ユーザーコスト ランキングを表示します。データは 5 分ごとに更新されます",
Expand Down
16 changes: 16 additions & 0 deletions messages/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,22 @@
"addFailed": "プロバイダーの追加に失敗しました",
"addProvider": "プロバイダーを追加",
"addSuccess": "プロバイダーが正常に追加されました",
"autoSort": {
"button": "優先度を自動ソート",
"dialogTitle": "プロバイダー優先度の自動ソート",
"dialogDescription": "コスト倍率に基づいて優先度を自動割り当て(低コスト = 高優先度)",
"changeCount": "{count} 件のプロバイダーが更新されます",
"noChanges": "変更不要(ソート済み)",
"costMultiplierHeader": "コスト倍率",
"priorityHeader": "優先度",
"providersHeader": "プロバイダー",
"changesTitle": "変更詳細",
"providerHeader": "プロバイダー",
"priorityChangeHeader": "優先度変更",
"confirm": "変更を適用",
"success": "{count} 件のプロバイダーの優先度を更新しました",
"error": "優先度の更新に失敗しました"
},
"circuitBroken": "サーキットブレーカー作動中",
"clone": "プロバイダーを複製",
"cloneFailed": "コピーに失敗しました",
Expand Down
5 changes: 5 additions & 0 deletions messages/ru/dashboard.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"actions": {
"copy": "Копировать",
"download": "Скачать",
"copied": "Скопировано"
},
"title": {
"costRanking": "Таблица расходов",
"costRankingDescription": "Просмотр рейтинга расходов пользователей, данные обновляются каждые 5 минут",
Expand Down
16 changes: 16 additions & 0 deletions messages/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,22 @@
"addFailed": "Ошибка добавления поставщика",
"addProvider": "Добавить провайдера",
"addSuccess": "Поставщик добавлен успешно",
"autoSort": {
"button": "Авто сортировка приоритета",
"dialogTitle": "Автоматическая сортировка приоритета поставщиков",
"dialogDescription": "Автоматически назначить приоритет на основе множителя стоимости (низкая стоимость = высокий приоритет)",
"changeCount": "{count} поставщиков будет обновлено",
"noChanges": "Изменения не требуются (уже отсортировано)",
"costMultiplierHeader": "Множитель стоимости",
"priorityHeader": "Приоритет",
"providersHeader": "Поставщики",
"changesTitle": "Детали изменений",
"providerHeader": "Поставщик",
"priorityChangeHeader": "Изменение приоритета",
"confirm": "Применить изменения",
"success": "Обновлён приоритет для {count} поставщиков",
"error": "Не удалось обновить приоритеты"
},
"circuitBroken": "Цепь разомкнута",
"clone": "Дублировать поставщика",
"cloneFailed": "Ошибка копирования",
Expand Down
5 changes: 5 additions & 0 deletions messages/zh-CN/dashboard.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"actions": {
"copy": "复制",
"download": "下载",
"copied": "已复制"
},
"title": {
"costRanking": "消耗排行榜",
"costRankingDescription": "查看用户消耗排名,数据每 5 分钟更新一次",
Expand Down
16 changes: 16 additions & 0 deletions messages/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@
"subtitle": "服务商管理",
"subtitleDesc": "配置上游服务商的金额限流和并发限制,留空表示无限制。",
"add": "添加供应商",
"autoSort": {
"button": "自动排序优先级",
"dialogTitle": "自动排序供应商优先级",
"dialogDescription": "根据成本倍率自动分配优先级(低成本 = 高优先级)",
"changeCount": "{count} 个供应商将被更新",
"noChanges": "无需更改(已排序)",
"costMultiplierHeader": "成本倍率",
"priorityHeader": "优先级",
"providersHeader": "供应商",
"changesTitle": "变更详情",
"providerHeader": "供应商",
"priorityChangeHeader": "优先级变更",
"confirm": "应用变更",
"success": "已更新 {count} 个供应商的优先级",
"error": "更新优先级失败"
},
"types": {
"claude": {
"label": "Claude",
Expand Down
5 changes: 5 additions & 0 deletions messages/zh-TW/dashboard.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"actions": {
"copy": "複製",
"download": "下載",
"copied": "已複製"
},
"title": {
"costRanking": "消耗排行榜",
"costRankingDescription": "查看用戶消耗排名,資料每 5 分鐘更新一次",
Expand Down
16 changes: 16 additions & 0 deletions messages/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,22 @@
"addFailed": "新增服務商失敗",
"addProvider": "新增服务商",
"addSuccess": "新增服務商成功",
"autoSort": {
"button": "自動排序優先級",
"dialogTitle": "自動排序供應商優先級",
"dialogDescription": "根據成本倍率自動分配優先級(低成本 = 高優先級)",
"changeCount": "{count} 個供應商將被更新",
"noChanges": "無需更改(已排序)",
"costMultiplierHeader": "成本倍率",
"priorityHeader": "優先級",
"providersHeader": "供應商",
"changesTitle": "變更詳情",
"providerHeader": "供應商",
"priorityChangeHeader": "優先級變更",
"confirm": "應用變更",
"success": "已更新 {count} 個供應商的優先級",
"error": "更新優先級失敗"
},
"circuitBroken": "熔断中",
"clone": "複製服務商",
"cloneFailed": "複製失敗",
Expand Down
148 changes: 148 additions & 0 deletions src/actions/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
getProviderStatistics,
resetProviderTotalCostResetAt,
updateProvider,
updateProviderPrioritiesBatch,
} from "@/repository/provider";
import type { CacheTtlPreference } from "@/types/cache";
import type {
Expand All @@ -54,6 +55,27 @@ import type {
} from "@/types/provider";
import type { ActionResult } from "./types";

type AutoSortResult = {
groups: Array<{
costMultiplier: number;
priority: number;
providers: Array<{ id: number; name: string }>;
}>;
changes: Array<{
providerId: number;
name: string;
oldPriority: number;
newPriority: number;
costMultiplier: number;
}>;
summary: {
totalProviders: number;
changedCount: number;
groupCount: number;
};
applied: boolean;
};
Comment on lines +58 to +77
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

建议将 AutoSortResult 类型提取到共享位置

此类型定义与 UI 组件文件 src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx(第 29-48 行)中的定义重复。这违反了 DRY 原则,增加了维护成本和不一致的风险。

♻️ 建议的重构方案

将类型提取到共享类型文件(例如 src/types/provider.tssrc/actions/types.ts):

// src/types/provider.ts 或 src/actions/types.ts
export type AutoSortResult = {
  groups: Array<{
    costMultiplier: number;
    priority: number;
    providers: Array<{ id: number; name: string }>;
  }>;
  changes: Array<{
    providerId: number;
    name: string;
    oldPriority: number;
    newPriority: number;
    costMultiplier: number;
  }>;
  summary: {
    totalProviders: number;
    changedCount: number;
    groupCount: number;
  };
  applied: boolean;
};

然后在两个文件中导入:

// src/actions/providers.ts
import type { AutoSortResult } from "@/types/provider";

// src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx
import type { AutoSortResult } from "@/types/provider";
🤖 Prompt for AI Agents
In @src/actions/providers.ts around lines 58 - 77, Duplicate AutoSortResult type
exists in providers.ts and the AutoSortPriorityDialog component; extract a
single exported AutoSortResult into a shared types module (e.g., create and
export AutoSortResult from a new types file) and replace both local definitions
with import type { AutoSortResult } from that module. Update the providers.ts
declaration and the AutoSortPriorityDialog usage to import the shared type and
remove the duplicated inline type definitions so both files reference the same
exported AutoSortResult.


const API_TEST_TIMEOUT_LIMITS = {
DEFAULT: 15000,
MIN: 5000,
Expand Down Expand Up @@ -740,6 +762,132 @@ export async function removeProvider(providerId: number): Promise<ActionResult>
}
}

export async function autoSortProviderPriority(args: {
confirm: boolean;
}): Promise<ActionResult<AutoSortResult>> {
try {
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { ok: false, error: "无权限执行此操作" };
}

const providers = await findAllProvidersFresh();
if (providers.length === 0) {
return {
ok: true,
data: {
groups: [],
changes: [],
summary: {
totalProviders: 0,
changedCount: 0,
groupCount: 0,
},
applied: args.confirm,
},
};
}

const groupsByCostMultiplier = new Map<number, typeof providers>();
for (const provider of providers) {
const rawCostMultiplier = Number(provider.costMultiplier);
const costMultiplier = Number.isFinite(rawCostMultiplier) ? rawCostMultiplier : 0;

if (!Number.isFinite(rawCostMultiplier)) {
logger.warn("autoSortProviderPriority:invalid_cost_multiplier", {
providerId: provider.id,
providerName: provider.name,
costMultiplier: provider.costMultiplier,
fallback: costMultiplier,
});
}

const bucket = groupsByCostMultiplier.get(costMultiplier);
if (bucket) {
bucket.push(provider);
} else {
groupsByCostMultiplier.set(costMultiplier, [provider]);
}
}

const sortedCostMultipliers = Array.from(groupsByCostMultiplier.keys()).sort((a, b) => a - b);
const groups: AutoSortResult["groups"] = [];
const changes: AutoSortResult["changes"] = [];

for (const [priority, costMultiplier] of sortedCostMultipliers.entries()) {
const groupProviders = groupsByCostMultiplier.get(costMultiplier) ?? [];
groups.push({
costMultiplier,
priority,
providers: groupProviders
.slice()
.sort((a, b) => a.id - b.id)
.map((provider) => ({ id: provider.id, name: provider.name })),
});

for (const provider of groupProviders) {
const oldPriority = provider.priority ?? 0;
const newPriority = priority;
if (oldPriority !== newPriority) {
changes.push({
providerId: provider.id,
name: provider.name,
oldPriority,
newPriority,
costMultiplier,
});
}
}
}

const summary: AutoSortResult["summary"] = {
totalProviders: providers.length,
changedCount: changes.length,
groupCount: groups.length,
};

if (!args.confirm) {
return {
ok: true,
data: {
groups,
changes,
summary,
applied: false,
},
};
}

if (changes.length > 0) {
await updateProviderPrioritiesBatch(
changes.map((change) => ({ id: change.providerId, priority: change.newPriority }))
);
try {
await publishProviderCacheInvalidation();
} catch (error) {
logger.warn("autoSortProviderPriority:cache_invalidation_failed", {
changedCount: changes.length,
error: error instanceof Error ? error.message : String(error),
});
}
}

return {
ok: true,
data: {
groups,
changes,
summary,
applied: true,
},
};
} catch (error) {
logger.error("autoSortProviderPriority:error", error);
const message = error instanceof Error ? error.message : "自动排序供应商优先级失败";
return { ok: false, error: message };
}
}

/**
* 获取所有供应商的熔断器健康状态
* 返回格式:{ providerId: { circuitState, failureCount, circuitOpenUntil, ... } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,8 @@ describe("SessionMessagesClient (request export actions)", () => {
const { container, unmount } = renderClient(<SessionMessagesClient />);
await flushEffects();

const buttons = Array.from(container.querySelectorAll("button"));
const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages"));
expect(downloadBtn).not.toBeUndefined();
const downloadBtn = container.querySelector('button[aria-label="actions.downloadMessages"]');
expect(downloadBtn).not.toBeNull();
click(downloadBtn as HTMLButtonElement);

expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -282,18 +281,17 @@ describe("SessionMessagesClient (request export actions)", () => {
2
);

const buttons = Array.from(container.querySelectorAll("button"));
const copyBtn = buttons.find((b) => b.textContent?.includes("actions.copyMessages"));
expect(copyBtn).not.toBeUndefined();
const copyBtn = container.querySelector('button[aria-label="actions.copyMessages"]');
expect(copyBtn).not.toBeNull();
await clickAsync(copyBtn as HTMLButtonElement);
expect(clipboardWriteText).toHaveBeenCalledWith(expectedJson);
act(() => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();

const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages"));
expect(downloadBtn).not.toBeUndefined();
const downloadBtn = container.querySelector('button[aria-label="actions.downloadMessages"]');
expect(downloadBtn).not.toBeNull();
click(downloadBtn as HTMLButtonElement);

expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -335,8 +333,8 @@ describe("SessionMessagesClient (request export actions)", () => {
const { container, unmount } = renderClient(<SessionMessagesClient />);
await flushEffects();

expect(container.textContent).not.toContain("actions.copyMessages");
expect(container.textContent).not.toContain("actions.downloadMessages");
expect(container.querySelector('button[aria-label="actions.copyMessages"]')).toBeNull();
expect(container.querySelector('button[aria-label="actions.downloadMessages"]')).toBeNull();

unmount();
});
Expand Down Expand Up @@ -366,8 +364,8 @@ describe("SessionMessagesClient (request export actions)", () => {
const { container, unmount } = renderClient(<SessionMessagesClient />);
await flushEffects();

expect(container.textContent).not.toContain("actions.copyMessages");
expect(container.textContent).not.toContain("actions.downloadMessages");
expect(container.querySelector('button[aria-label="actions.copyMessages"]')).toBeNull();
expect(container.querySelector('button[aria-label="actions.downloadMessages"]')).toBeNull();

unmount();
});
Expand Down
Loading
Loading