备注保存前获取完整用户详情构造请求体,TC03 清空备注场景不再丢失其他字段。 拆出 RemarkForm 组件,增加保存中禁用态和失败错误提示。
This commit is contained in:
@@ -16,6 +16,11 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@workspace/ui/components/dropdown-menu";
|
} from "@workspace/ui/components/dropdown-menu";
|
||||||
import { Input } from "@workspace/ui/components/input";
|
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 { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -31,13 +36,6 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@workspace/ui/components/sheet";
|
} 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 { Switch } from "@workspace/ui/components/switch";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -63,13 +61,16 @@ import {
|
|||||||
updateUserBasicInfo,
|
updateUserBasicInfo,
|
||||||
} from "@workspace/ui/services/admin/user";
|
} from "@workspace/ui/services/admin/user";
|
||||||
import { parseDeviceType } from "@workspace/ui/utils/device";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
import { useSubscribe } from "@/stores/subscribe";
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
import { formatDate } from "@/utils/common";
|
import { formatDate } from "@/utils/common";
|
||||||
import FamilyManagement from "./family";
|
import FamilyManagement from "./family";
|
||||||
|
import { RemarkForm } from "./remark-form";
|
||||||
|
import { buildUserBasicInfoPayload } from "./user-basic-info-payload";
|
||||||
import { UserDetail } from "./user-detail";
|
import { UserDetail } from "./user-detail";
|
||||||
import UserForm from "./user-form";
|
import UserForm from "./user-form";
|
||||||
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
|
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 { BasicInfoForm } from "./user-profile/basic-info-form";
|
||||||
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
||||||
import UserSubscription from "./user-subscription";
|
import UserSubscription from "./user-subscription";
|
||||||
|
|
||||||
// import EditUserGroupDialog from "./edit-user-group-dialog";
|
// import EditUserGroupDialog from "./edit-user-group-dialog";
|
||||||
|
|
||||||
|
type UserDeviceWithDeviceNo = API.UserDevice & {
|
||||||
// 为 RemarkForm 组件定义 props 类型
|
device_no?: string;
|
||||||
interface RemarkFormProps {
|
|
||||||
initialRemark?: string | null;
|
|
||||||
onSave: (remark: string) => void;
|
|
||||||
CloseComponent: React.ComponentType<{ asChild?: boolean; children: React.ReactNode }>;
|
|
||||||
}
|
|
||||||
// 新的子组件,在管理它自己的备注状态
|
|
||||||
const RemarkForm: React.FC<RemarkFormProps> = ({ onSave, initialRemark, CloseComponent }) => {
|
|
||||||
const [remark, setRemark] = useState<string>(initialRemark ?? '');
|
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setRemark(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveClick = () => {
|
|
||||||
onSave(remark);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className='mb-2 text-sm font-semibold'>备注</div>
|
|
||||||
<Input
|
|
||||||
type='text'
|
|
||||||
value={remark}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder='在此输入备注...'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
<CloseComponent asChild>
|
|
||||||
<Button onClick={handleSaveClick} variant='default' size={'sm'} className={'mt-2'}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</CloseComponent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function User() {
|
export default function User() {
|
||||||
@@ -184,7 +152,7 @@ export default function User() {
|
|||||||
userId={row.id}
|
userId={row.id}
|
||||||
/>,
|
/>,
|
||||||
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
|
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
|
||||||
/* <ConfirmButton
|
/* <ConfirmButton
|
||||||
cancelText={t("cancel", "Cancel")}
|
cancelText={t("cancel", "Cancel")}
|
||||||
confirmText={t("confirm", "Confirm")}
|
confirmText={t("confirm", "Confirm")}
|
||||||
description={t(
|
description={t(
|
||||||
@@ -293,61 +261,57 @@ export default function User() {
|
|||||||
{
|
{
|
||||||
id: "auth_methods",
|
id: "auth_methods",
|
||||||
accessorKey: "auth_methods",
|
accessorKey: "auth_methods",
|
||||||
header: '设备码/邮箱',
|
header: "设备码/邮箱",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const method = row.original.auth_methods?.[0];
|
const method = row.original.auth_methods?.[0];
|
||||||
const identifier = method?.auth_identifier || "";
|
const identifier = method?.auth_identifier || "";
|
||||||
const isDevice = method?.auth_type === "device";
|
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 deviceNo = firstDevice?.device_no;
|
||||||
const deviceType = parseDeviceType(firstDevice?.user_agent || "");
|
const deviceType = parseDeviceType(firstDevice?.user_agent || "");
|
||||||
const display = isDevice ? deviceNo || identifier : identifier;
|
const display = isDevice ? deviceNo || identifier : identifier;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* <Badge
|
{/* <Badge
|
||||||
className="mr-1 uppercase"
|
className="mr-1 uppercase"
|
||||||
title={method?.verified ? t("verified", "Verified") : ""}
|
title={method?.verified ? t("verified", "Verified") : ""}
|
||||||
>
|
>
|
||||||
{method?.auth_type}
|
{method?.auth_type}
|
||||||
</Badge>*/}
|
</Badge>*/}
|
||||||
{deviceType && (
|
{deviceType && (
|
||||||
<Badge className="mr-1" variant="secondary">
|
<Badge className="mr-1" variant="secondary">
|
||||||
{deviceType}
|
{deviceType}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span title={isDevice ? display : undefined}>{display}</span>
|
<span title={isDevice ? display : undefined}>{display}</span>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<div className={'flex items-center'}>
|
<div className={"flex items-center"}>
|
||||||
{row.original?.remark ? `(${row.original.remark})` : ''}
|
{row.original?.remark ? `(${row.original.remark})` : ""}
|
||||||
<FilePenLine size={14} className={'text-primary ml-2'} />
|
<FilePenLine className={"ml-2 text-primary"} size={14} />
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className={'w-64'}>
|
<PopoverContent className={"w-64"}>
|
||||||
<RemarkForm
|
<RemarkForm
|
||||||
initialRemark={row.original.remark}
|
initialRemark={row.original.remark}
|
||||||
CloseComponent={PopoverClose}
|
onSave={async (remark) => {
|
||||||
onSave={async (remark) => {
|
const { data } = await getUserDetail({
|
||||||
const {
|
id: row.original.id,
|
||||||
auth_methods: _auth_methods,
|
});
|
||||||
user_devices: _user_devices,
|
const user = data.data;
|
||||||
enable_balance_notify: _enable_balance_notify,
|
|
||||||
enable_login_notify: _enable_login_notify,
|
if (!user) {
|
||||||
enable_subscribe_notify: _enable_subscribe_notify,
|
throw new Error("User detail not found");
|
||||||
enable_trade_notify: _enable_trade_notify,
|
}
|
||||||
updated_at: _updated_at,
|
|
||||||
created_at: _created_at,
|
await updateUserBasicInfo(
|
||||||
id,
|
buildUserBasicInfoPayload(user, remark)
|
||||||
...rest
|
);
|
||||||
} = row.original;
|
toast.success(t("updateSuccess"));
|
||||||
await updateUserBasicInfo({
|
ref.current?.refresh();
|
||||||
user_id: id,
|
}}
|
||||||
...rest,
|
|
||||||
remark,
|
|
||||||
} as unknown as API.UpdateUserBasiceInfoRequest);
|
|
||||||
toast.success(t('updateSuccess'));
|
|
||||||
ref.current?.refresh();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -355,7 +319,7 @@ export default function User() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/* {
|
/* {
|
||||||
id: "balance",
|
id: "balance",
|
||||||
accessorKey: "balance",
|
accessorKey: "balance",
|
||||||
header: t("balance", "Balance"),
|
header: t("balance", "Balance"),
|
||||||
|
|||||||
@@ -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<typeof RemarkForm>[0]) {
|
||||||
|
return render(
|
||||||
|
<Popover open>
|
||||||
|
<RemarkForm {...props} />
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RemarkForm", () => {
|
||||||
|
it("submits an intentionally cleared remark", async () => {
|
||||||
|
const onSave = vi.fn<Parameters<typeof RemarkForm>[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<void>((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<Parameters<typeof RemarkForm>[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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemarkForm({ onSave, initialRemark }: RemarkFormProps) {
|
||||||
|
const [remark, setRemark] = useState<string>(initialRemark ?? "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setRemark(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(remark);
|
||||||
|
closeRef.current?.click();
|
||||||
|
} catch {
|
||||||
|
setError("备注保存失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<div className="mb-2 font-semibold text-sm">备注</div>
|
||||||
|
<Input
|
||||||
|
aria-invalid={Boolean(error)}
|
||||||
|
className="w-full"
|
||||||
|
disabled={saving}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="在此输入备注..."
|
||||||
|
type="text"
|
||||||
|
value={remark}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-2 text-destructive text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
className="mt-2"
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
<PopoverClose asChild>
|
||||||
|
<button className="sr-only" ref={closeRef} type="button">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</PopoverClose>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { UpdateUserBasicInfoBody } from "@workspace/ui/services/admin/user";
|
||||||
|
|
||||||
|
export type UserBasicInfoPayload = Omit<UpdateUserBasicInfoBody, "remark"> & {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
export type UpdateUserBasicInfoBody = Omit<
|
||||||
|
API.UpdateUserBasiceInfoRequest,
|
||||||
|
"password"
|
||||||
|
> & {
|
||||||
|
password?: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Create user POST /v1/admin/user/ */
|
/** Create user POST /v1/admin/user/ */
|
||||||
export async function createUser(
|
export async function createUser(
|
||||||
body: API.CreateUserRequest,
|
body: API.CreateUserRequest,
|
||||||
@@ -104,7 +112,7 @@ export async function deleteUserAuthMethod(
|
|||||||
|
|
||||||
/** Update user basic info PUT /v1/admin/user/basic */
|
/** Update user basic info PUT /v1/admin/user/basic */
|
||||||
export async function updateUserBasicInfo(
|
export async function updateUserBasicInfo(
|
||||||
body: API.UpdateUserBasiceInfoRequest,
|
body: UpdateUserBasicInfoBody,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
) {
|
) {
|
||||||
return request<API.Response & { data?: any }>(
|
return request<API.Response & { data?: any }>(
|
||||||
|
|||||||
Reference in New Issue
Block a user