diff --git a/apps/admin/src/sections/user/index.tsx b/apps/admin/src/sections/user/index.tsx index b5bf332..74b9998 100644 --- a/apps/admin/src/sections/user/index.tsx +++ b/apps/admin/src/sections/user/index.tsx @@ -16,6 +16,11 @@ import { DropdownMenuTrigger, } from "@workspace/ui/components/dropdown-menu"; import { Input } from "@workspace/ui/components/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@workspace/ui/components/popover"; import { ScrollArea } from "@workspace/ui/components/scroll-area"; import { Select, @@ -31,13 +36,6 @@ import { SheetTitle, SheetTrigger, } from "@workspace/ui/components/sheet"; -import { FilePenLine } from 'lucide-react'; -import { - Popover, - PopoverClose, - PopoverContent, - PopoverTrigger, -} from '@workspace/ui/components/popover'; import { Switch } from "@workspace/ui/components/switch"; import { Tabs, @@ -63,13 +61,16 @@ import { updateUserBasicInfo, } from "@workspace/ui/services/admin/user"; import { parseDeviceType } from "@workspace/ui/utils/device"; -import React, { useRef, useState, useCallback } from 'react'; +import { FilePenLine } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Display } from "@/components/display"; import { useSubscribe } from "@/stores/subscribe"; import { formatDate } from "@/utils/common"; import FamilyManagement from "./family"; +import { RemarkForm } from "./remark-form"; +import { buildUserBasicInfoPayload } from "./user-basic-info-payload"; import { UserDetail } from "./user-detail"; import UserForm from "./user-form"; import { UserInviteStatsSheet } from "./user-invite-stats-sheet"; @@ -77,44 +78,11 @@ import { AuthMethodsForm } from "./user-profile/auth-methods-form"; import { BasicInfoForm } from "./user-profile/basic-info-form"; import { NotifySettingsForm } from "./user-profile/notify-settings-form"; import UserSubscription from "./user-subscription"; + // import EditUserGroupDialog from "./edit-user-group-dialog"; - -// 为 RemarkForm 组件定义 props 类型 -interface RemarkFormProps { - initialRemark?: string | null; - onSave: (remark: string) => void; - CloseComponent: React.ComponentType<{ asChild?: boolean; children: React.ReactNode }>; -} -// 新的子组件,在管理它自己的备注状态 -const RemarkForm: React.FC = ({ onSave, initialRemark, CloseComponent }) => { - const [remark, setRemark] = useState(initialRemark ?? ''); - - const handleInputChange = (event: React.ChangeEvent) => { - setRemark(event.target.value); - }; - - const handleSaveClick = () => { - onSave(remark); - }; - - return ( - <> -
备注
- - - - - - ); +type UserDeviceWithDeviceNo = API.UserDevice & { + device_no?: string; }; export default function User() { @@ -184,7 +152,7 @@ export default function User() { userId={row.id} />, , - /* { const method = row.original.auth_methods?.[0]; const identifier = method?.auth_identifier || ""; const isDevice = method?.auth_type === "device"; - const firstDevice = row.original.user_devices?.[0] as any; + const firstDevice = row.original.user_devices?.[0] as + | UserDeviceWithDeviceNo + | undefined; const deviceNo = firstDevice?.device_no; const deviceType = parseDeviceType(firstDevice?.user_agent || ""); const display = isDevice ? deviceNo || identifier : identifier; return (
- {/* {method?.auth_type} */} {deviceType && ( - - {deviceType} - + + {deviceType} + )} {display} -
- {row.original?.remark ? `(${row.original.remark})` : ''} - +
+ {row.original?.remark ? `(${row.original.remark})` : ""} +
- + { - const { - auth_methods: _auth_methods, - user_devices: _user_devices, - enable_balance_notify: _enable_balance_notify, - enable_login_notify: _enable_login_notify, - enable_subscribe_notify: _enable_subscribe_notify, - enable_trade_notify: _enable_trade_notify, - updated_at: _updated_at, - created_at: _created_at, - id, - ...rest - } = row.original; - await updateUserBasicInfo({ - user_id: id, - ...rest, - remark, - } as unknown as API.UpdateUserBasiceInfoRequest); - toast.success(t('updateSuccess')); - ref.current?.refresh(); - }} + initialRemark={row.original.remark} + onSave={async (remark) => { + const { data } = await getUserDetail({ + id: row.original.id, + }); + const user = data.data; + + if (!user) { + throw new Error("User detail not found"); + } + + await updateUserBasicInfo( + buildUserBasicInfoPayload(user, remark) + ); + toast.success(t("updateSuccess")); + ref.current?.refresh(); + }} /> @@ -355,7 +319,7 @@ export default function User() { ); }, }, - /* { + /* { id: "balance", accessorKey: "balance", header: t("balance", "Balance"), diff --git a/apps/admin/src/sections/user/remark-form.test.tsx b/apps/admin/src/sections/user/remark-form.test.tsx new file mode 100644 index 0000000..c808188 --- /dev/null +++ b/apps/admin/src/sections/user/remark-form.test.tsx @@ -0,0 +1,85 @@ +/** + * @vitest-environment jsdom + */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { Popover } from "@workspace/ui/components/popover"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RemarkForm } from "./remark-form"; + +afterEach(() => { + cleanup(); +}); + +function renderRemarkForm(props: Parameters[0]) { + return render( + + + + ); +} + +describe("RemarkForm", () => { + it("submits an intentionally cleared remark", async () => { + const onSave = vi.fn[0]["onSave"]>(); + + renderRemarkForm({ initialRemark: "old remark", onSave }); + + fireEvent.change(screen.getByPlaceholderText("在此输入备注..."), { + target: { value: "" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("")); + }); + + it("shows loading and keeps the input disabled while saving", async () => { + let resolveSave: (() => void) | undefined; + const onSave = vi.fn( + () => + new Promise((resolve) => { + resolveSave = resolve; + }) + ); + + renderRemarkForm({ initialRemark: "old remark", onSave }); + + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + expect( + screen.getByRole("button", { name: "保存中..." }).hasAttribute("disabled") + ).toBe(true); + expect( + screen.getByPlaceholderText("在此输入备注...").hasAttribute("disabled") + ).toBe(true); + + resolveSave?.(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "保存" }).hasAttribute("disabled") + ).toBe(false) + ); + }); + + it("shows an error state and preserves input after save failure", async () => { + const onSave = vi.fn[0]["onSave"]>(); + onSave.mockRejectedValue(new Error("failed")); + + renderRemarkForm({ initialRemark: "old remark", onSave }); + + fireEvent.change(screen.getByPlaceholderText("在此输入备注..."), { + target: { value: "kept remark" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + expect((await screen.findByRole("alert")).textContent).toBe( + "备注保存失败,请重试" + ); + expect(screen.getByDisplayValue("kept remark")).not.toBeNull(); + }); +}); diff --git a/apps/admin/src/sections/user/remark-form.tsx b/apps/admin/src/sections/user/remark-form.tsx new file mode 100644 index 0000000..b7e0878 --- /dev/null +++ b/apps/admin/src/sections/user/remark-form.tsx @@ -0,0 +1,69 @@ +import { Button } from "@workspace/ui/components/button"; +import { Input } from "@workspace/ui/components/input"; +import { PopoverClose } from "@workspace/ui/components/popover"; +import { useRef, useState } from "react"; + +interface RemarkFormProps { + initialRemark?: string | null; + onSave: (remark: string) => Promise | void; +} + +export function RemarkForm({ onSave, initialRemark }: RemarkFormProps) { + const [remark, setRemark] = useState(initialRemark ?? ""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const closeRef = useRef(null); + + const handleInputChange = (event: React.ChangeEvent) => { + setRemark(event.target.value); + }; + + const handleSave = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSaving(true); + + try { + await onSave(remark); + closeRef.current?.click(); + } catch { + setError("备注保存失败,请重试"); + } finally { + setSaving(false); + } + }; + + return ( +
+
备注
+ + {error ? ( +

+ {error} +

+ ) : null} + + + + +
+ ); +} diff --git a/apps/admin/src/sections/user/user-basic-info-payload.test.ts b/apps/admin/src/sections/user/user-basic-info-payload.test.ts new file mode 100644 index 0000000..cedff8f --- /dev/null +++ b/apps/admin/src/sections/user/user-basic-info-payload.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { buildUserBasicInfoPayload } from "./user-basic-info-payload"; + +const user = { + id: 37, + avatar: "https://example.com/avatar.png", + balance: 1200, + commission: 300, + referral_percentage: 10, + only_first_purchase: true, + gift_amount: 500, + telegram: 123_456, + refer_code: "INVITE37", + referer_id: 7, + enable: true, + is_admin: false, + enable_balance_notify: true, + enable_login_notify: true, + enable_subscribe_notify: false, + enable_trade_notify: false, + user_group_id: 2, + group_locked: false, + auth_methods: [], + user_devices: [], + remark: "old remark", + rules: [], + created_at: 1_700_000_000, + updated_at: 1_700_000_100, +} satisfies API.User; + +describe("buildUserBasicInfoPayload", () => { + it("keeps the full basic user fields when updating a remark", () => { + expect(buildUserBasicInfoPayload(user, "new remark")).toEqual({ + user_id: 37, + avatar: "https://example.com/avatar.png", + balance: 1200, + commission: 300, + referral_percentage: 10, + only_first_purchase: true, + gift_amount: 500, + telegram: 123_456, + refer_code: "INVITE37", + referer_id: 7, + enable: true, + is_admin: false, + remark: "new remark", + }); + }); + + it("preserves an intentionally cleared remark", () => { + expect(buildUserBasicInfoPayload(user, "").remark).toBe(""); + }); +}); diff --git a/apps/admin/src/sections/user/user-basic-info-payload.ts b/apps/admin/src/sections/user/user-basic-info-payload.ts new file mode 100644 index 0000000..d55e15f --- /dev/null +++ b/apps/admin/src/sections/user/user-basic-info-payload.ts @@ -0,0 +1,26 @@ +import type { UpdateUserBasicInfoBody } from "@workspace/ui/services/admin/user"; + +export type UserBasicInfoPayload = Omit & { + remark: string; +}; + +export function buildUserBasicInfoPayload( + user: API.User, + remark: string +): UserBasicInfoPayload { + return { + user_id: user.id, + avatar: user.avatar, + balance: user.balance, + commission: user.commission, + referral_percentage: user.referral_percentage, + only_first_purchase: user.only_first_purchase, + gift_amount: user.gift_amount, + telegram: user.telegram, + refer_code: user.refer_code, + referer_id: user.referer_id, + enable: user.enable, + is_admin: user.is_admin ?? false, + remark, + }; +} diff --git a/packages/ui/src/services/admin/user.ts b/packages/ui/src/services/admin/user.ts index 022796e..855e25c 100644 --- a/packages/ui/src/services/admin/user.ts +++ b/packages/ui/src/services/admin/user.ts @@ -1,6 +1,14 @@ /* eslint-disable */ import request from "@workspace/ui/lib/request"; +export type UpdateUserBasicInfoBody = Omit< + API.UpdateUserBasiceInfoRequest, + "password" +> & { + password?: string; + remark?: string; +}; + /** Create user POST /v1/admin/user/ */ export async function createUser( body: API.CreateUserRequest, @@ -104,7 +112,7 @@ export async function deleteUserAuthMethod( /** Update user basic info PUT /v1/admin/user/basic */ export async function updateUserBasicInfo( - body: API.UpdateUserBasiceInfoRequest, + body: UpdateUserBasicInfoBody, options?: { [key: string]: any } ) { return request(