Răsfoiți Sursa

feat: support unicode provider groups

ding113 3 săptămâni în urmă
părinte
comite
b8b0f9d4a7
31 a modificat fișierele cu 254 adăugiri și 189 ștergeri
  1. 1 5
      src/actions/keys.ts
  2. 12 16
      src/actions/providers.ts
  3. 2 1
      src/actions/request-filters.ts
  4. 2 6
      src/actions/users.ts
  5. 3 4
      src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
  6. 3 4
      src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  7. 6 10
      src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx
  8. 3 4
      src/app/[locale]/dashboard/_components/user/forms/provider-group-select.tsx
  9. 1 0
      src/app/[locale]/dashboard/_components/user/forms/user-form.tsx
  10. 2 4
      src/app/[locale]/dashboard/_components/user/key-row-item.tsx
  11. 2 4
      src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  12. 1 23
      src/app/[locale]/dashboard/_components/user/utils/provider-group.ts
  13. 2 10
      src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx
  14. 2 4
      src/app/[locale]/dashboard/users/users-page-client.tsx
  15. 2 8
      src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts
  16. 2 4
      src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx
  17. 3 8
      src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx
  18. 2 6
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx
  19. 1 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
  20. 6 22
      src/app/[locale]/settings/providers/_components/provider-manager.tsx
  21. 3 7
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  22. 2 4
      src/app/v1/_lib/proxy/provider-selector.ts
  23. 2 6
      src/components/form/form-field.tsx
  24. 119 0
      src/components/ui/__tests__/provider-group-tag-input.test.tsx
  25. 6 0
      src/lib/provider-patch-contract.ts
  26. 3 2
      src/lib/request-filter-engine.ts
  27. 24 0
      src/lib/utils/provider-group.test.ts
  28. 31 15
      src/lib/utils/provider-group.ts
  29. 1 1
      src/repository/leaderboard.ts
  30. 2 6
      src/repository/provider.ts
  31. 3 5
      src/repository/user.ts

+ 1 - 5
src/actions/keys.ts

@@ -607,11 +607,7 @@ export async function removeKey(keyId: number): Promise<ActionResult> {
       for (const k of userKeys) {
         if (k.id === keyId) continue;
         const group = k.providerGroup || PROVIDER_GROUP.DEFAULT;
-        group
-          .split(",")
-          .map((g) => g.trim())
-          .filter(Boolean)
-          .forEach((g) => remainingGroups.add(g));
+        parseProviderGroups(group).forEach((g) => remainingGroups.add(g));
       }
 
       const { findUserById } = await import("@/repository/user");

+ 12 - 16
src/actions/providers.ts

@@ -44,6 +44,7 @@ import {
 } from "@/lib/redis/circuit-breaker-config";
 import { RedisKVStore } from "@/lib/redis/redis-kv-store";
 import { SessionManager } from "@/lib/session-manager";
+import { normalizeProviderGroupTag, parseProviderGroups } from "@/lib/utils/provider-group";
 import { maskKey } from "@/lib/utils/validation";
 import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n";
 import { validateProviderUrlForConnectivity } from "@/lib/validation/provider-url";
@@ -431,10 +432,7 @@ export async function getAvailableProviderGroups(userId?: number): Promise<strin
     const { findUserById } = await import("@/repository/user");
     const user = await findUserById(userId);
 
-    const userGroups = (user?.providerGroup || PROVIDER_GROUP.DEFAULT)
-      .split(",")
-      .map((g) => g.trim())
-      .filter(Boolean);
+    const userGroups = parseProviderGroups(user?.providerGroup || PROVIDER_GROUP.DEFAULT);
 
     // 管理员通配符:可访问所有分组
     if (userGroups.includes(PROVIDER_GROUP.ALL)) {
@@ -462,17 +460,12 @@ export async function getProviderGroupsWithCount(): Promise<
     const groupCounts = new Map<string, number>();
 
     for (const provider of providers) {
-      const groupTag = provider.groupTag?.trim();
-      if (!groupTag) {
+      const groups = parseProviderGroups(provider.groupTag);
+      if (groups.length === 0) {
         groupCounts.set(PROVIDER_GROUP.DEFAULT, (groupCounts.get(PROVIDER_GROUP.DEFAULT) || 0) + 1);
         continue;
       }
 
-      const groups = groupTag
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
-
       for (const group of groups) {
         groupCounts.set(group, (groupCounts.get(group) || 0) + 1);
       }
@@ -589,6 +582,7 @@ export async function addProvider(data: {
 
     const payload = {
       ...validated,
+      group_tag: normalizeProviderGroupTag(validated.group_tag),
       limit_5h_usd: validated.limit_5h_usd ?? null,
       limit_daily_usd: validated.limit_daily_usd ?? null,
       daily_reset_mode: validated.daily_reset_mode ?? "fixed",
@@ -766,6 +760,9 @@ export async function editProvider(
 
     const payload = {
       ...validated,
+      ...(validated.group_tag !== undefined && {
+        group_tag: normalizeProviderGroupTag(validated.group_tag),
+      }),
       ...(faviconUrl !== undefined && { favicon_url: faviconUrl }),
     };
 
@@ -2235,7 +2232,9 @@ export async function batchUpdateProviders(
     if (updates.cost_multiplier !== undefined) {
       repositoryUpdates.costMultiplier = updates.cost_multiplier.toString();
     }
-    if (updates.group_tag !== undefined) repositoryUpdates.groupTag = updates.group_tag;
+    if (updates.group_tag !== undefined) {
+      repositoryUpdates.groupTag = normalizeProviderGroupTag(updates.group_tag);
+    }
     if (updates.model_redirects !== undefined) {
       repositoryUpdates.modelRedirects = updates.model_redirects;
     }
@@ -4795,10 +4794,7 @@ async function fetchAnthropicModels(
  * 解析分组字符串为数组
  */
 function parseGroupString(groupString: string): string[] {
-  return groupString
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
+  return parseProviderGroups(groupString);
 }
 
 /**

+ 2 - 1
src/actions/request-filters.ts

@@ -6,6 +6,7 @@ import { getSession } from "@/lib/auth";
 import { logger } from "@/lib/logger";
 import { requestFilterEngine } from "@/lib/request-filter-engine";
 import type { FilterMatcher, FilterOperation, InsertOp } from "@/lib/request-filter-types";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import {
   createRequestFilter,
   deleteRequestFilter,
@@ -458,7 +459,7 @@ export async function getDistinctProviderGroupsAction(): Promise<ActionResult<st
     const allTags = new Set<string>();
     for (const row of result) {
       if (row.groupTag) {
-        const tags = row.groupTag.split(",").map((tag) => tag.trim());
+        const tags = parseProviderGroups(row.groupTag);
         for (const tag of tags) {
           if (tag) allTags.add(tag);
         }

+ 2 - 6
src/actions/users.ts

@@ -13,7 +13,7 @@ import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions"
 import { invalidateCachedUser } from "@/lib/security/api-key-auth-cache";
 import { parseDateInputAsTimezone } from "@/lib/utils/date-input";
 import { ERROR_CODES } from "@/lib/utils/error-messages";
-import { normalizeProviderGroup } from "@/lib/utils/provider-group";
+import { normalizeProviderGroup, parseProviderGroups } from "@/lib/utils/provider-group";
 import { resolveSystemTimezone } from "@/lib/utils/timezone";
 import { maskKey } from "@/lib/utils/validation";
 import { formatZodError } from "@/lib/utils/zod-i18n";
@@ -192,11 +192,7 @@ export async function syncUserProviderGroupFromKeys(userId: number): Promise<voi
     // NOTE(#400): Key.providerGroup is now required (no more null semantics).
     // For backward compatibility, treat null/empty as "default".
     const group = key.providerGroup || PROVIDER_GROUP.DEFAULT;
-    group
-      .split(",")
-      .map((g) => g.trim())
-      .filter(Boolean)
-      .forEach((g) => allGroups.add(g));
+    parseProviderGroups(group).forEach((g) => allGroups.add(g));
   }
 
   const newProviderGroup =

+ 3 - 4
src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx

@@ -20,6 +20,7 @@ import { Switch } from "@/components/ui/switch";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { getErrorMessage } from "@/lib/utils/error-messages";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import type { KeyDialogUserContext } from "@/types/user";
 
@@ -125,10 +126,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
   // 选择分组时,自动移除 default(当有多个分组时)
   const handleProviderGroupChange = useCallback(
     (newValue: string) => {
-      const groups = newValue
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
+      const groups = parseProviderGroups(newValue);
       if (groups.length > 1 && groups.includes(PROVIDER_GROUP.DEFAULT)) {
         const withoutDefault = groups.filter((g) => g !== PROVIDER_GROUP.DEFAULT);
         form.setValue("providerGroup", withoutDefault.join(","));
@@ -206,6 +204,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
             : t("providerGroup.description")
         }
         suggestions={providerGroupSuggestions}
+        validateTag={() => true}
         onInvalidTag={(_tag, reason) => {
           const messages: Record<string, string> = {
             empty: tUI("emptyTag"),

+ 3 - 4
src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx

@@ -34,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { getErrorMessage } from "@/lib/utils/error-messages";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import type { KeyDialogUserContext } from "@/types/user";
 
@@ -183,10 +184,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
   // 选择分组时,自动移除 default(当有多个分组时)
   const handleProviderGroupChange = useCallback(
     (newValue: string) => {
-      const groups = newValue
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
+      const groups = parseProviderGroups(newValue);
       if (groups.length > 1 && groups.includes(PROVIDER_GROUP.DEFAULT)) {
         const withoutDefault = groups.filter((g) => g !== PROVIDER_GROUP.DEFAULT);
         form.setValue("providerGroup", withoutDefault.join(","));
@@ -264,6 +262,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
             : t("providerGroup.description")
         }
         suggestions={providerGroupSuggestions}
+        validateTag={() => true}
         onInvalidTag={(_tag, reason) => {
           const messages: Record<string, string> = {
             empty: tUI("emptyTag"),

+ 6 - 10
src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx

@@ -19,6 +19,7 @@ import { Switch } from "@/components/ui/switch";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { cn } from "@/lib/utils";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
 import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display";
 import { ProviderGroupSelect } from "./provider-group-select";
@@ -125,10 +126,7 @@ function formatDateInput(date?: Date | null): string {
 }
 
 function normalizeGroupList(value?: string | null): string {
-  const groups = (value ?? "")
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
+  const groups = parseProviderGroups(value);
   if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
   return Array.from(new Set(groups)).sort().join(",");
 }
@@ -292,7 +290,7 @@ export function KeyEditSection({
     [userProviderGroup]
   );
   const userGroups = useMemo(
-    () => (normalizedUserProviderGroup ? normalizedUserProviderGroup.split(",") : []),
+    () => parseProviderGroups(normalizedUserProviderGroup),
     [normalizedUserProviderGroup]
   );
   const normalizedKeyProviderGroup = useMemo(
@@ -301,7 +299,7 @@ export function KeyEditSection({
   );
   const keyGroupOptions = useMemo(() => {
     if (!normalizedKeyProviderGroup) return [];
-    return normalizedKeyProviderGroup.split(",").filter(Boolean);
+    return parseProviderGroups(normalizedKeyProviderGroup);
   }, [normalizedKeyProviderGroup]);
   const _extraKeyGroupOption = useMemo(() => {
     if (!normalizedKeyProviderGroup) return null;
@@ -313,10 +311,7 @@ export function KeyEditSection({
   // 普通用户选择分组时,自动移除 default
   const handleUserProviderGroupChange = useCallback(
     (newValue: string) => {
-      const groups = newValue
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
+      const groups = parseProviderGroups(newValue);
       // 如果有多个分组且包含 default,移除 default
       if (groups.length > 1 && groups.includes(PROVIDER_GROUP.DEFAULT)) {
         const withoutDefault = groups.filter((g) => g !== PROVIDER_GROUP.DEFAULT);
@@ -507,6 +502,7 @@ export function KeyEditSection({
                 suggestions={userGroups}
                 maxTags={userGroups.length + 1}
                 maxTagLength={50}
+                validateTag={() => true}
                 description={
                   translations.fields.providerGroup.selectHint || "选择此 Key 可使用的供应商分组"
                 }

+ 3 - 4
src/app/[locale]/dashboard/_components/user/forms/provider-group-select.tsx

@@ -6,6 +6,7 @@ import { getProviderGroupsWithCount } from "@/actions/providers";
 import { TagInputField } from "@/components/form/form-field";
 import type { TagInputSuggestion } from "@/components/ui/tag-input";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 
 export interface ProviderGroupSelectProps {
   /** Comma-separated group tags. */
@@ -107,10 +108,7 @@ export function ProviderGroupSelect({
   // 选择新分组后自动移除 "default"
   const handleChange = useCallback(
     (newValue: string) => {
-      const groupList = newValue
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
+      const groupList = parseProviderGroups(newValue);
       // 如果有多个分组且包含 default,移除 default
       if (groupList.length > 1 && groupList.includes(PROVIDER_GROUP.DEFAULT)) {
         const withoutDefault = groupList.filter((g) => g !== PROVIDER_GROUP.DEFAULT);
@@ -131,6 +129,7 @@ export function ProviderGroupSelect({
       maxTags={20}
       suggestions={suggestions}
       disabled={disabled}
+      validateTag={() => true}
       onInvalidTag={(_tag, reason) => {
         toast.error(getTranslation(translations, `tagInputErrors.${reason}`, reason));
       }}

+ 1 - 0
src/app/[locale]/dashboard/_components/user/forms/user-form.tsx

@@ -217,6 +217,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
         placeholder={tForm("providerGroup.placeholder")}
         description={tForm("providerGroup.description")}
         suggestions={providerGroupSuggestions}
+        validateTag={() => true}
         onInvalidTag={(_tag, reason) => {
           const messages: Record<string, string> = {
             empty: tUI("emptyTag"),

+ 2 - 4
src/app/[locale]/dashboard/_components/user/key-row-item.tsx

@@ -35,6 +35,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
 import { cn } from "@/lib/utils";
 import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
 import { formatDate } from "@/lib/utils/date-format";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import { formatTokenAmount } from "@/lib/utils/token";
 import { type QuickRenewKey, QuickRenewKeyDialog } from "./forms/quick-renew-key-dialog";
 import { KeyFullDisplayDialog } from "./key-full-display-dialog";
@@ -113,10 +114,7 @@ export interface KeyRowItemProps {
 const EXPIRING_SOON_MS = 72 * 60 * 60 * 1000; // 72小时
 
 function splitGroups(value?: string | null): string[] {
-  return (value ?? "")
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
+  return parseProviderGroups(value);
 }
 
 function formatExpiry(expiresAt: string | null | undefined, locale: string): string {

+ 2 - 4
src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx

@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils";
 import { getContrastTextColor, getGroupColor } from "@/lib/utils/color";
 import { getCurrencySymbol } from "@/lib/utils/currency";
 import { formatDate } from "@/lib/utils/date-format";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { UserDisplay } from "@/types/user";
 import { EditKeyDialog } from "./edit-key-dialog";
 import { KeyRowItem } from "./key-row-item";
@@ -86,10 +87,7 @@ const EXPIRING_SOON_MS = 72 * 60 * 60 * 1000; // 72小时
 const MAX_VISIBLE_GROUPS = 2; // 最多显示的分组数量
 
 function splitGroups(value?: string | null): string[] {
-  return (value ?? "")
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
+  return parseProviderGroups(value);
 }
 
 function getExpiryStatus(

+ 1 - 23
src/app/[locale]/dashboard/_components/user/utils/provider-group.ts

@@ -1,23 +1 @@
-import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
-
-/**
- * Normalize provider group value to a consistent format.
- * - Trims whitespace
- * - Splits by comma and deduplicates
- * - Sorts alphabetically
- * - Returns DEFAULT if empty or invalid
- */
-export function normalizeProviderGroup(value: unknown): string {
-  if (value === null || value === undefined) return PROVIDER_GROUP.DEFAULT;
-  if (typeof value !== "string") return PROVIDER_GROUP.DEFAULT;
-  const trimmed = value.trim();
-  if (trimmed === "") return PROVIDER_GROUP.DEFAULT;
-
-  const groups = trimmed
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
-  if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
-
-  return Array.from(new Set(groups)).sort().join(",");
-}
+export { normalizeProviderGroup } from "@/lib/utils/provider-group";

+ 2 - 10
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx

@@ -24,6 +24,7 @@ import {
   isActualRequest,
   isHedgeRace,
 } from "@/lib/utils/provider-chain-formatter";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { ProviderChainItem } from "@/types/message";
 import { getFake200ReasonKey } from "./fake200-reason";
 
@@ -37,16 +38,7 @@ interface ProviderChainPopoverProps {
 }
 
 function parseGroupTags(groupTag?: string | null): string[] {
-  if (!groupTag) return [];
-  const seen = new Set<string>();
-  const groups: string[] = [];
-  for (const raw of groupTag.split(",")) {
-    const trimmed = raw.trim();
-    if (!trimmed || seen.has(trimmed)) continue;
-    seen.add(trimmed);
-    groups.push(trimmed);
-  }
-  return groups;
+  return Array.from(new Set(parseProviderGroups(groupTag)));
 }
 
 /**

+ 2 - 4
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -27,6 +27,7 @@ import { clearUsageCache } from "@/lib/dashboard/user-limit-usage-cache";
 import { loadUserUsagePagesSequentially } from "@/lib/dashboard/user-usage-loader";
 import { useDebounce } from "@/lib/hooks/use-debounce";
 import type { CurrencyCode } from "@/lib/utils/currency";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { User, UserDisplay } from "@/types/user";
 import { AddKeyDialog } from "../_components/user/add-key-dialog";
 import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog";
@@ -38,10 +39,7 @@ import { UserManagementTable } from "../_components/user/user-management-table";
  * This matches the server-side providerGroup handling in provider-selector.ts
  */
 function splitTags(value?: string | null): string[] {
-  return (value ?? "")
-    .split(",")
-    .map((t) => t.trim())
-    .filter(Boolean);
+  return parseProviderGroups(value);
 }
 
 interface UsersPageClientProps {

+ 2 - 8
src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts

@@ -1,3 +1,4 @@
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { CacheTtlPreference } from "@/types/cache";
 import type {
   AnthropicAdaptiveThinkingConfig,
@@ -116,14 +117,7 @@ export function analyzeBatchProviderSettings(providers: ProviderDisplay[]): Batc
       priority: analyzeField(providers, (p) => p.priority),
       weight: analyzeField(providers, (p) => p.weight),
       costMultiplier: analyzeField(providers, (p) => p.costMultiplier),
-      groupTag: analyzeField(providers, (p) =>
-        p.groupTag
-          ? p.groupTag
-              .split(",")
-              .map((t) => t.trim())
-              .filter(Boolean)
-          : []
-      ),
+      groupTag: analyzeField(providers, (p) => parseProviderGroups(p.groupTag)),
       preserveClientIp: analyzeField(providers, (p) => p.preserveClientIp),
       modelRedirects: analyzeField(providers, (p) => p.modelRedirects ?? {}),
       allowedModels: analyzeField(providers, (p) => p.allowedModels ?? []),

+ 2 - 4
src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx

@@ -12,6 +12,7 @@ import {
   DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 import { cn } from "@/lib/utils";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { ProviderDisplay, ProviderType } from "@/types/provider";
 
 export interface ProviderBatchToolbarProps {
@@ -59,10 +60,7 @@ export function ProviderBatchToolbar({
     const groupMap = new Map<string, number>();
     for (const p of providers) {
       if (p.groupTag) {
-        const tags = p.groupTag
-          .split(",")
-          .map((tag) => tag.trim())
-          .filter(Boolean);
+        const tags = parseProviderGroups(p.groupTag);
         for (const tag of tags) {
           groupMap.set(tag, (groupMap.get(tag) ?? 0) + 1);
         }

+ 3 - 8
src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx

@@ -34,6 +34,7 @@ import { TagInput } from "@/components/ui/tag-input";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import { PROVIDER_DEFAULTS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants";
 import { getProviderTypeConfig } from "@/lib/provider-type-utils";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import {
   extractBaseUrl,
   isValidUrl,
@@ -175,14 +176,7 @@ export function ProviderForm({
   const [costMultiplier, setCostMultiplier] = useState<number>(
     sourceProvider?.costMultiplier ?? 1.0
   );
-  const [groupTag, setGroupTag] = useState<string[]>(
-    sourceProvider?.groupTag
-      ? sourceProvider.groupTag
-          .split(",")
-          .map((t) => t.trim())
-          .filter(Boolean)
-      : []
-  );
+  const [groupTag, setGroupTag] = useState<string[]>(parseProviderGroups(sourceProvider?.groupTag));
   const [groupSuggestions, setGroupSuggestions] = useState<string[]>([]);
   const [limit5hUsd, setLimit5hUsd] = useState<number | null>(sourceProvider?.limit5hUsd ?? null);
   const [limitDailyUsd, setLimitDailyUsd] = useState<number | null>(
@@ -892,6 +886,7 @@ export function ProviderForm({
                     disabled={isPending}
                     maxTagLength={50}
                     suggestions={groupSuggestions}
+                    validateTag={() => true}
                     onInvalidTag={(_tag, reason) => {
                       const messages: Record<string, string> = {
                         empty: tUI("emptyTag"),

+ 2 - 6
src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx

@@ -10,6 +10,7 @@ import {
   useReducer,
   useRef,
 } from "react";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { ProviderDisplay, ProviderType } from "@/types/provider";
 import { analyzeBatchProviderSettings } from "../../batch-edit/analyze-batch-settings";
 import type {
@@ -353,12 +354,7 @@ export function createInitialState(
     },
     routing: {
       providerType: sourceProvider?.providerType ?? preset?.providerType ?? "claude",
-      groupTag: sourceProvider?.groupTag
-        ? sourceProvider.groupTag
-            .split(",")
-            .map((t) => t.trim())
-            .filter(Boolean)
-        : [],
+      groupTag: parseProviderGroups(sourceProvider?.groupTag),
       preserveClientIp: sourceProvider?.preserveClientIp ?? false,
       modelRedirects: sourceProvider?.modelRedirects ?? {},
       allowedModels: sourceProvider?.allowedModels ?? [],

+ 1 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx

@@ -160,6 +160,7 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) {
                 disabled={state.ui.isPending}
                 maxTagLength={50}
                 suggestions={groupSuggestions}
+                validateTag={() => true}
                 onInvalidTag={(_tag, reason) => {
                   const messages: Record<string, string> = {
                     empty: tUI("emptyTag"),

+ 6 - 22
src/app/[locale]/settings/providers/_components/provider-manager.tsx

@@ -17,6 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton";
 import { Switch } from "@/components/ui/switch";
 import { useDebounce } from "@/lib/hooks/use-debounce";
 import type { CurrencyCode } from "@/lib/utils/currency";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { ProviderDisplay, ProviderStatisticsMap, ProviderType } from "@/types/provider";
 import type { User } from "@/types/user";
 import {
@@ -144,10 +145,7 @@ export function ProviderManager({
     const groups = new Set<string>();
     let hasDefaultGroup = false;
     providers.forEach((p) => {
-      const tags = p.groupTag
-        ?.split(",")
-        .map((t) => t.trim())
-        .filter(Boolean);
+      const tags = parseProviderGroups(p.groupTag);
       if (!tags || tags.length === 0) {
         hasDefaultGroup = true;
       } else {
@@ -172,10 +170,7 @@ export function ProviderManager({
   // User's assigned groups (for non-admin users)
   const userGroups = useMemo(() => {
     if (!currentUser?.providerGroup) return [];
-    return currentUser.providerGroup
-      .split(",")
-      .map((g) => g.trim())
-      .filter(Boolean);
+    return parseProviderGroups(currentUser.providerGroup);
   }, [currentUser?.providerGroup]);
 
   // Check if current user is admin
@@ -192,10 +187,7 @@ export function ProviderManager({
         (p) =>
           p.name.toLowerCase().includes(term) ||
           p.url.toLowerCase().includes(term) ||
-          p.groupTag
-            ?.split(",")
-            .map((t) => t.trim().toLowerCase())
-            .some((tag) => tag.includes(term))
+          parseProviderGroups(p.groupTag).some((tag) => tag.toLowerCase().includes(term))
       );
     }
 
@@ -212,11 +204,7 @@ export function ProviderManager({
     // Filter by groups
     if (groupFilter.length > 0) {
       result = result.filter((p) => {
-        const providerGroups =
-          p.groupTag
-            ?.split(",")
-            .map((t) => t.trim())
-            .filter(Boolean) || [];
+        const providerGroups = parseProviderGroups(p.groupTag);
 
         // If provider has no groups and "default" is selected, include it
         if (providerGroups.length === 0 && groupFilter.includes("default")) {
@@ -341,11 +329,7 @@ export function ProviderManager({
       setSelectedProviderIds((prev) => {
         const next = new Set(prev);
         for (const p of filteredProviders) {
-          const tags =
-            p.groupTag
-              ?.split(",")
-              .map((tag) => tag.trim())
-              .filter(Boolean) ?? [];
+          const tags = parseProviderGroups(p.groupTag);
           if (tags.includes(group) || (group === "default" && tags.length === 0)) {
             next.add(p.id);
           }

+ 3 - 7
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -69,6 +69,7 @@ import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard";
 import { getContrastTextColor, getGroupColor } from "@/lib/utils/color";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import { formatCurrency } from "@/lib/utils/currency";
+import { normalizeProviderGroupTag, parseProviderGroups } from "@/lib/utils/provider-group";
 import type { ProviderDisplay, ProviderStatistics, ProviderVendor } from "@/types/provider";
 import type { User } from "@/types/user";
 import { ProviderForm } from "./forms/provider-form";
@@ -445,16 +446,11 @@ function ProviderRichListItemInner({
   const handleSaveWeight = createSaveHandler("weight");
   const handleSaveCostMultiplier = createSaveHandler("cost_multiplier");
 
-  const providerGroups = provider.groupTag
-    ? provider.groupTag
-        .split(",")
-        .map((t) => t.trim())
-        .filter(Boolean)
-    : [];
+  const providerGroups = parseProviderGroups(provider.groupTag);
 
   const handleSaveGroups = async (groups: string[]): Promise<boolean> => {
     try {
-      const groupTag = groups.length > 0 ? groups.join(",") : null;
+      const groupTag = normalizeProviderGroupTag(groups.join(","));
       const res = await editProvider(provider.id, { group_tag: groupTag });
       if (res.ok) {
         toast.success(tInline("saveSuccess"));

+ 2 - 4
src/app/v1/_lib/proxy/provider-selector.ts

@@ -3,6 +3,7 @@ import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { logger } from "@/lib/logger";
 import { RateLimitService } from "@/lib/rate-limit";
 import { SessionManager } from "@/lib/session-manager";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import { isProviderActiveNow } from "@/lib/utils/provider-schedule";
 import { resolveSystemTimezone } from "@/lib/utils/timezone";
 import { isVendorTypeCircuitOpen } from "@/lib/vendor-type-circuit-breaker";
@@ -48,10 +49,7 @@ async function getVerboseProviderErrorCached(): Promise<boolean> {
  * @returns 清理后的分组数组(去空格、去空项)
  */
 function parseGroupString(groupString: string): string[] {
-  return groupString
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
+  return parseProviderGroups(groupString);
 }
 
 /**

+ 2 - 6
src/components/form/form-field.tsx

@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { TagInput } from "@/components/ui/tag-input";
 import { cn } from "@/lib/utils";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 
 /**
  * 表单字段配置
@@ -175,12 +176,7 @@ export function TagInputField({
   const fieldId = tagInputProps.id || `field-${autoId}`;
 
   // 将字符串转换为数组
-  const tagsArray = value
-    ? value
-        .split(",")
-        .map((t) => t.trim())
-        .filter(Boolean)
-    : [];
+  const tagsArray = parseProviderGroups(value);
 
   // 将数组转换回字符串
   const handleChange = (tags: string[]) => {

+ 119 - 0
src/components/ui/__tests__/provider-group-tag-input.test.tsx

@@ -0,0 +1,119 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderGroupSelect } from "@/app/[locale]/dashboard/_components/user/forms/provider-group-select";
+import { TagInput } from "@/components/ui/tag-input";
+
+const providerActionsMocks = vi.hoisted(() => ({
+  getProviderGroupsWithCount: vi.fn(async () => ({ ok: true, data: [] })),
+}));
+
+const sonnerMocks = vi.hoisted(() => ({
+  toast: {
+    error: vi.fn(),
+  },
+}));
+
+vi.mock("@/actions/providers", () => providerActionsMocks);
+vi.mock("sonner", () => sonnerMocks);
+
+function render(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(node);
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function typeAndSubmit(input: HTMLInputElement, value: string) {
+  await act(async () => {
+    input.focus();
+    const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+    valueSetter?.call(input, value);
+    input.dispatchEvent(new Event("input", { bubbles: true }));
+    input.dispatchEvent(new Event("change", { bubbles: true }));
+    await new Promise((resolve) => setTimeout(resolve, 0));
+  });
+
+  await act(async () => {
+    input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+    await new Promise((resolve) => setTimeout(resolve, 0));
+  });
+}
+
+afterEach(() => {
+  while (document.body.firstChild) {
+    document.body.removeChild(document.body.firstChild);
+  }
+});
+
+beforeEach(() => {
+  vi.clearAllMocks();
+});
+
+describe("provider-group tag inputs", () => {
+  test("默认 TagInput 仍应拒绝中文标签", async () => {
+    const onChange = vi.fn();
+    const onInvalidTag = vi.fn();
+    const { container, unmount } = render(
+      <TagInput value={[]} onChange={onChange} onInvalidTag={onInvalidTag} />
+    );
+
+    const input = container.querySelector("input");
+    expect(input).toBeInstanceOf(HTMLInputElement);
+
+    await typeAndSubmit(input as HTMLInputElement, "中文分组");
+
+    expect(onChange).not.toHaveBeenCalled();
+    expect(onInvalidTag).toHaveBeenCalledWith("中文分组", "invalid_format");
+
+    unmount();
+  });
+
+  test("ProviderGroupSelect 应允许输入中文分组", async () => {
+    const onChange = vi.fn();
+    const translations = {
+      label: "Provider group",
+      placeholder: "Enter group",
+      description: "desc",
+      errors: {
+        loadFailed: "Load failed",
+      },
+      tagInputErrors: {
+        empty: "empty",
+        duplicate: "duplicate",
+        too_long: "too long",
+        invalid_format: "invalid format",
+        max_tags: "max tags",
+      },
+    };
+    const { container, unmount } = render(
+      <ProviderGroupSelect value="" onChange={onChange} translations={translations} />
+    );
+
+    const input = container.querySelector("input");
+    expect(input).toBeInstanceOf(HTMLInputElement);
+
+    await typeAndSubmit(input as HTMLInputElement, "中文分组");
+
+    expect(onChange).toHaveBeenCalledWith("中文分组");
+    expect(sonnerMocks.toast.error).not.toHaveBeenCalled();
+
+    unmount();
+  });
+});

+ 6 - 0
src/lib/provider-patch-contract.ts

@@ -1,3 +1,4 @@
+import { normalizeProviderGroupTag } from "@/lib/utils/provider-group";
 import type {
   ProviderBatchApplyUpdates,
   ProviderBatchPatch,
@@ -367,6 +368,11 @@ function normalizePatchField<T>(
       return createInvalidPatchShapeError(field, "set mode value is invalid for this field");
     }
 
+    if (field === "group_tag") {
+      const normalizedGroupTag = normalizeProviderGroupTag(input.set) ?? "";
+      return { ok: true, data: { mode: "set", value: normalizedGroupTag as T } };
+    }
+
     return { ok: true, data: { mode: "set", value: input.set as T } };
   }
 

+ 3 - 2
src/lib/request-filter-engine.ts

@@ -9,6 +9,7 @@ import type {
   RemoveOp,
   SetOp,
 } from "@/lib/request-filter-types";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type {
   RequestFilter,
   RequestFilterAction,
@@ -484,7 +485,7 @@ export class RequestFilterEngine {
     let providerTagsSet: Set<string> | null = null;
     if (this.hasGroupBasedFilters) {
       const providerGroupTag = session.provider.groupTag;
-      providerTagsSet = new Set(providerGroupTag?.split(",").map((t) => t.trim()) ?? []);
+      providerTagsSet = new Set(parseProviderGroups(providerGroupTag));
     }
 
     for (const filter of this.providerGuardFilters) {
@@ -574,7 +575,7 @@ export class RequestFilterEngine {
       let providerTagsSet: Set<string> | null = null;
       if (this.hasGroupBasedFinalFilters) {
         const providerGroupTag = session.provider.groupTag;
-        providerTagsSet = new Set(providerGroupTag?.split(",").map((t) => t.trim()) ?? []);
+        providerTagsSet = new Set(parseProviderGroups(providerGroupTag));
       }
 
       for (const filter of this.providerFinalFilters) {

+ 24 - 0
src/lib/utils/provider-group.test.ts

@@ -0,0 +1,24 @@
+import { describe, expect, test } from "vitest";
+import {
+  normalizeProviderGroup,
+  normalizeProviderGroupTag,
+  parseProviderGroups,
+} from "./provider-group";
+
+describe("provider-group utils", () => {
+  test("parseProviderGroups 应支持中文逗号和换行作为分隔符", () => {
+    expect(parseProviderGroups("研发,渠道\n直营")).toEqual(["研发", "渠道", "直营"]);
+  });
+
+  test("normalizeProviderGroup 应在支持中文标签的同时做去重和排序", () => {
+    expect(normalizeProviderGroup("研发,渠道\n研发")).toBe("渠道,研发");
+  });
+
+  test("normalizeProviderGroupTag 应支持中文标签并保留原始顺序", () => {
+    expect(normalizeProviderGroupTag("直营,华北\n直营")).toBe("直营,华北");
+  });
+
+  test("normalizeProviderGroupTag 在空输入时应返回 null", () => {
+    expect(normalizeProviderGroupTag("  , \n  ")).toBeNull();
+  });
+});

+ 31 - 15
src/lib/utils/provider-group.ts

@@ -1,5 +1,18 @@
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 
+const PROVIDER_GROUP_SEPARATOR = /[,,\n\r]+/;
+
+function splitProviderGroupValue(value: unknown): string[] {
+  if (typeof value !== "string") {
+    return [];
+  }
+
+  return value
+    .split(PROVIDER_GROUP_SEPARATOR)
+    .map((group) => group.trim())
+    .filter(Boolean);
+}
+
 /**
  * Normalize provider group value to a consistent format
  * - Returns "default" for null/undefined/empty values
@@ -7,26 +20,29 @@ import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
  * - Sorts groups alphabetically for consistency
  */
 export function normalizeProviderGroup(value: unknown): string {
-  if (value === null || value === undefined) return PROVIDER_GROUP.DEFAULT;
-  if (typeof value !== "string") return PROVIDER_GROUP.DEFAULT;
-  const trimmed = value.trim();
-  if (trimmed === "") return PROVIDER_GROUP.DEFAULT;
-
-  const groups = trimmed
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
+  const groups = splitProviderGroupValue(value);
   if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
 
   return Array.from(new Set(groups)).sort().join(",");
 }
 
 /**
- * Parse a comma-separated provider group string into an array
+ * Normalize provider group tag string for provider.groupTag storage.
+ * - Supports English comma, Chinese comma and line breaks as separators
+ * - Trims whitespace and removes duplicates while preserving input order
+ * - Returns null for null/undefined/empty values
  */
-export function parseProviderGroups(value: string): string[] {
-  return value
-    .split(",")
-    .map((g) => g.trim())
-    .filter(Boolean);
+export function normalizeProviderGroupTag(value: unknown): string | null {
+  const groups = splitProviderGroupValue(value);
+  if (groups.length === 0) return null;
+
+  return Array.from(new Set(groups)).join(",");
+}
+
+/**
+ * Parse a provider group / groupTag string into an array.
+ * Supports English comma, Chinese comma and line breaks as separators.
+ */
+export function parseProviderGroups(value: unknown): string[] {
+  return splitProviderGroupValue(value);
 }

+ 1 - 1
src/repository/leaderboard.ts

@@ -274,7 +274,7 @@ async function findLeaderboardWithTimezone(
   if (normalizedGroups.length > 0) {
     const groupConditions = normalizedGroups.map(
       (group) =>
-        sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))`
+        sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*[,,]+\\s*'))`
     );
     groupFilterCondition = sql`(${sql.join(groupConditions, sql` OR `)})`;
   }

+ 2 - 6
src/repository/provider.ts

@@ -7,6 +7,7 @@ import { getCachedProviders } from "@/lib/cache/provider-cache";
 import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants";
 import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker";
 import { logger } from "@/lib/logger";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import { resolveSystemTimezone } from "@/lib/utils/timezone";
 import type {
   AnthropicAdaptiveThinkingConfig,
@@ -1491,12 +1492,7 @@ export async function getDistinctProviderGroups(): Promise<string[]> {
   const allTags = result
     .map((r) => r.groupTag)
     .filter((tag): tag is string => tag !== null)
-    .flatMap((tag) =>
-      tag
-        .split(",")
-        .map((t) => t.trim())
-        .filter(Boolean)
-    );
+    .flatMap((tag) => parseProviderGroups(tag));
 
   return [...new Set(allTags)].sort();
 }

+ 3 - 5
src/repository/user.ts

@@ -4,6 +4,7 @@ import { and, asc, eq, isNull, type SQL, sql } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import { keys as keysTable, users } from "@/drizzle/schema";
 import { cacheUser, invalidateCachedUser } from "@/lib/security/api-key-auth-cache";
+import { parseProviderGroups } from "@/lib/utils/provider-group";
 import type { CreateUserData, UpdateUserData, User } from "@/types/user";
 import { toUser } from "./_shared/transformers";
 
@@ -245,7 +246,7 @@ export async function findUserListBatch(
   if (trimmedGroups.length > 0) {
     const groupConditions = trimmedGroups.map(
       (group) =>
-        sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))`
+        sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*[,,]+\\s*'))`
     );
     keyGroupFilterCondition = sql`(${sql.join(groupConditions, sql` OR `)})`;
   }
@@ -605,10 +606,7 @@ export async function getAllUserProviderGroups(): Promise<string[]> {
 
   const allGroups = new Set<string>();
   for (const row of result) {
-    const groups = row.providerGroup
-      ?.split(",")
-      .map((group) => group.trim())
-      .filter(Boolean);
+    const groups = parseProviderGroups(row.providerGroup);
     if (!groups || groups.length === 0) continue;
     for (const group of groups) {
       allGroups.add(group);