From 329184851f3109ac7a284aec533b6d206ce045c9 Mon Sep 17 00:00:00 2001 From: NieiR Date: Sun, 4 Jan 2026 23:47:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20unified-ed?= =?UTF-8?q?it-dialog=20=E4=B8=BA=E4=B8=93=E7=94=A8=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E7=BB=84=E4=BB=B6=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 1120 行的 unified-edit-dialog.tsx 拆分为 4 个单一职责组件: - CreateUserDialog: 创建用户 + 首个密钥 - EditUserDialog: 编辑用户信息 - AddKeyDialog: 添加新密钥 - EditKeyDialog: 编辑单个密钥 主要改动: - 提取共享逻辑到 hooks/ (翻译、模型建议) - 提取工具函数到 utils/ (表单处理、provider group) - 新增 getModelSuggestionsByProviderGroup action - 修复 expiresAt 清除时传参问题 (使用空字符串) - 添加单元测试覆盖新组件 BREAKING CHANGE: 删除 unified-edit-dialog.tsx,引用需更新为新组件 --- messages/en/dashboard.json | 21 +- messages/ja/dashboard.json | 19 +- messages/ru/dashboard.json | 19 +- messages/zh-CN/dashboard.json | 19 +- messages/zh-TW/dashboard.json | 19 +- src/actions/providers.ts | 76 ++ .../_components/user/add-key-dialog.tsx | 128 ++ .../_components/user/create-user-dialog.tsx | 425 +++++++ .../_components/user/edit-key-dialog.tsx | 64 + .../_components/user/edit-user-dialog.tsx | 268 ++++ .../_components/user/forms/add-key-form.tsx | 13 +- .../_components/user/forms/edit-key-form.tsx | 19 +- .../user/forms/key-edit-section.tsx | 100 +- .../user/hooks/use-key-translations.ts | 136 ++ .../user/hooks/use-model-suggestions.ts | 27 + .../user/hooks/use-user-translations.ts | 191 +++ .../_components/user/unified-edit-dialog.tsx | 1120 ----------------- .../_components/user/user-key-table-row.tsx | 78 +- .../user/user-management-table.tsx | 26 +- .../_components/user/utils/form-utils.ts | 9 + .../_components/user/utils/provider-group.ts | 23 + .../dashboard/users/users-page-client.tsx | 91 +- .../add-key-form-expiry-clear-ui.test.tsx | 140 +++ tests/unit/user-dialogs.test.tsx | 752 +++++++++++ 24 files changed, 2559 insertions(+), 1224 deletions(-) create mode 100644 src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx create mode 100644 src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx create mode 100644 src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx create mode 100644 src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx create mode 100644 src/app/[locale]/dashboard/_components/user/hooks/use-key-translations.ts create mode 100644 src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts create mode 100644 src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts delete mode 100644 src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx create mode 100644 src/app/[locale]/dashboard/_components/user/utils/form-utils.ts create mode 100644 src/app/[locale]/dashboard/_components/user/utils/provider-group.ts create mode 100644 tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx create mode 100644 tests/unit/user-dialogs.test.tsx diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index ff40e3b60..c5d9330bc 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -769,6 +769,12 @@ "defaultDescription": "default includes providers without groupTag.", "descriptionWithUserGroup": "Provider groups for this key (user groups: {group}; default: default)." }, + "successTitle": "Key Created Successfully", + "successDescription": "Your API key has been created successfully.", + "generatedKey": { + "label": "Generated Key", + "hint": "You can view and copy this key anytime from the key list." + }, "errors": { "userIdMissing": "User ID does not exist", "createFailed": "Failed to create, please try again later", @@ -1142,6 +1148,9 @@ "defaultGroup": "default", "userStatus": { "disabled": "Disabled" + }, + "actions": { + "addKey": "Add Key" } }, "keyFullDisplay": { @@ -1210,8 +1219,8 @@ "editKeyTitle": "Edit Key" }, "editDialog": { - "title": "Edit user and keys", - "description": "Edit user information and API key settings", + "title": "Edit user", + "description": "Edit user information", "userSection": "User settings", "keysSection": "Key settings", "scrollToKey": "Scroll to key", @@ -1310,6 +1319,10 @@ "saveFailed": "Failed to create user", "keyCreateFailed": "Failed to create key", "createSuccess": "User created successfully", + "successTitle": "Created Successfully", + "successDescription": "User and key have been created", + "generatedKey": "Generated Key", + "keyHint": "You can view and copy this key anytime from the user management page", "keysSection": "Keys", "addKey": "Add key", "removeKey": "Remove key", @@ -1404,7 +1417,7 @@ "label": "Enable dedicated balance page", "description": "Allow users to view their balance via a dedicated page", "descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.", - "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. However, they can modify their own key's provider group in the restricted Web UI." + "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. Instead, they will use the restricted Web UI." }, "cacheTtlOverride": { "label": "Cache TTL override", @@ -1529,7 +1542,7 @@ "label": "Independent Personal Usage Page", "description": "When enabled, this key can access an independent personal usage page", "descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.", - "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. However, they can modify their own key's provider group in the restricted Web UI." + "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. Instead, they will use the restricted Web UI." }, "providerGroup": { "label": "Provider Group", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 4111670ed..43721adc4 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -750,6 +750,12 @@ "defaultDescription": "default は groupTag 未設定のプロバイダーを含みます", "descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)" }, + "successTitle": "キーが正常に作成されました", + "successDescription": "APIキーが正常に作成されました。", + "generatedKey": { + "label": "生成されたキー", + "hint": "このキーはキー一覧からいつでも確認・コピーできます。" + }, "errors": { "userIdMissing": "ユーザーIDが存在しません", "createFailed": "作成に失敗しました。後でもう一度お試しください", @@ -1113,6 +1119,9 @@ "defaultGroup": "default", "userStatus": { "disabled": "無効" + }, + "actions": { + "addKey": "キーを追加" } }, "keyFullDisplay": { @@ -1172,8 +1181,8 @@ "failed": "更新に失敗しました" }, "editDialog": { - "title": "ユーザーとキーを編集", - "description": "ユーザー情報とAPIキーの設定を編集", + "title": "ユーザーを編集", + "description": "ユーザー情報を編集", "userSection": "ユーザー設定", "keysSection": "キー設定", "scrollToKey": "キーへスクロール", @@ -1272,6 +1281,10 @@ "saveFailed": "ユーザーの作成に失敗しました", "keyCreateFailed": "キーの作成に失敗しました", "createSuccess": "ユーザーが作成されました", + "successTitle": "作成完了", + "successDescription": "ユーザーとキーが作成されました", + "generatedKey": "生成されたキー", + "keyHint": "このキーはユーザー管理ページからいつでも確認・コピーできます", "keysSection": "キー", "addKey": "キーを追加", "removeKey": "キーを削除", @@ -1489,7 +1502,7 @@ "label": "独立した個人使用量ページ", "description": "有効にすると、このキーで独立した個人使用量ページにアクセスできます", "descriptionEnabled": "有効にすると、このキーはログイン時に独立した個人使用量ページにアクセスします。ただし、自分のキーのプロバイダーグループは変更できません。", - "descriptionDisabled": "無効にすると、ユーザーは個人使用量ページUIにアクセスできません。ただし、制限されたWeb UIで自分のキーのプロバイダーグループを変更できます。" + "descriptionDisabled": "無効にすると、ユーザーは個人使用量ページUIにアクセスできません。代わりに制限されたWeb UIを使用します。" }, "providerGroup": { "label": "プロバイダーグループ", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 942ebb537..356e2afdf 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -752,6 +752,12 @@ "defaultDescription": "default включает провайдеров без groupTag.", "descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)." }, + "successTitle": "Ключ успешно создан", + "successDescription": "Ваш API-ключ был успешно создан.", + "generatedKey": { + "label": "Сгенерированный ключ", + "hint": "Вы можете просмотреть и скопировать этот ключ в любое время из списка ключей." + }, "errors": { "userIdMissing": "ID пользователя не существует", "createFailed": "Не удалось создать, попробуйте позже", @@ -1120,6 +1126,9 @@ "defaultGroup": "default", "userStatus": { "disabled": "Отключен" + }, + "actions": { + "addKey": "Добавить ключ" } }, "keyFullDisplay": { @@ -1183,8 +1192,8 @@ "editKeyTitle": "Редактировать ключ" }, "editDialog": { - "title": "Редактировать пользователя и ключи", - "description": "Редактирование данных пользователя и настроек API-ключей", + "title": "Редактировать пользователя", + "description": "Редактирование данных пользователя", "userSection": "Настройки пользователя", "keysSection": "Настройки ключей", "scrollToKey": "Прокрутить к ключу", @@ -1283,6 +1292,10 @@ "saveFailed": "Не удалось создать пользователя", "keyCreateFailed": "Не удалось создать ключ", "createSuccess": "Пользователь создан", + "successTitle": "Успешно создано", + "successDescription": "Пользователь и ключ успешно созданы", + "generatedKey": "Сгенерированный ключ", + "keyHint": "Вы можете просмотреть и скопировать этот ключ в любое время на странице управления пользователями", "keysSection": "Ключи", "addKey": "Добавить ключ", "removeKey": "Удалить ключ", @@ -1377,7 +1390,7 @@ "label": "Включить отдельную страницу проверки баланса", "description": "Разрешить пользователю просматривать баланс на отдельной странице", "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.", - "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Однако он может изменять группу провайдеров своего ключа в ограниченном Web UI." + "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI." }, "cacheTtlOverride": { "label": "Переопределение Cache TTL", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index dfad6f083..7074481a2 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -770,6 +770,12 @@ "defaultDescription": "default 分组包含所有未设置 groupTag 的供应商", "descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})" }, + "successTitle": "密钥创建成功", + "successDescription": "您的 API 密钥已成功创建。", + "generatedKey": { + "label": "生成的密钥", + "hint": "您可以随时在密钥列表中查看和复制此密钥" + }, "errors": { "userIdMissing": "用户ID不存在", "createFailed": "创建失败,请稍后重试", @@ -1143,6 +1149,9 @@ "defaultGroup": "default", "userStatus": { "disabled": "已禁用" + }, + "actions": { + "addKey": "新增密钥" } }, "keyFullDisplay": { @@ -1211,8 +1220,8 @@ "editKeyTitle": "编辑 Key" }, "editDialog": { - "title": "编辑用户与密钥", - "description": "编辑用户信息和 API 密钥设置", + "title": "编辑用户", + "description": "编辑用户信息", "userSection": "用户设置", "keysSection": "密钥设置", "scrollToKey": "滚动到密钥", @@ -1311,6 +1320,10 @@ "saveFailed": "创建用户失败", "keyCreateFailed": "创建密钥失败", "createSuccess": "用户创建成功", + "successTitle": "创建成功", + "successDescription": "用户和密钥已成功创建", + "generatedKey": "生成的密钥", + "keyHint": "您可以随时在用户管理页面查看和复制此密钥", "keysSection": "密钥", "addKey": "添加密钥", "removeKey": "删除密钥", @@ -1528,7 +1541,7 @@ "label": "独立个人用量页面", "description": "启用后,此密钥可使用独立的个人用量查询页面", "descriptionEnabled": "启用后,此密钥在登录时将进入独立的个人用量页面。但不可修改自己密钥的供应商分组。", - "descriptionDisabled": "关闭后,用户将无法进入个人独立用量页面 UI。但可在受限的 Web UI功能中修改自己密钥的供应商分组。" + "descriptionDisabled": "关闭后,用户将无法进入个人独立用量页面 UI,而是进入受限的 Web UI。" }, "providerGroup": { "label": "供应商分组", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 40c18f295..c27eb01ee 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -750,6 +750,12 @@ "defaultDescription": "default 分組包含所有未設定 groupTag 的供應商", "descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})" }, + "successTitle": "金鑰建立成功", + "successDescription": "您的 API 金鑰已成功建立。", + "generatedKey": { + "label": "產生的金鑰", + "hint": "您可以隨時在金鑰列表中查看和複製此金鑰" + }, "errors": { "userIdMissing": "使用者 ID 不存在", "createFailed": "建立失敗,請稍後重試", @@ -1118,6 +1124,9 @@ "defaultGroup": "default", "userStatus": { "disabled": "已停用" + }, + "actions": { + "addKey": "新增金鑰" } }, "keyFullDisplay": { @@ -1181,8 +1190,8 @@ "editKeyTitle": "編輯 Key" }, "editDialog": { - "title": "編輯使用者與金鑰", - "description": "編輯使用者資訊和 API 金鑰設定", + "title": "編輯使用者", + "description": "編輯使用者資訊", "userSection": "使用者設定", "keysSection": "金鑰設定", "scrollToKey": "捲動到金鑰", @@ -1281,6 +1290,10 @@ "saveFailed": "建立使用者失敗", "keyCreateFailed": "建立金鑰失敗", "createSuccess": "使用者建立成功", + "successTitle": "建立成功", + "successDescription": "使用者和金鑰已成功建立", + "generatedKey": "產生的金鑰", + "keyHint": "您可以隨時在使用者管理頁面查看和複製此金鑰", "keysSection": "金鑰", "addKey": "新增金鑰", "removeKey": "刪除金鑰", @@ -1498,7 +1511,7 @@ "label": "獨立個人用量頁面", "description": "啟用後,此金鑰可使用獨立的個人用量查詢頁面", "descriptionEnabled": "啟用後,此金鑰在登入時將進入獨立的個人用量頁面。但不可修改自己金鑰的供應商分組。", - "descriptionDisabled": "關閉後,使用者將無法進入個人獨立用量頁面 UI。但可在受限的 Web UI功能中修改自己金鑰的供應商分組。" + "descriptionDisabled": "關閉後,使用者將無法進入個人獨立用量頁面 UI,而是進入受限的 Web UI。" }, "providerGroup": { "label": "供應商分組", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 6ae389675..4a8fb9788 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -3090,3 +3090,79 @@ async function fetchAnthropicModels( return handleFetchException(error, "fetchAnthropicModels"); } } + +/** + * 解析分组字符串为数组 + */ +function parseGroupString(groupString: string): string[] { + return groupString + .split(",") + .map((g) => g.trim()) + .filter(Boolean); +} + +/** + * 检查供应商分组是否匹配用户分组 + */ +function checkProviderGroupMatch(providerGroupTag: string | null, userGroups: string[]): boolean { + if (userGroups.includes(PROVIDER_GROUP.ALL)) { + return true; + } + + const providerTags = providerGroupTag + ? parseGroupString(providerGroupTag) + : [PROVIDER_GROUP.DEFAULT]; + + return providerTags.some((tag) => userGroups.includes(tag)); +} + +/** + * 根据供应商分组获取模型建议列表 + * + * 用于用户/密钥编辑时的模型限制下拉建议。 + * 从匹配分组的启用供应商中收集 allowedModels 并去重。 + * + * @param providerGroup - 可选的供应商分组(逗号分隔),默认为 "default" + * @returns 去重后的模型列表 + */ +export async function getModelSuggestionsByProviderGroup( + providerGroup?: string | null +): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未登录" }; + } + + // 获取所有启用的供应商 + const providers = await findAllProviders(); + const enabledProviders = providers.filter((p) => p.isEnabled); + + // 解析用户分组 + const userGroups = providerGroup ? parseGroupString(providerGroup) : [PROVIDER_GROUP.DEFAULT]; + + // 过滤匹配分组的供应商并收集 allowedModels + const modelSet = new Set(); + + for (const provider of enabledProviders) { + if (checkProviderGroupMatch(provider.groupTag, userGroups)) { + const models = provider.allowedModels; + if (models && Array.isArray(models)) { + for (const model of models) { + if (model) { + modelSet.add(model); + } + } + } + } + } + + // 转换为数组并排序 + const sortedModels = Array.from(modelSet).sort(); + + return { ok: true, data: sortedModels }; + } catch (error) { + logger.error("获取模型建议列表失败:", error); + return { ok: false, error: "获取模型建议列表失败" }; + } +} diff --git a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx new file mode 100644 index 000000000..4b12d238a --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { User } from "@/types/user"; +import { AddKeyForm } from "./forms/add-key-form"; + +export interface AddKeyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + userId: number; + user?: User; + isAdmin?: boolean; + onSuccess?: () => void; +} + +interface GeneratedKeyInfo { + generatedKey: string; + name: string; +} + +export function AddKeyDialog({ + open, + onOpenChange, + userId, + user, + isAdmin, + onSuccess, +}: AddKeyDialogProps) { + const t = useTranslations("dashboard.addKeyForm"); + const tCommon = useTranslations("common"); + const [generatedKey, setGeneratedKey] = useState(null); + const [copied, setCopied] = useState(false); + + const handleSuccess = (result: GeneratedKeyInfo) => { + setGeneratedKey(result); + onSuccess?.(); + }; + + const handleCopy = async () => { + if (!generatedKey) return; + try { + await navigator.clipboard.writeText(generatedKey.generatedKey); + setCopied(true); + toast.success(tCommon("copySuccess")); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("[AddKeyDialog] copy failed", error); + toast.error(tCommon("copyFailed")); + } + }; + + const handleClose = () => { + setGeneratedKey(null); + setCopied(false); + onOpenChange(false); + }; + + return ( + + + {generatedKey ? ( + <> + + {t("successTitle")} + {t("successDescription")} + +
+
+ + +
+
+ +
+ + +
+

{t("generatedKey.hint")}

+
+
+ +
+
+ + ) : ( + <> + + {t("title")} + {t("description")} + + + + )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx new file mode 100644 index 000000000..d124bf8d1 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { Check, Copy, Loader2, UserPlus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; +import { addKey } from "@/actions/keys"; +import { createUserOnly, removeUser } from "@/actions/users"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; +import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas"; +import { KeyEditSection } from "./forms/key-edit-section"; +import { UserEditSection } from "./forms/user-edit-section"; +import { useKeyTranslations } from "./hooks/use-key-translations"; +import { useModelSuggestions } from "./hooks/use-model-suggestions"; +import { useUserTranslations } from "./hooks/use-user-translations"; +import { getFirstErrorMessage } from "./utils/form-utils"; +import { normalizeProviderGroup } from "./utils/provider-group"; + +export interface CreateUserDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +const CreateUserSchema = UpdateUserSchema.extend({ + name: z.string().min(1).max(64), + providerGroup: z.string().max(50).nullable().optional(), + allowedClients: z.array(z.string().max(64)).max(50).optional().default([]), + allowedModels: z.array(z.string().max(64)).max(50).optional().default([]), + dailyQuota: z.number().nullable().optional(), +}); + +const CreateKeySchema = KeyFormSchema.extend({ + id: z.number(), + isEnabled: z.boolean().optional(), + // 覆盖 expiresAt 以支持 Date 类型(KeyEditSection 返回 Date 对象) + expiresAt: z + .union([z.date(), z.string(), z.null(), z.undefined()]) + .optional() + .transform((val) => { + if (val === null || val === undefined || val === "") return undefined; + if (val instanceof Date) return val.toISOString(); + return val; + }), +}); + +const CreateFormSchema = z.object({ + user: CreateUserSchema, + key: CreateKeySchema, +}); + +type CreateFormValues = z.infer; + +function getNextTempKeyId() { + return -Math.floor(Date.now() + Math.random() * 1000); +} + +function buildDefaultValues(): CreateFormValues { + return { + user: { + name: "", + note: "", + tags: [], + expiresAt: undefined, + providerGroup: PROVIDER_GROUP.DEFAULT, + rpm: 0, + limit5hUsd: null, + dailyQuota: null, + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + allowedClients: [], + allowedModels: [], + }, + key: { + id: getNextTempKeyId(), + name: "default", + isEnabled: true, + expiresAt: undefined, + canLoginWebUi: false, + providerGroup: PROVIDER_GROUP.DEFAULT, + cacheTtlPreference: "inherit" as const, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + }, + }; +} + +interface GeneratedKeyInfo { + generatedKey: string; + keyName: string; + userName: string; +} + +function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const t = useTranslations("dashboard.userManagement"); + const tCommon = useTranslations("common"); + const [isPending, startTransition] = useTransition(); + const [generatedKey, setGeneratedKey] = useState(null); + const [copied, setCopied] = useState(false); + + // Use shared hooks + const modelSuggestions = useModelSuggestions(PROVIDER_GROUP.DEFAULT); + const userEditTranslations = useUserTranslations({ showProviderGroup: false }); + const keyEditTranslations = useKeyTranslations(); + + const defaultValues = useMemo(() => buildDefaultValues(), []); + + const form = useZodForm({ + schema: CreateFormSchema, + defaultValues, + onSubmit: async (data) => { + startTransition(async () => { + try { + // Create user first + const userRes = await createUserOnly({ + name: data.user.name, + note: data.user.note, + tags: data.user.tags, + expiresAt: data.user.expiresAt ?? null, + rpm: data.user.rpm, + limit5hUsd: data.user.limit5hUsd, + dailyQuota: data.user.dailyQuota ?? undefined, + limitWeeklyUsd: data.user.limitWeeklyUsd, + limitMonthlyUsd: data.user.limitMonthlyUsd, + limitTotalUsd: data.user.limitTotalUsd, + limitConcurrentSessions: data.user.limitConcurrentSessions, + dailyResetMode: data.user.dailyResetMode, + dailyResetTime: data.user.dailyResetTime, + allowedClients: data.user.allowedClients, + allowedModels: data.user.allowedModels, + }); + if (!userRes.ok) { + toast.error(userRes.error || t("createDialog.saveFailed")); + return; + } + + const newUserId = userRes.data.user.id; + + // Create the first key + const keyRes = await addKey({ + userId: newUserId, + name: data.key.name, + // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃 + expiresAt: data.key.expiresAt ?? "", + canLoginWebUi: data.key.canLoginWebUi, + providerGroup: normalizeProviderGroup(data.key.providerGroup), + cacheTtlPreference: data.key.cacheTtlPreference, + limit5hUsd: data.key.limit5hUsd, + limitDailyUsd: data.key.limitDailyUsd, + dailyResetMode: data.key.dailyResetMode, + dailyResetTime: data.key.dailyResetTime, + limitWeeklyUsd: data.key.limitWeeklyUsd, + limitMonthlyUsd: data.key.limitMonthlyUsd, + limitTotalUsd: data.key.limitTotalUsd, + limitConcurrentSessions: data.key.limitConcurrentSessions, + }); + + if (!keyRes.ok) { + // Rollback: delete the user since key creation failed + try { + await removeUser(newUserId); + } catch (rollbackError) { + console.error("[CreateUserDialog] rollback failed", rollbackError); + } + toast.error(keyRes.error || t("createDialog.keyCreateFailed", { name: data.key.name })); + return; + } + + // Show generated key + setGeneratedKey({ + generatedKey: keyRes.data?.generatedKey || "", + keyName: data.key.name, + userName: data.user.name, + }); + + onSuccess?.(); + queryClient.invalidateQueries({ queryKey: ["users"] }); + router.refresh(); + } catch (error) { + console.error("[CreateUserDialog] submit failed", error); + toast.error(t("createDialog.saveFailed")); + } + }); + }, + }); + + const errorMessage = useMemo(() => getFirstErrorMessage(form.errors), [form.errors]); + + const currentUserDraft = form.values.user || defaultValues.user; + const currentKeyDraft = form.values.key || defaultValues.key; + + const handleUserChange = (field: string | Record, value?: any) => { + const prev = form.values.user || defaultValues.user; + const next = { ...prev }; + + if (typeof field === "object") { + Object.entries(field).forEach(([key, val]) => { + const mappedField = key === "description" ? "note" : key; + (next as any)[mappedField] = mappedField === "expiresAt" ? (val ?? undefined) : val; + }); + } else { + const mappedField = field === "description" ? "note" : field; + if (mappedField === "expiresAt") { + (next as any)[mappedField] = value ?? undefined; + } else { + (next as any)[mappedField] = value; + } + } + + // 直接替换整个 user 对象,因为 useZodForm.setValue 不支持嵌套路径 + form.setValue("user" as any, next); + }; + + const handleKeyChange = (field: string | Record, value?: any) => { + const prev = form.values.key || defaultValues.key; + const next = { ...prev }; + + if (typeof field === "object") { + Object.entries(field).forEach(([key, val]) => { + (next as any)[key] = key === "expiresAt" ? (val ?? undefined) : val; + }); + } else { + if (field === "expiresAt") { + (next as any)[field] = value ?? undefined; + } else { + (next as any)[field] = value; + } + } + + // 直接替换整个 key 对象,因为 useZodForm.setValue 不支持嵌套路径 + form.setValue("key" as any, next); + }; + + const handleCopy = async () => { + if (!generatedKey) return; + try { + await navigator.clipboard.writeText(generatedKey.generatedKey); + setCopied(true); + toast.success(tCommon("copySuccess")); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("[CreateUserDialog] copy failed", error); + toast.error(tCommon("copyFailed")); + } + }; + + const handleClose = () => { + setGeneratedKey(null); + setCopied(false); + onOpenChange(false); + }; + + // Show generated key result + if (generatedKey) { + return ( + + +
+
+ {t("createDialog.successDescription")} +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+

{t("createDialog.keyHint")}

+
+
+ + + +
+ ); + } + + return ( + +
+ +
+
+ {t("createDialog.description")} +
+ +
+ + + + + +
+ + {errorMessage &&
{errorMessage}
} + + + + + +
+
+ ); +} + +export function CreateUserDialog(props: CreateUserDialogProps) { + return ( + + {props.open ? : null} + + ); +} diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx new file mode 100644 index 000000000..afaf10a2f --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { User } from "@/types/user"; +import { EditKeyForm } from "./forms/edit-key-form"; + +export interface EditKeyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + keyData: { + id: number; + name: string; + expiresAt: string; + canLoginWebUi?: boolean; + providerGroup?: string | null; + cacheTtlPreference?: "inherit" | "5m" | "1h"; + limit5hUsd?: number | null; + limitDailyUsd?: number | null; + dailyResetMode?: "fixed" | "rolling"; + dailyResetTime?: string; + limitWeeklyUsd?: number | null; + limitMonthlyUsd?: number | null; + limitTotalUsd?: number | null; + limitConcurrentSessions?: number; + }; + user?: User; + isAdmin?: boolean; + onSuccess?: () => void; +} + +export function EditKeyDialog({ + open, + onOpenChange, + keyData, + user, + isAdmin, + onSuccess, +}: EditKeyDialogProps) { + const t = useTranslations("quota.keys.editKeyForm"); + + const handleSuccess = () => { + onSuccess?.(); + onOpenChange(false); + }; + + return ( + + + + {t("title")} + {t("description")} + + + + + ); +} diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx new file mode 100644 index 000000000..cc21562b3 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, UserCog } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useMemo, useTransition } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; +import { editUser, removeUser, toggleUserEnabled } from "@/actions/users"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { UpdateUserSchema } from "@/lib/validation/schemas"; +import type { UserDisplay } from "@/types/user"; +import { DangerZone } from "./forms/danger-zone"; +import { UserEditSection } from "./forms/user-edit-section"; +import { useModelSuggestions } from "./hooks/use-model-suggestions"; +import { useUserTranslations } from "./hooks/use-user-translations"; +import { getFirstErrorMessage } from "./utils/form-utils"; +import { normalizeProviderGroup } from "./utils/provider-group"; + +export interface EditUserDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user: UserDisplay; + onSuccess?: () => void; +} + +const EditUserSchema = UpdateUserSchema.extend({ + name: z.string().min(1).max(64), + providerGroup: z.string().max(50).nullable().optional(), + allowedClients: z.array(z.string().max(64)).max(50).optional().default([]), + allowedModels: z.array(z.string().max(64)).max(50).optional().default([]), + dailyQuota: z.number().nullable().optional(), +}); + +type EditUserValues = z.infer; + +function buildDefaultValues(user: UserDisplay): EditUserValues { + return { + name: user.name || "", + note: user.note || "", + tags: user.tags || [], + expiresAt: user.expiresAt ?? undefined, + providerGroup: normalizeProviderGroup(user.providerGroup), + rpm: user.rpm ?? 0, + limit5hUsd: user.limit5hUsd ?? null, + dailyQuota: user.dailyQuota ?? null, + limitWeeklyUsd: user.limitWeeklyUsd ?? null, + limitMonthlyUsd: user.limitMonthlyUsd ?? null, + limitTotalUsd: user.limitTotalUsd ?? null, + limitConcurrentSessions: user.limitConcurrentSessions ?? null, + dailyResetMode: user.dailyResetMode ?? "fixed", + dailyResetTime: user.dailyResetTime ?? "00:00", + allowedClients: user.allowedClients || [], + allowedModels: user.allowedModels || [], + }; +} + +function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const t = useTranslations("dashboard.userManagement"); + const tCommon = useTranslations("common"); + const [isPending, startTransition] = useTransition(); + + // Use shared hooks + const modelSuggestions = useModelSuggestions(user.providerGroup); + const showUserProviderGroup = Boolean(user.providerGroup?.trim()); + const userEditTranslations = useUserTranslations({ showProviderGroup: showUserProviderGroup }); + + const defaultValues = useMemo(() => buildDefaultValues(user), [user]); + + const form = useZodForm({ + schema: EditUserSchema, + defaultValues, + onSubmit: async (data) => { + startTransition(async () => { + try { + const userRes = await editUser(user.id, { + name: data.name, + note: data.note, + tags: data.tags, + expiresAt: data.expiresAt ?? null, + providerGroup: normalizeProviderGroup(data.providerGroup), + rpm: data.rpm, + limit5hUsd: data.limit5hUsd, + dailyQuota: data.dailyQuota, + limitWeeklyUsd: data.limitWeeklyUsd, + limitMonthlyUsd: data.limitMonthlyUsd, + limitTotalUsd: data.limitTotalUsd, + limitConcurrentSessions: data.limitConcurrentSessions, + dailyResetMode: data.dailyResetMode, + dailyResetTime: data.dailyResetTime, + allowedClients: data.allowedClients, + allowedModels: data.allowedModels, + }); + if (!userRes.ok) { + toast.error(userRes.error || t("editDialog.saveFailed")); + return; + } + + toast.success(t("editDialog.saveSuccess")); + onSuccess?.(); + onOpenChange(false); + queryClient.invalidateQueries({ queryKey: ["users"] }); + router.refresh(); + } catch (error) { + console.error("[EditUserDialog] submit failed", error); + toast.error(t("editDialog.saveFailed")); + } + }); + }, + }); + + const errorMessage = useMemo(() => getFirstErrorMessage(form.errors), [form.errors]); + + const currentUserDraft = form.values || defaultValues; + + const handleUserChange = (field: string | Record, value?: any) => { + const prev = form.values || defaultValues; + const next = { ...prev } as EditUserValues; + + if (typeof field === "object") { + Object.entries(field).forEach(([key, val]) => { + const mappedField = key === "description" ? "note" : key; + (next as any)[mappedField] = mappedField === "expiresAt" ? (val ?? undefined) : val; + }); + } else { + const mappedField = field === "description" ? "note" : field; + if (mappedField === "expiresAt") { + (next as any)[mappedField] = value ?? undefined; + } else { + (next as any)[mappedField] = value; + } + } + // Set all changed fields + Object.keys(next).forEach((key) => { + if ((next as any)[key] !== (prev as any)[key]) { + form.setValue(key as keyof EditUserValues, (next as any)[key]); + } + }); + }; + + const handleDisableUser = async () => { + const res = await toggleUserEnabled(user.id, false); + if (!res.ok) { + throw new Error(res.error || t("editDialog.operationFailed")); + } + toast.success(t("editDialog.userDisabled")); + onSuccess?.(); + queryClient.invalidateQueries({ queryKey: ["users"] }); + router.refresh(); + }; + + const handleEnableUser = async () => { + const res = await toggleUserEnabled(user.id, true); + if (!res.ok) { + throw new Error(res.error || t("editDialog.operationFailed")); + } + toast.success(t("editDialog.userEnabled")); + onSuccess?.(); + queryClient.invalidateQueries({ queryKey: ["users"] }); + router.refresh(); + }; + + const handleDeleteUser = async () => { + const res = await removeUser(user.id); + if (!res.ok) { + throw new Error(res.error || t("editDialog.deleteFailed")); + } + toast.success(t("editDialog.userDeleted")); + onSuccess?.(); + onOpenChange(false); + queryClient.invalidateQueries({ queryKey: ["users"] }); + router.refresh(); + }; + + return ( + +
+ +
+
+ {t("editDialog.description")} +
+ +
+ { + if (user.isEnabled) { + await handleDisableUser(); + } else { + await handleEnableUser(); + } + }} + showProviderGroup={showUserProviderGroup} + onChange={handleUserChange} + translations={userEditTranslations} + modelSuggestions={modelSuggestions} + /> + + } + /> +
+ + {errorMessage &&
{errorMessage}
} + + + + + +
+
+ ); +} + +export function EditUserDialog(props: EditUserDialogProps) { + return ( + + {props.open ? : null} + + ); +} diff --git a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx index a3e040a39..377d5e50c 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx @@ -53,7 +53,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF defaultValues: { name: "", expiresAt: "", - canLoginWebUi: true, + canLoginWebUi: false, providerGroup: PROVIDER_GROUP.DEFAULT, cacheTtlPreference: "inherit", limit5hUsd: null, @@ -74,7 +74,8 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF const result = await addKey({ userId: userId!, name: data.name, - expiresAt: data.expiresAt || undefined, + // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃 + expiresAt: data.expiresAt ?? "", canLoginWebUi: data.canLoginWebUi, limit5hUsd: data.limit5hUsd, limitDailyUsd: data.limitDailyUsd, @@ -151,6 +152,10 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF touched={form.getFieldProps("expiresAt").touched} /> + {/* Balance Query Page toggle uses inverted logic by design: + - canLoginWebUi=true means user accesses full WebUI (switch OFF) + - canLoginWebUi=false means user uses independent balance page (switch ON) + The switch represents "enable independent page" which is !canLoginWebUi */}
form.setValue("canLoginWebUi", checked)} + checked={!form.values.canLoginWebUi} + onCheckedChange={(checked) => form.setValue("canLoginWebUi", !checked)} />
diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx index c2d2fc7c5..2458b2927 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx @@ -51,6 +51,9 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK const router = useRouter(); const t = useTranslations("quota.keys.editKeyForm"); const tKeyEdit = useTranslations("dashboard.userManagement.keyEditSection.fields"); + const tBalancePage = useTranslations( + "dashboard.userManagement.keyEditSection.fields.balanceQueryPage" + ); const tUI = useTranslations("ui.tagInput"); const tCommon = useTranslations("common"); const tErrors = useTranslations("errors"); @@ -168,17 +171,25 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK touched={form.getFieldProps("expiresAt").touched} /> + {/* Balance Query Page toggle uses inverted logic by design: + - canLoginWebUi=true means user accesses full WebUI (switch OFF) + - canLoginWebUi=false means user uses independent balance page (switch ON) + The switch represents "enable independent page" which is !canLoginWebUi */}
-

{t("canLoginWebUi.description")}

+

+ {form.values.canLoginWebUi + ? tBalancePage("descriptionDisabled") + : tBalancePage("descriptionEnabled")} +

form.setValue("canLoginWebUi", checked)} + checked={!form.values.canLoginWebUi} + onCheckedChange={(checked) => form.setValue("canLoginWebUi", !checked)} />
diff --git a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx index e45b34eed..5da461294 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx @@ -48,6 +48,10 @@ export interface KeyEditSectionProps { /** 是否是最后一个启用的 key (用于禁用 Switch 防止全部禁用) */ isLastEnabledKey?: boolean; userProviderGroup?: string; + /** 是否显示限额规则区域,默认为 true */ + showLimitRules?: boolean; + /** 是否显示到期时间区域,默认为 true */ + showExpireTime?: boolean; onChange: { (field: string, value: any): void; (batch: Record): void; @@ -136,6 +140,8 @@ export function KeyEditSection({ isAdmin = false, isLastEnabledKey = false, userProviderGroup, + showLimitRules = true, + showExpireTime = true, onChange, scrollRef, translations, @@ -370,55 +376,59 @@ export function KeyEditSection({ - {/* 到期时间区域 */} -
-
-
- onChange("expiresAt", parseDateStringEndOfDay(val))} - /> - onChange("expiresAt", toEndOfDay(date))} - /> -
- - {/* 限额规则区域 */} -
-
+ {/* 到期时间区域 - 仅在 showExpireTime 为 true 时显示 */} + {showExpireTime && ( +
-
+ onChange("expiresAt", parseDateStringEndOfDay(val))} + /> + onChange("expiresAt", toEndOfDay(date))} + /> +
+ )} + + {/* 限额规则区域 - 仅在 showLimitRules 为 true 时显示 */} + {showLimitRules && ( +
+
+
+
+
- -
- + - -
+ + + )} {/* 特殊功能区域 */}
{ + return { + sections: { + basicInfo: t("keyEditSection.sections.basicInfo"), + expireTime: t("keyEditSection.sections.expireTime"), + limitRules: t("keyEditSection.sections.limitRules"), + specialFeatures: t("keyEditSection.sections.specialFeatures"), + }, + fields: { + keyName: { + label: t("keyEditSection.fields.keyName.label"), + placeholder: t("keyEditSection.fields.keyName.placeholder"), + }, + balanceQueryPage: { + label: t("keyEditSection.fields.balanceQueryPage.label"), + description: t("keyEditSection.fields.balanceQueryPage.description"), + descriptionEnabled: t("keyEditSection.fields.balanceQueryPage.descriptionEnabled"), + descriptionDisabled: t("keyEditSection.fields.balanceQueryPage.descriptionDisabled"), + }, + providerGroup: { + label: t("keyEditSection.fields.providerGroup.label"), + placeholder: t("keyEditSection.fields.providerGroup.placeholder"), + }, + cacheTtl: { + label: t("keyEditSection.fields.cacheTtl.label"), + options: { + inherit: t("keyEditSection.fields.cacheTtl.options.inherit"), + "5m": t("keyEditSection.fields.cacheTtl.options.5m"), + "1h": t("keyEditSection.fields.cacheTtl.options.1h"), + }, + }, + enableStatus: { + label: t("keyEditSection.fields.enableStatus.label"), + description: t("keyEditSection.fields.enableStatus.description"), + }, + }, + limitRules: { + addRule: t("limitRules.addRule"), + ruleTypes: { + limit5h: t("limitRules.ruleTypes.limit5h"), + limitDaily: t("limitRules.ruleTypes.limitDaily"), + limitWeekly: t("limitRules.ruleTypes.limitWeekly"), + limitMonthly: t("limitRules.ruleTypes.limitMonthly"), + limitTotal: t("limitRules.ruleTypes.limitTotal"), + limitSessions: t("limitRules.ruleTypes.limitSessions"), + }, + quickValues: { + unlimited: t("limitRules.quickValues.unlimited"), + "10": t("limitRules.quickValues.10"), + "50": t("limitRules.quickValues.50"), + "100": t("limitRules.quickValues.100"), + "500": t("limitRules.quickValues.500"), + }, + }, + quickExpire: { + week: t("quickExpire.oneWeek"), + month: t("quickExpire.oneMonth"), + threeMonths: t("quickExpire.threeMonths"), + year: t("quickExpire.oneYear"), + }, + }; + }, [t]); +} diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts new file mode 100644 index 000000000..9a440744a --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getModelSuggestionsByProviderGroup } from "@/actions/providers"; + +/** + * Hook to fetch model suggestions for autocomplete. + * Returns an array of model names available for the given provider group. + * @param providerGroup - The provider group to filter models by (comma-separated) + */ +export function useModelSuggestions(providerGroup?: string | null): string[] { + const [modelSuggestions, setModelSuggestions] = useState([]); + + useEffect(() => { + getModelSuggestionsByProviderGroup(providerGroup) + .then((res) => { + if (res.ok && res.data) { + setModelSuggestions(res.data); + } + }) + .catch(() => { + // Silently fail - model suggestions are optional enhancement + }); + }, [providerGroup]); + + return modelSuggestions; +} diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts new file mode 100644 index 000000000..1360c99f1 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts @@ -0,0 +1,191 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; + +export interface UserEditTranslations { + sections: { + basicInfo: string; + expireTime: string; + limitRules: string; + accessRestrictions: string; + }; + fields: { + username: { + label: string; + placeholder: string; + }; + description: { + label: string; + placeholder: string; + }; + tags: { + label: string; + placeholder: string; + }; + providerGroup?: { + label: string; + placeholder: string; + }; + enableStatus: { + label: string; + enabledDescription: string; + disabledDescription: string; + confirmEnable: string; + confirmDisable: string; + confirmEnableTitle: string; + confirmDisableTitle: string; + confirmEnableDescription: string; + confirmDisableDescription: string; + cancel: string; + processing: string; + }; + allowedClients: { + label: string; + description: string; + customLabel: string; + customPlaceholder: string; + }; + allowedModels: { + label: string; + placeholder: string; + description: string; + }; + }; + presetClients: { + "claude-cli": string; + "gemini-cli": string; + "factory-cli": string; + "codex-cli": string; + }; + limitRules: { + addRule: string; + ruleTypes: { + limitRpm: string; + limit5h: string; + limitDaily: string; + limitWeekly: string; + limitMonthly: string; + limitTotal: string; + limitSessions: string; + }; + quickValues: { + unlimited: string; + "10": string; + "50": string; + "100": string; + "500": string; + }; + }; + quickExpire: { + week: string; + month: string; + threeMonths: string; + year: string; + }; +} + +export interface UseUserTranslationsOptions { + showProviderGroup?: boolean; +} + +/** + * Hook to build user edit section translations. + * Centralizes all translation key lookups for UserEditSection. + */ +export function useUserTranslations( + options: UseUserTranslationsOptions = {} +): UserEditTranslations { + const { showProviderGroup = false } = options; + const t = useTranslations("dashboard.userManagement"); + + return useMemo(() => { + return { + sections: { + basicInfo: t("userEditSection.sections.basicInfo"), + expireTime: t("userEditSection.sections.expireTime"), + limitRules: t("userEditSection.sections.limitRules"), + accessRestrictions: t("userEditSection.sections.accessRestrictions"), + }, + fields: { + username: { + label: t("userEditSection.fields.username.label"), + placeholder: t("userEditSection.fields.username.placeholder"), + }, + description: { + label: t("userEditSection.fields.description.label"), + placeholder: t("userEditSection.fields.description.placeholder"), + }, + tags: { + label: t("userEditSection.fields.tags.label"), + placeholder: t("userEditSection.fields.tags.placeholder"), + }, + providerGroup: showProviderGroup + ? { + label: t("userEditSection.fields.providerGroup.label"), + placeholder: t("userEditSection.fields.providerGroup.placeholder"), + } + : undefined, + enableStatus: { + label: t("userEditSection.fields.enableStatus.label"), + enabledDescription: t("userEditSection.fields.enableStatus.enabledDescription"), + disabledDescription: t("userEditSection.fields.enableStatus.disabledDescription"), + confirmEnable: t("userEditSection.fields.enableStatus.confirmEnable"), + confirmDisable: t("userEditSection.fields.enableStatus.confirmDisable"), + confirmEnableTitle: t("userEditSection.fields.enableStatus.confirmEnableTitle"), + confirmDisableTitle: t("userEditSection.fields.enableStatus.confirmDisableTitle"), + confirmEnableDescription: t( + "userEditSection.fields.enableStatus.confirmEnableDescription" + ), + confirmDisableDescription: t( + "userEditSection.fields.enableStatus.confirmDisableDescription" + ), + cancel: t("userEditSection.fields.enableStatus.cancel"), + processing: t("userEditSection.fields.enableStatus.processing"), + }, + allowedClients: { + label: t("userEditSection.fields.allowedClients.label"), + description: t("userEditSection.fields.allowedClients.description"), + customLabel: t("userEditSection.fields.allowedClients.customLabel"), + customPlaceholder: t("userEditSection.fields.allowedClients.customPlaceholder"), + }, + allowedModels: { + label: t("userEditSection.fields.allowedModels.label"), + placeholder: t("userEditSection.fields.allowedModels.placeholder"), + description: t("userEditSection.fields.allowedModels.description"), + }, + }, + presetClients: { + "claude-cli": t("userEditSection.presetClients.claude-cli"), + "gemini-cli": t("userEditSection.presetClients.gemini-cli"), + "factory-cli": t("userEditSection.presetClients.factory-cli"), + "codex-cli": t("userEditSection.presetClients.codex-cli"), + }, + limitRules: { + addRule: t("limitRules.addRule"), + ruleTypes: { + limitRpm: t("limitRules.ruleTypes.limitRpm"), + limit5h: t("limitRules.ruleTypes.limit5h"), + limitDaily: t("limitRules.ruleTypes.limitDaily"), + limitWeekly: t("limitRules.ruleTypes.limitWeekly"), + limitMonthly: t("limitRules.ruleTypes.limitMonthly"), + limitTotal: t("limitRules.ruleTypes.limitTotal"), + limitSessions: t("limitRules.ruleTypes.limitSessions"), + }, + quickValues: { + unlimited: t("limitRules.quickValues.unlimited"), + "10": t("limitRules.quickValues.10"), + "50": t("limitRules.quickValues.50"), + "100": t("limitRules.quickValues.100"), + "500": t("limitRules.quickValues.500"), + }, + }, + quickExpire: { + week: t("quickExpire.oneWeek"), + month: t("quickExpire.oneMonth"), + threeMonths: t("quickExpire.threeMonths"), + year: t("quickExpire.oneYear"), + }, + }; + }, [t, showProviderGroup]); +} diff --git a/src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx deleted file mode 100644 index 05947ae7f..000000000 --- a/src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx +++ /dev/null @@ -1,1120 +0,0 @@ -"use client"; - -import { useQueryClient } from "@tanstack/react-query"; -import { - ChevronDown, - ChevronUp, - KeyRound, - Loader2, - Plus, - Trash2, - UserCog, - UserPlus, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useEffect, useMemo, useRef, useState, useTransition } from "react"; -import { toast } from "sonner"; -import { z } from "zod"; -import { addKey, editKey, removeKey } from "@/actions/keys"; -import { getFilterOptions } from "@/actions/usage-logs"; -import { createUserOnly, editUser, removeUser, toggleUserEnabled } from "@/actions/users"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Separator } from "@/components/ui/separator"; -import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; -import { useZodForm } from "@/lib/hooks/use-zod-form"; -import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas"; -import type { UserDisplay } from "@/types/user"; -import { DangerZone } from "./forms/danger-zone"; -import { KeyEditSection } from "./forms/key-edit-section"; -import { UserEditSection } from "./forms/user-edit-section"; - -export interface UnifiedEditDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - mode: "create" | "edit"; - user?: UserDisplay; // Required in edit mode, optional in create mode - keyOnlyMode?: boolean; - scrollToKeyId?: number; - onSuccess?: () => void; - currentUser?: { id: number; role: string }; -} - -const UnifiedUserSchema = UpdateUserSchema.extend({ - name: z.string().min(1).max(64), - providerGroup: z.string().max(50).nullable().optional(), - allowedClients: z.array(z.string().max(64)).max(50).optional().default([]), - allowedModels: z.array(z.string().max(64)).max(50).optional().default([]), - dailyQuota: z.number().nullable().optional(), -}); - -const UnifiedKeySchema = KeyFormSchema.extend({ - id: z.number(), // Negative IDs indicate new keys to be created - isEnabled: z.boolean().optional(), -}); - -const UnifiedEditSchema = z.object({ - user: UnifiedUserSchema, - keys: z.array(UnifiedKeySchema), -}); - -type UnifiedEditValues = z.infer; - -// Generate unique temporary negative IDs for new keys using timestamp + random -function getNextTempKeyId() { - return -Math.floor(Date.now() + Math.random() * 1000); -} - -function parseYmdToEndOfDayIso(value: string): string | undefined { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined; - const [year, month, day] = value.split("-").map((v) => Number(v)); - if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return undefined; - const date = new Date(year, month - 1, day); - if (Number.isNaN(date.getTime())) return undefined; - date.setHours(23, 59, 59, 999); - return date.toISOString(); -} - -function getKeyExpiresAtIso(expiresAt: string): string | undefined { - if (!expiresAt) return undefined; - const ymd = parseYmdToEndOfDayIso(expiresAt); - if (ymd) return ymd; - const parsed = new Date(expiresAt); - if (Number.isNaN(parsed.getTime())) return undefined; - return parsed.toISOString(); -} - -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(","); -} - -function buildDefaultValues( - mode: "create" | "edit", - user?: UserDisplay, - keyOnlyMode?: boolean -): UnifiedEditValues { - if (mode === "create") { - return { - user: { - name: keyOnlyMode ? (user?.name ?? "self") : "", - note: "", - tags: [], - expiresAt: undefined, - providerGroup: PROVIDER_GROUP.DEFAULT, - rpm: 0, - limit5hUsd: null, - dailyQuota: null, - limitWeeklyUsd: null, - limitMonthlyUsd: null, - limitTotalUsd: null, - limitConcurrentSessions: null, - dailyResetMode: "fixed", - dailyResetTime: "00:00", - allowedClients: [], - allowedModels: [], - }, - keys: [ - { - id: getNextTempKeyId(), - name: "default", - isEnabled: true, - expiresAt: undefined, - canLoginWebUi: false, - providerGroup: PROVIDER_GROUP.DEFAULT, - cacheTtlPreference: "inherit" as const, - limit5hUsd: null, - limitDailyUsd: null, - dailyResetMode: "fixed", - dailyResetTime: "00:00", - limitWeeklyUsd: null, - limitMonthlyUsd: null, - limitTotalUsd: null, - limitConcurrentSessions: 0, - }, - ], - }; - } - - // Edit mode - user must exist - if (!user) { - throw new Error("User is required in edit mode"); - } - - return { - user: { - name: user.name || "", - note: user.note || "", - tags: user.tags || [], - expiresAt: user.expiresAt ?? undefined, - providerGroup: normalizeProviderGroup(user.providerGroup), - rpm: user.rpm ?? 0, - limit5hUsd: user.limit5hUsd ?? null, - dailyQuota: user.dailyQuota ?? null, - limitWeeklyUsd: user.limitWeeklyUsd ?? null, - limitMonthlyUsd: user.limitMonthlyUsd ?? null, - limitTotalUsd: user.limitTotalUsd ?? null, - limitConcurrentSessions: user.limitConcurrentSessions ?? null, - dailyResetMode: user.dailyResetMode ?? "fixed", - dailyResetTime: user.dailyResetTime ?? "00:00", - allowedClients: user.allowedClients || [], - allowedModels: user.allowedModels || [], - }, - keys: user.keys.map((key) => ({ - id: key.id, - name: key.name || "", - isEnabled: key.status === "enabled", - expiresAt: getKeyExpiresAtIso(key.expiresAt), - canLoginWebUi: key.canLoginWebUi ?? false, - providerGroup: normalizeProviderGroup(key.providerGroup), - cacheTtlPreference: "inherit" as const, - limit5hUsd: key.limit5hUsd ?? null, - limitDailyUsd: key.limitDailyUsd ?? null, - dailyResetMode: key.dailyResetMode ?? "fixed", - dailyResetTime: key.dailyResetTime ?? "00:00", - limitWeeklyUsd: key.limitWeeklyUsd ?? null, - limitMonthlyUsd: key.limitMonthlyUsd ?? null, - limitTotalUsd: key.limitTotalUsd ?? null, - limitConcurrentSessions: key.limitConcurrentSessions ?? 0, - })), - }; -} - -function getFirstErrorMessage(errors: Record) { - if (errors._form) return errors._form; - const first = Object.entries(errors).find(([, msg]) => Boolean(msg)); - return first?.[1] || null; -} - -function UnifiedEditDialogInner({ - onOpenChange, - mode, - user, - keyOnlyMode, - scrollToKeyId, - onSuccess, - currentUser, -}: UnifiedEditDialogProps) { - const router = useRouter(); - const queryClient = useQueryClient(); - const t = useTranslations("dashboard.userManagement"); - const tUsers = useTranslations("dashboard.users"); - const tCommon = useTranslations("common"); - const [isPending, startTransition] = useTransition(); - const keyScrollRef = useRef(null); - const isAdmin = currentUser?.role === "admin"; - const isKeyOnlyMode = Boolean(keyOnlyMode); - const [deletedKeyIds, setDeletedKeyIds] = useState([]); - const [keyToDelete, setKeyToDelete] = useState<{ id: number; name: string } | null>(null); - const [newlyAddedKeyId, setNewlyAddedKeyId] = useState(null); - const [expandedKeyIds, setExpandedKeyIds] = useState>(() => { - // Create mode or single key: all expanded - if (mode === "create") return new Set([-1]); // placeholder for new keys - if (!user || user.keys.length <= 1) return new Set(user?.keys.map((k) => k.id) || []); - // Edit mode with multiple keys: only scrollToKeyId expanded - if (scrollToKeyId) return new Set([scrollToKeyId]); - return new Set(); // All collapsed - }); - const [modelSuggestions, setModelSuggestions] = useState([]); - - // Fetch model suggestions for access restrictions - useEffect(() => { - getFilterOptions() - .then((res) => { - if (res.ok && res.data) { - setModelSuggestions(res.data.models); - } - }) - .catch(() => { - // Silently fail - model suggestions are optional enhancement - // User can still manually type model names - }); - }, []); - - // Auto-scroll to newly added key - useEffect(() => { - if (newlyAddedKeyId && keyScrollRef.current) { - // Small delay to ensure DOM is updated - const timer = setTimeout(() => { - keyScrollRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - setNewlyAddedKeyId(null); - }, 100); - return () => clearTimeout(timer); - } - }, [newlyAddedKeyId]); - - const defaultValues = useMemo( - () => buildDefaultValues(mode, user, keyOnlyMode), - [mode, user, keyOnlyMode] - ); - - const userProviderGroups = useMemo(() => { - return normalizeProviderGroup(user?.providerGroup) - .split(",") - .map((g) => g.trim()) - .filter(Boolean); - }, [user?.providerGroup]); - - const toggleKeyExpanded = (keyId: number) => { - setExpandedKeyIds((prev) => { - const next = new Set(prev); - if (next.has(keyId)) { - next.delete(keyId); - } else { - next.add(keyId); - } - return next; - }); - }; - - const form = useZodForm({ - schema: UnifiedEditSchema, - defaultValues, - onSubmit: async (data) => { - startTransition(async () => { - try { - // 验证: 编辑模式下,至少需要一个启用的 key(防止用户禁用所有 key) - if (mode === "edit") { - const enabledKeyCount = data.keys.filter((k) => k.isEnabled).length; - if (enabledKeyCount === 0) { - toast.error(t("editDialog.atLeastOneKeyEnabled")); - return; - } - } - - if (mode === "create") { - if (isKeyOnlyMode) { - const targetUserId = user?.id ?? currentUser?.id; - if (!targetUserId) { - toast.error(t("editDialog.operationFailed")); - return; - } - - for (const key of data.keys) { - const keyRes = await addKey({ - userId: targetUserId, - name: key.name, - expiresAt: key.expiresAt || undefined, - canLoginWebUi: key.canLoginWebUi, - providerGroup: normalizeProviderGroup(key.providerGroup), - cacheTtlPreference: key.cacheTtlPreference, - limit5hUsd: key.limit5hUsd, - limitDailyUsd: key.limitDailyUsd, - dailyResetMode: key.dailyResetMode, - dailyResetTime: key.dailyResetTime, - limitWeeklyUsd: key.limitWeeklyUsd, - limitMonthlyUsd: key.limitMonthlyUsd, - limitTotalUsd: key.limitTotalUsd, - limitConcurrentSessions: key.limitConcurrentSessions, - }); - if (!keyRes.ok) { - toast.error( - keyRes.error || t("createDialog.keyCreateFailed", { name: key.name }) - ); - return; - } - } - - toast.success(t("editDialog.saveSuccess")); - } else { - // Create user first - const userRes = await createUserOnly({ - name: data.user.name, - note: data.user.note, - tags: data.user.tags, - expiresAt: data.user.expiresAt ?? null, - rpm: data.user.rpm, - limit5hUsd: data.user.limit5hUsd, - dailyQuota: data.user.dailyQuota ?? undefined, - limitWeeklyUsd: data.user.limitWeeklyUsd, - limitMonthlyUsd: data.user.limitMonthlyUsd, - limitTotalUsd: data.user.limitTotalUsd, - limitConcurrentSessions: data.user.limitConcurrentSessions, - dailyResetMode: data.user.dailyResetMode, - dailyResetTime: data.user.dailyResetTime, - allowedClients: data.user.allowedClients, - allowedModels: data.user.allowedModels, - }); - if (!userRes.ok) { - toast.error(userRes.error || t("createDialog.saveFailed")); - return; - } - - const newUserId = userRes.data.user.id; - - // Create all keys for the new user - // If any key creation fails, rollback by deleting the user - for (const key of data.keys) { - const keyRes = await addKey({ - userId: newUserId, - name: key.name, - expiresAt: key.expiresAt || undefined, - canLoginWebUi: key.canLoginWebUi, - providerGroup: normalizeProviderGroup(key.providerGroup), - cacheTtlPreference: key.cacheTtlPreference, - limit5hUsd: key.limit5hUsd, - limitDailyUsd: key.limitDailyUsd, - dailyResetMode: key.dailyResetMode, - dailyResetTime: key.dailyResetTime, - limitWeeklyUsd: key.limitWeeklyUsd, - limitMonthlyUsd: key.limitMonthlyUsd, - limitTotalUsd: key.limitTotalUsd, - limitConcurrentSessions: key.limitConcurrentSessions, - }); - if (!keyRes.ok) { - // Rollback: delete the user since key creation failed - try { - await removeUser(newUserId); - } catch (rollbackError) { - console.error("[UnifiedEditDialog] rollback failed", rollbackError); - } - toast.error( - keyRes.error || t("createDialog.keyCreateFailed", { name: key.name }) - ); - return; - } - } - - toast.success(t("createDialog.createSuccess")); - } - } else { - // Edit mode - user must exist - if (!user) return; - - if (!isKeyOnlyMode) { - const userRes = await editUser(user.id, { - name: data.user.name, - note: data.user.note, - tags: data.user.tags, - expiresAt: data.user.expiresAt ?? null, - providerGroup: normalizeProviderGroup(data.user.providerGroup), - rpm: data.user.rpm, - limit5hUsd: data.user.limit5hUsd, - dailyQuota: data.user.dailyQuota, - limitWeeklyUsd: data.user.limitWeeklyUsd, - limitMonthlyUsd: data.user.limitMonthlyUsd, - limitTotalUsd: data.user.limitTotalUsd, - limitConcurrentSessions: data.user.limitConcurrentSessions, - dailyResetMode: data.user.dailyResetMode, - dailyResetTime: data.user.dailyResetTime, - allowedClients: data.user.allowedClients, - allowedModels: data.user.allowedModels, - }); - if (!userRes.ok) { - toast.error(userRes.error || t("editDialog.saveFailed")); - return; - } - } - - // Handle keys: edit existing, create new (negative ID), delete removed - for (const key of data.keys) { - if (key.id < 0) { - // New key - create it - const keyRes = await addKey({ - userId: user.id, - name: key.name, - // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃 - expiresAt: key.expiresAt ?? "", - isEnabled: key.isEnabled, - canLoginWebUi: key.canLoginWebUi, - providerGroup: normalizeProviderGroup(key.providerGroup), - cacheTtlPreference: key.cacheTtlPreference, - limit5hUsd: key.limit5hUsd, - limitDailyUsd: key.limitDailyUsd, - dailyResetMode: key.dailyResetMode, - dailyResetTime: key.dailyResetTime, - limitWeeklyUsd: key.limitWeeklyUsd, - limitMonthlyUsd: key.limitMonthlyUsd, - limitTotalUsd: key.limitTotalUsd, - limitConcurrentSessions: key.limitConcurrentSessions, - }); - if (!keyRes.ok) { - toast.error( - keyRes.error || t("createDialog.keyCreateFailed", { name: key.name }) - ); - return; - } - } else { - // Existing key - edit it - const keyRes = await editKey(key.id, { - name: key.name, - // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃 - expiresAt: key.expiresAt ?? "", - canLoginWebUi: key.canLoginWebUi, - isEnabled: key.isEnabled, - providerGroup: normalizeProviderGroup(key.providerGroup), - cacheTtlPreference: key.cacheTtlPreference, - limit5hUsd: key.limit5hUsd, - limitDailyUsd: key.limitDailyUsd, - dailyResetMode: key.dailyResetMode, - dailyResetTime: key.dailyResetTime, - limitWeeklyUsd: key.limitWeeklyUsd, - limitMonthlyUsd: key.limitMonthlyUsd, - limitTotalUsd: key.limitTotalUsd, - limitConcurrentSessions: key.limitConcurrentSessions, - }); - if (!keyRes.ok) { - toast.error(keyRes.error || t("editDialog.keySaveFailed", { name: key.name })); - return; - } - } - } - - // Delete removed keys - for (const deletedKeyId of deletedKeyIds) { - const deleteRes = await removeKey(deletedKeyId); - if (!deleteRes.ok) { - toast.error(deleteRes.error || t("editDialog.keyDeleteFailed")); - return; - } - } - - toast.success(t("editDialog.saveSuccess")); - } - - onSuccess?.(); - onOpenChange(false); - queryClient.invalidateQueries({ queryKey: ["users"] }); - router.refresh(); - } catch (error) { - console.error("[UnifiedEditDialog] submit failed", error); - toast.error( - mode === "create" - ? isKeyOnlyMode - ? t("editDialog.operationFailed") - : t("createDialog.saveFailed") - : t("editDialog.saveFailed") - ); - } - }); - }, - }); - - const errorMessage = useMemo(() => getFirstErrorMessage(form.errors), [form.errors]); - - const keys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"]; - const currentUserDraft = form.values.user || defaultValues.user; - const showUserProviderGroup = mode === "edit" && Boolean(user?.providerGroup?.trim()); - - const userEditTranslations = useMemo(() => { - return { - sections: { - basicInfo: t("userEditSection.sections.basicInfo"), - expireTime: t("userEditSection.sections.expireTime"), - limitRules: t("userEditSection.sections.limitRules"), - accessRestrictions: t("userEditSection.sections.accessRestrictions"), - }, - fields: { - username: { - label: t("userEditSection.fields.username.label"), - placeholder: t("userEditSection.fields.username.placeholder"), - }, - description: { - label: t("userEditSection.fields.description.label"), - placeholder: t("userEditSection.fields.description.placeholder"), - }, - tags: { - label: t("userEditSection.fields.tags.label"), - placeholder: t("userEditSection.fields.tags.placeholder"), - }, - providerGroup: showUserProviderGroup - ? { - label: t("userEditSection.fields.providerGroup.label"), - placeholder: t("userEditSection.fields.providerGroup.placeholder"), - } - : undefined, - enableStatus: - mode === "edit" && isAdmin - ? { - label: t("userEditSection.fields.enableStatus.label"), - enabledDescription: t("userEditSection.fields.enableStatus.enabledDescription"), - disabledDescription: t("userEditSection.fields.enableStatus.disabledDescription"), - confirmEnable: t("userEditSection.fields.enableStatus.confirmEnable"), - confirmDisable: t("userEditSection.fields.enableStatus.confirmDisable"), - confirmEnableTitle: t("userEditSection.fields.enableStatus.confirmEnableTitle"), - confirmDisableTitle: t("userEditSection.fields.enableStatus.confirmDisableTitle"), - confirmEnableDescription: t( - "userEditSection.fields.enableStatus.confirmEnableDescription" - ), - confirmDisableDescription: t( - "userEditSection.fields.enableStatus.confirmDisableDescription" - ), - cancel: t("userEditSection.fields.enableStatus.cancel"), - processing: t("userEditSection.fields.enableStatus.processing"), - } - : undefined, - allowedClients: { - label: t("userEditSection.fields.allowedClients.label"), - description: t("userEditSection.fields.allowedClients.description"), - customLabel: t("userEditSection.fields.allowedClients.customLabel"), - customPlaceholder: t("userEditSection.fields.allowedClients.customPlaceholder"), - }, - allowedModels: { - label: t("userEditSection.fields.allowedModels.label"), - placeholder: t("userEditSection.fields.allowedModels.placeholder"), - description: t("userEditSection.fields.allowedModels.description"), - }, - }, - presetClients: { - "claude-cli": t("userEditSection.presetClients.claude-cli"), - "gemini-cli": t("userEditSection.presetClients.gemini-cli"), - "factory-cli": t("userEditSection.presetClients.factory-cli"), - "codex-cli": t("userEditSection.presetClients.codex-cli"), - }, - limitRules: { - addRule: t("limitRules.addRule"), - ruleTypes: { - limitRpm: t("limitRules.ruleTypes.limitRpm"), - limit5h: t("limitRules.ruleTypes.limit5h"), - limitDaily: t("limitRules.ruleTypes.limitDaily"), - limitWeekly: t("limitRules.ruleTypes.limitWeekly"), - limitMonthly: t("limitRules.ruleTypes.limitMonthly"), - limitTotal: t("limitRules.ruleTypes.limitTotal"), - limitSessions: t("limitRules.ruleTypes.limitSessions"), - }, - quickValues: { - unlimited: t("limitRules.quickValues.unlimited"), - "10": t("limitRules.quickValues.10"), - "50": t("limitRules.quickValues.50"), - "100": t("limitRules.quickValues.100"), - "500": t("limitRules.quickValues.500"), - }, - }, - quickExpire: { - week: t("quickExpire.oneWeek"), - month: t("quickExpire.oneMonth"), - threeMonths: t("quickExpire.threeMonths"), - year: t("quickExpire.oneYear"), - }, - }; - }, [t, showUserProviderGroup, mode, isAdmin]); - - const keyEditTranslations = useMemo(() => { - return { - sections: { - basicInfo: t("keyEditSection.sections.basicInfo"), - expireTime: t("keyEditSection.sections.expireTime"), - limitRules: t("keyEditSection.sections.limitRules"), - specialFeatures: t("keyEditSection.sections.specialFeatures"), - }, - fields: { - keyName: { - label: t("keyEditSection.fields.keyName.label"), - placeholder: t("keyEditSection.fields.keyName.placeholder"), - }, - enableStatus: { - label: t("keyEditSection.fields.enableStatus.label"), - description: t("keyEditSection.fields.enableStatus.description"), - cannotDisableTooltip: t("keyEditSection.fields.enableStatus.cannotDisableTooltip"), - }, - balanceQueryPage: { - label: t("keyEditSection.fields.balanceQueryPage.label"), - description: t("keyEditSection.fields.balanceQueryPage.description"), - descriptionEnabled: t("keyEditSection.fields.balanceQueryPage.descriptionEnabled"), - descriptionDisabled: t("keyEditSection.fields.balanceQueryPage.descriptionDisabled"), - }, - providerGroup: { - label: t("keyEditSection.fields.providerGroup.label"), - placeholder: t("keyEditSection.fields.providerGroup.placeholder"), - selectHint: t("keyEditSection.fields.providerGroup.selectHint"), - allGroups: t("keyEditSection.fields.providerGroup.allGroups"), - noGroupHint: t("keyEditSection.fields.providerGroup.noGroupHint"), - }, - cacheTtl: { - label: t("keyEditSection.fields.cacheTtl.label"), - options: { - inherit: t("keyEditSection.fields.cacheTtl.options.inherit"), - "5m": t("keyEditSection.fields.cacheTtl.options.5m"), - "1h": t("keyEditSection.fields.cacheTtl.options.1h"), - }, - }, - }, - limitRules: { - title: t("keyEditSection.limitRules.title"), - limitTypes: { - limitRpm: t("limitRules.ruleTypes.limitRpm"), - limit5h: t("limitRules.ruleTypes.limit5h"), - limitDaily: t("limitRules.ruleTypes.limitDaily"), - limitWeekly: t("limitRules.ruleTypes.limitWeekly"), - limitMonthly: t("limitRules.ruleTypes.limitMonthly"), - limitTotal: t("limitRules.ruleTypes.limitTotal"), - limitSessions: t("limitRules.ruleTypes.limitSessions"), - }, - quickValues: { - unlimited: t("limitRules.quickValues.unlimited"), - "10": t("limitRules.quickValues.10"), - "50": t("limitRules.quickValues.50"), - "100": t("limitRules.quickValues.100"), - "500": t("limitRules.quickValues.500"), - }, - actions: { - add: t("keyEditSection.limitRules.actions.add"), - remove: t("keyEditSection.limitRules.actions.remove"), - }, - daily: { - mode: { - fixed: t("keyEditSection.limitRules.daily.mode.fixed"), - rolling: t("keyEditSection.limitRules.daily.mode.rolling"), - }, - }, - }, - quickExpire: { - week: t("quickExpire.oneWeek"), - month: t("quickExpire.oneMonth"), - threeMonths: t("quickExpire.threeMonths"), - year: t("quickExpire.oneYear"), - }, - }; - }, [t]); - - const handleUserChange = (field: string | Record, value?: any) => { - const prev = form.values.user || (defaultValues.user as UnifiedEditValues["user"]); - const next = { ...prev } as UnifiedEditValues["user"]; - - if (typeof field === "object") { - // Batch update: apply multiple fields at once - Object.entries(field).forEach(([key, val]) => { - const mappedField = key === "description" ? "note" : key; - (next as any)[mappedField] = mappedField === "expiresAt" ? (val ?? undefined) : val; - }); - } else { - // Single field update (backward compatible) - const mappedField = field === "description" ? "note" : field; - if (mappedField === "expiresAt") { - (next as any)[mappedField] = value ?? undefined; - } else { - (next as any)[mappedField] = value; - } - } - form.setValue("user", next); - }; - - const handleKeyChange = (keyId: number, field: string | Record, value?: any) => { - const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"]; - const nextKeys = prevKeys.map((k) => { - if (k.id !== keyId) return k; - - if (typeof field === "object") { - // Batch update - const updates: Record = {}; - Object.entries(field).forEach(([key, val]) => { - if (key === "expiresAt") { - updates[key] = val ? (val as Date).toISOString() : undefined; - } else { - updates[key] = val; - } - }); - return { ...k, ...updates }; - } - - // Single field update (backward compatible) - if (field === "expiresAt") { - return { ...k, expiresAt: value ? (value as Date).toISOString() : undefined }; - } - return { ...k, [field]: value }; - }); - form.setValue("keys", nextKeys); - }; - - const handleAddKey = () => { - const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"]; - const newKeyId = getNextTempKeyId(); - const newKey = { - id: newKeyId, - name: "", - isEnabled: true, - expiresAt: undefined, - canLoginWebUi: true, - providerGroup: PROVIDER_GROUP.DEFAULT, - cacheTtlPreference: "inherit" as const, - limit5hUsd: null, - limitDailyUsd: null, - dailyResetMode: "fixed" as const, - dailyResetTime: "00:00", - limitWeeklyUsd: null, - limitMonthlyUsd: null, - limitTotalUsd: null, - limitConcurrentSessions: 0, - }; - form.setValue("keys", [...prevKeys, newKey]); - // Trigger auto-scroll to the newly added key - setNewlyAddedKeyId(newKeyId); - // Auto-expand the newly added key - setExpandedKeyIds((prev) => new Set([...prev, newKeyId])); - }; - - const handleRemoveKey = (keyId: number, keyName: string) => { - if (keyId < 0) { - // New key (not yet saved) - remove directly without confirmation - const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"]; - form.setValue( - "keys", - prevKeys.filter((k) => k.id !== keyId) - ); - } else { - // Existing key - show confirmation dialog - setKeyToDelete({ id: keyId, name: keyName }); - } - }; - - const confirmRemoveKey = () => { - if (!keyToDelete) return; - const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"]; - form.setValue( - "keys", - prevKeys.filter((k) => k.id !== keyToDelete.id) - ); - setDeletedKeyIds((prev) => [...prev, keyToDelete.id]); - setKeyToDelete(null); - }; - - const handleDisableUser = async () => { - if (!user) return; - const res = await toggleUserEnabled(user.id, false); - if (!res.ok) { - throw new Error(res.error || t("editDialog.operationFailed")); - } - toast.success(t("editDialog.userDisabled")); - onSuccess?.(); - queryClient.invalidateQueries({ queryKey: ["users"] }); - router.refresh(); - }; - - const handleEnableUser = async () => { - if (!user) return; - const res = await toggleUserEnabled(user.id, true); - if (!res.ok) { - throw new Error(res.error || t("editDialog.operationFailed")); - } - toast.success(t("editDialog.userEnabled")); - onSuccess?.(); - queryClient.invalidateQueries({ queryKey: ["users"] }); - router.refresh(); - }; - - const handleDeleteUser = async () => { - if (!user) return; - const res = await removeUser(user.id); - if (!res.ok) { - throw new Error(res.error || t("editDialog.deleteFailed")); - } - toast.success(t("editDialog.userDeleted")); - onSuccess?.(); - onOpenChange(false); - queryClient.invalidateQueries({ queryKey: ["users"] }); - router.refresh(); - }; - - return ( - -
- -
- {isKeyOnlyMode ? ( -
- - {mode === "create" ? t("createDialog.description") : t("editDialog.description")} - -
- -
- {isKeyOnlyMode && userProviderGroups.length > 0 && ( -
-
{tUsers("dialog.userProviderGroup")}
-
- {userProviderGroups.map((group) => ( - - {group} - - ))} -
-

- {tUsers("dialog.userProviderGroupHint")} -

-
- )} - - {isKeyOnlyMode ? null : ( - <> - { - if (user.isEnabled) { - await handleDisableUser(); - } else { - await handleEnableUser(); - } - } - : undefined - } - showProviderGroup={showUserProviderGroup} - onChange={handleUserChange} - translations={userEditTranslations} - modelSuggestions={modelSuggestions} - /> - - - - )} - -
-
-
-
- -
-
- {keys.map((key, index) => { - const isExpanded = - mode === "create" || keys.length === 1 || expandedKeyIds.has(key.id); - const showCollapseButton = mode === "edit" && keys.length > 1; - // 计算当前是否是最后一个启用的 key - const enabledKeysCount = keys.filter((k) => k.isEnabled).length; - const isLastEnabledKey = key.isEnabled && enabledKeysCount === 1; - - return ( -
-
- Key #{index + 1} -
- - - {/* Collapsed view */} - {!isExpanded && ( -
toggleKeyExpanded(key.id)} - > -
- {key.name || "Unnamed Key"} - - {key.isEnabled ? t("keyStatus.enabled") : t("keyStatus.disabled")} - - - {normalizeProviderGroup(key.providerGroup)} - -
- {showCollapseButton && ( - - )} -
- )} - - {/* Expanded view */} - {isExpanded && ( - <> - {showCollapseButton && ( -
- -
- )} - , value?: any) => - handleKeyChange(key.id, fieldOrBatch, value)) as { - (field: string, value: any): void; - (batch: Record): void; - } - } - scrollRef={ - scrollToKeyId === key.id || newlyAddedKeyId === key.id - ? keyScrollRef - : undefined - } - translations={keyEditTranslations} - /> - - )} -
- ); - })} -
-
- - {mode === "edit" && isAdmin && user && ( - } - /> - )} -
- - {errorMessage &&
{errorMessage}
} - - - - - -
- - {/* Delete key confirmation dialog */} - !open && setKeyToDelete(null)}> - - - {t("createDialog.confirmRemoveKeyTitle")} - - {t("createDialog.confirmRemoveKeyDescription", { name: keyToDelete?.name || "" })} - - - - {tCommon("cancel")} - {tCommon("confirm")} - - - -
- ); -} - -export function UnifiedEditDialog(props: UnifiedEditDialogProps) { - return ( - - {props.open ? ( - - ) : null} - - ); -} diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index 3eb2b121f..b85269904 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, ChevronRight, SquarePen } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, SquarePen } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils"; import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; import { formatDate } from "@/lib/utils/date-format"; import type { UserDisplay } from "@/types/user"; +import { EditKeyDialog } from "./edit-key-dialog"; import { KeyRowItem } from "./key-row-item"; import { UserLimitBadge } from "./user-limit-badge"; @@ -31,7 +32,8 @@ export interface UserKeyTableRowProps { onSelect?: (checked: boolean) => void; selectedKeyIds?: Set; onSelectKey?: (keyId: number, checked: boolean) => void; - onEditUser: (scrollToKeyId?: number) => void; + onEditUser: () => void; + onAddKey?: () => void; onQuickRenew?: (user: UserDisplay) => void; optimisticExpiresAt?: Date; currentUser?: { role: string }; @@ -61,6 +63,7 @@ export interface UserKeyTableRowProps { details: string; logs: string; delete: string; + addKey?: string; }; userStatus?: { disabled: string; @@ -119,6 +122,7 @@ export function UserKeyTableRow({ selectedKeyIds, onSelectKey, onEditUser, + onAddKey, onQuickRenew, optimisticExpiresAt, currencyCode, @@ -136,6 +140,8 @@ export function UserKeyTableRow({ const [localIsEnabled, setLocalIsEnabled] = useState(user.isEnabled); // 乐观更新:本地状态跟踪过期时间 const [localExpiresAt, setLocalExpiresAt] = useState(user.expiresAt); + // Key 编辑 Dialog 状态 + const [editingKeyId, setEditingKeyId] = useState(null); const isExpanded = isMultiSelectMode ? true : expanded; const resolvedGridColumnsClass = gridColumnsClass ?? DEFAULT_GRID_COLUMNS_CLASS; @@ -456,10 +462,10 @@ export function UserKeyTableRow({ isMultiSelectMode={isMultiSelectMode} isSelected={selectedKeyIds?.has(key.id) ?? false} onSelect={(checked) => onSelectKey?.(key.id, checked)} - onEdit={() => onEditUser(key.id)} + onEdit={() => setEditingKeyId(key.id)} onDelete={() => handleDeleteKey(key.id)} onViewLogs={() => router.push(`/dashboard/logs?keyId=${key.id}`)} - onViewDetails={() => onEditUser(key.id)} + onViewDetails={() => setEditingKeyId(key.id)} currencyCode={currencyCode} translations={keyRowTranslations} highlight={highlightKeyIds?.has(key.id)} @@ -471,8 +477,72 @@ export function UserKeyTableRow({ {translations.noKeys} )} + {onAddKey && ( +
+ +
+ )} ) : null} + + {/* Key 编辑 Dialog */} + {editingKeyId !== null && + (() => { + const editingKey = user.keys.find((k) => k.id === editingKeyId); + if (!editingKey) return null; + return ( + { + if (!open) setEditingKeyId(null); + }} + keyData={{ + id: editingKey.id, + name: editingKey.name, + expiresAt: editingKey.expiresAt, + canLoginWebUi: editingKey.canLoginWebUi, + providerGroup: editingKey.providerGroup ?? null, + limit5hUsd: editingKey.limit5hUsd, + limitDailyUsd: editingKey.limitDailyUsd, + dailyResetMode: editingKey.dailyResetMode, + dailyResetTime: editingKey.dailyResetTime, + limitWeeklyUsd: editingKey.limitWeeklyUsd, + limitMonthlyUsd: editingKey.limitMonthlyUsd, + limitTotalUsd: editingKey.limitTotalUsd, + limitConcurrentSessions: editingKey.limitConcurrentSessions, + }} + user={ + { + id: user.id, + name: user.name, + providerGroup: user.providerGroup ?? null, + limit5hUsd: user.limit5hUsd ?? undefined, + limitWeeklyUsd: user.limitWeeklyUsd ?? undefined, + limitMonthlyUsd: user.limitMonthlyUsd ?? undefined, + limitTotalUsd: user.limitTotalUsd ?? undefined, + limitConcurrentSessions: user.limitConcurrentSessions ?? undefined, + } as any + } + isAdmin={isAdmin} + onSuccess={() => { + setEditingKeyId(null); + queryClient.invalidateQueries({ queryKey: ["users"] }); + router.refresh(); + }} + /> + ); + })()} ); } diff --git a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx index 50e1c932c..ba13fa92d 100644 --- a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx @@ -12,8 +12,8 @@ import { useVirtualizer } from "@/hooks/use-virtualizer"; import { cn } from "@/lib/utils"; import type { User, UserDisplay } from "@/types/user"; import { BatchEditToolbar } from "./batch-edit/batch-edit-toolbar"; +import { EditUserDialog } from "./edit-user-dialog"; import { QuickRenewDialog, type QuickRenewUser } from "./forms/quick-renew-dialog"; -import { UnifiedEditDialog } from "./unified-edit-dialog"; import { UserKeyTableRow } from "./user-key-table-row"; export interface UserManagementTableProps { @@ -26,6 +26,7 @@ export interface UserManagementTableProps { currentUser?: User; currencyCode?: string; onCreateUser?: () => void; + onAddKey?: (user: UserDisplay) => void; highlightKeyIds?: Set; autoExpandOnFilter?: boolean; isMultiSelectMode?: boolean; @@ -110,6 +111,7 @@ export function UserManagementTable({ currentUser, currencyCode, onCreateUser, + onAddKey, highlightKeyIds, autoExpandOnFilter, isMultiSelectMode, @@ -140,7 +142,6 @@ export function UserManagementTable({ const prevAutoExpandRef = useRef(autoExpandOnFilter); const [editDialogOpen, setEditDialogOpen] = useState(false); const [editingUserId, setEditingUserId] = useState(null); - const [scrollToKeyId, setScrollToKeyId] = useState(undefined); // Quick renew dialog state const [quickRenewOpen, setQuickRenewOpen] = useState(false); @@ -204,7 +205,10 @@ export function UserManagementTable({ collapse: translations.table.collapse, noKeys: translations.table.noKeys, defaultGroup: translations.table.defaultGroup, - actions: translations.actions, + actions: { + ...translations.actions, + addKey: tUserMgmt("table.actions.addKey"), + }, userStatus: { disabled: tUserMgmt("keyStatus.disabled"), }, @@ -306,7 +310,6 @@ export function UserManagementTable({ if (!editingUser) { setEditDialogOpen(false); setEditingUserId(null); - setScrollToKeyId(undefined); } }, [editDialogOpen, editingUser]); @@ -323,9 +326,8 @@ export function UserManagementTable({ setExpandedUsers(new Map(users.map((user) => [user.id, nextExpanded]))); }; - const openEditDialog = (userId: number, keyId?: number) => { + const openEditDialog = (userId: number) => { setEditingUserId(userId); - setScrollToKeyId(keyId); setEditDialogOpen(true); }; @@ -333,7 +335,6 @@ export function UserManagementTable({ setEditDialogOpen(open); if (open) return; setEditingUserId(null); - setScrollToKeyId(undefined); }; // Quick renew handlers @@ -568,7 +569,8 @@ export function UserManagementTable({ } selectedKeyIds={selectedKeyIdSet} onSelectKey={showMultiSelect ? onSelectKey : undefined} - onEditUser={(keyId) => openEditDialog(user.id, keyId)} + onEditUser={() => openEditDialog(user.id)} + onAddKey={onAddKey ? () => onAddKey(user) : undefined} onQuickRenew={isAdmin ? handleOpenQuickRenew : undefined} optimisticExpiresAt={optimisticUserExpiries.get(user.id)} currentUser={currentUser} @@ -592,15 +594,11 @@ export function UserManagementTable({ ) : null} - {editingUser ? ( - ) : null} diff --git a/src/app/[locale]/dashboard/_components/user/utils/form-utils.ts b/src/app/[locale]/dashboard/_components/user/utils/form-utils.ts new file mode 100644 index 000000000..f65b9b4fe --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/utils/form-utils.ts @@ -0,0 +1,9 @@ +/** + * Get the first error message from a form errors object. + * Prioritizes _form error, then returns the first non-empty message. + */ +export function getFirstErrorMessage(errors: Record): string | null { + if (errors._form) return errors._form; + const first = Object.entries(errors).find(([, msg]) => Boolean(msg)); + return first?.[1] || null; +} diff --git a/src/app/[locale]/dashboard/_components/user/utils/provider-group.ts b/src/app/[locale]/dashboard/_components/user/utils/provider-group.ts new file mode 100644 index 000000000..c1d5fde4f --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/utils/provider-group.ts @@ -0,0 +1,23 @@ +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(","); +} diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index eeaa7e990..8ef674df9 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -5,8 +5,9 @@ import { QueryClientProvider, useInfiniteQuery, useQuery, + useQueryClient, } from "@tanstack/react-query"; -import { Key, Loader2, Plus, Search } from "lucide-react"; +import { Loader2, Plus, Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users"; @@ -23,8 +24,9 @@ import { Skeleton } from "@/components/ui/skeleton"; import { TagInput } from "@/components/ui/tag-input"; import { useDebounce } from "@/lib/hooks/use-debounce"; 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"; -import { UnifiedEditDialog } from "../_components/user/unified-edit-dialog"; +import { CreateUserDialog } from "../_components/user/create-user-dialog"; import { UserManagementTable } from "../_components/user/user-management-table"; const queryClient = new QueryClient({ @@ -65,6 +67,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { const tUserMgmt = useTranslations("dashboard.userManagement"); const tKeyList = useTranslations("dashboard.keyList"); const tCommon = useTranslations("common"); + const queryClient = useQueryClient(); const isAdmin = currentUser.role === "admin"; const [searchTerm, setSearchTerm] = useState(""); const [tagFilters, setTagFilters] = useState([]); @@ -224,6 +227,10 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { // Create dialog state const [showCreateDialog, setShowCreateDialog] = useState(false); + // Add key dialog state + const [showAddKeyDialog, setShowAddKeyDialog] = useState(false); + const [addKeyUser, setAddKeyUser] = useState(null); + const handleCreateUser = useCallback(() => { setShowCreateDialog(true); }, []); @@ -236,6 +243,22 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { setShowCreateDialog(open); }, []); + const handleAddKey = useCallback((user: UserDisplay) => { + setAddKeyUser(user); + setShowAddKeyDialog(true); + }, []); + + const handleAddKeyDialogClose = useCallback((open: boolean) => { + setShowAddKeyDialog(open); + if (!open) { + setAddKeyUser(null); + } + }, []); + + const handleKeyCreated = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + }, [queryClient]); + const hasPendingFilterChanges = useMemo(() => { const normalize = (values: string[]) => [...values].sort().join("|"); return ( @@ -473,16 +496,11 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { : t("description", { count: visibleUsers.length })}

- {isAdmin ? ( + {isAdmin && ( - ) : ( - )} @@ -620,6 +638,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { currentUser={currentUser} currencyCode="USD" onCreateUser={isAdmin ? handleCreateUser : handleCreateKey} + onAddKey={handleAddKey} highlightKeyIds={shouldHighlightKeys ? matchingKeyIds : undefined} autoExpandOnFilter={shouldHighlightKeys} isMultiSelectMode={isAdmin && isMultiSelectMode} @@ -646,15 +665,53 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { /> ) : null} - {/* Create User Dialog */} - + {/* Create User Dialog (Admin) or Add Key Dialog (non-Admin) */} + {isAdmin ? ( + + ) : selfUser ? ( + + ) : null} + + {/* Add Key Dialog (triggered from key list) */} + {addKeyUser && ( + + )} ); } diff --git a/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx b/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx new file mode 100644 index 000000000..ba96678e0 --- /dev/null +++ b/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx @@ -0,0 +1,140 @@ +/** + * @vitest-environment happy-dom + * + * AddKeyForm: expiresAt 清除测试 + * 验证当用户清除到期时间后,提交时 expiresAt 字段被正确传递(空字符串而非 undefined) + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { Dialog } from "@/components/ui/dialog"; +import { AddKeyForm } from "@/app/[locale]/dashboard/_components/user/forms/add-key-form"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +const keysActionMocks = vi.hoisted(() => ({ + addKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })), +})); +vi.mock("@/actions/keys", () => keysActionMocks); + +const providersActionMocks = vi.hoisted(() => ({ + getAvailableProviderGroups: vi.fn(async () => []), +})); +vi.mock("@/actions/providers", () => providersActionMocks); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + quota: read("quota.json"), + ui: read("ui.json"), + dashboard: read("dashboard.json"), + forms: read("forms.json"), + }; +} + +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(); + }, + }; +} + +describe("AddKeyForm: expiresAt 清除测试", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("提交时应携带 expiresAt 字段(即使为空)", async () => { + const messages = loadMessages(); + const onSuccess = vi.fn(); + + const { unmount } = render( + + {}}> + + + + ); + + // 填写必填字段 - Key Name + const nameInput = document.body.querySelector('input[placeholder*="key"]') as HTMLInputElement; + if (nameInput) { + await act(async () => { + nameInput.value = "test-key"; + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + nameInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + } + + // 提交表单 + const submit = document.body.querySelector('button[type="submit"]') as HTMLButtonElement | null; + expect(submit).toBeTruthy(); + + await act(async () => { + submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 50)); + }); + + // 验证 addKey 被调用且 expiresAt 字段存在 + if (keysActionMocks.addKey.mock.calls.length > 0) { + const payload = keysActionMocks.addKey.mock.calls[0][0] as Record; + + // 关键点:expiresAt 必须存在于 payload 中 + // 即使值为空字符串,也必须显式传递,后端才能识别为"清除" + expect(Object.hasOwn(payload, "expiresAt")).toBe(true); + // 空字符串或 undefined 都是有效的清除值,但根据修复,应该是空字符串 + expect(payload.expiresAt === "" || payload.expiresAt === undefined).toBe(true); + } + + unmount(); + }); + + test("expiresAt 使用 ?? 而非 || 确保空字符串不被转换", async () => { + // 直接测试代码逻辑:验证 expiresAt ?? "" 的行为 + // 当 expiresAt 为 ""(用户清除日期)时,应保持 "" + // 当 expiresAt 为 null/undefined 时,应变为 "" + + const testCases = [ + { input: "", expected: "" }, // 用户清除日期 + { input: null, expected: "" }, // null 转为 "" + { input: undefined, expected: "" }, // undefined 转为 "" + { input: "2026-01-01", expected: "2026-01-01" }, // 有值时保持原值 + ]; + + for (const { input, expected } of testCases) { + const result = input ?? ""; + expect(result).toBe(expected); + } + }); +}); diff --git a/tests/unit/user-dialogs.test.tsx b/tests/unit/user-dialogs.test.tsx new file mode 100644 index 000000000..fefd024bd --- /dev/null +++ b/tests/unit/user-dialogs.test.tsx @@ -0,0 +1,752 @@ +/** + * @vitest-environment happy-dom + * + * 单元测试:用户管理 Dialog 组件 + * + * 测试对象: + * - EditUserDialog + * - EditKeyDialog + * - AddKeyDialog + * - CreateUserDialog + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; + +// ==================== Mocks ==================== + +// Mock next/navigation +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + refresh: vi.fn(), + replace: vi.fn(), + }), +})); + +// Mock @/i18n/routing +vi.mock("@/i18n/routing", () => ({ + Link: ({ children }: { children: ReactNode }) => children, + useRouter: () => ({ + push: vi.fn(), + refresh: vi.fn(), + replace: vi.fn(), + }), +})); + +// Mock Server Actions +const mockEditUser = vi.fn().mockResolvedValue({ ok: true }); +const mockRemoveUser = vi.fn().mockResolvedValue({ ok: true }); +const mockToggleUserEnabled = vi.fn().mockResolvedValue({ ok: true }); +const mockAddKey = vi.fn().mockResolvedValue({ ok: true, data: { key: "sk-test-key" } }); +const mockEditKey = vi.fn().mockResolvedValue({ ok: true }); +const mockCreateUserOnly = vi.fn().mockResolvedValue({ ok: true, data: { user: { id: 1 } } }); + +vi.mock("@/actions/users", () => ({ + editUser: (...args: unknown[]) => mockEditUser(...args), + removeUser: (...args: unknown[]) => mockRemoveUser(...args), + toggleUserEnabled: (...args: unknown[]) => mockToggleUserEnabled(...args), + createUserOnly: (...args: unknown[]) => mockCreateUserOnly(...args), +})); + +vi.mock("@/actions/keys", () => ({ + addKey: (...args: unknown[]) => mockAddKey(...args), + editKey: (...args: unknown[]) => mockEditKey(...args), + removeKey: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock("@/actions/usage-logs", () => { + return { + getFilterOptions: () => Promise.resolve({ ok: true, data: { models: [] } }), + }; +}); + +// Mock sonner toast +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock Dialog components to simplify rendering +vi.mock("@/components/ui/dialog", () => { + type PropsWithChildren = { children?: ReactNode }; + type DialogContentProps = PropsWithChildren & { className?: string }; + + function Dialog({ children }: PropsWithChildren) { + return
{children}
; + } + + function DialogContent({ children, className }: DialogContentProps) { + return ( +
+ {children} +
+ ); + } + + function DialogHeader({ children }: PropsWithChildren) { + return
{children}
; + } + + function DialogTitle({ children }: PropsWithChildren) { + return

{children}

; + } + + function DialogDescription({ children, className }: PropsWithChildren & { className?: string }) { + return ( +

+ {children} +

+ ); + } + + function DialogFooter({ children, className }: PropsWithChildren & { className?: string }) { + return ( +
+ {children} +
+ ); + } + + return { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter }; +}); + +// Mock form components +vi.mock("@/app/[locale]/dashboard/_components/user/forms/user-edit-section", () => ({ + UserEditSection: ({ user, onChange, translations: _translations }: any) => ( +
+ onChange("name", e.target.value)} + /> +
+ ), +})); + +vi.mock("@/app/[locale]/dashboard/_components/user/forms/key-edit-section", () => ({ + KeyEditSection: ({ keyData, onChange, translations: _translations }: any) => ( +
+ onChange("name", e.target.value)} + /> +
+ ), +})); + +vi.mock("@/app/[locale]/dashboard/_components/user/forms/danger-zone", () => ({ + DangerZone: ({ userId, userName, onDelete }: any) => ( +
+ +
+ ), +})); + +vi.mock("@/app/[locale]/dashboard/_components/user/forms/add-key-form", () => ({ + AddKeyForm: ({ userId, onSuccess }: any) => ( +
+ +
+ ), +})); + +vi.mock("@/app/[locale]/dashboard/_components/user/forms/edit-key-form", () => ({ + EditKeyForm: ({ keyData, onSuccess }: any) => ( +
+ +
+ ), +})); + +// Import components after mocks +import { EditUserDialog } from "@/app/[locale]/dashboard/_components/user/edit-user-dialog"; +import { EditKeyDialog } from "@/app/[locale]/dashboard/_components/user/edit-key-dialog"; +import { AddKeyDialog } from "@/app/[locale]/dashboard/_components/user/add-key-dialog"; +import { CreateUserDialog } from "@/app/[locale]/dashboard/_components/user/create-user-dialog"; +import type { UserDisplay } from "@/types/user"; + +// ==================== Test Utilities ==================== + +const messages = { + common: { + save: "Save", + cancel: "Cancel", + close: "Close", + copySuccess: "Copied", + copyFailed: "Copy failed", + }, + dashboard: { + userManagement: { + editDialog: { + title: "Edit User", + description: "Edit user information", + saving: "Saving...", + saveSuccess: "User saved", + saveFailed: "Save failed", + operationFailed: "Operation failed", + userDisabled: "User disabled", + userEnabled: "User enabled", + deleteFailed: "Delete failed", + userDeleted: "User deleted", + }, + createDialog: { + title: "Create User", + description: "Create a new user with API key", + creating: "Creating...", + create: "Create", + saveFailed: "Create failed", + successTitle: "User Created", + successDescription: "User created successfully", + generatedKey: "Generated Key", + keyHint: "Save this key, it cannot be recovered", + }, + userEditSection: { + sections: { + basicInfo: "Basic Info", + expireTime: "Expiration", + limitRules: "Limits", + accessRestrictions: "Access", + }, + fields: { + username: { label: "Username", placeholder: "Enter username" }, + description: { label: "Note", placeholder: "Enter note" }, + tags: { label: "Tags", placeholder: "Enter tags" }, + providerGroup: { label: "Provider Group", placeholder: "Select group" }, + enableStatus: { + label: "Status", + enabledDescription: "Enabled", + disabledDescription: "Disabled", + confirmEnable: "Enable", + confirmDisable: "Disable", + confirmEnableTitle: "Enable User", + confirmDisableTitle: "Disable User", + confirmEnableDescription: "Enable this user?", + confirmDisableDescription: "Disable this user?", + cancel: "Cancel", + processing: "Processing...", + }, + allowedClients: { + label: "Allowed Clients", + description: "Restrict clients", + customLabel: "Custom", + customPlaceholder: "Custom client", + }, + allowedModels: { + label: "Allowed Models", + placeholder: "Select models", + description: "Restrict models", + }, + }, + presetClients: { + "claude-cli": "Claude CLI", + "gemini-cli": "Gemini CLI", + "factory-cli": "Factory CLI", + "codex-cli": "Codex CLI", + }, + }, + keyEditSection: { + sections: { + basicInfo: "Basic Information", + expireTime: "Expiration Time", + limitRules: "Limit Rules", + specialFeatures: "Special Features", + }, + fields: { + keyName: { label: "Key Name", placeholder: "Enter key name" }, + providerGroup: { label: "Provider Group", placeholder: "Default: default" }, + cacheTtl: { + label: "Cache TTL Override", + options: { inherit: "No override", "5m": "5m", "1h": "1h" }, + }, + balanceQueryPage: { + label: "Independent Personal Usage Page", + description: "When enabled, this key can access an independent personal usage page", + descriptionEnabled: "Enabled description", + descriptionDisabled: "Disabled description", + }, + enableStatus: { + label: "Enable Status", + description: "Disabled keys cannot be used", + }, + }, + }, + dangerZone: { + title: "Danger Zone", + deleteUser: "Delete User", + deleteUserDescription: "This action cannot be undone", + deleteConfirm: "Type username to confirm", + deleteButton: "Delete", + }, + limitRules: { + addRule: "Add Rule", + ruleTypes: { + limitRpm: "RPM", + limit5h: "5h Limit", + limitDaily: "Daily", + limitWeekly: "Weekly", + limitMonthly: "Monthly", + limitTotal: "Total", + limitSessions: "Sessions", + }, + quickValues: { + unlimited: "Unlimited", + "10": "$10", + "50": "$50", + "100": "$100", + "500": "$500", + }, + }, + quickExpire: { + oneWeek: "1 Week", + oneMonth: "1 Month", + threeMonths: "3 Months", + oneYear: "1 Year", + }, + }, + addKeyForm: { + title: "Add Key", + description: "Add a new API key", + successTitle: "Key Created", + successDescription: "Key created successfully", + generatedKey: { + label: "Generated Key", + hint: "Save this key", + }, + keyName: { + label: "Key Name", + }, + }, + }, + quota: { + keys: { + editKeyForm: { + title: "Edit Key", + description: "Edit key settings", + }, + }, + }, +}; + +let queryClient: QueryClient; + +function renderWithProviders(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(); + }, + }; +} + +// Mock user data +const mockUser: UserDisplay = { + id: 1, + name: "Test User", + note: "Test note", + role: "user", + rpm: 10, + dailyQuota: 100, + providerGroup: "default", + tags: ["test"], + keys: [], + isEnabled: true, + expiresAt: null, +}; + +// ==================== Tests ==================== + +describe("EditUserDialog", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("renders dialog with user data when open", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( + "Edit User" + ); + expect(container.querySelector('[data-testid="user-edit-section"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="danger-zone"]')).not.toBeNull(); + + unmount(); + }); + + test("does not render content when closed", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + // Dialog root exists but content should be minimal + expect(container.querySelector('[data-testid="user-edit-section"]')).toBeNull(); + + unmount(); + }); + + test("passes correct user id to UserEditSection", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + const userEditSection = container.querySelector('[data-testid="user-edit-section"]'); + expect(userEditSection?.getAttribute("data-user-id")).toBe("1"); + + unmount(); + }); + + test("passes correct user id to DangerZone", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + const dangerZone = container.querySelector('[data-testid="danger-zone"]'); + expect(dangerZone?.getAttribute("data-user-id")).toBe("1"); + + unmount(); + }); + + test("has save and cancel buttons", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + const buttons = container.querySelectorAll("button"); + const buttonTexts = Array.from(buttons).map((b) => b.textContent); + + expect(buttonTexts).toContain("Save"); + expect(buttonTexts).toContain("Cancel"); + + unmount(); + }); +}); + +describe("EditKeyDialog", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + const mockKeyData = { + id: 1, + name: "Test Key", + expiresAt: "2025-12-31", + canLoginWebUi: false, + providerGroup: null, + }; + + test("renders dialog with key data when open", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( + "Edit Key" + ); + expect(container.querySelector('[data-testid="edit-key-form"]')).not.toBeNull(); + + unmount(); + }); + + test("passes keyData to EditKeyForm", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + const editKeyForm = container.querySelector('[data-testid="edit-key-form"]'); + expect(editKeyForm?.getAttribute("data-key-id")).toBe("1"); + + unmount(); + }); + + test("calls onOpenChange when dialog is closed", () => { + const onOpenChange = vi.fn(); + const onSuccess = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + // Simulate clicking save in the mocked form + const submitButton = container.querySelector('[data-testid="edit-key-submit"]') as HTMLElement; + act(() => { + submitButton?.click(); + }); + + expect(onSuccess).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + + unmount(); + }); +}); + +describe("AddKeyDialog", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("renders dialog with add key form when open", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( + "Add Key" + ); + expect(container.querySelector('[data-testid="add-key-form"]')).not.toBeNull(); + + unmount(); + }); + + test("passes userId to AddKeyForm", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + const addKeyForm = container.querySelector('[data-testid="add-key-form"]'); + expect(addKeyForm?.getAttribute("data-user-id")).toBe("42"); + + unmount(); + }); + + test("calls onSuccess after successful key creation", () => { + const onOpenChange = vi.fn(); + const onSuccess = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + // Initially shows form + expect(container.querySelector('[data-testid="add-key-form"]')).not.toBeNull(); + + // Simulate successful key creation + const submitButton = container.querySelector('[data-testid="add-key-submit"]') as HTMLElement; + act(() => { + submitButton?.click(); + }); + + // onSuccess should be called + expect(onSuccess).toHaveBeenCalled(); + + // The component should now show the success view with generated key info + // (key name "test" from mock result) + expect(container.textContent).toContain("Key Created"); + + unmount(); + }); +}); + +describe("CreateUserDialog", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + mockCreateUserOnly.mockResolvedValue({ ok: true, data: { user: { id: 1 } } }); + mockAddKey.mockResolvedValue({ ok: true, data: { key: "sk-new-user-key" } }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("renders dialog with user and key sections when open", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain( + "Create User" + ); + expect(container.querySelector('[data-testid="user-edit-section"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="key-edit-section"]')).not.toBeNull(); + + unmount(); + }); + + test("does not render content when closed", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + expect(container.querySelector('[data-testid="user-edit-section"]')).toBeNull(); + expect(container.querySelector('[data-testid="key-edit-section"]')).toBeNull(); + + unmount(); + }); + + test("has create and cancel buttons", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + const buttons = container.querySelectorAll("button"); + const buttonTexts = Array.from(buttons).map((b) => b.textContent); + + expect(buttonTexts).toContain("Create"); + expect(buttonTexts).toContain("Cancel"); + + unmount(); + }); +}); + +describe("Dialog Component Integration", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("EditUserDialog re-renders with new user when user prop changes", () => { + const onOpenChange = vi.fn(); + + const { container, unmount } = renderWithProviders( + + ); + + // Check initial user + let userEditSection = container.querySelector('[data-testid="user-edit-section"]'); + expect(userEditSection?.getAttribute("data-user-id")).toBe("1"); + + unmount(); + + // Render with different user + const newUser = { ...mockUser, id: 2, name: "New User" }; + const { container: container2, unmount: unmount2 } = renderWithProviders( + + ); + + userEditSection = container2.querySelector('[data-testid="user-edit-section"]'); + expect(userEditSection?.getAttribute("data-user-id")).toBe("2"); + + unmount2(); + }); + + test("all dialogs have accessible title", () => { + const onOpenChange = vi.fn(); + + // EditUserDialog + const edit = renderWithProviders( + + ); + expect(edit.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); + edit.unmount(); + + // EditKeyDialog + const editKey = renderWithProviders( + + ); + expect(editKey.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); + editKey.unmount(); + + // AddKeyDialog + const addKey = renderWithProviders( + + ); + expect(addKey.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); + addKey.unmount(); + + // CreateUserDialog + const create = renderWithProviders( + + ); + expect(create.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull(); + create.unmount(); + }); +}); From 3fc8585dae59bd5d8b0ce6a256d89b99ce9352a8 Mon Sep 17 00:00:00 2001 From: NieiR Date: Mon, 5 Jan 2026 10:49:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=B8=BA=20Key=20Dialog=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=AE=9A=E4=B9=89=E7=B2=BE=E7=A1=AE=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=20as=20any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 KeyDialogUserContext 类型,替代过于宽泛的 User 类型 - 移除 4 处 as any 类型断言 - 清理 key-list-header.tsx 中伪造的字段 Addresses Gemini Code Review feedback on type safety --- .../_components/user/add-key-dialog.tsx | 4 +- .../_components/user/edit-key-dialog.tsx | 4 +- .../_components/user/forms/add-key-form.tsx | 4 +- .../_components/user/forms/edit-key-form.tsx | 4 +- .../_components/user/key-list-header.tsx | 14 +------ .../_components/user/user-key-table-row.tsx | 21 ++++------ .../dashboard/users/users-page-client.tsx | 42 ++++++++----------- src/types/user.ts | 14 +++++++ 8 files changed, 51 insertions(+), 56 deletions(-) diff --git a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx index 4b12d238a..ab5f45392 100644 --- a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx @@ -14,14 +14,14 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { User } from "@/types/user"; +import type { KeyDialogUserContext } from "@/types/user"; import { AddKeyForm } from "./forms/add-key-form"; export interface AddKeyDialogProps { open: boolean; onOpenChange: (open: boolean) => void; userId: number; - user?: User; + user?: KeyDialogUserContext; isAdmin?: boolean; onSuccess?: () => void; } diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx index afaf10a2f..884745ab8 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx @@ -8,7 +8,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import type { User } from "@/types/user"; +import type { KeyDialogUserContext } from "@/types/user"; import { EditKeyForm } from "./forms/edit-key-form"; export interface EditKeyDialogProps { @@ -30,7 +30,7 @@ export interface EditKeyDialogProps { limitTotalUsd?: number | null; limitConcurrentSessions?: number; }; - user?: User; + user?: KeyDialogUserContext; isAdmin?: boolean; onSuccess?: () => void; } diff --git a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx index 377d5e50c..c63eea78c 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx @@ -21,11 +21,11 @@ import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { useZodForm } from "@/lib/hooks/use-zod-form"; import { getErrorMessage } from "@/lib/utils/error-messages"; import { KeyFormSchema } from "@/lib/validation/schemas"; -import type { User } from "@/types/user"; +import type { KeyDialogUserContext } from "@/types/user"; interface AddKeyFormProps { userId?: number; - user?: User; + user?: KeyDialogUserContext; isAdmin?: boolean; onSuccess?: (result: { generatedKey: string; name: string }) => void; } diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx index 2458b2927..cabddee3e 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx @@ -21,7 +21,7 @@ import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { useZodForm } from "@/lib/hooks/use-zod-form"; import { getErrorMessage } from "@/lib/utils/error-messages"; import { KeyFormSchema } from "@/lib/validation/schemas"; -import type { User } from "@/types/user"; +import type { KeyDialogUserContext } from "@/types/user"; interface EditKeyFormProps { keyData?: { @@ -40,7 +40,7 @@ interface EditKeyFormProps { limitTotalUsd?: number | null; limitConcurrentSessions?: number; }; - user?: User; + user?: KeyDialogUserContext; isAdmin?: boolean; onSuccess?: () => void; } diff --git a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx index a05244716..e80a11d94 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx @@ -322,22 +322,12 @@ export function KeyListHeader({ activeUser ? { id: activeUser.id, - name: activeUser.name, - description: activeUser.note || "", - role: activeUser.role, - rpm: activeUser.rpm, - dailyQuota: activeUser.dailyQuota, - providerGroup: activeUser.providerGroup || "default", - createdAt: new Date(), - updatedAt: new Date(), + providerGroup: activeUser.providerGroup ?? null, limit5hUsd: activeUser.limit5hUsd ?? undefined, limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined, limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined, + limitTotalUsd: activeUser.limitTotalUsd ?? undefined, limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined, - dailyResetMode: activeUser.dailyResetMode ?? "fixed", - dailyResetTime: activeUser.dailyResetTime ?? "00:00", - isEnabled: activeUser.isEnabled, - expiresAt: activeUser.expiresAt ?? undefined, } : undefined } diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index b85269904..5be783d78 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -522,18 +522,15 @@ export function UserKeyTableRow({ limitTotalUsd: editingKey.limitTotalUsd, limitConcurrentSessions: editingKey.limitConcurrentSessions, }} - user={ - { - id: user.id, - name: user.name, - providerGroup: user.providerGroup ?? null, - limit5hUsd: user.limit5hUsd ?? undefined, - limitWeeklyUsd: user.limitWeeklyUsd ?? undefined, - limitMonthlyUsd: user.limitMonthlyUsd ?? undefined, - limitTotalUsd: user.limitTotalUsd ?? undefined, - limitConcurrentSessions: user.limitConcurrentSessions ?? undefined, - } as any - } + user={{ + id: user.id, + providerGroup: user.providerGroup ?? null, + limit5hUsd: user.limit5hUsd ?? undefined, + limitWeeklyUsd: user.limitWeeklyUsd ?? undefined, + limitMonthlyUsd: user.limitMonthlyUsd ?? undefined, + limitTotalUsd: user.limitTotalUsd ?? undefined, + limitConcurrentSessions: user.limitConcurrentSessions ?? undefined, + }} isAdmin={isAdmin} onSuccess={() => { setEditingKeyId(null); diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index 8ef674df9..d7997a207 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -673,18 +673,15 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { open={showCreateDialog} onOpenChange={handleCreateDialogClose} userId={selfUser.id} - user={ - { - id: selfUser.id, - name: selfUser.name, - providerGroup: selfUser.providerGroup ?? null, - limit5hUsd: selfUser.limit5hUsd ?? undefined, - limitWeeklyUsd: selfUser.limitWeeklyUsd ?? undefined, - limitMonthlyUsd: selfUser.limitMonthlyUsd ?? undefined, - limitTotalUsd: selfUser.limitTotalUsd ?? undefined, - limitConcurrentSessions: selfUser.limitConcurrentSessions ?? undefined, - } as any - } + user={{ + id: selfUser.id, + providerGroup: selfUser.providerGroup ?? null, + limit5hUsd: selfUser.limit5hUsd ?? undefined, + limitWeeklyUsd: selfUser.limitWeeklyUsd ?? undefined, + limitMonthlyUsd: selfUser.limitMonthlyUsd ?? undefined, + limitTotalUsd: selfUser.limitTotalUsd ?? undefined, + limitConcurrentSessions: selfUser.limitConcurrentSessions ?? undefined, + }} isAdmin={false} onSuccess={handleKeyCreated} /> @@ -696,18 +693,15 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { open={showAddKeyDialog} onOpenChange={handleAddKeyDialogClose} userId={addKeyUser.id} - user={ - { - id: addKeyUser.id, - name: addKeyUser.name, - providerGroup: addKeyUser.providerGroup ?? null, - limit5hUsd: addKeyUser.limit5hUsd ?? undefined, - limitWeeklyUsd: addKeyUser.limitWeeklyUsd ?? undefined, - limitMonthlyUsd: addKeyUser.limitMonthlyUsd ?? undefined, - limitTotalUsd: addKeyUser.limitTotalUsd ?? undefined, - limitConcurrentSessions: addKeyUser.limitConcurrentSessions ?? undefined, - } as any - } + user={{ + id: addKeyUser.id, + providerGroup: addKeyUser.providerGroup ?? null, + limit5hUsd: addKeyUser.limit5hUsd ?? undefined, + limitWeeklyUsd: addKeyUser.limitWeeklyUsd ?? undefined, + limitMonthlyUsd: addKeyUser.limitMonthlyUsd ?? undefined, + limitTotalUsd: addKeyUser.limitTotalUsd ?? undefined, + limitConcurrentSessions: addKeyUser.limitConcurrentSessions ?? undefined, + }} isAdmin={isAdmin} onSuccess={handleKeyCreated} /> diff --git a/src/types/user.ts b/src/types/user.ts index f380b30bf..75decef2b 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -155,6 +155,20 @@ export interface UserDisplay { allowedModels?: string[]; // 允许的AI模型(空数组=无限制) } +/** + * Key Dialog 所需的用户上下文(精简版) + * 用于 AddKeyDialog/EditKeyDialog 组件,只包含限额相关字段 + */ +export interface KeyDialogUserContext { + id: number; + providerGroup?: string | null; + limit5hUsd?: number; + limitWeeklyUsd?: number; + limitMonthlyUsd?: number; + limitTotalUsd?: number | null; + limitConcurrentSessions?: number; +} + /** * 用户表单数据 */