Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 b533ac2732 fix: set admin api base url 2026-05-25 21:45:40 -07:00
8 changed files with 81 additions and 286 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=https://tapi.hifast.biz
+79 -43
View File
@@ -16,11 +16,6 @@ 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,
@@ -36,6 +31,13 @@ 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,
@@ -61,16 +63,13 @@ 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 { FilePenLine } from "lucide-react"; import React, { useRef, useState, useCallback } from '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";
@@ -78,11 +77,44 @@ 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 & {
device_no?: string; // 为 RemarkForm 组件定义 props 类型
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() {
@@ -152,7 +184,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(
@@ -261,57 +293,61 @@ 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 const firstDevice = row.original.user_devices?.[0] as any;
| 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 className={"ml-2 text-primary"} size={14} /> <FilePenLine size={14} className={'text-primary ml-2'} />
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className={"w-64"}> <PopoverContent className={'w-64'}>
<RemarkForm <RemarkForm
initialRemark={row.original.remark} initialRemark={row.original.remark}
onSave={async (remark) => { CloseComponent={PopoverClose}
const { data } = await getUserDetail({ onSave={async (remark) => {
id: row.original.id, const {
}); auth_methods: _auth_methods,
const user = data.data; user_devices: _user_devices,
enable_balance_notify: _enable_balance_notify,
if (!user) { enable_login_notify: _enable_login_notify,
throw new Error("User detail not found"); enable_subscribe_notify: _enable_subscribe_notify,
} enable_trade_notify: _enable_trade_notify,
updated_at: _updated_at,
await updateUserBasicInfo( created_at: _created_at,
buildUserBasicInfoPayload(user, remark) id,
); ...rest
toast.success(t("updateSuccess")); } = row.original;
ref.current?.refresh(); await updateUserBasicInfo({
}} user_id: id,
...rest,
remark,
} as unknown as API.UpdateUserBasiceInfoRequest);
toast.success(t('updateSuccess'));
ref.current?.refresh();
}}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -319,7 +355,7 @@ export default function User() {
); );
}, },
}, },
/* { /* {
id: "balance", id: "balance",
accessorKey: "balance", accessorKey: "balance",
header: t("balance", "Balance"), header: t("balance", "Balance"),
@@ -1,85 +0,0 @@
/**
* @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();
});
});
@@ -1,69 +0,0 @@
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>
);
}
@@ -1,53 +0,0 @@
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("");
});
});
@@ -1,26 +0,0 @@
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
View File
@@ -13,7 +13,6 @@
[context.admin.environment] [context.admin.environment]
NODE_VERSION = "20" NODE_VERSION = "20"
VITE_API_BASE_URL = "https://tapi.hifast.biz"
# User 用户前台应用 # User 用户前台应用
[context.user] [context.user]
+1 -9
View File
@@ -1,14 +1,6 @@
/* 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,
@@ -112,7 +104,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: UpdateUserBasicInfoBody, body: API.UpdateUserBasiceInfoRequest,
options?: { [key: string]: any } options?: { [key: string]: any }
) { ) {
return request<API.Response & { data?: any }>( return request<API.Response & { data?: any }>(