🎉 feat: initialization
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@workspace/ui/components/radio-group";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string(),
|
||||
type: z.enum(["image", "video"]),
|
||||
content: z.string(),
|
||||
description: z.string(),
|
||||
target_url: z.string().url(),
|
||||
start_time: z.number(),
|
||||
end_time: z.number(),
|
||||
});
|
||||
|
||||
interface AdsFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function AdsForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: AdsFormProps<T>) {
|
||||
const { t } = useTranslation("ads");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
} as any,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
const type = form.watch("type");
|
||||
const startTime = form.watch("start_time");
|
||||
|
||||
const renderContentField = () => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.content", "Content")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue("content", value);
|
||||
}}
|
||||
placeholder={
|
||||
type === "image"
|
||||
? "https://example.com/image.jpg"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4 px-6 pt-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.title", "Title")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t("form.enterTitle", "Enter title")}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.type", "Type")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
className="flex gap-4"
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="image" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{t("form.typeImage", "Image")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="video" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{t("form.typeVideo", "Video")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{renderContentField()}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.description", "Description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"form.enterDescription",
|
||||
"Enter description"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.targetUrl", "Target URL")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"form.enterTargetUrl",
|
||||
"Enter target URL"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="start_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.startTime", "Start Time")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={Number(new Date().toISOString().slice(0, 16))}
|
||||
onValueChange={(value) => {
|
||||
const timestamp = value
|
||||
? new Date(value).getTime()
|
||||
: 0;
|
||||
form.setValue(field.name, timestamp);
|
||||
const endTime = form.getValues("end_time");
|
||||
if (endTime && timestamp > endTime) {
|
||||
form.setValue("end_time", "");
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"form.enterStartTime",
|
||||
"Select start time"
|
||||
)}
|
||||
step="1"
|
||||
type="datetime-local"
|
||||
value={
|
||||
field.value
|
||||
? new Date(field.value).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="end_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.endTime", "End Time")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={!startTime}
|
||||
min={Number(
|
||||
startTime
|
||||
? new Date(startTime).toISOString().slice(0, 16)
|
||||
: new Date().toISOString().slice(0, 16)
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
const timestamp = value
|
||||
? new Date(value).getTime()
|
||||
: 0;
|
||||
if (!startTime || timestamp < startTime) return;
|
||||
form.setValue(field.name, timestamp);
|
||||
}}
|
||||
placeholder={t("form.enterEndTime", "Select end time")}
|
||||
step="1"
|
||||
type="datetime-local"
|
||||
value={
|
||||
field.value
|
||||
? new Date(field.value).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("form.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("form.confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createAds,
|
||||
deleteAds,
|
||||
getAdsList,
|
||||
updateAds,
|
||||
} from "@workspace/ui/services/admin/ads";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import AdsForm from "./ads-form";
|
||||
|
||||
export default function Ads() {
|
||||
const { t } = useTranslation("ads");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
return (
|
||||
<ProTable<API.Ads, Record<string, unknown>>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<AdsForm<API.UpdateAdsRequest>
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAds({ ...row, ...values });
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
title={t("editAds", "Edit Ad")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Are you sure you want to delete this ad? This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteAds({ id: row.id });
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status", "Status"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("status") === 1}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateAds({
|
||||
...row.original,
|
||||
status: checked ? 1 : 0,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: t("title", "Title"),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("type", "Type"),
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
return <Badge>{type}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "target_url",
|
||||
header: t("targetUrl", "Target URL"),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: t("form.description", "Description"),
|
||||
},
|
||||
{
|
||||
accessorKey: "period",
|
||||
header: t("validityPeriod", "Validity Period"),
|
||||
cell: ({ row }) => {
|
||||
const { start_time, end_time } = row.original;
|
||||
return (
|
||||
<>
|
||||
{formatDate(start_time)} - {formatDate(end_time)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
toolbar: (
|
||||
<AdsForm<API.CreateAdsRequest>
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createAds({
|
||||
...values,
|
||||
status: 0,
|
||||
});
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
title={t("createAds", "Create Ad")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "status",
|
||||
placeholder: t("status", "Status"),
|
||||
options: [
|
||||
{ label: t("enabled", "Enabled"), value: "1" },
|
||||
{ label: t("disabled", "Disabled"), value: "0" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getAdsList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createAnnouncement,
|
||||
deleteAnnouncement,
|
||||
getAnnouncementList,
|
||||
updateAnnouncement,
|
||||
} from "@workspace/ui/services/admin/announcement";
|
||||
import { format } from "date-fns";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import NoticeForm from "./notice-form";
|
||||
|
||||
export default function Page() {
|
||||
const { t } = useTranslation("announcement");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
return (
|
||||
<ProTable<API.Announcement, { enable: boolean; search: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render(row) {
|
||||
return [
|
||||
<NoticeForm<API.Announcement>
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAnnouncement({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("editAnnouncement", "Edit Announcement")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteDescription",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteAnnouncement({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteDescription",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
for (const element of rows) {
|
||||
await deleteAnnouncement({
|
||||
id: element.id!,
|
||||
});
|
||||
}
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "show",
|
||||
header: t("show", "Show"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("show")}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateAnnouncement({
|
||||
...row.original,
|
||||
show: checked,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "pinned",
|
||||
header: t("pinned", "Pinned"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("pinned")}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateAnnouncement({
|
||||
...row.original,
|
||||
pinned: checked,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "popup",
|
||||
header: t("popup", "Popup"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("popup")}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateAnnouncement({
|
||||
...row.original,
|
||||
popup: checked,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: t("title", "Title"),
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: t("content", "Content"),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: t("updatedAt", "Updated At"),
|
||||
cell: ({ row }) =>
|
||||
format(row.getValue("updated_at"), "yyyy-MM-dd HH:mm:ss"),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("announcementList", "Announcement List"),
|
||||
toolbar: (
|
||||
<NoticeForm<API.CreateAnnouncementRequest>
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createAnnouncement(values);
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createAnnouncement", "Create Announcement")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "enable",
|
||||
placeholder: t("enable", "Enable"),
|
||||
options: [
|
||||
{ label: t("show", "Show"), value: "false" },
|
||||
{ label: t("hide", "Hide"), value: "true" },
|
||||
],
|
||||
},
|
||||
{ key: "search" },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getAnnouncementList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string(),
|
||||
content: z.string().optional(),
|
||||
});
|
||||
|
||||
interface AnnouncementFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function AnnouncementForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: AnnouncementFormProps<T>) {
|
||||
const { t } = useTranslation("announcement");
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
content: "",
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[800px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4 pt-4"
|
||||
id="notice-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.title", "Title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("form.titlePlaceholder", "Enter title")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.content", "Content")}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value || "");
|
||||
}}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("form.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}{" "}
|
||||
{t("form.confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const appleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
team_id: z.string().optional(),
|
||||
key_id: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
redirect_url: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type AppleFormData = z.infer<typeof appleSchema>;
|
||||
|
||||
export default function AppleForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "apple"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "apple",
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<AppleFormData>({
|
||||
resolver: zodResolver(appleSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
config: {
|
||||
team_id: "",
|
||||
key_id: "",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
redirect_url: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
config: {
|
||||
team_id: data.config?.team_id || "",
|
||||
key_id: data.config?.key_id || "",
|
||||
client_id: data.config?.client_id || "",
|
||||
client_secret: data.config?.client_secret || "",
|
||||
redirect_url: data.config?.redirect_url || "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: AppleFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
...values.config,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:apple" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t("apple.title", "Apple Sign-In")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"apple.description",
|
||||
"Authenticate users with Apple accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("apple.title", "Apple Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="apple-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.enableDescription",
|
||||
"When enabled, users can sign in with their Apple ID"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.team_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.teamId", "Team ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="ABCDE1FGHI"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("apple.teamIdDescription", "Apple Developer Team ID")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.key_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.keyId", "Key ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="ABC1234567"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.keyIdDescription",
|
||||
"Your private key ID from Apple Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.clientId", "Service ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="com.your.app.service"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.clientIdDescription",
|
||||
"Apple Service ID, available from Apple Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("apple.clientSecret", "Private Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-20"
|
||||
onChange={field.onChange}
|
||||
placeholder={
|
||||
"-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----"
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.clientSecretDescription",
|
||||
"Private key content (.p8 file) for authenticating with Apple"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.redirect_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("apple.redirectUri", "Redirect URL")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="https://your-domain.com"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.redirectUriDescription",
|
||||
"API address for redirect URL after successful Apple authentication. Do not end with /"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="apple-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { uid } from "radash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deviceSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string(),
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
show_ads: z.boolean().optional(),
|
||||
only_real_device: z.boolean().optional(),
|
||||
enable_security: z.boolean().optional(),
|
||||
security_secret: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type DeviceFormData = z.infer<typeof deviceSchema>;
|
||||
|
||||
export default function DeviceForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "device"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "device",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<DeviceFormData>({
|
||||
resolver: zodResolver(deviceSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "device",
|
||||
enabled: false,
|
||||
config: {
|
||||
show_ads: false,
|
||||
only_real_device: false,
|
||||
enable_security: false,
|
||||
security_secret: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: DeviceFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function generateSecurityKey() {
|
||||
const id = uid(32).toLowerCase();
|
||||
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
||||
form.setValue("config.security_secret", formatted);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:devices" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("device.title", "Device Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("device.description", "Authenticate users with device")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("device.title", "Device Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="device-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("device.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.enableDescription",
|
||||
"When enabled, users can sign in with device"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.show_ads"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("device.showAds", "Show Ads")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.showAdsDescription",
|
||||
"When enabled, ads will be shown"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.only_real_device"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("device.blockVirtualMachine", "Block Virtual Machine")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.blockVirtualMachineDescription",
|
||||
"Block virtual machine login, only allow real device"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_security"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("device.enableSecurity", "Enable Security")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.enableSecurityDescription",
|
||||
"When enabled, application requests must carry communication key"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.security_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("device.communicationKey", "Communication Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="e.g., 12345678-1234-1234-1234-123456789abc"
|
||||
suffix={
|
||||
<div className="flex h-9 items-center text-nowrap bg-muted px-3">
|
||||
<Icon
|
||||
className="size-4 cursor-pointer"
|
||||
icon="mdi:dice-multiple"
|
||||
onClick={generateSecurityKey}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.communicationKeyDescription",
|
||||
"The key used for secure communication between application and server"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="device-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { HTMLEditor } from "@workspace/ui/composed/editor/html";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
testEmailSend,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const emailSettingsSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string(),
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
enable_verify: z.boolean(),
|
||||
enable_domain_suffix: z.boolean(),
|
||||
domain_suffix_list: z.string().optional(),
|
||||
verify_email_template: z.string().optional(),
|
||||
expiration_email_template: z.string().optional(),
|
||||
maintenance_email_template: z.string().optional(),
|
||||
traffic_exceed_email_template: z.string().optional(),
|
||||
platform: z.string(),
|
||||
platform_config: z
|
||||
.object({
|
||||
host: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
ssl: z.boolean(),
|
||||
user: z.string().optional(),
|
||||
pass: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
|
||||
|
||||
export default function EmailSettingsForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState<string>();
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "email"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "email",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<EmailSettingsFormData>({
|
||||
resolver: zodResolver(emailSettingsSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "email",
|
||||
enabled: false,
|
||||
config: {
|
||||
enable_verify: false,
|
||||
enable_domain_suffix: false,
|
||||
domain_suffix_list: "",
|
||||
verify_email_template: "",
|
||||
expiration_email_template: "",
|
||||
maintenance_email_template: "",
|
||||
traffic_exceed_email_template: "",
|
||||
platform: "smtp",
|
||||
platform_config: {
|
||||
host: "",
|
||||
port: 587,
|
||||
ssl: false,
|
||||
user: "",
|
||||
pass: "",
|
||||
from: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: EmailSettingsFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...values,
|
||||
config: {
|
||||
...values.config,
|
||||
platform: "smtp",
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:email-outline" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("email.title", "Email Settings")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.description",
|
||||
"Configure email authentication and templates"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="md:!max-w-screen-lg max-w-full">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("email.title", "Email Settings")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="email-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Tabs className="space-y-2" defaultValue="basic">
|
||||
<TabsList className="flex h-full w-full flex-wrap *:flex-auto md:flex-nowrap">
|
||||
<TabsTrigger value="basic">
|
||||
{t("email.basicSettings", "Basic Settings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="smtp">
|
||||
{t("email.smtpSettings", "SMTP Settings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="verify">
|
||||
{t("email.verifyTemplate", "Verify Template")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="expiration">
|
||||
{t("email.expirationTemplate", "Expiration Template")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="maintenance">
|
||||
{t("email.maintenanceTemplate", "Maintenance Template")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="traffic">
|
||||
{t("email.trafficTemplate", "Traffic Template")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent className="space-y-2" value="basic">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.enableDescription",
|
||||
"When enabled, users can sign in with email"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_verify"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.emailVerification", "Email Verification")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.emailVerificationDescription",
|
||||
"Require email verification for new users"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_domain_suffix"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.emailSuffixWhitelist",
|
||||
"Email Suffix Whitelist"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.emailSuffixWhitelistDescription",
|
||||
"Only allow emails from whitelisted domains"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.domain_suffix_list"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.whitelistSuffixes", "Whitelist Suffixes")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
onChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.whitelistSuffixesPlaceholder",
|
||||
"gmail.com, outlook.com"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.whitelistSuffixesDescription",
|
||||
"One domain suffix per line"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="smtp">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpServerAddress", "SMTP Server Address")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpServerAddressDescription",
|
||||
"The SMTP server hostname"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpServerPort", "SMTP Server Port")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder="587"
|
||||
type="number"
|
||||
value={field.value?.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpServerPortDescription",
|
||||
"The SMTP server port (usually 25, 465, or 587)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.smtpEncryptionMethod",
|
||||
"SSL/TLS Encryption"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpEncryptionMethodDescription",
|
||||
"Enable SSL/TLS encryption for SMTP connection"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpAccount", "SMTP Account")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpAccountDescription",
|
||||
"The SMTP authentication username"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.pass"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpPassword", "SMTP Password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpPasswordDescription",
|
||||
"The SMTP authentication password"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.from"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.senderAddress", "Sender Address")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.senderAddressDescription",
|
||||
"The email address that appears in the From field"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<FormLabel>
|
||||
{t("email.sendTestEmail", "Send Test Email")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => setTestEmail(value as string)}
|
||||
placeholder="test@example.com"
|
||||
type="email"
|
||||
value={testEmail}
|
||||
/>
|
||||
<Button
|
||||
disabled={!testEmail || isFetching}
|
||||
onClick={async () => {
|
||||
if (!testEmail) return;
|
||||
try {
|
||||
await testEmailSend({ email: testEmail });
|
||||
toast.success(
|
||||
t("email.sendSuccess", "Email sent successfully")
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
t("email.sendFailure", "Email send failed")
|
||||
);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("email.sendTestEmail", "Send Test Email")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"email.sendTestEmailDescription",
|
||||
"Send a test email to verify your SMTP configuration"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="verify">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.verify_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.verifyEmailTemplate",
|
||||
"Verify Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.Type}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.type.description",
|
||||
"Email type (1: Register, 2: Reset Password)"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-6 text-orange-600 dark:text-orange-400">
|
||||
💡{" "}
|
||||
{t(
|
||||
"email.templateVariables.type.conditionalSyntax",
|
||||
"Use conditional syntax to display different content"
|
||||
)}
|
||||
<br />
|
||||
<code className="rounded bg-orange-50 px-1 text-xs dark:bg-orange-900/20">
|
||||
{"{{if eq .Type 1}}...{{else}}...{{end}}"}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.Expire}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.expire.description",
|
||||
"Code expiration time"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.Code}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.code.description",
|
||||
"Verification code"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="expiration">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.expiration_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.expirationEmailTemplate",
|
||||
"Expiration Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.ExpireDate}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.expireDate.description",
|
||||
"Subscription expiration date"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="maintenance">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.maintenance_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.maintenanceEmailTemplate",
|
||||
"Maintenance Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.MaintenanceDate}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.maintenanceDate.description",
|
||||
"Maintenance date"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.MaintenanceTime}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.maintenanceTime.description",
|
||||
"Maintenance time"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="traffic">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.traffic_exceed_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.trafficExceedEmailTemplate",
|
||||
"Traffic Exceed Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="email-settings-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const facebookSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
type FacebookFormData = z.infer<typeof facebookSchema>;
|
||||
|
||||
export default function FacebookForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "facebook"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "facebook",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<FacebookFormData>({
|
||||
resolver: zodResolver(facebookSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
client_id: data.config?.client_id || "",
|
||||
client_secret: data.config?.client_secret || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: FacebookFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
client_id: values.client_id,
|
||||
client_secret: values.client_secret,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:facebook" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("facebook.title", "Facebook Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"facebook.description",
|
||||
"Authenticate users with Facebook accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("facebook.title", "Facebook Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="facebook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("facebook.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"facebook.enableDescription",
|
||||
"When enabled, users can sign in with their Facebook account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("facebook.clientId", "App ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="1234567890123456"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"facebook.clientIdDescription",
|
||||
"Facebook App ID, available from Facebook Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("facebook.clientSecret", "App Secret")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="1234567890abcdef1234567890abcdef"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"facebook.clientSecretDescription",
|
||||
"Facebook App Secret, available from Facebook Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="facebook-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const githubSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
type GithubFormData = z.infer<typeof githubSchema>;
|
||||
|
||||
export default function GithubForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "github"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "github",
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<GithubFormData>({
|
||||
resolver: zodResolver(githubSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
client_id: data.config?.client_id || "",
|
||||
client_secret: data.config?.client_secret || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: GithubFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
client_id: values.client_id,
|
||||
client_secret: values.client_secret,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:github" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("github.title", "GitHub Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"github.description",
|
||||
"Authenticate users with GitHub accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("github.title", "GitHub Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="github-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("github.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"github.enableDescription",
|
||||
"When enabled, users can sign in with their GitHub account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("github.clientId", "Client ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="e.g., Iv1.1234567890abcdef"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"github.clientIdDescription",
|
||||
"GitHub OAuth App Client ID, available from GitHub Developer Settings"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("github.clientSecret", "Client Secret")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="e.g., 1234567890abcdef1234567890abcdef12345678"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"github.clientSecretDescription",
|
||||
"GitHub OAuth App Client Secret, available from GitHub Developer Settings"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="github-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const googleSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string().default("google").optional(),
|
||||
enabled: z.boolean().default(false).optional(),
|
||||
config: z
|
||||
.object({
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type GoogleFormData = z.infer<typeof googleSchema>;
|
||||
|
||||
export default function GoogleForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "google"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "google",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<GoogleFormData>({
|
||||
resolver: zodResolver(googleSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "google",
|
||||
enabled: false,
|
||||
config: {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: GoogleFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:google" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("google.title", "Google Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"google.description",
|
||||
"Authenticate users with Google accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("google.title", "Google Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="google-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("google.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"google.enableDescription",
|
||||
"When enabled, users can sign in with their Google account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("google.clientId", "Client ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="123456789-abc123def456.apps.googleusercontent.com"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"google.clientIdDescription",
|
||||
"Google OAuth Client ID, available from Google Cloud Console"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("google.clientSecret", "Client Secret")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"google.clientSecretDescription",
|
||||
"Google OAuth Client Secret, available from Google Cloud Console"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="google-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import TagInput from "@workspace/ui/composed/tag-input";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
getSmsPlatform,
|
||||
testSmsSend,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const phoneSettingsSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string(),
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
enable_whitelist: z.boolean().optional(),
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
platform: z.string().optional(),
|
||||
platform_config: z
|
||||
.object({
|
||||
access: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
template_code: z.string().optional(),
|
||||
sign_name: z.string().optional(),
|
||||
phone_number: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type PhoneSettingsFormData = z.infer<typeof phoneSettingsSchema>;
|
||||
|
||||
export default function PhoneSettingsForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testParams, setTestParams] = useState<API.TestSmsSendRequest>({
|
||||
telephone: "",
|
||||
area_code: "1",
|
||||
});
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "mobile"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "mobile",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: platforms } = useQuery({
|
||||
queryKey: ["getSmsPlatform"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSmsPlatform();
|
||||
return data.data?.list;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<PhoneSettingsFormData>({
|
||||
resolver: zodResolver(phoneSettingsSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "mobile",
|
||||
enabled: false,
|
||||
config: {
|
||||
enable_whitelist: false,
|
||||
whitelist: [],
|
||||
platform: "",
|
||||
platform_config: {
|
||||
access: "",
|
||||
endpoint: "",
|
||||
secret: "",
|
||||
template_code: "code",
|
||||
sign_name: "",
|
||||
phone_number: "",
|
||||
template: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPlatform = platforms?.find(
|
||||
(platform) => platform.platform === form.watch("config.platform")
|
||||
);
|
||||
const { platform_url, platform_field_description: platformConfig } =
|
||||
selectedPlatform ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: PhoneSettingsFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:phone-settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t("phone.title", "SMS Settings")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("phone.description", "Configure SMS authentication")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("phone.title", "SMS Settings")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="phone-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("phone.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
disabled={isFetching}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.enableTip",
|
||||
"When enabled, users can sign in with their phone number"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.whitelistValidation", "Whitelist Validation")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.whitelistValidationTip",
|
||||
"Only allow phone numbers with whitelisted area codes"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.whitelistAreaCode", "Whitelist Area Codes")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
onChange={field.onChange}
|
||||
placeholder="1, 852, 886, 888"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.whitelistAreaCodeTip",
|
||||
"Enter area codes separated by commas"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("phone.platform", "SMS Platform")}</FormLabel>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms?.map((item) => (
|
||||
<SelectItem
|
||||
key={item.platform}
|
||||
value={item.platform}
|
||||
>
|
||||
{item.platform}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{platform_url && (
|
||||
<Button asChild size="sm">
|
||||
<Link target="_blank" to={platform_url}>
|
||||
{t("phone.applyPlatform", "Apply")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("phone.platformTip", "Select SMS service provider")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.access"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.accessLabel", "Access Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.access,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.access,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{platformConfig?.endpoint && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.endpointLabel", "Endpoint")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.endpoint,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.endpoint,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.secretLabel", "Secret Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.secret,
|
||||
}
|
||||
)}
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.secret,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{platformConfig?.template_code && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.template_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.templateCodeLabel", "Template Code")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.template_code,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.template_code,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.sign_name && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.sign_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.signNameLabel", "Sign Name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.sign_name,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.sign_name,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.phone_number && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.phone_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.phoneNumberLabel", "Phone Number")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.phone_number,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.phone_number,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.code_variable && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("phone.template", "Template")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
disabled={isFetching}
|
||||
onChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.placeholders.template",
|
||||
"Use {{code}} for verification code",
|
||||
{
|
||||
code: platformConfig?.code_variable,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.templateTip",
|
||||
"Use {{code}} variable for the verification code",
|
||||
{
|
||||
code: platformConfig?.code_variable,
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div>
|
||||
<FormLabel>{t("phone.testSms", "Test SMS")}</FormLabel>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
{t(
|
||||
"phone.testSmsTip",
|
||||
"Send a test SMS to verify configuration"
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AreaCodeSelect
|
||||
onChange={(value) => {
|
||||
if (value.phone) {
|
||||
setTestParams((prev) => ({
|
||||
...prev,
|
||||
area_code: value.phone!,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={testParams.area_code}
|
||||
/>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
setTestParams((prev) => ({
|
||||
...prev,
|
||||
telephone: value as string,
|
||||
}));
|
||||
}}
|
||||
placeholder={t("phone.testSmsPhone", "Phone number")}
|
||||
value={testParams.telephone}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
!(testParams.telephone && testParams.area_code) ||
|
||||
isFetching
|
||||
}
|
||||
onClick={async () => {
|
||||
if (
|
||||
isFetching ||
|
||||
!testParams.telephone ||
|
||||
!testParams.area_code
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await testSmsSend(testParams);
|
||||
toast.success(
|
||||
t("phone.sendSuccess", "SMS sent successfully")
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("phone.sendFailed", "SMS send failed"));
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("phone.testSms", "Test SMS")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="phone-settings-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const telegramSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
bot: z.string().optional(),
|
||||
bot_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type TelegramFormData = z.infer<typeof telegramSchema>;
|
||||
|
||||
export default function TelegramForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "telegram"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "telegram",
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<TelegramFormData>({
|
||||
resolver: zodResolver(telegramSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
bot: "",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
bot: data.config?.bot || "",
|
||||
bot_token: data.config?.bot_token || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: TelegramFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
bot: values.bot,
|
||||
bot_token: values.bot_token,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:telegram" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("telegram.title", "Telegram Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"telegram.description",
|
||||
"Authenticate users with Telegram accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("telegram.title", "Telegram Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="telegram-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("telegram.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"telegram.enableDescription",
|
||||
"When enabled, users can sign in with their Telegram account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("telegram.clientId", "Bot ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="6123456789"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"telegram.clientIdDescription",
|
||||
"Telegram Bot ID, available from @BotFather"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("telegram.clientSecret", "Bot Token")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"telegram.clientSecretDescription",
|
||||
"Telegram Bot Token, available from @BotFather"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="telegram-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@workspace/ui/components/table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AppleForm from "./forms/apple-form";
|
||||
import DeviceForm from "./forms/device-form";
|
||||
import EmailSettingsForm from "./forms/email-settings-form";
|
||||
import FacebookForm from "./forms/facebook-form";
|
||||
import GithubForm from "./forms/github-form";
|
||||
import GoogleForm from "./forms/google-form";
|
||||
import PhoneSettingsForm from "./forms/phone-settings-form";
|
||||
import TelegramForm from "./forms/telegram-form";
|
||||
|
||||
export default function AuthControl() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
|
||||
const formSections = [
|
||||
{
|
||||
title: t("communicationMethods", "Communication Methods"),
|
||||
forms: [
|
||||
{ component: EmailSettingsForm },
|
||||
{ component: PhoneSettingsForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("socialAuthMethods", "Social Authentication Methods"),
|
||||
forms: [
|
||||
{ component: AppleForm },
|
||||
{ component: GoogleForm },
|
||||
{ component: FacebookForm },
|
||||
{ component: GithubForm },
|
||||
{ component: TelegramForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("deviceAuthMethods", "Device Authentication Methods"),
|
||||
forms: [{ component: DeviceForm }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{formSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<h2 className="mb-4 font-semibold text-lg">{section.title}</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{section.forms.map((form, formIndex) => {
|
||||
const FormComponent = form.component;
|
||||
return (
|
||||
<TableRow key={formIndex}>
|
||||
<TableCell>
|
||||
<FormComponent />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
resetPassword,
|
||||
userLogin,
|
||||
userRegister,
|
||||
} from "@workspace/ui/services/common/auth";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { USER_EMAIL, USER_PASSWORD } from "@/config";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { getRedirectUrl, setAuthorization } from "@/utils/common";
|
||||
import LoginForm from "./login-form";
|
||||
import RegisterForm from "./register-form";
|
||||
import ResetForm from "./reset-form";
|
||||
|
||||
export default function EmailAuthForm() {
|
||||
const { t } = useTranslation("auth");
|
||||
const navigate = useNavigate();
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [type, setType] = useState<"login" | "register" | "reset">("login");
|
||||
const [loading, startTransition] = useTransition();
|
||||
const [initialValues, setInitialValues] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({
|
||||
email: USER_EMAIL,
|
||||
password: USER_PASSWORD,
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (params: any) => {
|
||||
const onLogin = async (token?: string) => {
|
||||
if (!token) return;
|
||||
setAuthorization(token);
|
||||
await getUserInfo();
|
||||
navigate({ to: getRedirectUrl() });
|
||||
};
|
||||
startTransition(async () => {
|
||||
try {
|
||||
switch (type) {
|
||||
case "login": {
|
||||
const login = await userLogin(params);
|
||||
toast.success(t("login.success", "Login successful!"));
|
||||
onLogin(login.data.data?.token);
|
||||
break;
|
||||
}
|
||||
case "register": {
|
||||
const create = await userRegister(params);
|
||||
toast.success(t("register.success", "Registration successful!"));
|
||||
onLogin(create.data.data?.token);
|
||||
break;
|
||||
}
|
||||
case "reset":
|
||||
await resetPassword(params);
|
||||
toast.success(t("reset.success", "Password reset successful!"));
|
||||
setType("login");
|
||||
break;
|
||||
}
|
||||
} catch (_error) {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let UserForm: ReactNode = null;
|
||||
switch (type) {
|
||||
case "login":
|
||||
UserForm = (
|
||||
<LoginForm
|
||||
initialValues={initialValues}
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
onSwitchForm={setType}
|
||||
setInitialValues={setInitialValues}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "register":
|
||||
UserForm = (
|
||||
<RegisterForm
|
||||
initialValues={initialValues}
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
onSwitchForm={setType}
|
||||
setInitialValues={setInitialValues}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "reset":
|
||||
UserForm = (
|
||||
<ResetForm
|
||||
initialValues={initialValues}
|
||||
loading={loading}
|
||||
onSubmit={handleFormSubmit}
|
||||
onSwitchForm={setType}
|
||||
setInitialValues={setInitialValues}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-11 text-center">
|
||||
<h1 className="mb-3 font-bold text-2xl">
|
||||
{t(`${type || "check"}.title`)}
|
||||
</h1>
|
||||
<div className="font-medium text-muted-foreground">
|
||||
{t(`${type || "check"}.description`)}
|
||||
</div>
|
||||
</div>
|
||||
{UserForm}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
|
||||
|
||||
export default function LoginForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
|
||||
}) {
|
||||
const { t } = useTranslation("auth");
|
||||
const { common } = useGlobalStore();
|
||||
const { verify } = common;
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(t("login.email", "Email")),
|
||||
password: z.string(),
|
||||
cf_token:
|
||||
verify.enable_login_verify && verify.turnstile_site_key
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const turnstile = useRef<TurnstileRef>(null);
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
try {
|
||||
onSubmit(data);
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form className="grid gap-6" onSubmit={handleSubmit}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"login.emailPlaceholder",
|
||||
"Enter your email..."
|
||||
)}
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"login.passwordPlaceholder",
|
||||
"Enter your password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{verify.enable_login_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cf_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile
|
||||
id="login"
|
||||
{...field}
|
||||
ref={turnstile}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button disabled={loading} type="submit">
|
||||
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
|
||||
{t("login.title", "Login")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div className="mt-4 flex w-full justify-between text-sm">
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={() => onSwitchForm("reset")}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{t("login.forgotPassword", "Forgot Password?")}
|
||||
</Button>
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm("register");
|
||||
}}
|
||||
variant="link"
|
||||
>
|
||||
{t("login.registerAccount", "Register Account")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { Markdown } from "@workspace/ui/composed/markdown";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import SendCode from "../send-code";
|
||||
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
|
||||
|
||||
export default function RegisterForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
|
||||
}) {
|
||||
const { t } = useTranslation("auth");
|
||||
const { common } = useGlobalStore();
|
||||
const { verify, auth, invite } = common;
|
||||
|
||||
const handleCheckUser = async (email: string) => {
|
||||
try {
|
||||
if (!auth.email.enable_domain_suffix) return true;
|
||||
const domain = email.split("@")[1];
|
||||
const isValid = auth.email?.domain_suffix_list
|
||||
.split("\n")
|
||||
.includes(domain || "");
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.log("Error checking user:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.email(t("register.email", "Email"))
|
||||
.refine(handleCheckUser, {
|
||||
message: t("register.whitelist", "Email domain not allowed"),
|
||||
}),
|
||||
password: z.string(),
|
||||
repeat_password: z.string(),
|
||||
code: auth.email.enable_verify ? z.string() : z.string().nullish(),
|
||||
invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
|
||||
cf_token:
|
||||
verify.enable_register_verify && verify.turnstile_site_key
|
||||
? z.string()
|
||||
: z.string().nullish(),
|
||||
})
|
||||
.superRefine(({ password, repeat_password }, ctx) => {
|
||||
if (password !== repeat_password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("register.passwordMismatch", "Passwords do not match"),
|
||||
path: ["repeat_password"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
invite: localStorage.getItem("invite") || "",
|
||||
},
|
||||
});
|
||||
|
||||
const turnstile = useRef<TurnstileRef>(null);
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
try {
|
||||
onSubmit(data);
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{auth.register.stop_register ? (
|
||||
<Markdown>{t("register.message", "Registration is disabled")}</Markdown>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form className="grid gap-6" onSubmit={handleSubmit}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"register.emailPlaceholder",
|
||||
"Enter your email..."
|
||||
)}
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"register.passwordPlaceholder",
|
||||
"Enter your password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repeat_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t(
|
||||
"register.repeatPasswordPlaceholder",
|
||||
"Enter password again..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{auth.email.enable_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t(
|
||||
"register.codePlaceholder",
|
||||
"Enter code..."
|
||||
)}
|
||||
type="text"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
<SendCode
|
||||
params={{
|
||||
...form.getValues(),
|
||||
type: 1,
|
||||
}}
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="invite"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading || !!localStorage.getItem("invite")}
|
||||
placeholder={t("register.invite", "Invite Code")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{verify.enable_register_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cf_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile
|
||||
id="register"
|
||||
{...field}
|
||||
ref={turnstile}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button disabled={loading} type="submit">
|
||||
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
|
||||
{t("register.title", "Register")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<div className="mt-4 text-right text-sm">
|
||||
{t("register.existingAccount", "Already have an account?")}
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm("login");
|
||||
}}
|
||||
variant="link"
|
||||
>
|
||||
{t("register.switchToLogin", "Login")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import SendCode from "../send-code";
|
||||
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
|
||||
|
||||
export default function ResetForm({
|
||||
loading,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
setInitialValues,
|
||||
onSwitchForm,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
onSubmit: (data: any) => void;
|
||||
initialValues: any;
|
||||
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
|
||||
}) {
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const { common } = useGlobalStore();
|
||||
const { verify, auth } = common;
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(t("reset.email", "Email")),
|
||||
password: z.string(),
|
||||
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
|
||||
cf_token:
|
||||
verify.enable_register_verify && verify.turnstile_site_key
|
||||
? z.string()
|
||||
: z.string().nullish(),
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const turnstile = useRef<TurnstileRef>(null);
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
try {
|
||||
onSubmit(data);
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form className="grid gap-6" onSubmit={handleSubmit}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"reset.emailPlaceholder",
|
||||
"Enter your email..."
|
||||
)}
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t("reset.codePlaceholder", "Enter code...")}
|
||||
type="text"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
<SendCode
|
||||
params={{
|
||||
...form.getValues(),
|
||||
type: 2,
|
||||
}}
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"reset.passwordPlaceholder",
|
||||
"Enter your new password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{verify.enable_reset_password_verify && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cf_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CloudFlareTurnstile
|
||||
id="reset"
|
||||
{...field}
|
||||
ref={turnstile}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button disabled={loading} type="submit">
|
||||
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
|
||||
{t("reset.title", "Reset Password")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div className="mt-4 text-right text-sm">
|
||||
{t("reset.existingAccount", "Remember your password?")}
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm("login");
|
||||
}}
|
||||
variant="link"
|
||||
>
|
||||
{t("reset.switchToLogin", "Login")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
|
||||
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import EmailAuthForm from "./email/auth-form";
|
||||
|
||||
export default function Auth() {
|
||||
const { common, user } = useGlobalStore();
|
||||
const { site } = common;
|
||||
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// navigate({ to: "/dashboard" });
|
||||
}
|
||||
}, [navigate, user]);
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-screen items-center bg-muted/50">
|
||||
<div className="flex size-full flex-auto flex-col justify-center lg:flex-row">
|
||||
<div className="flex lg:w-1/2 lg:flex-auto">
|
||||
<div className="flex w-full flex-col items-center justify-center px-5 py-4 md:px-14 lg:py-14">
|
||||
<Link className="mb-0 flex flex-col items-center lg:mb-12" to="/">
|
||||
<img
|
||||
alt="logo"
|
||||
height={48}
|
||||
src={site.site_logo || "/favicon.svg"}
|
||||
width={48}
|
||||
/>
|
||||
<span className="font-semibold text-2xl">{site.site_name}</span>
|
||||
</Link>
|
||||
<DotLottieReact
|
||||
autoplay
|
||||
className="mx-auto hidden w-full lg:block"
|
||||
loop
|
||||
src="./lotties/login.json"
|
||||
/>
|
||||
<p className="hidden w-[275px] text-center md:w-1/2 lg:block xl:w-[500px]">
|
||||
{site.site_desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-initial justify-center p-8 lg:flex-auto lg:justify-end">
|
||||
<div className="flex flex-col items-center rounded-2xl md:w-[600px] lg:flex-auto lg:bg-background lg:p-10 lg:shadow">
|
||||
<div className="flex flex-col items-stretch justify-center md:w-[400px] lg:h-full">
|
||||
<div className="flex flex-col justify-center pb-14 lg:flex-auto lg:pb-20">
|
||||
<EmailAuthForm />
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{/* <div className='text-primary flex gap-5 text-sm font-semibold'>
|
||||
<Link href='/tos'>{t('tos')}</Link>
|
||||
</div> */}
|
||||
<div className="flex items-center gap-5">
|
||||
<LanguageSwitch />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
sendEmailCode,
|
||||
sendSmsCode,
|
||||
} from "@workspace/ui/services/common/common";
|
||||
import { useCountDown } from "ahooks";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SendCodeProps {
|
||||
type: "email" | "phone";
|
||||
params: {
|
||||
email?: string;
|
||||
type?: 1 | 2;
|
||||
telephone_area_code?: string;
|
||||
telephone?: string;
|
||||
};
|
||||
}
|
||||
export default function SendCode({ type, params }: SendCodeProps) {
|
||||
const { t } = useTranslation("auth");
|
||||
const [targetDate, setTargetDate] = useState<number>();
|
||||
|
||||
const [, { seconds }] = useCountDown({
|
||||
targetDate,
|
||||
onEnd: () => {
|
||||
setTargetDate(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const getEmailCode = async () => {
|
||||
if (params.email && params.type) {
|
||||
await sendEmailCode({
|
||||
email: params.email,
|
||||
type: params.type,
|
||||
});
|
||||
setTargetDate(Date.now() + 60_000);
|
||||
}
|
||||
};
|
||||
|
||||
const getPhoneCode = async () => {
|
||||
if (params.telephone && params.telephone_area_code && params.type) {
|
||||
await sendSmsCode({
|
||||
telephone: params.telephone,
|
||||
telephone_area_code: params.telephone_area_code,
|
||||
type: params.type,
|
||||
});
|
||||
setTargetDate(Date.now() + 60_000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (type === "email") {
|
||||
getEmailCode();
|
||||
} else {
|
||||
getPhoneCode();
|
||||
}
|
||||
};
|
||||
const disabled =
|
||||
seconds > 0 ||
|
||||
(type === "email"
|
||||
? !params.email
|
||||
: !(params.telephone && params.telephone_area_code));
|
||||
|
||||
return (
|
||||
<Button disabled={disabled} onClick={handleSendCode} type="button">
|
||||
{seconds > 0 ? `${seconds}s` : t("get", "Get Code")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Turnstile, { useTurnstile } from "react-turnstile";
|
||||
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
export type TurnstileRef = {
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const CloudFlareTurnstile = forwardRef<
|
||||
TurnstileRef,
|
||||
{
|
||||
id?: string;
|
||||
value?: null | string;
|
||||
onChange: (value?: string) => void;
|
||||
}
|
||||
>(function CloudFlareTurnstile({ id, value, onChange }, ref) {
|
||||
const { common } = useGlobalStore();
|
||||
const { verify } = common;
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
const turnstile = useTurnstile();
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
reset: () => turnstile.reset(),
|
||||
}),
|
||||
[turnstile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === "") {
|
||||
turnstile.reset();
|
||||
}
|
||||
}, [turnstile, value]);
|
||||
|
||||
return (
|
||||
verify.turnstile_site_key && (
|
||||
<Turnstile
|
||||
fixedSize
|
||||
id={id}
|
||||
language={locale.toLowerCase()}
|
||||
onExpire={() => {
|
||||
onChange();
|
||||
turnstile.reset();
|
||||
}}
|
||||
onTimeout={() => {
|
||||
onChange();
|
||||
turnstile.reset();
|
||||
}}
|
||||
onVerify={(token) => onChange(token)}
|
||||
sitekey={verify.turnstile_site_key}
|
||||
theme={resolvedTheme as "light" | "dark"}
|
||||
/>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export default CloudFlareTurnstile;
|
||||
@@ -0,0 +1,397 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@workspace/ui/components/radio-group";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { DatePicker } from "@workspace/ui/composed/date-picker";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
code: z.string().optional(),
|
||||
count: z.number().optional(),
|
||||
type: z.number().optional(),
|
||||
discount: z.number().optional(),
|
||||
start_time: z.number().optional(),
|
||||
expire_time: z.number().optional(),
|
||||
subscribe: z.array(z.number()).nullish(),
|
||||
user_limit: z.number().optional(),
|
||||
});
|
||||
|
||||
interface CouponFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function CouponForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: CouponFormProps<T>) {
|
||||
const { t } = useTranslation("coupon");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
type: 1,
|
||||
...initialValues,
|
||||
} as any,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
const type = form.watch("type");
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4 px-6 pt-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.name", "Name")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"form.enterCouponName",
|
||||
"Enter Coupon Name"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.customCouponCode", "Custom Coupon Code")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"form.customCouponCodePlaceholder",
|
||||
"Custom Coupon Code (leave blank for auto-generation)"
|
||||
)}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.type", "Coupon Type")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
className="flex gap-2"
|
||||
defaultValue={String(field.value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, Number(value));
|
||||
form.setValue("discount", "");
|
||||
}}
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="1" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{t(
|
||||
"form.percentageDiscount",
|
||||
"Percentage Discount"
|
||||
)}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="2" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{t("form.amountDiscount", "Amount Discount")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="discount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.percentageDiscount", "Percentage Discount")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
max={100}
|
||||
min={1}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t("form.enterValue", "Enter Value")}
|
||||
suffix="%"
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === 2 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="discount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.amountDiscount", "Amount Discount")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t("form.enterValue", "Enter Value")}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subscribe"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.specifiedServer", "Specified Subscription")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, true>
|
||||
multiple
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
options={subscribes?.map((item) => ({
|
||||
value: item.id!,
|
||||
label: item.name!,
|
||||
}))}
|
||||
placeholder={t(
|
||||
"form.selectServer",
|
||||
"Select Subscription"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="start_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.startTime", "Start Time")}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
disabled={(date: Date) =>
|
||||
date < new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
onChange={(value: number | undefined) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t("form.enterValue", "Enter Value")}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expire_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.expireTime", "Expire Time")}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
onChange={(value: number | undefined) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={t("form.enterValue", "Enter Value")}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="count"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.count", "Max Usage Count")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={0}
|
||||
placeholder={t(
|
||||
"form.countPlaceholder",
|
||||
"Max Usage Count (leave blank for no limit)"
|
||||
)}
|
||||
step={1}
|
||||
type="number"
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.userLimit", "Max Usage Count per User")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={0}
|
||||
placeholder={t(
|
||||
"form.userLimitPlaceholder",
|
||||
"Max Usage Count per User (leave blank for no limit)"
|
||||
)}
|
||||
step={1}
|
||||
type="number"
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("form.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}{" "}
|
||||
{t("form.confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
batchDeleteCoupon,
|
||||
createCoupon,
|
||||
deleteCoupon,
|
||||
getCouponList,
|
||||
updateCoupon,
|
||||
} from "@workspace/ui/services/admin/coupon";
|
||||
import { 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 CouponForm from "./coupon-form";
|
||||
|
||||
export default function Coupon() {
|
||||
const { t } = useTranslation("coupon");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { subscribes } = useSubscribe();
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
return (
|
||||
<ProTable<API.Coupon, { group_id: number; query: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<CouponForm<API.UpdateCouponRequest>
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateCoupon({ ...row, ...values });
|
||||
toast.success(t("updateSuccess", "Update Success"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("editCoupon", "Edit Coupon")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteCoupon({ id: row.id });
|
||||
toast.success(t("deleteSuccess", "Delete Success"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
batchRender: (rows) => [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await batchDeleteCoupon({ ids: rows.map((item) => item.id) });
|
||||
toast.success(t("deleteSuccess", "Delete Success"));
|
||||
ref.current?.reset();
|
||||
}}
|
||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "enable",
|
||||
header: t("enable", "Enable"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("enable")}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateCoupon({
|
||||
...row.original,
|
||||
enable: checked,
|
||||
} as API.UpdateCouponRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("name", "Name"),
|
||||
},
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: t("code", "Code"),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("type", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.getValue("type") === 1 ? "default" : "secondary"}
|
||||
>
|
||||
{row.getValue("type") === 1
|
||||
? t("percentage", "Percentage")
|
||||
: t("amount", "Amount")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "discount",
|
||||
header: t("discount", "Discount"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.getValue("type") === 1 ? "default" : "secondary"}
|
||||
>
|
||||
{row.getValue("type") === 1 ? (
|
||||
`${row.original.discount} %`
|
||||
) : (
|
||||
<Display type="currency" value={row.original.discount} />
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "count",
|
||||
header: t("count", "Count"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{t("count", "Count")}:{" "}
|
||||
{row.original.count === 0
|
||||
? t("unlimited", "Unlimited")
|
||||
: row.original.count}
|
||||
</span>
|
||||
<span>
|
||||
{t("remainingTimes", "Remaining")}:{" "}
|
||||
{row.original.count === 0
|
||||
? t("unlimited", "Unlimited")
|
||||
: row.original.count - row.original.used_count}
|
||||
</span>
|
||||
<span>
|
||||
{t("usedTimes", "Usage Times")}: {row.original.used_count}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expire",
|
||||
header: t("validityPeriod", "Validity Period"),
|
||||
cell: ({ row }) => {
|
||||
const { start_time, expire_time } = row.original;
|
||||
if (start_time) {
|
||||
return expire_time ? (
|
||||
<>
|
||||
{formatDate(start_time)} - {formatDate(expire_time)}
|
||||
</>
|
||||
) : start_time ? (
|
||||
formatDate(start_time)
|
||||
) : (
|
||||
"--"
|
||||
);
|
||||
}
|
||||
return "--";
|
||||
},
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
toolbar: (
|
||||
<CouponForm<API.CreateCouponRequest>
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createCoupon({
|
||||
...values,
|
||||
enable: false,
|
||||
});
|
||||
toast.success(t("createSuccess", "Create Success"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createCoupon", "Create Coupon")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "subscribe",
|
||||
placeholder: t("subscribe", "Subscribe"),
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getCouponList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@workspace/ui/components/avatar";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BillingProps {
|
||||
type: "dashboard" | "payment";
|
||||
}
|
||||
|
||||
interface ItemType {
|
||||
logo: string;
|
||||
title: string;
|
||||
description: string;
|
||||
expiryDate: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
async function getBillingURL() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/perfect-panel/ppanel-assets/commits"
|
||||
);
|
||||
const json = await response.json();
|
||||
const version = json[0]?.sha || "latest";
|
||||
const url = new URL(
|
||||
"https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets"
|
||||
);
|
||||
url.pathname += `@${version}/billing/index.json`;
|
||||
return url.toString();
|
||||
} catch (_error) {
|
||||
return "https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets/billing/index.json";
|
||||
}
|
||||
}
|
||||
|
||||
export default function Billing({ type }: BillingProps) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
const { data: list } = useQuery({
|
||||
queryKey: ["billing", type],
|
||||
queryFn: async () => {
|
||||
const url = await getBillingURL();
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const now = Date.now();
|
||||
|
||||
return Array.isArray(data[type])
|
||||
? data[type].filter((item: { expiryDate: string }) => {
|
||||
const expiryDate = Date.parse(item.expiryDate);
|
||||
return !Number.isNaN(expiryDate) && expiryDate > now;
|
||||
})
|
||||
: [];
|
||||
},
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
if (!list?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text mt-2 font-bold">
|
||||
<span>{t("billing.title", "Sponsor")}</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
{t(
|
||||
"billing.description",
|
||||
"Sponsoring helps PPanel to continue releasing updates!"
|
||||
)}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
{list.map((item: ItemType, index: number) => (
|
||||
<a
|
||||
href={item.href}
|
||||
key={index}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Card className="h-full cursor-pointer">
|
||||
<CardHeader className="flex flex-row gap-2 p-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={item.logo} />
|
||||
<AvatarFallback>{item.title}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@workspace/ui/components/chart";
|
||||
import { Empty } from "@workspace/ui/components/empty";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { queryRevenueStatistics } from "@workspace/ui/services/admin/console";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
import { Display } from "@/components/display";
|
||||
|
||||
export function RevenueStatisticsCard() {
|
||||
const { t, i18n } = useTranslation("dashboard");
|
||||
const locale = i18n.language;
|
||||
|
||||
const IncomeStatisticsConfig = {
|
||||
new_purchase: {
|
||||
label: t("newPurchase", "New Purchase"),
|
||||
color: "var(--color-chart-1)",
|
||||
},
|
||||
repurchase: {
|
||||
label: t("repurchase", "Repurchase"),
|
||||
color: "var(--color-chart-2)",
|
||||
},
|
||||
total: {
|
||||
label: t("totalIncome", "Total Income"),
|
||||
color: "var(--color-chart-3)",
|
||||
},
|
||||
};
|
||||
|
||||
const { data: RevenueStatistics } = useQuery({
|
||||
queryKey: ["queryRevenueStatistics"],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryRevenueStatistics();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="today">
|
||||
<Card className="h-full pb-0">
|
||||
<CardHeader className="!flex-row flex items-center justify-between">
|
||||
<CardTitle>{t("revenueTitle", "Revenue Statistics")}</CardTitle>
|
||||
<TabsList>
|
||||
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
|
||||
<TabsTrigger value="month">{t("month", "Month")}</TabsTrigger>
|
||||
<TabsTrigger value="total">{t("total", "Total")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
<TabsContent className="h-full" value="today">
|
||||
<CardContent className="h-80">
|
||||
{RevenueStatistics?.today.new_order_amount ||
|
||||
RevenueStatistics?.today.renewal_order_amount ? (
|
||||
<ChartContainer
|
||||
className="mx-auto max-h-80"
|
||||
config={IncomeStatisticsConfig}
|
||||
>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
cursor={false}
|
||||
/>
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: "new_purchase",
|
||||
value: unitConversion(
|
||||
"centsToDollars",
|
||||
RevenueStatistics?.today.new_order_amount
|
||||
),
|
||||
fill: "var(--color-new_purchase)",
|
||||
},
|
||||
{
|
||||
type: "repurchase",
|
||||
value: unitConversion(
|
||||
"centsToDollars",
|
||||
RevenueStatistics?.today.renewal_order_amount
|
||||
),
|
||||
fill: "var(--color-repurchase)",
|
||||
},
|
||||
]}
|
||||
dataKey="value"
|
||||
innerRadius={50}
|
||||
nameKey="type"
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
>
|
||||
<tspan
|
||||
className="fill-foreground font-bold text-2xl"
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
>
|
||||
{unitConversion(
|
||||
"centsToDollars",
|
||||
RevenueStatistics?.today.amount_total
|
||||
)}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="!py-5 flex h-20 flex-row border-t">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("totalIncome", "Total Income")}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
<Display
|
||||
type="currency"
|
||||
value={RevenueStatistics?.today.amount_total}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
<Display
|
||||
type="currency"
|
||||
value={RevenueStatistics?.today.new_order_amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
<Display
|
||||
type="currency"
|
||||
value={RevenueStatistics?.today.renewal_order_amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="h-full" value="month">
|
||||
<CardContent className="h-80">
|
||||
{RevenueStatistics?.monthly.list &&
|
||||
RevenueStatistics?.monthly.list.length > 0 ? (
|
||||
<ChartContainer
|
||||
className="max-h-80 w-full"
|
||||
config={IncomeStatisticsConfig}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
RevenueStatistics?.monthly.list?.map((item) => ({
|
||||
date: item.date,
|
||||
new_purchase: unitConversion(
|
||||
"centsToDollars",
|
||||
item.new_order_amount
|
||||
),
|
||||
repurchase: unitConversion(
|
||||
"centsToDollars",
|
||||
item.renewal_order_amount
|
||||
),
|
||||
total: unitConversion(
|
||||
"centsToDollars",
|
||||
item.new_order_amount + item.renewal_order_amount
|
||||
),
|
||||
})) || []
|
||||
}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => {
|
||||
const [year, month, day] = value.split("-");
|
||||
return new Date(year, month - 1, day).toLocaleDateString(
|
||||
locale,
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
);
|
||||
}}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="new_purchase"
|
||||
fill="var(--color-new_purchase)"
|
||||
radius={[0, 0, 4, 4]}
|
||||
stackId="a"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="repurchase"
|
||||
fill="var(--color-repurchase)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
stackId="a"
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] bg-[--color-bg]"
|
||||
style={
|
||||
{
|
||||
"--color-bg": `var(--color-${name})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
{IncomeStatisticsConfig[
|
||||
name as keyof typeof IncomeStatisticsConfig
|
||||
]?.label || name}
|
||||
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
{index === 1 && (
|
||||
<div className="flex basis-full items-center border-t pt-1.5 font-medium text-foreground text-xs">
|
||||
{t("totalIncome", "Total Income")}
|
||||
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
|
||||
{item.payload.total}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
cursor={false}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="!py-5 flex h-20 flex-row border-t">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("totalIncome", "Total Income")}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
<Display
|
||||
type="currency"
|
||||
value={RevenueStatistics?.monthly.amount_total}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
<Display
|
||||
type="currency"
|
||||
value={RevenueStatistics?.monthly.new_order_amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
<Display
|
||||
type="currency"
|
||||
value={RevenueStatistics?.monthly.renewal_order_amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="h-full" value="total">
|
||||
<CardContent className="h-80">
|
||||
{RevenueStatistics?.all.list &&
|
||||
RevenueStatistics?.all.list.length > 0 ? (
|
||||
<ChartContainer
|
||||
className="max-h-80 w-full"
|
||||
config={IncomeStatisticsConfig}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
RevenueStatistics?.all.list?.map((item) => ({
|
||||
date: item.date,
|
||||
new_purchase: unitConversion(
|
||||
"centsToDollars",
|
||||
item.new_order_amount
|
||||
),
|
||||
repurchase: unitConversion(
|
||||
"centsToDollars",
|
||||
item.renewal_order_amount
|
||||
),
|
||||
total: unitConversion(
|
||||
"centsToDollars",
|
||||
item.new_order_amount + item.renewal_order_amount
|
||||
),
|
||||
})) || []
|
||||
}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => {
|
||||
const [year, month] = value.split("-");
|
||||
return new Date(year, month - 1).toLocaleDateString(
|
||||
locale,
|
||||
{
|
||||
month: "short",
|
||||
}
|
||||
);
|
||||
}}
|
||||
tickLine={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] bg-[--color-bg]"
|
||||
style={
|
||||
{
|
||||
"--color-bg": `var(--color-${name})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
{IncomeStatisticsConfig[
|
||||
name as keyof typeof IncomeStatisticsConfig
|
||||
]?.label || name}
|
||||
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
{index === 1 && (
|
||||
<div className="flex basis-full items-center border-t pt-1.5 font-medium text-foreground text-xs">
|
||||
{t("totalIncome", "Total Income")}
|
||||
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
|
||||
{item.payload.total}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
cursor={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="new_purchase"
|
||||
fill="var(--color-new_purchase)"
|
||||
fillOpacity={0.4}
|
||||
stackId="a"
|
||||
stroke="var(--color-new_purchase)"
|
||||
type="natural"
|
||||
/>
|
||||
<Area
|
||||
dataKey="repurchase"
|
||||
fill="var(--color-repurchase)"
|
||||
fillOpacity={0.4}
|
||||
stackId="a"
|
||||
stroke="var(--color-repurchase)"
|
||||
type="natural"
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="!py-5 flex h-20 flex-row border-t">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("totalIncome", "Total Income")}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
<Display
|
||||
type="currency"
|
||||
value={RevenueStatistics?.all.amount_total}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@workspace/ui/components/chart";
|
||||
import { Empty } from "@workspace/ui/components/empty";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
queryServerTotalData,
|
||||
queryTicketWaitReply,
|
||||
} from "@workspace/ui/services/admin/console";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { UserSubscribeDetail } from "@/sections/user/user-detail";
|
||||
import { RevenueStatisticsCard } from "./revenue-statistics-card";
|
||||
import SystemVersionCard from "./system-version-card";
|
||||
import { UserStatisticsCard } from "./user-statistics-card";
|
||||
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
const { data: TicketTotal } = useQuery({
|
||||
queryKey: ["queryTicketWaitReply"],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryTicketWaitReply();
|
||||
return data.data?.count;
|
||||
},
|
||||
});
|
||||
const { data: ServerTotal } = useQuery({
|
||||
queryKey: ["queryServerTotalData"],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryServerTotalData();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const [dataType, setDataType] = useState<string | "nodes" | "users">("nodes");
|
||||
const [timeFrame, setTimeFrame] = useState<string | "today" | "yesterday">(
|
||||
"today"
|
||||
);
|
||||
|
||||
const trafficData = {
|
||||
nodes: {
|
||||
today:
|
||||
ServerTotal?.server_traffic_ranking_today?.map((item) => ({
|
||||
name: item.name,
|
||||
traffic: item.download + item.upload,
|
||||
})) || [],
|
||||
yesterday:
|
||||
ServerTotal?.server_traffic_ranking_yesterday?.map((item) => ({
|
||||
name: item.name,
|
||||
traffic: item.download + item.upload,
|
||||
})) || [],
|
||||
},
|
||||
users: {
|
||||
today:
|
||||
ServerTotal?.user_traffic_ranking_today?.map((item) => ({
|
||||
name: item.sid,
|
||||
traffic: item.download + item.upload,
|
||||
})) || [],
|
||||
yesterday:
|
||||
ServerTotal?.user_traffic_ranking_yesterday?.map((item) => ({
|
||||
name: item.sid,
|
||||
traffic: item.download + item.upload,
|
||||
})) || [],
|
||||
},
|
||||
};
|
||||
const currentData =
|
||||
trafficData[dataType as "nodes" | "users"][
|
||||
timeFrame as "today" | "yesterday"
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
title: t("onlineUsersCount", "Online Users"),
|
||||
value: ServerTotal?.online_users || 0,
|
||||
subtitle: t("currentlyOnline", "Currently Online"),
|
||||
icon: "uil:users-alt",
|
||||
href: "/dashboard/user",
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
iconBg: "bg-blue-100 dark:bg-blue-900/30",
|
||||
},
|
||||
|
||||
{
|
||||
title: t("todayTraffic", "Today Traffic"),
|
||||
value: formatBytes(
|
||||
(ServerTotal?.today_upload || 0) +
|
||||
(ServerTotal?.today_download || 0)
|
||||
),
|
||||
subtitle: `↑${formatBytes(ServerTotal?.today_upload || 0)} ↓${formatBytes(ServerTotal?.today_download || 0)}`,
|
||||
icon: "uil:exchange-alt",
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
iconBg: "bg-purple-100 dark:bg-purple-900/30",
|
||||
},
|
||||
{
|
||||
title: t("monthTraffic", "Month Traffic"),
|
||||
value: formatBytes(
|
||||
(ServerTotal?.monthly_upload || 0) +
|
||||
(ServerTotal?.monthly_download || 0)
|
||||
),
|
||||
subtitle: `↑${formatBytes(ServerTotal?.monthly_upload || 0)} ↓${formatBytes(ServerTotal?.monthly_download || 0)}`,
|
||||
icon: "uil:cloud-data-connection",
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
iconBg: "bg-orange-100 dark:bg-orange-900/30",
|
||||
},
|
||||
{
|
||||
title: t("totalServers", "Total Servers"),
|
||||
value:
|
||||
(ServerTotal?.online_servers || 0) +
|
||||
(ServerTotal?.offline_servers || 0),
|
||||
subtitle: `${t("online", "Online")} ${ServerTotal?.online_servers || 0} ${t("offline", "Offline")} ${ServerTotal?.offline_servers || 0}`,
|
||||
icon: "uil:server-network",
|
||||
href: "/dashboard/servers",
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
iconBg: "bg-green-100 dark:bg-green-900/30",
|
||||
},
|
||||
{
|
||||
title: t("pendingTickets", "Pending Tickets"),
|
||||
value: TicketTotal || 0,
|
||||
subtitle: t("pending", "Pending"),
|
||||
icon: "uil:clipboard-notes",
|
||||
href: "/dashboard/ticket",
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
iconBg: "bg-red-100 dark:bg-red-900/30",
|
||||
},
|
||||
].map((item, index) => (
|
||||
<Link
|
||||
className={item.href ? "" : "pointer-events-none"}
|
||||
key={index}
|
||||
to={item.href || "#"}
|
||||
>
|
||||
<Card className={`group ${item.href ? "cursor-pointer" : ""}`}>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="mb-2 font-medium text-muted-foreground text-sm">
|
||||
{item.title}
|
||||
</p>
|
||||
<div className={`mb-1 font-bold text-2xl ${item.color}`}>
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="h-4 text-muted-foreground text-xs">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-full p-3 ${item.iconBg} transition-transform duration-300 group-hover:scale-110`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-6 w-6 ${item.color}`}
|
||||
icon={item.icon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<SystemVersionCard />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<RevenueStatisticsCard />
|
||||
<UserStatisticsCard />
|
||||
<Card>
|
||||
<CardHeader className="!flex-row flex items-center justify-between">
|
||||
<CardTitle>{t("trafficRank", "Traffic Rank")}</CardTitle>
|
||||
<Tabs onValueChange={setTimeFrame} value={timeFrame}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
|
||||
<TabsTrigger value="yesterday">
|
||||
{t("yesterday", "Yesterday")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h4 className="font-semibold">
|
||||
{dataType === "nodes"
|
||||
? t("nodeTraffic", "Node Traffic")
|
||||
: t("userTraffic", "User Traffic")}
|
||||
</h4>
|
||||
<Select defaultValue="nodes" onValueChange={setDataType}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue
|
||||
placeholder={t("selectTypePlaceholder", "Select Type")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nodes">{t("nodes", "Nodes")}</SelectItem>
|
||||
<SelectItem value="users">{t("users", "Users")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{currentData.length > 0 ? (
|
||||
<ChartContainer
|
||||
className="max-h-80"
|
||||
config={{
|
||||
traffic: {
|
||||
label: t("traffic", "Traffic"),
|
||||
color: "var(--primary)",
|
||||
},
|
||||
type: {
|
||||
label: t("type", "Type"),
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
label: {
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BarChart data={currentData} height={400} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(value || 0)}
|
||||
tickLine={false}
|
||||
type="number"
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
dataKey="name"
|
||||
interval={0}
|
||||
tickFormatter={(_value, index) => String(index + 1)}
|
||||
tickLine={false}
|
||||
tickMargin={0}
|
||||
type="category"
|
||||
width={15}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatBytes(Number(value) || 0)}
|
||||
label={true}
|
||||
labelFormatter={(label, [payload]) =>
|
||||
dataType === "nodes" ? (
|
||||
`${t("nodes", "Nodes")}: ${label}`
|
||||
) : (
|
||||
<>
|
||||
<div className="w-80">
|
||||
<UserSubscribeDetail
|
||||
enabled={true}
|
||||
id={payload?.payload.name}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div>{`${t("users", "Users")}: ${label}`}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="traffic"
|
||||
fill="var(--primary)"
|
||||
radius={[0, 4, 4, 0]}
|
||||
>
|
||||
<LabelList
|
||||
className="fill-[var(--foreground)]"
|
||||
dataKey="name"
|
||||
fontSize={12}
|
||||
offset={8}
|
||||
position="insideLeft"
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@workspace/ui/components/accordion";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { getSystemLog } from "@workspace/ui/services/admin/tool";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SystemLogsDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
variant?: "default" | "outline" | "ghost" | "secondary";
|
||||
size?: "sm" | "default" | "lg";
|
||||
}
|
||||
|
||||
export default function SystemLogsDialog({
|
||||
trigger,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
}: SystemLogsDialogProps) {
|
||||
const { t } = useTranslation("tool");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: logs,
|
||||
refetch,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["getSystemLog"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSystemLog();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const defaultTrigger = (
|
||||
<Button size={size} variant={variant}>
|
||||
{t("systemLogs", "System Logs")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>{trigger || defaultTrigger}</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("systemLogs", "System Logs")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[60vh] max-h-[80vh] min-h-[400px] w-full rounded-lg border bg-muted/30 p-1">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Icon
|
||||
className="h-8 w-8 animate-spin text-primary"
|
||||
icon="uil:loading"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Accordion className="w-full" collapsible type="single">
|
||||
{logs?.map((log: any, index: number) => (
|
||||
<AccordionItem
|
||||
className="px-4"
|
||||
key={index}
|
||||
value={`item-${index}`}
|
||||
>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex w-full flex-col items-start space-y-2 sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0">
|
||||
<span className="font-medium text-xs sm:text-sm">
|
||||
{log.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2">
|
||||
{Object.entries(log).map(([key, value]) => (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm"
|
||||
key={key}
|
||||
>
|
||||
<span className="font-medium">{key}:</span>
|
||||
<span className="break-all">{value as string}</span>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
refetch();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<Icon
|
||||
className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`}
|
||||
icon="uil:refresh"
|
||||
/>
|
||||
<span>{t("refreshLogs", "Refresh Logs")}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@workspace/ui/components/alert-dialog";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { getVersion, restartSystem } from "@workspace/ui/services/admin/tool";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import packageJson from "../../../../../../package.json";
|
||||
import SystemLogsDialog from "./system-logs-dialog";
|
||||
|
||||
export default function SystemVersionCard() {
|
||||
const { t } = useTranslation("tool");
|
||||
const [openRestart, setOpenRestart] = useState(false);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ["getVersionInfo"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const [webResponse, serverResponse, systemResponse] = await Promise.all(
|
||||
[
|
||||
fetch(
|
||||
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/ppanel-web/resolved?specifier=latest"
|
||||
),
|
||||
fetch(
|
||||
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/server/resolved?specifier=latest"
|
||||
),
|
||||
getVersion(),
|
||||
]
|
||||
);
|
||||
|
||||
const webData = webResponse.ok ? await webResponse.json() : null;
|
||||
const serverData = serverResponse.ok
|
||||
? await serverResponse.json()
|
||||
: null;
|
||||
const systemData = systemResponse.data.data;
|
||||
|
||||
const rawVersion = (systemData?.version || "")
|
||||
.replace(" Develop", "")
|
||||
.trim();
|
||||
const timeMatch = rawVersion.match(/\(([^)]+)\)/);
|
||||
const timestamp = timeMatch ? timeMatch[1] : "";
|
||||
const versionWithoutTime = rawVersion.replace(/\([^)]*\)/, "").trim();
|
||||
|
||||
const isDevelopment = !/^[Vv]?\d+\.\d+\.\d+(-[a-zA-Z]+(\.\d+)?)?$/.test(
|
||||
versionWithoutTime
|
||||
);
|
||||
|
||||
let displayVersion = versionWithoutTime;
|
||||
if (
|
||||
!(
|
||||
isDevelopment ||
|
||||
versionWithoutTime.startsWith("V") ||
|
||||
versionWithoutTime.startsWith("v")
|
||||
)
|
||||
) {
|
||||
displayVersion = `V${versionWithoutTime}`;
|
||||
}
|
||||
const lastUpdated = formatDate(new Date(timestamp || Date.now())) || "";
|
||||
|
||||
const systemInfo = {
|
||||
isRelease: !isDevelopment,
|
||||
version: displayVersion,
|
||||
lastUpdated,
|
||||
};
|
||||
|
||||
const latestReleases = {
|
||||
web: webData
|
||||
? {
|
||||
version: webData.version,
|
||||
url: `https://github.com/perfect-panel/ppanel-web/releases/tag/v${webData.version}`,
|
||||
}
|
||||
: null,
|
||||
server: serverData
|
||||
? {
|
||||
version: serverData.version,
|
||||
url: `https://github.com/perfect-panel/server/releases/tag/v${serverData.version}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const hasNewVersion =
|
||||
latestReleases.web &&
|
||||
packageJson.version !== latestReleases.web.version.replace(/^v/, "");
|
||||
|
||||
const hasServerNewVersion =
|
||||
latestReleases.server &&
|
||||
systemInfo.version &&
|
||||
systemInfo.version.replace(/^V/, "") !==
|
||||
latestReleases.server.version.replace(/^v/, "");
|
||||
|
||||
return {
|
||||
systemInfo,
|
||||
latestReleases,
|
||||
hasNewVersion,
|
||||
hasServerNewVersion,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch version info:", error);
|
||||
return {
|
||||
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
|
||||
latestReleases: { web: null, server: null },
|
||||
hasNewVersion: false,
|
||||
hasServerNewVersion: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
retry: 1,
|
||||
retryDelay: 10_000,
|
||||
initialData: {
|
||||
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
|
||||
latestReleases: { web: null, server: null },
|
||||
hasNewVersion: false,
|
||||
hasServerNewVersion: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { systemInfo, latestReleases, hasNewVersion, hasServerNewVersion } =
|
||||
versionInfo;
|
||||
|
||||
return (
|
||||
<Card className="gap-0 p-3">
|
||||
<CardHeader className="mb-2 p-0">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
{t("systemServices", "System Services")}
|
||||
<div className="flex items-center space-x-2">
|
||||
<SystemLogsDialog size="sm" variant="outline" />
|
||||
<AlertDialog onOpenChange={setOpenRestart} open={openRestart}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive">
|
||||
{t("systemReboot", "System Reboot")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("confirmSystemReboot", "Confirm System Reboot")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
"rebootDescription",
|
||||
"Are you sure you want to reboot the system? This action cannot be undone."
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel", "Cancel")}</AlertDialogCancel>
|
||||
<Button
|
||||
disabled={isRestarting}
|
||||
onClick={async () => {
|
||||
setIsRestarting(true);
|
||||
await restartSystem();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
setIsRestarting(false);
|
||||
setOpenRestart(false);
|
||||
}}
|
||||
>
|
||||
{isRestarting && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{isRestarting
|
||||
? t("rebooting", "Rebooting...")
|
||||
: t("confirmReboot", "Confirm Reboot")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-0">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-2 h-4 w-4 text-green-600" icon="mdi:web" />
|
||||
<span className="font-medium text-sm">
|
||||
{t("webVersion", "Web Version")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge>V{packageJson.version}</Badge>
|
||||
{hasNewVersion && (
|
||||
<Link
|
||||
className="flex items-center space-x-1"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
to={
|
||||
latestReleases?.web?.url ||
|
||||
"https://github.com/perfect-panel/ppanel-web/releases"
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
className="animate-pulse px-2 py-0.5 text-xs"
|
||||
variant="destructive"
|
||||
>
|
||||
{t("newVersionAvailable", "New Version Available")}
|
||||
<Icon icon="mdi:open-in-new" />
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-2 h-4 w-4 text-blue-600" icon="mdi:server" />
|
||||
<span className="font-medium text-sm">
|
||||
{t("serverVersion", "Server Version")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={systemInfo?.isRelease ? "default" : "destructive"}>
|
||||
{systemInfo?.version || "V1.0.0"}
|
||||
</Badge>
|
||||
{hasServerNewVersion && (
|
||||
<Link
|
||||
className="flex items-center space-x-1"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
to={
|
||||
latestReleases?.server?.url ||
|
||||
"https://github.com/perfect-panel/server/releases"
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
className="animate-pulse px-2 py-0.5 text-xs"
|
||||
variant="destructive"
|
||||
>
|
||||
{t("newVersionAvailable", "New Version Available")}
|
||||
<Icon icon="mdi:open-in-new" />
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@workspace/ui/components/chart";
|
||||
import { Empty } from "@workspace/ui/components/empty";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { queryUserStatistics } from "@workspace/ui/services/admin/console";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
|
||||
export function UserStatisticsCard() {
|
||||
const { t, i18n } = useTranslation("dashboard");
|
||||
const locale = i18n.language;
|
||||
|
||||
const UserStatisticsConfig = {
|
||||
register: {
|
||||
label: t("register", "Register"),
|
||||
color: "var(--color-chart-1)",
|
||||
},
|
||||
new_purchase: {
|
||||
label: t("newPurchase", "New Purchase"),
|
||||
color: "var(--color-chart-2)",
|
||||
},
|
||||
repurchase: {
|
||||
label: t("repurchase", "Repurchase"),
|
||||
color: "var(--color-chart-3)",
|
||||
},
|
||||
};
|
||||
|
||||
const { data: UserStatistics } = useQuery({
|
||||
queryKey: ["queryUserStatistics"],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryUserStatistics();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="today">
|
||||
<Card className="h-full pb-0">
|
||||
<CardHeader className="!flex-row flex items-center justify-between">
|
||||
<CardTitle>{t("userTitle", "User Statistics")}</CardTitle>
|
||||
<TabsList>
|
||||
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
|
||||
<TabsTrigger value="month">{t("month", "Month")}</TabsTrigger>
|
||||
<TabsTrigger value="total">{t("total", "Total")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
|
||||
<TabsContent className="h-full" value="today">
|
||||
<CardContent className="h-80">
|
||||
{UserStatistics?.today.register ||
|
||||
UserStatistics?.today.new_order_users ||
|
||||
UserStatistics?.today.renewal_order_users ? (
|
||||
<ChartContainer
|
||||
className="mx-auto max-h-80"
|
||||
config={UserStatisticsConfig}
|
||||
>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
cursor={false}
|
||||
/>
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: "register",
|
||||
value: UserStatistics?.today.register || 0,
|
||||
fill: "var(--color-register)",
|
||||
},
|
||||
{
|
||||
type: "new_purchase",
|
||||
value: UserStatistics?.today.new_order_users || 0,
|
||||
fill: "var(--color-new_purchase)",
|
||||
},
|
||||
{
|
||||
type: "repurchase",
|
||||
value: UserStatistics?.today.renewal_order_users || 0,
|
||||
fill: "var(--color-repurchase)",
|
||||
},
|
||||
]}
|
||||
dataKey="value"
|
||||
innerRadius={50}
|
||||
nameKey="type"
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
const total =
|
||||
(UserStatistics?.today.register || 0) +
|
||||
(UserStatistics?.today.new_order_users || 0) +
|
||||
(UserStatistics?.today.renewal_order_users || 0);
|
||||
return (
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
>
|
||||
<tspan
|
||||
className="fill-foreground font-bold text-3xl"
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
>
|
||||
{total}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="!py-5 flex flex-row border-t">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
{UserStatistics?.today.register}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
{UserStatistics?.today.new_order_users}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
{UserStatistics?.today.renewal_order_users}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="h-full" value="month">
|
||||
<CardContent className="h-80">
|
||||
{UserStatistics?.monthly.list &&
|
||||
UserStatistics?.monthly.list.length > 0 ? (
|
||||
<ChartContainer
|
||||
className="max-h-80 w-full"
|
||||
config={UserStatisticsConfig}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
UserStatistics?.monthly.list?.map((item) => ({
|
||||
date: item.date,
|
||||
register: item.register,
|
||||
new_purchase: item.new_order_users,
|
||||
repurchase: item.renewal_order_users,
|
||||
})) || []
|
||||
}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => {
|
||||
const [year, month, day] = value.split("-");
|
||||
return new Date(year, month - 1, day).toLocaleDateString(
|
||||
locale,
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
);
|
||||
}}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="register"
|
||||
fill="var(--color-register)"
|
||||
radius={[0, 0, 4, 4]}
|
||||
stackId="a"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="new_purchase"
|
||||
fill="var(--color-new_purchase)"
|
||||
radius={0}
|
||||
stackId="a"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="repurchase"
|
||||
fill="var(--color-repurchase)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
stackId="a"
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent />}
|
||||
cursor={false}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="!py-5 flex flex-row border-t">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
{UserStatistics?.monthly.register}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
{UserStatistics?.monthly.new_order_users}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
{UserStatistics?.monthly.renewal_order_users}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="h-full" value="total">
|
||||
<CardContent className="h-80">
|
||||
{UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? (
|
||||
<ChartContainer
|
||||
className="max-h-80 w-full"
|
||||
config={UserStatisticsConfig}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
UserStatistics?.all.list?.map((item) => ({
|
||||
date: item.date,
|
||||
register: item.register,
|
||||
new_purchase: item.new_order_users,
|
||||
repurchase: item.renewal_order_users,
|
||||
})) || []
|
||||
}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => {
|
||||
const [year, month] = value.split("-");
|
||||
return new Date(year, month - 1).toLocaleDateString(
|
||||
locale,
|
||||
{
|
||||
month: "short",
|
||||
}
|
||||
);
|
||||
}}
|
||||
tickLine={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
cursor={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="register"
|
||||
fill="var(--color-register)"
|
||||
fillOpacity={0.4}
|
||||
stackId="a"
|
||||
stroke="var(--color-register)"
|
||||
type="natural"
|
||||
/>
|
||||
<Area
|
||||
dataKey="new_purchase"
|
||||
fill="var(--color-new_purchase)"
|
||||
fillOpacity={0.4}
|
||||
stackId="a"
|
||||
stroke="var(--color-new_purchase)"
|
||||
type="natural"
|
||||
/>
|
||||
<Area
|
||||
dataKey="repurchase"
|
||||
fill="var(--color-repurchase)"
|
||||
fillOpacity={0.4}
|
||||
stackId="a"
|
||||
stroke="var(--color-repurchase)"
|
||||
type="natural"
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="!py-5 flex flex-row border-t">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-0.5">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className="font-bold text-xl tabular-nums leading-none">
|
||||
{UserStatistics?.all.register}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Billing from "./components/billing";
|
||||
import Statistics from "./components/statistics";
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<Statistics />
|
||||
<Billing type="dashboard" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { TagInput } from "@workspace/ui/composed/tag-input";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
content: z.string().nullish(),
|
||||
});
|
||||
|
||||
interface DocumentFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function DocumentForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: DocumentFormProps<T>) {
|
||||
const { t } = useTranslation("document");
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
tags: [],
|
||||
...initialValues,
|
||||
} as any,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset({
|
||||
tags: [],
|
||||
...initialValues,
|
||||
});
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4 px-6 pt-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.title", "Title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"form.titlePlaceholder",
|
||||
"Enter document title"
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.tags", "Tags")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
onChange={(value) => form.setValue(field.name, value)}
|
||||
placeholder={t("form.tagsPlaceholder", "Enter tags")}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.content", "Content")}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("form.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}{" "}
|
||||
{t("form.confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
batchDeleteDocument,
|
||||
createDocument,
|
||||
deleteDocument,
|
||||
getDocumentList,
|
||||
updateDocument,
|
||||
} from "@workspace/ui/services/admin/document";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import DocumentForm from "./document-form";
|
||||
|
||||
export default function Page() {
|
||||
const { t } = useTranslation("document");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
return (
|
||||
<ProTable<API.Document, { tag: string; search: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render(row) {
|
||||
return [
|
||||
<DocumentForm<API.UpdateDocumentRequest>
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateDocument({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
title={t("editDocument", "Edit Document")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteDescription",
|
||||
"Are you sure you want to delete this document? This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteDocument({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteDescription",
|
||||
"Are you sure you want to delete this document? This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await batchDeleteDocument({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "show",
|
||||
header: t("show", "Show"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("show")}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateDocument({
|
||||
...row.original,
|
||||
show: checked,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: t("title", "Title"),
|
||||
},
|
||||
{
|
||||
accessorKey: "tags",
|
||||
header: t("tags", "Tags"),
|
||||
cell: ({ row }) => row.original.tags.join(", "),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: t("updatedAt", "Updated At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("updated_at")),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("DocumentList", "Document List"),
|
||||
toolbar: (
|
||||
<DocumentForm<API.CreateDocumentRequest>
|
||||
key="create"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createDocument({
|
||||
...values,
|
||||
show: false,
|
||||
});
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
title={t("createDocument", "Create Document")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "search",
|
||||
},
|
||||
{
|
||||
key: "tag",
|
||||
placeholder: t("tags", "Tags"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getDocumentList({ ...pagination, ...filter });
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterBalanceLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { OrderLink } from "@/components/order-link";
|
||||
import { UserDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function BalanceLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// i18n type declarations for extraction
|
||||
// t("type.231", "Auto Reset")
|
||||
// t("type.232", "Advance Reset")
|
||||
// t("type.233", "Paid Reset")
|
||||
// t("type.321", "Recharge")
|
||||
// t("type.322", "Withdraw")
|
||||
// t("type.323", "Payment")
|
||||
// t("type.324", "Refund")
|
||||
// t("type.325", "Reward")
|
||||
// t("type.326", "Admin Adjust")
|
||||
// t("type.331", "Purchase")
|
||||
// t("type.332", "Renewal")
|
||||
// t("type.333", "Refund")
|
||||
// t("type.334", "Withdraw")
|
||||
// t("type.335", "Admin Adjust")
|
||||
// t("type.341", "Increase")
|
||||
// t("type.342", "Reduce")
|
||||
|
||||
const getBalanceTypeText = (type: number) => {
|
||||
const typeText = t(`type.${type}`, { defaultValue: "" });
|
||||
if (!typeText) {
|
||||
return `${t("unknown", "Unknown")} (${type})`;
|
||||
}
|
||||
return typeText;
|
||||
};
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.BalanceLog, { search?: string }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: t("column.amount", "Amount"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.original.amount} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order_no",
|
||||
header: t("column.orderNo", "Order No."),
|
||||
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "balance",
|
||||
header: t("column.balance", "Balance"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.original.balance} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("column.type", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge>{getBalanceTypeText(row.original.type)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.balance", "Balance Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterBalanceLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_id: (filter as any)?.user_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterCommissionLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { OrderLink } from "@/components/order-link";
|
||||
import { UserDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function CommissionLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const getCommissionTypeText = (type: number) => {
|
||||
const typeText = t(`type.${type}`, { defaultValue: "" });
|
||||
if (!typeText) {
|
||||
return `${t("unknown", "Unknown")} (${type})`;
|
||||
}
|
||||
return typeText;
|
||||
};
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.CommissionLog, { search?: string }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: t("column.amount", "Amount"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.original.amount} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order_no",
|
||||
header: t("column.orderNo", "Order No."),
|
||||
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("column.type", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge>{getCommissionTypeText(row.original.type)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.commission", "Commission Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterCommissionLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_id: (filter as any)?.user_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterEmailLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function EmailLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
search: sp.search || undefined,
|
||||
date: sp.date || today,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.MessageLog, { search?: string }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "platform",
|
||||
header: t("column.platform", "Platform"),
|
||||
cell: ({ row }) => <Badge>{row.getValue("platform")}</Badge>,
|
||||
},
|
||||
{ accessorKey: "to", header: t("column.to", "To") },
|
||||
{ accessorKey: "subject", header: t("column.subject", "Subject") },
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: t("column.content", "Content"),
|
||||
cell: ({ row }) => (
|
||||
<pre className="wrap-break-word max-w-[480px] overflow-auto whitespace-pre-wrap text-xs">
|
||||
{JSON.stringify(row.original.content || {}, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("column.status", "Status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
const getStatusVariant = (status: any) => {
|
||||
if (status === 1) {
|
||||
return "default";
|
||||
}
|
||||
if (status === 0) {
|
||||
return "destructive";
|
||||
}
|
||||
return "outline";
|
||||
};
|
||||
|
||||
const getStatusText = (status: any) => {
|
||||
if (status === 1) return t("sent", "Sent");
|
||||
if (status === 0) return t("failed", "Failed");
|
||||
return t("unknown", "Unknown");
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={getStatusVariant(status)}>
|
||||
{getStatusText(status)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.created_at),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.email", "Email Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[{ key: "search" }, { key: "date", type: "date" }]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterEmailLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
search: filter?.search,
|
||||
date: (filter as any)?.date,
|
||||
});
|
||||
const list = ((data?.data?.list || []) as API.MessageLog[]) || [];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterGiftLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { OrderLink } from "@/components/order-link";
|
||||
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function GiftLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const getGiftTypeText = (type: number) => {
|
||||
const typeText = t(`type.${type}`, { defaultValue: "" });
|
||||
if (!typeText) {
|
||||
return `${t("unknown", "Unknown")} (${type})`;
|
||||
}
|
||||
return typeText;
|
||||
};
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.GiftLog, { search?: string }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "subscribe_id",
|
||||
header: t("column.subscribe", "Subscribe"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeDetail
|
||||
enabled
|
||||
hoverCard
|
||||
id={Number(row.original.subscribe_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order_no",
|
||||
header: t("column.orderNo", "Order No."),
|
||||
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: t("column.amount", "Amount"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.original.amount} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "balance",
|
||||
header: t("column.balance", "Balance"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.original.balance} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("column.type", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge>{getGiftTypeText(row.original.type)}</Badge>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "remark", header: t("column.remark", "Remark") },
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.gift", "Gift Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterGiftLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_id: (filter as any)?.user_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@workspace/ui/components/tooltip";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterLoginLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IpLink } from "@/components/ip-link";
|
||||
import { UserDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function LoginLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.LoginLog, { date?: string; user_id?: number }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Badge className="capitalize">{row.original.method}</Badge>{" "}
|
||||
<UserDetail id={Number(row.original.user_id)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "login_ip",
|
||||
header: t("column.ip", "IP"),
|
||||
cell: ({ row }) => (
|
||||
<IpLink ip={String((row.original as any).login_ip || "")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
header: t("column.userAgent", "User Agent"),
|
||||
cell: ({ row }) => {
|
||||
const userAgent = String(row.original.user_agent || "");
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="max-w-48 cursor-help truncate">
|
||||
{userAgent}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="wrap-break-word max-w-md">{userAgent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "success",
|
||||
header: t("column.success", "Success"),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.success ? "default" : "destructive"}>
|
||||
{row.original.success
|
||||
? t("success", "Success")
|
||||
: t("failed", "Failed")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.login", "Login Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterLoginLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_id: (filter as any)?.user_id,
|
||||
});
|
||||
const list = ((data?.data?.list || []) as API.LoginLog[]) || [];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterMobileLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function MobileLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
search: sp.search || undefined,
|
||||
date: sp.date || today,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.MessageLog, { search?: string }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "platform",
|
||||
header: t("column.platform", "Platform"),
|
||||
cell: ({ row }) => <Badge>{row.getValue("platform")}</Badge>,
|
||||
},
|
||||
{ accessorKey: "to", header: t("column.to", "To") },
|
||||
{ accessorKey: "subject", header: t("column.subject", "Subject") },
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: t("column.content", "Content"),
|
||||
cell: ({ row }) => (
|
||||
<pre className="wrap-break-word max-w-[480px] overflow-auto whitespace-pre-wrap text-xs">
|
||||
{JSON.stringify(row.original.content || {}, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("column.status", "Status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
const getStatusVariant = (status: any) => {
|
||||
if (status === 1) {
|
||||
return "default";
|
||||
}
|
||||
if (status === 0) {
|
||||
return "destructive";
|
||||
}
|
||||
return "outline";
|
||||
};
|
||||
|
||||
const getStatusText = (status: any) => {
|
||||
if (status === 1) return t("sent", "Sent");
|
||||
if (status === 0) return t("failed", "Failed");
|
||||
return t("unknown", "Unknown");
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={getStatusVariant(status)}>
|
||||
{getStatusText(status)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.created_at),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.mobile", "SMS Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[{ key: "search" }, { key: "date", type: "date" }]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterMobileLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
search: filter?.search,
|
||||
date: (filter as any)?.date,
|
||||
});
|
||||
const list = ((data?.data?.list || []) as API.MessageLog[]) || [];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@workspace/ui/components/tooltip";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterRegisterLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IpLink } from "@/components/ip-link";
|
||||
import { UserDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function RegisterLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.RegisterLog, { date?: string; user_id?: number }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "auth_method",
|
||||
header: t("column.identifier", "Identifier"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center">
|
||||
<Badge className="capitalize">{row.original.auth_method}</Badge>
|
||||
<span className="ml-1 text-sm">{row.original.identifier}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "register_ip",
|
||||
header: t("column.ip", "IP"),
|
||||
cell: ({ row }) => (
|
||||
<IpLink ip={String((row.original as any).register_ip || "")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
header: t("column.userAgent", "User Agent"),
|
||||
cell: ({ row }) => {
|
||||
const userAgent = String(row.original.user_agent || "");
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="max-w-48 cursor-help truncate">
|
||||
{userAgent}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="wrap-break-word max-w-md">{userAgent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.register", "Register Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterRegisterLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_id: (filter as any)?.user_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterResetSubscribeLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OrderLink } from "@/components/order-link";
|
||||
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function ResetSubscribeLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const getResetSubscribeTypeText = (type: number) => {
|
||||
const typeText = t(`type.${type}`, { defaultValue: "" });
|
||||
if (!typeText) {
|
||||
return `${t("unknown", "Unknown")} (${type})`;
|
||||
}
|
||||
return typeText;
|
||||
};
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_subscribe_id: sp.user_subscribe_id
|
||||
? Number(sp.user_subscribe_id)
|
||||
: undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<
|
||||
API.ResetSubscribeLog,
|
||||
{ date?: string; user_subscribe_id?: number }
|
||||
>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "user_subscribe_id",
|
||||
header: t("column.subscribeId", "Subscribe ID"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeDetail
|
||||
enabled
|
||||
hoverCard
|
||||
id={Number(row.original.user_subscribe_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("column.type", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge>{getResetSubscribeTypeText(row.original.type)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "order_no",
|
||||
header: t("column.orderNo", "Order No."),
|
||||
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.resetSubscribe", "Reset Subscribe Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{
|
||||
key: "user_subscribe_id",
|
||||
placeholder: t("column.subscribeId", "Subscribe ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterResetSubscribeLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterServerTrafficLog } from "@workspace/ui/services/admin/log";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useServer } from "@/stores/server";
|
||||
|
||||
export default function ServerTrafficLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
const { getServerName } = useServer();
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
server_id: sp.server_id ? Number(sp.server_id) : undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.ServerTrafficLog, { date?: string; server_id?: number }>
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<Button asChild key="detail">
|
||||
<Link
|
||||
search={{ date: row.date, server_id: row.server_id }}
|
||||
to="/dashboard/log/traffic-details"
|
||||
>
|
||||
{t("detail", "Detail")}
|
||||
</Link>
|
||||
</Button>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "server_id",
|
||||
header: t("column.server", "Server"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{row.original.server_id}</Badge>
|
||||
<span>{getServerName(row.original.server_id)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "upload",
|
||||
header: t("column.upload", "Upload"),
|
||||
cell: ({ row }) => formatBytes(row.original.upload),
|
||||
},
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: t("column.download", "Download"),
|
||||
cell: ({ row }) => formatBytes(row.original.download),
|
||||
},
|
||||
{
|
||||
accessorKey: "total",
|
||||
header: t("column.total", "Total"),
|
||||
cell: ({ row }) => formatBytes(row.original.total),
|
||||
},
|
||||
{ accessorKey: "date", header: t("column.date", "Date") },
|
||||
]}
|
||||
header={{ title: t("title.serverTraffic", "Server Traffic Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "server_id", placeholder: t("column.serverId", "Server ID") },
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterServerTrafficLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
server_id: (filter as any)?.server_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterUserSubscribeTrafficLog } from "@workspace/ui/services/admin/log";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
|
||||
|
||||
export default function SubscribeTrafficLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
user_subscribe_id: sp.user_subscribe_id
|
||||
? Number(sp.user_subscribe_id)
|
||||
: undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<
|
||||
API.UserSubscribeTrafficLog,
|
||||
{ date?: string; user_id?: number; user_subscribe_id?: number }
|
||||
>
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<Button asChild key="detail">
|
||||
<Link
|
||||
search={{
|
||||
date: row.date,
|
||||
user_id: row.user_id,
|
||||
subscribe_id: row.subscribe_id,
|
||||
}}
|
||||
to="/dashboard/log/traffic-details"
|
||||
>
|
||||
{t("detail", "Detail")}
|
||||
</Link>
|
||||
</Button>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "subscribe_id",
|
||||
header: t("column.subscribe", "Subscribe"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeDetail
|
||||
enabled
|
||||
hoverCard
|
||||
id={Number(row.original.subscribe_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "upload",
|
||||
header: t("column.upload", "Upload"),
|
||||
cell: ({ row }) => formatBytes(row.original.upload),
|
||||
},
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: t("column.download", "Download"),
|
||||
cell: ({ row }) => formatBytes(row.original.download),
|
||||
},
|
||||
{
|
||||
accessorKey: "total",
|
||||
header: t("column.total", "Total"),
|
||||
cell: ({ row }) => formatBytes(row.original.total),
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: t("column.date", "Date"),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.subscribeTraffic", "Subscribe Traffic Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
{
|
||||
key: "user_subscribe_id",
|
||||
placeholder: t("column.subscribeId", "Subscribe ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterUserSubscribeTrafficLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_id: (filter as any)?.user_id,
|
||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
||||
});
|
||||
const list =
|
||||
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@workspace/ui/components/tooltip";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterSubscribeLog } from "@workspace/ui/services/admin/log";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IpLink } from "@/components/ip-link";
|
||||
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function SubscribeLogPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
user_subscribe_id: sp.user_subscribe_id
|
||||
? Number(sp.user_subscribe_id)
|
||||
: undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.SubscribeLog, { date?: string; user_id?: number }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "user_subscribe_id",
|
||||
header: t("column.subscribe", "Subscribe"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeDetail
|
||||
enabled
|
||||
hoverCard
|
||||
id={Number(row.original.user_subscribe_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "client_ip",
|
||||
header: t("column.ip", "IP"),
|
||||
cell: ({ row }) => (
|
||||
<IpLink ip={String((row.original as any).client_ip || "")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
header: t("column.userAgent", "User Agent"),
|
||||
cell: ({ row }) => {
|
||||
const userAgent = String(row.original.user_agent || "");
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="max-w-48 cursor-help truncate">
|
||||
{userAgent}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="wrap-break-word max-w-md">{userAgent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.subscribe", "Subscribe Log") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
{
|
||||
key: "user_subscribe_id",
|
||||
placeholder: t("column.subscribeId", "Subscribe ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterSubscribeLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
user_id: (filter as any)?.user_id,
|
||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { filterTrafficLogDetails } from "@workspace/ui/services/admin/log";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
|
||||
import { useServer } from "@/stores/server";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function TrafficDetailsPage() {
|
||||
const { t } = useTranslation("log");
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
const { getServerName } = useServer();
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const initialFilters = {
|
||||
date: sp.date || today,
|
||||
server_id: sp.server_id ? Number(sp.server_id) : undefined,
|
||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||
subscribe_id: sp.subscribe_id ? Number(sp.subscribe_id) : undefined,
|
||||
};
|
||||
return (
|
||||
<ProTable<API.TrafficLogDetails, { search?: string }>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "server_id",
|
||||
header: t("column.server", "Server"),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{getServerName(row.original.server_id)} ({row.original.server_id})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "user_id",
|
||||
header: t("column.user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "subscribe_id",
|
||||
header: t("column.subscribe", "Subscribe"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeDetail
|
||||
enabled
|
||||
hoverCard
|
||||
id={Number(row.original.subscribe_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "upload",
|
||||
header: t("column.upload", "Upload"),
|
||||
cell: ({ row }) => formatBytes(row.original.upload),
|
||||
},
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: t("column.download", "Download"),
|
||||
cell: ({ row }) => formatBytes(row.original.download),
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: t("column.time", "Time"),
|
||||
cell: ({ row }) => formatDate(row.original.timestamp),
|
||||
},
|
||||
]}
|
||||
header={{ title: t("title.trafficDetails", "Traffic Details") }}
|
||||
initialFilters={initialFilters}
|
||||
params={[
|
||||
{ key: "date", type: "date" },
|
||||
{ key: "server_id", placeholder: t("column.serverId", "Server ID") },
|
||||
{ key: "user_id", placeholder: t("column.userId", "User ID") },
|
||||
{
|
||||
key: "subscribe_id",
|
||||
placeholder: t("column.subscribeId", "Subscribe ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterTrafficLogDetails({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
date: (filter as any)?.date,
|
||||
server_id: (filter as any)?.server_id,
|
||||
user_id: (filter as any)?.user_id,
|
||||
subscribe_id: (filter as any)?.subscribe_id,
|
||||
});
|
||||
const list = (data?.data?.list || []) as any[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { HTMLEditor } from "@workspace/ui/composed/editor/html";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
createBatchSendEmailTask,
|
||||
getPreSendEmailCount,
|
||||
} from "@workspace/ui/services/admin/marketing";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function EmailBroadcastForm() {
|
||||
const { t } = useTranslation("marketing");
|
||||
|
||||
// Define schema with internationalized error messages
|
||||
const emailBroadcastSchema = z.object({
|
||||
subject: z
|
||||
.string()
|
||||
.min(
|
||||
1,
|
||||
`${t("subject", "Email Subject")} ${t("cannotBeEmpty", "cannot be empty")}`
|
||||
),
|
||||
content: z
|
||||
.string()
|
||||
.min(
|
||||
1,
|
||||
`${t("content", "Email Content")} ${t("cannotBeEmpty", "cannot be empty")}`
|
||||
),
|
||||
scope: z.number(),
|
||||
register_start_time: z.string().optional(),
|
||||
register_end_time: z.string().optional(),
|
||||
additional: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(value) => {
|
||||
if (!value || value.trim() === "") return true;
|
||||
const emails = value
|
||||
.split("\n")
|
||||
.filter((email) => email.trim() !== "");
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emails.every((email) => emailRegex.test(email.trim()));
|
||||
},
|
||||
{
|
||||
message: t(
|
||||
"pleaseEnterValidEmailAddresses",
|
||||
"Please enter valid email addresses, one per line"
|
||||
),
|
||||
}
|
||||
),
|
||||
scheduled: z.string().optional(),
|
||||
interval: z
|
||||
.number()
|
||||
.min(
|
||||
0.1,
|
||||
t("emailIntervalMinimum", "Email interval must be at least 0.1 seconds")
|
||||
)
|
||||
.optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1, t("dailyLimit", "Daily limit must be at least 1"))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type EmailBroadcastFormData = z.infer<typeof emailBroadcastSchema>;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [estimatedRecipients, setEstimatedRecipients] = useState<{
|
||||
users: number;
|
||||
additional: number;
|
||||
total: number;
|
||||
}>({ users: 0, additional: 0, total: 0 });
|
||||
|
||||
const form = useForm<EmailBroadcastFormData>({
|
||||
resolver: zodResolver(emailBroadcastSchema),
|
||||
defaultValues: {
|
||||
subject: "",
|
||||
content: "",
|
||||
scope: 1, // ScopeAll
|
||||
register_start_time: "",
|
||||
register_end_time: "",
|
||||
additional: "",
|
||||
scheduled: "",
|
||||
interval: 1,
|
||||
limit: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate recipient count
|
||||
const calculateRecipients = async () => {
|
||||
const formData = form.getValues();
|
||||
|
||||
try {
|
||||
// Call API to get actual recipient count
|
||||
const scope = formData.scope || 1; // Default to ScopeAll
|
||||
|
||||
// Convert dates to timestamps if they exist
|
||||
let register_start_time = 0;
|
||||
let register_end_time = 0;
|
||||
|
||||
if (formData.register_start_time) {
|
||||
register_start_time = Math.floor(
|
||||
new Date(formData.register_start_time).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
if (formData.register_end_time) {
|
||||
register_end_time = Math.floor(
|
||||
new Date(formData.register_end_time).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
const response = await getPreSendEmailCount({
|
||||
scope,
|
||||
register_start_time,
|
||||
register_end_time,
|
||||
});
|
||||
|
||||
const userCount = response.data?.data?.count || 0;
|
||||
|
||||
// Calculate additional email count
|
||||
const additionalEmails = formData.additional || "";
|
||||
const additionalCount = additionalEmails
|
||||
.split("\n")
|
||||
.filter((email: string) => email.trim() !== "").length;
|
||||
|
||||
const total = userCount + additionalCount;
|
||||
|
||||
setEstimatedRecipients({
|
||||
users: userCount,
|
||||
additional: additionalCount,
|
||||
total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get recipient count:", error);
|
||||
// Set to 0 if API fails, don't use fallback simulation
|
||||
const additionalEmails = formData.additional || "";
|
||||
const additionalCount = additionalEmails
|
||||
.split("\n")
|
||||
.filter((email: string) => email.trim() !== "").length;
|
||||
|
||||
setEstimatedRecipients({
|
||||
users: 0,
|
||||
additional: additionalCount,
|
||||
total: additionalCount,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to form changes
|
||||
const watchedValues = form.watch();
|
||||
|
||||
// Use useEffect to respond to form changes, but only when sheet is open
|
||||
useEffect(() => {
|
||||
if (!open) return; // Only calculate when sheet is open
|
||||
|
||||
const debounceTimer = setTimeout(() => {
|
||||
calculateRecipients();
|
||||
}, 500); // Add debounce to avoid too frequent API calls
|
||||
|
||||
return () => clearTimeout(debounceTimer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
open, // Add open dependency
|
||||
watchedValues.scope,
|
||||
watchedValues.register_start_time,
|
||||
watchedValues.register_end_time,
|
||||
watchedValues.additional,
|
||||
]);
|
||||
|
||||
const onSubmit = async (data: EmailBroadcastFormData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Validate scheduled send time
|
||||
let scheduled: number | undefined;
|
||||
if (data.scheduled && data.scheduled.trim() !== "") {
|
||||
const scheduledDate = new Date(data.scheduled);
|
||||
const now = new Date();
|
||||
if (scheduledDate <= now) {
|
||||
toast.error(
|
||||
t(
|
||||
"scheduledSendTimeMustBeLater",
|
||||
"Scheduled send time must be later than current time"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
scheduled = Math.floor(scheduledDate.getTime());
|
||||
}
|
||||
|
||||
let register_start_time = 0;
|
||||
let register_end_time = 0;
|
||||
|
||||
if (data.register_start_time) {
|
||||
register_start_time = Math.floor(
|
||||
new Date(data.register_start_time).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
if (data.register_end_time) {
|
||||
register_end_time = Math.floor(
|
||||
new Date(data.register_end_time).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare API request data
|
||||
const requestData: API.CreateBatchSendEmailTaskRequest = {
|
||||
subject: data.subject,
|
||||
content: data.content,
|
||||
scope: data.scope,
|
||||
register_start_time,
|
||||
register_end_time,
|
||||
additional: data.additional || undefined,
|
||||
scheduled,
|
||||
interval: data.interval ? data.interval * 1000 : undefined, // Convert seconds to milliseconds
|
||||
limit: data.limit,
|
||||
};
|
||||
|
||||
// Call API to create batch send email task
|
||||
await createBatchSendEmailTask(requestData);
|
||||
|
||||
if (!data.scheduled || data.scheduled.trim() === "") {
|
||||
toast.success(
|
||||
t(
|
||||
"emailBroadcastTaskCreatedSuccessfully",
|
||||
"Email broadcast task created successfully"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("emailAddedToScheduledQueue", "Email added to scheduled send queue")
|
||||
);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Email broadcast failed:", error);
|
||||
toast.error(t("sendFailed", "Send failed, please try again"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:email-send" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("emailBroadcast", "Email Broadcast")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"createNewEmailBroadcastCampaign",
|
||||
"Create new email broadcast campaign"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[700px] max-w-full md:max-w-screen-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("createBroadcast", "Create Broadcast")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="broadcast-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Tabs className="space-y-2" defaultValue="content">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="content">
|
||||
{t("content", "Email Content")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
{t("sendSettings", "Send Settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Email Content Tab */}
|
||||
<TabsContent className="space-y-2" value="content">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("subject", "Email Subject")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={`${t("pleaseEnter", "Please enter")} ${t("subject", "Email Subject").toLowerCase()}`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("content", "Email Content")}</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value || "");
|
||||
}}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"useMarkdownEditor",
|
||||
"Use Markdown editor to write email content with preview functionality"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Send Settings Tab */}
|
||||
<TabsContent className="space-y-2" value="settings">
|
||||
{/* Send scope and estimated recipients */}
|
||||
<div className="grid grid-cols-2 items-center gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scope"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("sendScope", "Send Scope")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number.parseInt(value, 10))
|
||||
}
|
||||
value={field.value?.toString() || "1"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectSendScope",
|
||||
"Select send scope"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
{t("allUsers", "All Users")}
|
||||
</SelectItem>{" "}
|
||||
{/* ScopeAll */}
|
||||
<SelectItem value="2">
|
||||
{t(
|
||||
"subscribedUsersOnly",
|
||||
"Subscribed users only"
|
||||
)}
|
||||
</SelectItem>{" "}
|
||||
{/* ScopeActive */}
|
||||
<SelectItem value="3">
|
||||
{t(
|
||||
"expiredSubscriptionUsersOnly",
|
||||
"Expired subscription users only"
|
||||
)}
|
||||
</SelectItem>{" "}
|
||||
{/* ScopeExpired */}
|
||||
<SelectItem value="4">
|
||||
{t(
|
||||
"noSubscriptionUsersOnly",
|
||||
"No subscription users only"
|
||||
)}
|
||||
</SelectItem>{" "}
|
||||
{/* ScopeNone */}
|
||||
<SelectItem value="5">
|
||||
{t(
|
||||
"specificUsersOnly",
|
||||
"Additional emails only (skip platform users)"
|
||||
)}
|
||||
</SelectItem>{" "}
|
||||
{/* ScopeSkip */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"sendScopeDescription",
|
||||
'Choose the user scope for email sending. Select "Additional emails only" to send only to the email addresses filled below'
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Estimated recipients info */}
|
||||
<div className="flex justify-end">
|
||||
<div className="border-l-4 border-l-primary bg-primary/10 px-4 py-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("estimatedRecipients", "Estimated recipients")}:{" "}
|
||||
</span>
|
||||
<span className="font-medium text-lg text-primary">
|
||||
{estimatedRecipients.total}
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
({t("users", "users")}: {estimatedRecipients.users},{" "}
|
||||
{t("additional", "Additional")}:{" "}
|
||||
{estimatedRecipients.additional})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="register_start_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"registrationStartDate",
|
||||
"Registration Start Date"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={form.watch("scope") === 5}
|
||||
onValueChange={field.onChange}
|
||||
step="1" // ScopeSkip
|
||||
type="datetime-local"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"includeUsersRegisteredAfter",
|
||||
"Include users registered on or after this date"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="register_end_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("registrationEndDate", "Registration End Date")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={form.watch("scope") === 5}
|
||||
onValueChange={field.onChange}
|
||||
step="1" // ScopeSkip
|
||||
type="datetime-local"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"includeUsersRegisteredBefore",
|
||||
"Include users registered on or before this date"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional recipients */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="additional"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"additionalRecipientEmails",
|
||||
"Additional recipient emails"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="min-h-[120px] font-mono text-sm"
|
||||
placeholder={`${t("pleaseEnter", "Please enter")}${t("additionalRecipientEmails", "Additional recipient emails").toLowerCase()},${t("onePerLine", "one per line")},for example:\nexample1@domain.com\nexample2@domain.com\nexample3@domain.com`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"additionalRecipientsDescription",
|
||||
"These emails will receive the broadcast in addition to the user filter above"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Send time settings */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("scheduledSend", "Schedule Send")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"leaveEmptyForImmediateSend",
|
||||
"Leave empty for immediate send"
|
||||
)}
|
||||
step="1"
|
||||
type="datetime-local"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"selectSendTime",
|
||||
"Select send time, leave empty for immediate send"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Send rate control */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("emailInterval", "Email Interval (seconds)")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
min={1}
|
||||
placeholder="1"
|
||||
step={0.1}
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
Number.parseFloat(e.target.value) || 1
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"intervalTimeBetweenEmails",
|
||||
"Interval time between each email"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("dailySendLimit", "Daily Send Limit")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
min={1}
|
||||
placeholder="1000"
|
||||
step={1}
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
Number.parseInt(e.target.value, 10) || 1000
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"maximumNumberPerDay",
|
||||
"Maximum number of emails to send per day"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex flex-row items-center justify-end gap-2 pt-3">
|
||||
<Button onClick={() => setOpen(false)} variant="outline">
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="broadcast-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 h-4 w-4 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{loading
|
||||
? t("processing", "Processing...")
|
||||
: !form.watch("scheduled") ||
|
||||
form.watch("scheduled")?.trim() === ""
|
||||
? t("sendNow", "Send Now")
|
||||
: t("scheduleSend", "Schedule Send")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
getBatchSendEmailTaskList,
|
||||
stopBatchSendEmailTask,
|
||||
} from "@workspace/ui/services/admin/marketing";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function EmailTaskManager() {
|
||||
const { t } = useTranslation("marketing");
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
const [selectedTask, setSelectedTask] =
|
||||
useState<API.BatchSendEmailTask | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const stopTask = async (taskId: number) => {
|
||||
try {
|
||||
await stopBatchSendEmailTask({
|
||||
id: taskId,
|
||||
});
|
||||
toast.success(t("taskStoppedSuccessfully", "Task stopped successfully"));
|
||||
ref.current?.refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to stop task:", error);
|
||||
toast.error(t("failedToStopTask", "Failed to stop task"));
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: number) => {
|
||||
const statusConfig = {
|
||||
0: {
|
||||
label: t("notStarted", "Not Started"),
|
||||
variant: "secondary" as const,
|
||||
},
|
||||
1: { label: t("inProgress", "In Progress"), variant: "default" as const },
|
||||
2: { label: t("completed", "Completed"), variant: "default" as const },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig] || {
|
||||
label: `${t("status", "Status")} ${status}`,
|
||||
variant: "secondary" as const,
|
||||
};
|
||||
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:email-multiple"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("emailTaskManager", "Email Task Manager")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"viewAndManageEmailBroadcastTasks",
|
||||
"View and manage email broadcast tasks"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[1000px] max-w-full md:max-w-screen-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("emailBroadcastTasks", "Email Broadcast Tasks")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6">
|
||||
<div className="mt-4 space-y-4">
|
||||
<ProTable<
|
||||
API.BatchSendEmailTask,
|
||||
API.GetBatchSendEmailTaskListParams
|
||||
>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<Dialog key="view-content">
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setSelectedTask(row as API.BatchSendEmailTask)
|
||||
}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<Icon icon="mdi:eye" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("emailContent", "Email Content")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[60vh] pr-4">
|
||||
{selectedTask && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
|
||||
{t("subject", "Email Subject")}
|
||||
</h4>
|
||||
<p className="font-medium">
|
||||
{selectedTask.subject}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
|
||||
{t("content", "Email Content")}
|
||||
</h4>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedTask.content,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{selectedTask.additional && (
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"additionalRecipients",
|
||||
"Additional Recipients"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm">
|
||||
{selectedTask.additional}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
...([0, 1].includes(row.status)
|
||||
? [
|
||||
<Button
|
||||
key="stop"
|
||||
onClick={() => stopTask(row.id)}
|
||||
variant="destructive"
|
||||
>
|
||||
{t("stop", "Stop")}
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t("subject", "Email Subject"),
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="max-w-[200px] truncate font-medium"
|
||||
title={row.getValue("subject") as string}
|
||||
>
|
||||
{row.getValue("subject") as string}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "scope",
|
||||
header: t("recipientType", "Recipient Type"),
|
||||
cell: ({ row }) => {
|
||||
const scope = row.original.scope;
|
||||
const scopeLabels = {
|
||||
1: t("allUsers", "All Users"), // ScopeAll
|
||||
2: t("subscribedUsers", "Subscribed Users"), // ScopeActive
|
||||
3: t("expiredUsers", "Expired Users"), // ScopeExpired
|
||||
4: t("nonSubscribers", "Non-subscribers"), // ScopeNone
|
||||
5: t("specificUsers", "Specific Users"), // ScopeSkip
|
||||
};
|
||||
return (
|
||||
scopeLabels[scope as keyof typeof scopeLabels] ||
|
||||
`${t("scope", "Send Scope")} ${scope}`
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status", "Status"),
|
||||
cell: ({ row }) =>
|
||||
getStatusBadge(row.getValue("status") as number),
|
||||
},
|
||||
{
|
||||
accessorKey: "progress",
|
||||
header: t("progress", "Progress"),
|
||||
cell: ({ row }) => {
|
||||
const task = row.original as API.BatchSendEmailTask;
|
||||
const progress =
|
||||
task.total > 0 ? (task.current / task.total) * 100 : 0;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>
|
||||
{task.current} / {task.total}
|
||||
</span>
|
||||
<span>{progress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduled",
|
||||
header: t("sendTime", "Send Time"),
|
||||
cell: ({ row }) => {
|
||||
const scheduled = row.getValue("scheduled") as number;
|
||||
return scheduled && scheduled > 0
|
||||
? formatDate(scheduled)
|
||||
: "--";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("createdAt", "Created At"),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.getValue("created_at") as number;
|
||||
return formatDate(createdAt);
|
||||
},
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: "status",
|
||||
placeholder: t("status", "Status"),
|
||||
options: [
|
||||
{ label: t("notStarted", "Not Started"), value: "0" },
|
||||
{ label: t("inProgress", "In Progress"), value: "1" },
|
||||
{ label: t("completed", "Completed"), value: "2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
placeholder: t("sendScope", "Send Scope"),
|
||||
options: [
|
||||
{ label: t("allUsers", "All Users"), value: "1" },
|
||||
{
|
||||
label: t("subscribedUsers", "Subscribed Users"),
|
||||
value: "2",
|
||||
},
|
||||
{ label: t("expiredUsers", "Expired Users"), value: "3" },
|
||||
{
|
||||
label: t("nonSubscribers", "Non-subscribers"),
|
||||
value: "4",
|
||||
},
|
||||
{ label: t("specificUsers", "Specific Users"), value: "5" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const response = await getBatchSendEmailTaskList({
|
||||
...filters,
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
});
|
||||
return {
|
||||
list: response.data?.data?.list || [],
|
||||
total: response.data?.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@workspace/ui/components/table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmailBroadcastForm from "./email/broadcast-form";
|
||||
import EmailTaskManager from "./email/task-manager";
|
||||
import QuotaBroadcastForm from "./quota/broadcast-form";
|
||||
import QuotaTaskManager from "./quota/task-manager";
|
||||
|
||||
export default function MarketingPage() {
|
||||
const { t } = useTranslation("marketing");
|
||||
|
||||
const formSections = [
|
||||
{
|
||||
title: t("emailMarketing", "Email Marketing"),
|
||||
forms: [
|
||||
{ component: EmailBroadcastForm },
|
||||
{ component: EmailTaskManager },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("quotaService", "Quota Service"),
|
||||
forms: [
|
||||
{ component: QuotaBroadcastForm },
|
||||
{ component: QuotaTaskManager },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{formSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<h2 className="mb-4 font-semibold text-lg">{section.title}</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{section.forms.map((form, formIndex) => {
|
||||
const FormComponent = form.component;
|
||||
return (
|
||||
<TableRow key={formIndex}>
|
||||
<TableCell>
|
||||
<FormComponent />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@workspace/ui/components/radio-group";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
createQuotaTask,
|
||||
queryQuotaTaskPreCount,
|
||||
} from "@workspace/ui/services/admin/marketing";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Display } from "@/components/display";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
|
||||
export default function QuotaBroadcastForm() {
|
||||
const { t } = useTranslation("marketing");
|
||||
|
||||
// Define schema with internationalized error messages
|
||||
const quotaBroadcastSchema = z.object({
|
||||
subscribers: z
|
||||
.array(z.number())
|
||||
.min(1, t("pleaseSelectSubscribers", "Please select packages")),
|
||||
is_active: z.boolean(),
|
||||
start_time: z.string().optional(),
|
||||
end_time: z.string().optional(),
|
||||
reset_traffic: z.boolean(),
|
||||
days: z.number().optional(),
|
||||
gift_type: z.number(),
|
||||
gift_value: z.number().optional(),
|
||||
});
|
||||
|
||||
type QuotaBroadcastFormData = z.infer<typeof quotaBroadcastSchema>;
|
||||
|
||||
const form = useForm<QuotaBroadcastFormData>({
|
||||
resolver: zodResolver(quotaBroadcastSchema),
|
||||
mode: "onChange", // Enable real-time validation
|
||||
defaultValues: {
|
||||
subscribers: [],
|
||||
is_active: true,
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
reset_traffic: false,
|
||||
days: 0,
|
||||
gift_type: 1,
|
||||
gift_value: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const [recipients, setRecipients] = useState<number>(0);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
// Calculate recipient count
|
||||
const calculateRecipients = async () => {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
const formData = form.getValues();
|
||||
let start_time = 0;
|
||||
let end_time = 0;
|
||||
|
||||
if (formData.start_time) {
|
||||
start_time = new Date(formData.start_time).getTime();
|
||||
}
|
||||
|
||||
if (formData.end_time) {
|
||||
end_time = new Date(formData.end_time).getTime();
|
||||
}
|
||||
|
||||
const response = await queryQuotaTaskPreCount({
|
||||
subscribers: formData.subscribers,
|
||||
is_active: formData.is_active,
|
||||
start_time,
|
||||
end_time,
|
||||
});
|
||||
|
||||
if (response.data?.data?.count !== undefined) {
|
||||
setRecipients(response.data.data.count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to calculate recipients:", error);
|
||||
toast.error(
|
||||
t("failedToCalculateRecipients", "Failed to calculate recipients")
|
||||
);
|
||||
setRecipients(0);
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch form values and recalculate recipients only when sheet is open
|
||||
const watchedValues = form.watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return; // Only calculate when sheet is open
|
||||
|
||||
const debounceTimer = setTimeout(() => {
|
||||
calculateRecipients();
|
||||
}, 500); // Add debounce to avoid too frequent API calls
|
||||
|
||||
return () => clearTimeout(debounceTimer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
open,
|
||||
watchedValues.subscribers,
|
||||
watchedValues.is_active,
|
||||
watchedValues.start_time,
|
||||
watchedValues.end_time,
|
||||
]);
|
||||
|
||||
const onSubmit = async (data: QuotaBroadcastFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let start_time = 0;
|
||||
let end_time = 0;
|
||||
|
||||
if (data.start_time) {
|
||||
start_time = Math.floor(new Date(data.start_time).getTime());
|
||||
}
|
||||
|
||||
if (data.end_time) {
|
||||
end_time = Math.floor(new Date(data.end_time).getTime());
|
||||
}
|
||||
|
||||
await createQuotaTask({
|
||||
subscribers: data.subscribers,
|
||||
is_active: data.is_active,
|
||||
start_time,
|
||||
end_time,
|
||||
reset_traffic: data.reset_traffic,
|
||||
days: data.days || 0,
|
||||
gift_type: data.gift_type,
|
||||
gift_value: data.gift_value || 0,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t("quotaTaskCreatedSuccessfully", "Quota task created successfully")
|
||||
);
|
||||
form.reset();
|
||||
setRecipients(0);
|
||||
setOpen(false); // Close the sheet after successful submission
|
||||
} catch (error) {
|
||||
console.error("Failed to create quota task:", error);
|
||||
toast.error(t("failedToCreateQuotaTask", "Failed to create quota task"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:gift" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("quotaBroadcast", "Quota Distribution")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"createAndSendQuotaTasks",
|
||||
"Create and Distribute Quota Tasks"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("createQuotaTask", "Create Quota Task")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-32px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 space-y-6"
|
||||
id="quota-broadcast-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{/* Subscribers selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subscribers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("subscribers", "Packages")}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
multiple={true}
|
||||
onChange={field.onChange}
|
||||
options={subscribes?.map((subscribe) => ({
|
||||
value: subscribe.id!,
|
||||
label: subscribe.name!,
|
||||
children: (
|
||||
<div>
|
||||
<div>{subscribe.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<Display
|
||||
type="traffic"
|
||||
value={subscribe.traffic || 0}
|
||||
/>{" "}
|
||||
/{" "}
|
||||
<Display
|
||||
type="currency"
|
||||
value={subscribe.unit_price || 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
placeholder={t(
|
||||
"pleaseSelectSubscribers",
|
||||
"Please select packages"
|
||||
)}
|
||||
value={field.value || []}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Subscription count info and active status */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("validOnly", "Valid Only")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"selectValidSubscriptionsOnly",
|
||||
"Select currently valid subscriptions only"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center border-l-4 border-l-primary bg-primary/10 px-4 py-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("subscriptionCount", "Subscription Count")}:{" "}
|
||||
</span>
|
||||
<span className="font-medium text-lg text-primary">
|
||||
{isCalculating ? (
|
||||
<Icon
|
||||
className="ml-2 h-4 w-4 animate-spin"
|
||||
icon="mdi:loading"
|
||||
/>
|
||||
) : (
|
||||
recipients.toLocaleString()
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription validity period range */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="start_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"subscriptionValidityStartDate",
|
||||
"Subscription Validity Start Date"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
step="1"
|
||||
type="datetime-local"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"includeSubscriptionsValidAfter",
|
||||
"Include subscriptions valid on or after this date"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="end_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"subscriptionValidityEndDate",
|
||||
"Subscription Validity End Date"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
step="1"
|
||||
type="datetime-local"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"includeSubscriptionsValidBefore",
|
||||
"Include subscriptions valid on or before this date"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset traffic */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reset_traffic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("resetTraffic", "Reset Traffic")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resetTrafficDescription",
|
||||
"Whether to reset subscription used traffic"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Quota days */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="days"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("quotaDays", "Extend Expiration Days")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={1}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number.parseInt(value, 10))
|
||||
}
|
||||
type="number"
|
||||
value={field.value?.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"numberOfDaysForTheQuota",
|
||||
"Number of days to extend subscription expiration"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Gift configuration */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gift_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("giftType", "Gift Amount Type")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
className="flex gap-4"
|
||||
defaultValue={String(field.value)}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(Number(value));
|
||||
form.setValue("gift_value", 0);
|
||||
}}
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="1" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{t("fixedAmount", "Fixed Amount")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="2" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{t("percentageAmount", "Percentage Amount")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Gift amount based on type */}
|
||||
{form.watch("gift_type") === 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gift_value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput<number>
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={1}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
placeholder={t("enterAmount", "Enter amount")}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{form.watch("gift_type") === 2 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gift_value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
max={100}
|
||||
min={1}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
placeholder={t("enterPercentage", "Enter percentage")}
|
||||
suffix="%"
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"percentageAmountDescription",
|
||||
"Gift percentage amount based on current package price"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex flex-row items-center justify-end gap-2 pt-3">
|
||||
<Button onClick={() => setOpen(false)} variant="outline">
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!form.formState.isValid ||
|
||||
form.watch("subscribers").length === 0
|
||||
}
|
||||
form="quota-broadcast-form"
|
||||
type="submit"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Icon className="mr-2 h-4 w-4 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("createQuotaTask", "Create Quota Task")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { queryQuotaTaskList } from "@workspace/ui/services/admin/marketing";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export default function QuotaTaskManager() {
|
||||
const { t } = useTranslation("marketing");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
const subscribeMap =
|
||||
subscribes?.reduce(
|
||||
(acc, subscribe) => {
|
||||
acc[subscribe.id!] = subscribe.name!;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>
|
||||
) || {};
|
||||
|
||||
const getStatusBadge = (status: number) => {
|
||||
const statusConfig = {
|
||||
0: {
|
||||
label: t("notStarted", "Not Started"),
|
||||
variant: "secondary" as const,
|
||||
},
|
||||
1: { label: t("inProgress", "In Progress"), variant: "default" as const },
|
||||
2: { label: t("completed", "Completed"), variant: "default" as const },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig] || {
|
||||
label: `${t("status", "Status")} ${status}`,
|
||||
variant: "secondary" as const,
|
||||
};
|
||||
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:database-plus" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("quotaTaskManager", "Quota Task Manager")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("viewAndManageQuotaTasks", "View and manage quota tasks")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[1000px] max-w-full md:max-w-screen-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("quotaTasks", "Quota Tasks")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6">
|
||||
<div className="mt-4 space-y-4">
|
||||
{open && (
|
||||
<ProTable<API.QuotaTask, API.QueryQuotaTaskListParams>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "subscribers",
|
||||
header: t("subscribers", "Packages"),
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const subscribers = row.getValue(
|
||||
"subscribers"
|
||||
) as number[];
|
||||
const subscriptionNames =
|
||||
subscribers
|
||||
?.map((id) => subscribeMap[id])
|
||||
.filter(Boolean) || [];
|
||||
|
||||
if (subscriptionNames.length === 0) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("noSubscriptions", "No Subscriptions")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{subscriptionNames.map((name, index) => (
|
||||
<span
|
||||
className="rounded bg-muted px-2 py-1 text-xs"
|
||||
key={index}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "is_active",
|
||||
header: t("validOnly", "Valid Only"),
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const isActive = row.getValue("is_active") as boolean;
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{isActive ? t("yes", "Yes") : t("no", "No")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reset_traffic",
|
||||
header: t("resetTraffic", "Reset Traffic"),
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const resetTraffic = row.getValue(
|
||||
"reset_traffic"
|
||||
) as boolean;
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{resetTraffic ? t("yes", "Yes") : t("no", "No")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "gift_value",
|
||||
header: t("giftAmount", "Gift Amount"),
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const giftValue = row.getValue("gift_value") as number;
|
||||
const task = row.original as API.QuotaTask;
|
||||
const giftType = task.gift_type;
|
||||
|
||||
return (
|
||||
<div className="font-medium text-sm">
|
||||
{giftType === 1 ? (
|
||||
<Display type="currency" value={giftValue} />
|
||||
) : (
|
||||
`${giftValue}%`
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "days",
|
||||
header: t("quotaDays", "Extend Expiration Days"),
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const days = row.getValue("days") as number;
|
||||
return (
|
||||
<span className="font-medium">
|
||||
{days} {t("days", "Days")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "time_range",
|
||||
header: t("timeRange", "Time Range"),
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const task = row.original as API.QuotaTask;
|
||||
const startTime = task.start_time;
|
||||
const endTime = task.end_time;
|
||||
|
||||
if (!(startTime || endTime)) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("noTimeLimit", "No Time Limit")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-xs">
|
||||
{startTime && (
|
||||
<div>
|
||||
{t("startTime", "Start Time")}:{" "}
|
||||
{formatDate(startTime)}
|
||||
</div>
|
||||
)}
|
||||
{endTime && (
|
||||
<div>
|
||||
{t("endTime", "End Time")}: {formatDate(endTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status", "Status"),
|
||||
size: 100,
|
||||
cell: ({ row }) =>
|
||||
getStatusBadge(row.getValue("status") as number),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("createdAt", "Created At"),
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.getValue("created_at") as number;
|
||||
return formatDate(createdAt);
|
||||
},
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: "status",
|
||||
placeholder: t("status", "Status"),
|
||||
options: [
|
||||
{ label: t("notStarted", "Not Started"), value: "0" },
|
||||
{ label: t("inProgress", "In Progress"), value: "1" },
|
||||
{ label: t("completed", "Completed"), value: "2" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const response = await queryQuotaTaskList({
|
||||
...filters,
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
});
|
||||
return {
|
||||
list: response.data?.data?.list || [],
|
||||
total: response.data?.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createNode,
|
||||
deleteNode,
|
||||
filterNodeList,
|
||||
resetSortWithNode,
|
||||
toggleNodeStatus,
|
||||
updateNode,
|
||||
} from "@workspace/ui/services/admin/server";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useNode } from "@/stores/node";
|
||||
import { useServer } from "@/stores/server";
|
||||
import NodeForm from "./node-form";
|
||||
|
||||
export default function Nodes() {
|
||||
const { t } = useTranslation("nodes");
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Use our zustand store for server data
|
||||
const { getServerName, getServerAddress, getProtocolPort } = useServer();
|
||||
const { fetchNodes, fetchTags } = useNode();
|
||||
|
||||
return (
|
||||
<ProTable<API.Node, { search: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<NodeForm
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const body: API.UpdateNodeRequest = {
|
||||
...row,
|
||||
...values,
|
||||
} as any;
|
||||
await updateNode(body);
|
||||
toast.success(t("updated", "Updated"));
|
||||
ref.current?.refresh();
|
||||
fetchNodes();
|
||||
fetchTags();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("drawerEditTitle", "Edit Node")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"confirmDeleteDesc",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteNode({ id: row.id } as any);
|
||||
toast.success(t("deleted", "Deleted"));
|
||||
ref.current?.refresh();
|
||||
fetchNodes();
|
||||
fetchTags();
|
||||
}}
|
||||
title={t("confirmDeleteTitle", "Delete this node?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
<Button
|
||||
key="copy"
|
||||
onClick={async () => {
|
||||
const {
|
||||
id: _id,
|
||||
sort: _sort,
|
||||
enabled: _enabled,
|
||||
updated_at: _updated_at,
|
||||
created_at: _created_at,
|
||||
...rest
|
||||
} = row as any;
|
||||
await createNode({
|
||||
...rest,
|
||||
enabled: false,
|
||||
});
|
||||
toast.success(t("copied", "Copied"));
|
||||
ref.current?.refresh();
|
||||
fetchNodes();
|
||||
fetchTags();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("copy", "Copy")}
|
||||
</Button>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"confirmDeleteDesc",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await Promise.all(
|
||||
rows.map((r) => deleteNode({ id: r.id } as any))
|
||||
);
|
||||
toast.success(t("deleted", "Deleted"));
|
||||
ref.current?.refresh();
|
||||
fetchNodes();
|
||||
fetchTags();
|
||||
}}
|
||||
title={t("confirmDeleteTitle", "Delete this node?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
id: "enabled",
|
||||
header: t("enabled", "Enabled"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={async (v) => {
|
||||
await toggleNodeStatus({ id: row.original.id, enable: v });
|
||||
toast.success(
|
||||
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
|
||||
);
|
||||
ref.current?.refresh();
|
||||
fetchNodes();
|
||||
fetchTags();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "name", header: t("name", "Name") },
|
||||
|
||||
{
|
||||
id: "address_port",
|
||||
header: `${t("address", "Address")}:${t("port", "Port")}`,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.address || "—"}:${row.original.port || "—"}`,
|
||||
},
|
||||
|
||||
{
|
||||
id: "server_id",
|
||||
header: t("server", "Server"),
|
||||
cell: ({ row }) =>
|
||||
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "tags",
|
||||
header: t("tags", "Tags"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(row.original.tags || []).length === 0
|
||||
? "—"
|
||||
: row.original.tags.map((tg) => (
|
||||
<Badge key={tg} variant="outline">
|
||||
{tg}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("pageTitle", "Nodes"),
|
||||
toolbar: (
|
||||
<NodeForm
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const body: API.CreateNodeRequest = {
|
||||
name: values.name,
|
||||
server_id: Number(values.server_id!),
|
||||
protocol: values.protocol,
|
||||
address: values.address,
|
||||
port: Number(values.port!),
|
||||
tags: values.tags || [],
|
||||
enabled: false,
|
||||
};
|
||||
await createNode(body);
|
||||
toast.success(t("created", "Created"));
|
||||
ref.current?.refresh();
|
||||
fetchNodes();
|
||||
fetchTags();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("drawerCreateTitle", "Create Node")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
onSort={async (source, target, items) => {
|
||||
const sourceIndex = items.findIndex(
|
||||
(item) => String(item.id) === source
|
||||
);
|
||||
const targetIndex = items.findIndex(
|
||||
(item) => String(item.id) === target
|
||||
);
|
||||
|
||||
const originalSorts = items.map((item) => item.sort);
|
||||
|
||||
const [movedItem] = items.splice(sourceIndex, 1);
|
||||
items.splice(targetIndex, 0, movedItem!);
|
||||
|
||||
const updatedItems = items.map((item, index) => {
|
||||
const originalSort = originalSorts[index];
|
||||
const newSort = originalSort !== undefined ? originalSort : item.sort;
|
||||
return { ...item, sort: newSort };
|
||||
});
|
||||
|
||||
const changedItems = updatedItems.filter(
|
||||
(item, index) => item.sort !== items[index]?.sort
|
||||
);
|
||||
|
||||
if (changedItems.length > 0) {
|
||||
resetSortWithNode({
|
||||
sort: changedItems.map((item) => ({
|
||||
id: item.id,
|
||||
sort: item.sort,
|
||||
})) as API.SortItem[],
|
||||
});
|
||||
toast.success(t("sorted_success", "Sorted successfully"));
|
||||
}
|
||||
return updatedItems;
|
||||
}}
|
||||
params={[{ key: "search" }]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterNodeList({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
search: filter?.search || undefined,
|
||||
});
|
||||
const list = (data?.data?.list || []) as API.Node[];
|
||||
const total = Number(data?.data?.total || list.length);
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import TagInput from "@workspace/ui/composed/tag-input";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useNode } from "@/stores/node";
|
||||
import { useServer } from "@/stores/server";
|
||||
|
||||
export type ProtocolName =
|
||||
| "shadowsocks"
|
||||
| "vmess"
|
||||
| "vless"
|
||||
| "trojan"
|
||||
| "hysteria"
|
||||
| "tuic"
|
||||
| "anytls"
|
||||
| "naive"
|
||||
| "http"
|
||||
| "socks"
|
||||
| "mieru";
|
||||
|
||||
const buildSchema = (t: TFunction) =>
|
||||
z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t("errors.nameRequired", "Please enter a name")),
|
||||
server_id: z
|
||||
.number({ message: t("errors.serverRequired", "Please select a server") })
|
||||
.int()
|
||||
.gt(0, t("errors.serverRequired", "Please select a server"))
|
||||
.optional(),
|
||||
protocol: z
|
||||
.string()
|
||||
.min(1, t("errors.protocolRequired", "Please select a protocol")),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t("errors.serverAddrRequired", "Please enter an entry address")),
|
||||
port: z
|
||||
.number({
|
||||
message: t("errors.portRange", "Port must be between 1 and 65535"),
|
||||
})
|
||||
.int()
|
||||
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
|
||||
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
|
||||
|
||||
export default function NodeForm(props: {
|
||||
trigger: string;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
initialValues?: Partial<NodeFormValues>;
|
||||
onSubmit: (values: NodeFormValues) => Promise<boolean> | boolean;
|
||||
}) {
|
||||
const { trigger, title, loading, initialValues, onSubmit } = props;
|
||||
const { t } = useTranslation("nodes");
|
||||
const Scheme = useMemo(() => buildSchema(t), [t]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
const addAutoFilledField = (fieldName: string) => {
|
||||
setAutoFilledFields((prev) => new Set(prev).add(fieldName));
|
||||
};
|
||||
|
||||
const removeAutoFilledField = (fieldName: string) => {
|
||||
setAutoFilledFields((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(fieldName);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm<NodeFormValues>({
|
||||
resolver: zodResolver(Scheme),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
server_id: undefined,
|
||||
protocol: "",
|
||||
address: "",
|
||||
port: 0,
|
||||
tags: [],
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
const serverId = form.watch("server_id");
|
||||
|
||||
const { servers, getAvailableProtocols } = useServer();
|
||||
const { tags } = useNode();
|
||||
|
||||
const existingTags: string[] = tags || [];
|
||||
|
||||
const availableProtocols = getAvailableProtocols(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
form.reset({
|
||||
name: "",
|
||||
server_id: undefined,
|
||||
protocol: "",
|
||||
address: "",
|
||||
port: 0,
|
||||
tags: [],
|
||||
...initialValues,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValues]);
|
||||
|
||||
function handleServerChange(nextId?: number | null) {
|
||||
const id = nextId ?? undefined;
|
||||
form.setValue("server_id", id);
|
||||
|
||||
if (!id) {
|
||||
setAutoFilledFields(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedServer = servers.find((s) => s.id === id);
|
||||
if (!selectedServer) return;
|
||||
|
||||
const currentValues = form.getValues();
|
||||
const fieldsToFill: string[] = [];
|
||||
|
||||
if (!currentValues.name || autoFilledFields.has("name")) {
|
||||
form.setValue("name", selectedServer.name as string, {
|
||||
shouldDirty: false,
|
||||
});
|
||||
fieldsToFill.push("name");
|
||||
}
|
||||
|
||||
if (!currentValues.address || autoFilledFields.has("address")) {
|
||||
form.setValue("address", selectedServer.address as string, {
|
||||
shouldDirty: false,
|
||||
});
|
||||
fieldsToFill.push("address");
|
||||
}
|
||||
|
||||
const protocols = getAvailableProtocols(id);
|
||||
const firstProtocol = protocols[0];
|
||||
|
||||
if (
|
||||
firstProtocol &&
|
||||
(!currentValues.protocol || autoFilledFields.has("protocol"))
|
||||
) {
|
||||
form.setValue("protocol", firstProtocol.protocol, { shouldDirty: false });
|
||||
fieldsToFill.push("protocol");
|
||||
|
||||
if (
|
||||
!currentValues.port ||
|
||||
currentValues.port === 0 ||
|
||||
autoFilledFields.has("port")
|
||||
) {
|
||||
const port = firstProtocol.port || 0;
|
||||
form.setValue("port", port, { shouldDirty: false });
|
||||
fieldsToFill.push("port");
|
||||
}
|
||||
}
|
||||
|
||||
setAutoFilledFields(new Set(fieldsToFill));
|
||||
}
|
||||
|
||||
const handleManualFieldChange = (
|
||||
fieldName: keyof NodeFormValues,
|
||||
value: any
|
||||
) => {
|
||||
form.setValue(fieldName, value);
|
||||
removeAutoFilledField(fieldName);
|
||||
};
|
||||
|
||||
function handleProtocolChange(nextProto?: ProtocolName | null) {
|
||||
const protocol = (nextProto || "") as ProtocolName | "";
|
||||
form.setValue("protocol", protocol);
|
||||
|
||||
if (!(protocol && serverId)) {
|
||||
removeAutoFilledField("protocol");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValues = form.getValues();
|
||||
const isPortAutoFilled = autoFilledFields.has("port");
|
||||
|
||||
removeAutoFilledField("protocol");
|
||||
|
||||
if (!currentValues.port || currentValues.port === 0 || isPortAutoFilled) {
|
||||
const protocolData = availableProtocols.find(
|
||||
(p) => p.protocol === protocol
|
||||
);
|
||||
|
||||
if (protocolData) {
|
||||
const port = protocolData.port || 0;
|
||||
form.setValue("port", port, { shouldDirty: false });
|
||||
addAutoFilledField("port");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(values: NodeFormValues) {
|
||||
const result = await onSubmit(values);
|
||||
if (result) {
|
||||
setOpen(false);
|
||||
setAutoFilledFields(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setAutoFilledFields(new Set());
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="w-[560px] max-w-full">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
|
||||
<Form {...form}>
|
||||
<form className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="server_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("server", "Server")}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
onChange={(v) => handleServerChange(v)}
|
||||
options={servers.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${(s.address as any) || ""})`,
|
||||
}))}
|
||||
placeholder={t("select_server", "Select server…")}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("protocol", "Protocol")}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<string, false>
|
||||
onChange={(v) =>
|
||||
handleProtocolChange((v as ProtocolName) || null)
|
||||
}
|
||||
options={availableProtocols.map((p) => ({
|
||||
value: p.protocol,
|
||||
label: `${p.protocol}${p.port ? ` (${p.port})` : ""}`,
|
||||
}))}
|
||||
placeholder={t("select_protocol", "Select protocol…")}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name", "Name")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(v) =>
|
||||
handleManualFieldChange("name", v as string)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("address", "Address")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(v) =>
|
||||
handleManualFieldChange("address", v as string)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("port", "Port")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
max={65_535}
|
||||
min={1}
|
||||
onValueChange={(v) =>
|
||||
handleManualFieldChange("port", Number(v))
|
||||
}
|
||||
placeholder="1-65535"
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("tags", "Tags")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
onChange={(v) => form.setValue(field.name, v)}
|
||||
options={existingTags}
|
||||
placeholder={t(
|
||||
"tags_placeholder",
|
||||
"Use Enter or comma (,) to add multiple tags"
|
||||
)}
|
||||
value={field.value || []}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"tags_description",
|
||||
"Permission grouping tag (incl. plan binding and delivery policies)."
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={form.handleSubmit(handleSubmit, (errors) => {
|
||||
const key = Object.keys(errors)[0] as keyof typeof errors;
|
||||
if (key) toast.error(String(errors[key]?.message));
|
||||
return false;
|
||||
})}
|
||||
>
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@workspace/ui/components/hover-card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import {
|
||||
getOrderList,
|
||||
updateOrderStatus,
|
||||
} from "@workspace/ui/services/admin/order";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import { UserDetail } from "../user/user-detail";
|
||||
|
||||
export default function Order() {
|
||||
const { t } = useTranslation("order");
|
||||
|
||||
const statusOptions = [
|
||||
{
|
||||
value: 1,
|
||||
label: t("status.1", "Pending"),
|
||||
className: "bg-orange-500",
|
||||
},
|
||||
{ value: 2, label: t("status.2", "Paid"), className: "bg-green-500" },
|
||||
{
|
||||
value: 3,
|
||||
label: t("status.3", "Cancelled"),
|
||||
className: "bg-gray-500",
|
||||
},
|
||||
{ value: 4, label: t("status.4", "Closed"), className: "bg-red-500" },
|
||||
{
|
||||
value: 5,
|
||||
label: t("status.5", "Completed"),
|
||||
className: "bg-green-500",
|
||||
},
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 1, label: t("type.1", "New Purchase") },
|
||||
{ value: 2, label: t("type.2", "Renewal") },
|
||||
{ value: 3, label: t("type.3", "Reset Traffic") },
|
||||
{ value: 4, label: t("type.4", "Recharge") },
|
||||
];
|
||||
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
const { subscribes, getSubscribeName } = useSubscribe();
|
||||
|
||||
return (
|
||||
<ProTable<API.Order, any>
|
||||
action={ref}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "order_no",
|
||||
header: t("orderNumber", "Order Number"),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("type.0", "Type"),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("type") as number;
|
||||
return (
|
||||
typeOptions.find((opt) => opt.value === type)?.label ||
|
||||
t(`type.${type}`)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "subscribe_id",
|
||||
header: t("subscribe", "Subscribe"),
|
||||
cell: ({ row }) => {
|
||||
const order = row.original as API.Order;
|
||||
if (order.type === 4) {
|
||||
const type = row.getValue("type") as number;
|
||||
return (
|
||||
typeOptions.find((opt) => opt.value === type)?.label ||
|
||||
t(`type.${type}`)
|
||||
);
|
||||
}
|
||||
const name = getSubscribeName(order.subscribe_id);
|
||||
const quantity = order.quantity;
|
||||
return name ? `${name} × ${quantity}` : "";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: t("amount", "Amount"),
|
||||
cell: ({ row }) => {
|
||||
const order = row.original as API.Order;
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button className="p-0" variant="link">
|
||||
<Display type="currency" value={order.amount} />
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="grid gap-3">
|
||||
{order.trade_no && (
|
||||
<>
|
||||
<div className="font-semibold">
|
||||
{t("tradeNo", "Transaction Number")}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{order.trade_no}
|
||||
</span>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("subscribePrice", "Subscription Price")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order.price} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("discount", "Discount Amount")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order.discount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("couponDiscount", "Coupon Discount")}
|
||||
</span>
|
||||
<span>
|
||||
<Display
|
||||
type="currency"
|
||||
value={order.coupon_discount}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("feeAmount", "Fee Amount")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order.fee_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{t("total", "Total")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={order.amount} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("method", "Payment Method")}
|
||||
</span>
|
||||
<span>
|
||||
{order.payment?.name || order.payment?.platform}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "user_id",
|
||||
header: t("user", "User"),
|
||||
cell: ({ row }) => {
|
||||
const order = row.original as API.Order;
|
||||
return <UserDetail id={order.user_id} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: t("updateTime", "Update Time"),
|
||||
cell: ({ row }) => {
|
||||
const order = row.original as API.Order;
|
||||
return formatDate(order.updated_at);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status.0", "Status"),
|
||||
cell: ({ row }) => {
|
||||
const order = row.original as API.Order;
|
||||
const option = statusOptions.find(
|
||||
(opt) => opt.value === order.status
|
||||
);
|
||||
if ([1, 3, 4].includes(row.getValue("status"))) {
|
||||
return (
|
||||
<Combobox<number, false>
|
||||
className={cn(option?.className)}
|
||||
onChange={async (value) => {
|
||||
await updateOrderStatus({
|
||||
id: order.id,
|
||||
status: value,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
options={statusOptions}
|
||||
placeholder={t("status.0", "Status")}
|
||||
value={order.status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge>
|
||||
{option?.label || t(`status.${row.getValue("status")}`)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
params={[
|
||||
{
|
||||
key: "status",
|
||||
placeholder: t("status.0", "Status"),
|
||||
options: statusOptions.map((item) => ({
|
||||
label: item.label,
|
||||
value: String(item.value),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "subscribe_id",
|
||||
placeholder: `${t("subscribe", "Subscribe")}`,
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{ key: "search" },
|
||||
{
|
||||
key: "user_id",
|
||||
placeholder: `${t("user", "User")} ID`,
|
||||
options: undefined,
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getOrderList({ ...pagination, ...filter });
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Billing from "../dashboard/components/billing";
|
||||
import PaymentTable from "./payment-table";
|
||||
|
||||
export default function Payment() {
|
||||
return (
|
||||
<>
|
||||
<PaymentTable />
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<Billing type="payment" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@workspace/ui/components/radio-group";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { getPaymentPlatform } from "@workspace/ui/services/admin/payment";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as z from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
interface PaymentFormProps<T extends { platform?: string }> {
|
||||
trigger: React.ReactNode;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
initialValues?: T;
|
||||
onSubmit: (values: T) => Promise<boolean>;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export default function PaymentForm<T extends { platform?: string }>({
|
||||
trigger,
|
||||
title,
|
||||
loading,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
isEdit,
|
||||
}: PaymentFormProps<T>) {
|
||||
const { t } = useTranslation("payment");
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: platformData } = useQuery({
|
||||
queryKey: ["getPaymentPlatform"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getPaymentPlatform();
|
||||
return data?.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, { message: t("nameRequired", "Name is required") }),
|
||||
platform: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
config: z.any(),
|
||||
fee_mode: z.number().min(0).max(2),
|
||||
fee_percent: z.number().optional(),
|
||||
fee_amount: z.number().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
platform: "",
|
||||
icon: "",
|
||||
domain: "",
|
||||
config: {},
|
||||
fee_mode: 0,
|
||||
fee_percent: 0,
|
||||
fee_amount: 0,
|
||||
...(initialValues as any),
|
||||
},
|
||||
});
|
||||
|
||||
const feeMode = form.watch("fee_mode");
|
||||
const platformValue = form.watch("platform");
|
||||
const configValues = form.watch("config");
|
||||
|
||||
const currentPlatform = platformData?.find(
|
||||
(p) => p.platform === platformValue
|
||||
);
|
||||
const currentFieldDescriptions =
|
||||
currentPlatform?.platform_field_description || {};
|
||||
const configFields = Object.keys(currentFieldDescriptions) || [];
|
||||
const platformUrl = currentPlatform?.platform_url || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (feeMode === 0) {
|
||||
form.setValue("fee_amount", 0);
|
||||
form.setValue("fee_percent", 0);
|
||||
} else if (feeMode === 1) {
|
||||
form.setValue("fee_amount", 0);
|
||||
} else if (feeMode === 2) {
|
||||
form.setValue("fee_percent", 0);
|
||||
}
|
||||
}, [feeMode, form]);
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const cleanedValues = { ...values };
|
||||
|
||||
if (values.fee_mode === 0) {
|
||||
cleanedValues.fee_amount = undefined;
|
||||
cleanedValues.fee_percent = undefined;
|
||||
} else if (values.fee_mode === 1) {
|
||||
cleanedValues.fee_amount = undefined;
|
||||
} else if (values.fee_mode === 2) {
|
||||
cleanedValues.fee_percent = undefined;
|
||||
}
|
||||
|
||||
const success = await onSubmit(cleanedValues as unknown as T);
|
||||
if (success) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const openPlatformUrl = () => {
|
||||
if (platformUrl) {
|
||||
window.open(platformUrl, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
||||
<SheetContent className="w-[550px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-48px-36px-36px-24px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6 px-6 pt-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name", "Name")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
form.setValue("name", value as string)
|
||||
}
|
||||
placeholder={t(
|
||||
"namePlaceholder",
|
||||
"Enter payment method name"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("icon", "Icon")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
form.setValue("icon", value as string)
|
||||
}
|
||||
placeholder={t("iconPlaceholder", "Enter icon URL")}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("domain", "Domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
form.setValue("domain", value as string)
|
||||
}
|
||||
placeholder="http(s)://example.com"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fee_mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("handlingFee", "Handling Fee")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
className="flex flex-wrap gap-4"
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number.parseInt(value, 10))
|
||||
}
|
||||
value={field.value.toString()}
|
||||
>
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="0" />
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0 cursor-pointer">
|
||||
{t("noFee", "No Fee")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="1" />
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0 cursor-pointer">
|
||||
{t("percentFee", "Percentage")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="2" />
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0 cursor-pointer">
|
||||
{t("fixedFee", "Fixed Amount")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{feeMode === 1 && (
|
||||
<div className="grid grid-cols-1 sm:w-1/2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fee_percent"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("feePercent", "Fee Percentage")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
step="0.01"
|
||||
suffix="%"
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feeMode === 2 && (
|
||||
<div className="grid grid-cols-1 sm:w-1/2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fee_amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("feeAmount", "Fixed Amount")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
unitConversion("dollarsToCents", value)
|
||||
)
|
||||
}
|
||||
prefix={currency.currency_symbol}
|
||||
step="0.01"
|
||||
suffix={currency.currency_unit}
|
||||
type="number"
|
||||
value={unitConversion(
|
||||
"centsToDollars",
|
||||
field.value
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(!platformValue ||
|
||||
platformData?.find((p) => p.platform === platformValue)) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("platform", "Platform")}</FormLabel>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
disabled={isEdit && Boolean(initialValues?.platform)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue("platform", value as string);
|
||||
form.setValue("config", {});
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectPlatform",
|
||||
"Select Platform"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{platformData?.map((platform) => (
|
||||
<SelectItem
|
||||
key={platform.platform}
|
||||
value={platform.platform}
|
||||
>
|
||||
{platform.platform}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{platformUrl ? (
|
||||
<div className="mt-1 flex justify-end">
|
||||
<Button
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={openPlatformUrl}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon
|
||||
className="mr-1 h-3 w-3"
|
||||
icon="tabler:external-link"
|
||||
/>
|
||||
{t("applyForPayment", "Apply for Payment")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 h-6" />
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{configFields.length > 0 && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{configFields.map((fieldKey) => (
|
||||
<FormItem key={fieldKey}>
|
||||
<FormLabel>
|
||||
{currentFieldDescriptions[fieldKey]}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={fieldKey === "webhook_secret"}
|
||||
onValueChange={(value) => {
|
||||
const newConfig = { ...configValues };
|
||||
newConfig[fieldKey] = value;
|
||||
form.setValue("config", newConfig);
|
||||
}}
|
||||
placeholder={t("configPlaceholder", {
|
||||
field: currentFieldDescriptions[fieldKey],
|
||||
defaultValue:
|
||||
"Please fill in the provided {{field}} configuration",
|
||||
})}
|
||||
value={
|
||||
configValues &&
|
||||
configValues[fieldKey] !== undefined
|
||||
? configValues[fieldKey]
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("description", "Description")}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
onChange={(value: string | undefined) =>
|
||||
form.setValue(field.name, value as string)
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button disabled={loading} onClick={handleClose} variant="outline">
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("submit", "Submit")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@workspace/ui/components/avatar";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createPaymentMethod,
|
||||
deletePaymentMethod,
|
||||
getPaymentMethodList,
|
||||
updatePaymentMethod,
|
||||
} from "@workspace/ui/services/admin/payment";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Display } from "@/components/display";
|
||||
import PaymentForm from "./payment-form";
|
||||
|
||||
export default function PaymentTable() {
|
||||
const { t } = useTranslation("payment");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
return (
|
||||
<ProTable<API.PaymentConfig, { search: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<PaymentForm<API.UpdatePaymentMethodRequest>
|
||||
initialValues={row}
|
||||
isEdit
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updatePaymentMethod({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("editPayment", "Edit Payment Method")}
|
||||
trigger={<Button>{t("edit", "Edit")}</Button>}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Are you sure you want to delete this payment method? This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deletePaymentMethod({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
<Button
|
||||
key="copy"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { id: _id, ...params } = row;
|
||||
await createPaymentMethod({
|
||||
...params,
|
||||
enable: false,
|
||||
});
|
||||
toast.success(t("copySuccess", "Copied successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("copy", "Copy")}
|
||||
</Button>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteWarning",
|
||||
"Are you sure you want to delete this payment method? This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
for (const row of rows) {
|
||||
await deletePaymentMethod({ id: row.id });
|
||||
}
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">
|
||||
{t("batchDelete", "Batch Delete")}
|
||||
</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "enable",
|
||||
header: t("enable", "Enable"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={Boolean(row.getValue("enable"))}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updatePaymentMethod({
|
||||
...row.original,
|
||||
enable: checked,
|
||||
});
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "icon",
|
||||
header: t("icon", "Icon"),
|
||||
cell: ({ row }) => {
|
||||
const icon = row.getValue("icon") as string;
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
{icon ? (
|
||||
<AvatarImage alt={row.getValue("name")} src={icon} />
|
||||
) : null}
|
||||
<AvatarFallback>
|
||||
{(row.getValue("name") as string)?.charAt(0) || "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("name", "Name"),
|
||||
},
|
||||
{
|
||||
accessorKey: "platform",
|
||||
header: t("platform", "Platform"),
|
||||
cell: ({ row }) => <Badge>{t(row.original.platform)}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: "notify_url",
|
||||
header: t("notify_url", "Notify URL"),
|
||||
},
|
||||
{
|
||||
accessorKey: "fee",
|
||||
header: t("handlingFee", "Handling Fee"),
|
||||
cell: ({ row }) => {
|
||||
const feeMode = row.original.fee_mode;
|
||||
if (feeMode === 1) {
|
||||
return <Badge>{row.original.fee_percent}%</Badge>;
|
||||
}
|
||||
if (feeMode === 2) {
|
||||
return (
|
||||
<Badge>
|
||||
<Display type="currency" value={row.original.fee_amount} />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return "--";
|
||||
},
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("paymentManagement", "Payment Management"),
|
||||
toolbar: (
|
||||
<PaymentForm<API.CreatePaymentMethodRequest>
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createPaymentMethod({
|
||||
...values,
|
||||
enable: false,
|
||||
});
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createPayment", "Add Payment Method")}
|
||||
trigger={<Button>{t("create", "Add Payment Method")}</Button>}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "search",
|
||||
placeholder: t("searchPlaceholder", "Enter search terms"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getPaymentMethodList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data?.data?.list || [],
|
||||
total: data?.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SubscribeTable from "./subscribe-table";
|
||||
|
||||
export default function Product() {
|
||||
return <SubscribeTable />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
batchDeleteSubscribe,
|
||||
createSubscribe,
|
||||
deleteSubscribe,
|
||||
getSubscribeList,
|
||||
subscribeSort,
|
||||
updateSubscribe,
|
||||
} from "@workspace/ui/services/admin/subscribe";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Display } from "@/components/display";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
import SubscribeForm from "./subscribe-form";
|
||||
|
||||
export default function SubscribeTable() {
|
||||
const { t } = useTranslation("product");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
const { fetchSubscribes } = useSubscribe();
|
||||
return (
|
||||
<ProTable<API.SubscribeItem, { group_id: number; query: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<SubscribeForm<API.SubscribeItem>
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribe({
|
||||
...row,
|
||||
...values,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
toast.success(t("updateSuccess"));
|
||||
ref.current?.refresh();
|
||||
fetchSubscribes();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("editSubscribe")}
|
||||
trigger={t("edit")}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel")}
|
||||
confirmText={t("confirm")}
|
||||
description={t("deleteWarning")}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteSubscribe({
|
||||
id: row.id!,
|
||||
});
|
||||
toast.success(t("deleteSuccess"));
|
||||
ref.current?.refresh();
|
||||
fetchSubscribes();
|
||||
}}
|
||||
title={t("confirmDelete")}
|
||||
trigger={<Button variant="destructive">{t("delete")}</Button>}
|
||||
/>,
|
||||
<Button
|
||||
key="copy"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const {
|
||||
id: _id,
|
||||
sort: _sort,
|
||||
sell: _sell,
|
||||
updated_at: _updated_at,
|
||||
created_at: _created_at,
|
||||
...params
|
||||
} = row;
|
||||
await createSubscribe({
|
||||
...params,
|
||||
show: false,
|
||||
sell: false,
|
||||
} as API.CreateSubscribeRequest);
|
||||
toast.success(t("copySuccess"));
|
||||
ref.current?.refresh();
|
||||
fetchSubscribes();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{t("copy")}
|
||||
</Button>,
|
||||
],
|
||||
batchRender: (rows) => [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel")}
|
||||
confirmText={t("confirm")}
|
||||
description={t("deleteWarning")}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await batchDeleteSubscribe({
|
||||
ids: rows.map((item) => item.id) as number[],
|
||||
});
|
||||
|
||||
toast.success(t("deleteSuccess"));
|
||||
ref.current?.reset();
|
||||
fetchSubscribes();
|
||||
}}
|
||||
title={t("confirmDelete")}
|
||||
trigger={<Button variant="destructive">{t("delete")}</Button>}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "show",
|
||||
header: t("show"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("show")}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
show: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
fetchSubscribes();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "sell",
|
||||
header: t("sell"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("sell")}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
sell: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
fetchSubscribes();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("name"),
|
||||
},
|
||||
{
|
||||
accessorKey: "unit_price",
|
||||
header: t("unitPrice"),
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Display type="currency" value={row.getValue("unit_price")} />/
|
||||
{t(
|
||||
row.original.unit_time
|
||||
? `form.${row.original.unit_time}`
|
||||
: "form.Month"
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "replacement",
|
||||
header: t("replacement"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.getValue("replacement")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "traffic",
|
||||
header: t("traffic"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" unlimited value={row.getValue("traffic")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "device_limit",
|
||||
header: t("deviceLimit"),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type="number"
|
||||
unlimited
|
||||
value={row.getValue("device_limit")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "inventory",
|
||||
header: t("inventory"),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type="number"
|
||||
unlimited
|
||||
value={
|
||||
row.getValue("inventory") === -1 ? 0 : row.getValue("inventory")
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "quota",
|
||||
header: t("quota"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="number" unlimited value={row.getValue("quota")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "language",
|
||||
header: t("language"),
|
||||
cell: ({ row }) => {
|
||||
const language = row.getValue("language") as string;
|
||||
return language ? (
|
||||
<Badge variant="outline">{language}</Badge>
|
||||
) : (
|
||||
"--"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "sold",
|
||||
header: t("sold"),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">{row.getValue("sold")}</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
toolbar: (
|
||||
<SubscribeForm<API.CreateSubscribeRequest>
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createSubscribe({
|
||||
...values,
|
||||
show: false,
|
||||
sell: false,
|
||||
});
|
||||
toast.success(t("createSuccess"));
|
||||
ref.current?.refresh();
|
||||
fetchSubscribes();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createSubscribe")}
|
||||
trigger={t("create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
onSort={async (source, target, items) => {
|
||||
const sourceIndex = items.findIndex(
|
||||
(item) => String(item.id) === source
|
||||
);
|
||||
const targetIndex = items.findIndex(
|
||||
(item) => String(item.id) === target
|
||||
);
|
||||
|
||||
const originalSorts = items.map((item) => item.sort);
|
||||
|
||||
const [movedItem] = items.splice(sourceIndex, 1);
|
||||
items.splice(targetIndex, 0, movedItem!);
|
||||
|
||||
const updatedItems = items.map((item, index) => {
|
||||
const originalSort = originalSorts[index];
|
||||
const newSort = originalSort !== undefined ? originalSort : item.sort;
|
||||
return { ...item, sort: newSort };
|
||||
});
|
||||
|
||||
const changedItems = updatedItems.filter(
|
||||
(item, index) => item.sort !== items[index]?.sort
|
||||
);
|
||||
|
||||
if (changedItems.length > 0) {
|
||||
subscribeSort({
|
||||
sort: changedItems.map((item) => ({
|
||||
id: item.id,
|
||||
sort: item.sort,
|
||||
})) as API.SortItem[],
|
||||
});
|
||||
}
|
||||
|
||||
return updatedItems;
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "search",
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getSubscribeList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Card, CardContent } from "@workspace/ui/components/card";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getNodeMultiplier,
|
||||
setNodeMultiplier,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function DynamicMultiplier() {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [timeSlots, setTimeSlots] = useState<API.TimePeriod[]>([]);
|
||||
|
||||
const { data: periodsResp, refetch: refetchPeriods } = useQuery({
|
||||
queryKey: ["getNodeMultiplier"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeMultiplier();
|
||||
return (data.data?.periods || []) as API.TimePeriod[];
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (periodsResp) {
|
||||
setTimeSlots(periodsResp);
|
||||
}
|
||||
}, [periodsResp]);
|
||||
|
||||
async function savePeriods() {
|
||||
await setNodeMultiplier({ periods: timeSlots });
|
||||
await refetchPeriods();
|
||||
toast.success(t("server_config.saveSuccess", "Saved successfully"));
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex cursor-pointer items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:clock-time-eight"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t(
|
||||
"server_config.dynamic_multiplier",
|
||||
"Dynamic multiplier"
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-muted-foreground text-sm">
|
||||
{t(
|
||||
"server_config.dynamic_multiplier_desc",
|
||||
"Define time slots and multipliers to adjust traffic accounting."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("server_config.dynamic_multiplier", "Dynamic multiplier")}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t(
|
||||
"server_config.dynamic_multiplier_desc",
|
||||
"Define time slots and multipliers to adjust traffic accounting."
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-60px-env(safe-area-inset-top))] px-6">
|
||||
<div className="space-y-4 pt-4">
|
||||
<ArrayInput<API.TimePeriod>
|
||||
fields={[
|
||||
{
|
||||
name: "start_time",
|
||||
prefix: t("server_config.fields.start_time", "Start time"),
|
||||
type: "time",
|
||||
step: "1",
|
||||
},
|
||||
{
|
||||
name: "end_time",
|
||||
prefix: t("server_config.fields.end_time", "End time"),
|
||||
type: "time",
|
||||
step: "1",
|
||||
},
|
||||
{
|
||||
name: "multiplier",
|
||||
prefix: t("server_config.fields.multiplier", "Multiplier"),
|
||||
type: "number",
|
||||
placeholder: "0",
|
||||
},
|
||||
]}
|
||||
onChange={setTimeSlots}
|
||||
value={timeSlots}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="flex-row justify-between pt-3">
|
||||
<Button
|
||||
onClick={() => setTimeSlots(periodsResp || [])}
|
||||
variant="outline"
|
||||
>
|
||||
{t("server_config.fields.reset", "Reset")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setOpen(false)} variant="outline">
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={savePeriods}>{t("actions.save", "Save")}</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export const protocols = [
|
||||
"shadowsocks",
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"hysteria",
|
||||
"tuic",
|
||||
"anytls",
|
||||
"socks",
|
||||
"naive",
|
||||
"http",
|
||||
"mieru",
|
||||
] as const;
|
||||
|
||||
// Global label map for display; fallback to raw value if missing
|
||||
export const LABELS = {
|
||||
// transport
|
||||
tcp: "TCP",
|
||||
udp: "UDP",
|
||||
websocket: "WebSocket",
|
||||
grpc: "gRPC",
|
||||
mkcp: "mKCP",
|
||||
httpupgrade: "HTTP Upgrade",
|
||||
xhttp: "XHTTP",
|
||||
// security
|
||||
none: "NONE",
|
||||
tls: "TLS",
|
||||
reality: "Reality",
|
||||
// fingerprint
|
||||
chrome: "Chrome",
|
||||
firefox: "Firefox",
|
||||
safari: "Safari",
|
||||
ios: "IOS",
|
||||
android: "Android",
|
||||
edge: "edge",
|
||||
"360": "360",
|
||||
qq: "QQ",
|
||||
// multiplex
|
||||
low: "Low",
|
||||
middle: "Middle",
|
||||
high: "High",
|
||||
} as const;
|
||||
|
||||
// Flat arrays for enum-like sets
|
||||
export const SS_CIPHERS = [
|
||||
"aes-128-gcm",
|
||||
"aes-192-gcm",
|
||||
"aes-256-gcm",
|
||||
"chacha20-ietf-poly1305",
|
||||
"2022-blake3-aes-128-gcm",
|
||||
"2022-blake3-aes-256-gcm",
|
||||
"2022-blake3-chacha20-poly1305",
|
||||
] as const;
|
||||
|
||||
export const TRANSPORTS = {
|
||||
vmess: ["tcp", "websocket", "grpc"] as const,
|
||||
vless: ["tcp", "websocket", "grpc", "mkcp", "httpupgrade", "xhttp"] as const,
|
||||
trojan: ["tcp", "websocket", "grpc"] as const,
|
||||
mieru: ["tcp", "udp"] as const,
|
||||
} as const;
|
||||
|
||||
export const SECURITY = {
|
||||
shadowsocks: ["none", "http", "tls"] as const,
|
||||
vmess: ["none", "tls"] as const,
|
||||
vless: ["none", "tls", "reality"] as const,
|
||||
trojan: ["tls"] as const,
|
||||
hysteria: ["tls"] as const,
|
||||
tuic: ["tls"] as const,
|
||||
anytls: ["tls"] as const,
|
||||
naive: ["none", "tls"] as const,
|
||||
http: ["none", "tls"] as const,
|
||||
} as const;
|
||||
|
||||
export const FLOWS = {
|
||||
vless: [
|
||||
"none",
|
||||
"xtls-rprx-direct",
|
||||
"xtls-rprx-splice",
|
||||
"xtls-rprx-vision",
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
export const TUIC_UDP_RELAY_MODES = ["native", "quic"] as const;
|
||||
export const TUIC_CONGESTION = ["bbr", "cubic", "new_reno"] as const;
|
||||
export const XHTTP_MODES = [
|
||||
"auto",
|
||||
"packet-up",
|
||||
"stream-up",
|
||||
"stream-one",
|
||||
] as const;
|
||||
export const ENCRYPTION_TYPES = ["none", "mlkem768x25519plus"] as const;
|
||||
export const ENCRYPTION_MODES = ["native", "xorpub", "random"] as const;
|
||||
export const ENCRYPTION_RTT = ["0rtt", "1rtt"] as const;
|
||||
export const FINGERPRINTS = [
|
||||
"chrome",
|
||||
"firefox",
|
||||
"safari",
|
||||
"ios",
|
||||
"android",
|
||||
"edge",
|
||||
"360",
|
||||
"qq",
|
||||
] as const;
|
||||
|
||||
export const CERT_MODES = ["none", "http", "dns", "self"] as const;
|
||||
|
||||
export const multiplexLevels = ["none", "low", "middle", "high"] as const;
|
||||
|
||||
export function getLabel(value: string): string {
|
||||
const label = (LABELS as Record<string, string>)[value];
|
||||
return label ?? value.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { XHTTP_MODES } from "./constants";
|
||||
import type { ProtocolType } from "./types";
|
||||
|
||||
export function getProtocolDefaultConfig(proto: ProtocolType) {
|
||||
switch (proto) {
|
||||
case "shadowsocks":
|
||||
return {
|
||||
type: "shadowsocks",
|
||||
enable: false,
|
||||
port: null,
|
||||
cipher: "chacha20-ietf-poly1305",
|
||||
server_key: null,
|
||||
obfs: "none",
|
||||
obfs_host: null,
|
||||
obfs_path: null,
|
||||
sni: null,
|
||||
allow_insecure: null,
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "vmess":
|
||||
return {
|
||||
type: "vmess",
|
||||
enable: false,
|
||||
host: null,
|
||||
port: null,
|
||||
transport: "tcp",
|
||||
security: "none",
|
||||
path: null,
|
||||
service_name: null,
|
||||
sni: null,
|
||||
allow_insecure: null,
|
||||
fingerprint: "chrome",
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "vless":
|
||||
return {
|
||||
type: "vless",
|
||||
enable: false,
|
||||
host: null,
|
||||
port: null,
|
||||
transport: "tcp",
|
||||
security: "none",
|
||||
flow: "none",
|
||||
path: null,
|
||||
service_name: null,
|
||||
sni: null,
|
||||
allow_insecure: null,
|
||||
fingerprint: "chrome",
|
||||
reality_server_addr: null,
|
||||
reality_server_port: null,
|
||||
reality_private_key: null,
|
||||
reality_public_key: null,
|
||||
reality_short_id: null,
|
||||
xhttp_mode: XHTTP_MODES[0], // 'auto'
|
||||
xhttp_extra: null,
|
||||
encryption: "none",
|
||||
encryption_mode: null,
|
||||
encryption_rtt: null,
|
||||
encryption_ticket: null,
|
||||
encryption_server_padding: null,
|
||||
encryption_private_key: null,
|
||||
encryption_client_padding: null,
|
||||
encryption_password: null,
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "trojan":
|
||||
return {
|
||||
type: "trojan",
|
||||
enable: false,
|
||||
host: null,
|
||||
port: null,
|
||||
transport: "tcp",
|
||||
security: "tls",
|
||||
path: null,
|
||||
service_name: null,
|
||||
sni: null,
|
||||
allow_insecure: null,
|
||||
fingerprint: "chrome",
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "hysteria":
|
||||
return {
|
||||
type: "hysteria",
|
||||
enable: false,
|
||||
port: null,
|
||||
hop_ports: null,
|
||||
hop_interval: null,
|
||||
obfs: "none",
|
||||
obfs_password: null,
|
||||
security: "tls",
|
||||
up_mbps: null,
|
||||
down_mbps: null,
|
||||
sni: null,
|
||||
allow_insecure: null,
|
||||
fingerprint: "chrome",
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "tuic":
|
||||
return {
|
||||
type: "tuic",
|
||||
enable: false,
|
||||
port: null,
|
||||
disable_sni: false,
|
||||
reduce_rtt: false,
|
||||
udp_relay_mode: "native",
|
||||
congestion_controller: "bbr",
|
||||
security: "tls",
|
||||
sni: null,
|
||||
allow_insecure: false,
|
||||
fingerprint: "chrome",
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "socks":
|
||||
return {
|
||||
type: "socks",
|
||||
enable: false,
|
||||
port: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "naive":
|
||||
return {
|
||||
type: "naive",
|
||||
enable: false,
|
||||
port: null,
|
||||
security: "none",
|
||||
sni: null,
|
||||
allow_insecure: null,
|
||||
fingerprint: "chrome",
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "http":
|
||||
return {
|
||||
type: "http",
|
||||
enable: false,
|
||||
port: null,
|
||||
security: "none",
|
||||
sni: null,
|
||||
allow_insecure: null,
|
||||
fingerprint: "chrome",
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
case "mieru":
|
||||
return {
|
||||
type: "mieru",
|
||||
enable: false,
|
||||
port: null,
|
||||
multiplex: "none",
|
||||
transport: "tcp",
|
||||
} as any;
|
||||
case "anytls":
|
||||
return {
|
||||
type: "anytls",
|
||||
enable: false,
|
||||
port: null,
|
||||
security: "tls",
|
||||
padding_scheme: null,
|
||||
sni: null,
|
||||
allow_insecure: false,
|
||||
fingerprint: "chrome",
|
||||
cert_mode: "none",
|
||||
cert_dns_provider: null,
|
||||
cert_dns_env: null,
|
||||
ratio: 1,
|
||||
} as any;
|
||||
default:
|
||||
return {} as any;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Re-export all constants
|
||||
export {
|
||||
ENCRYPTION_MODES,
|
||||
ENCRYPTION_RTT,
|
||||
ENCRYPTION_TYPES,
|
||||
FINGERPRINTS,
|
||||
FLOWS,
|
||||
getLabel,
|
||||
LABELS,
|
||||
multiplexLevels,
|
||||
protocols,
|
||||
SECURITY,
|
||||
SS_CIPHERS,
|
||||
TRANSPORTS,
|
||||
TUIC_CONGESTION,
|
||||
TUIC_UDP_RELAY_MODES,
|
||||
XHTTP_MODES,
|
||||
} from "./constants";
|
||||
// Re-export defaults
|
||||
export { getProtocolDefaultConfig } from "./defaults";
|
||||
// Re-export all schemas
|
||||
export { formSchema, protocolApiScheme } from "./schemas";
|
||||
// Re-export all types
|
||||
export type { FieldConfig, ProtocolType } from "./types";
|
||||
// Re-export hooks
|
||||
export { useProtocolFields } from "./useProtocolFields";
|
||||
@@ -0,0 +1,225 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CERT_MODES,
|
||||
ENCRYPTION_MODES,
|
||||
ENCRYPTION_RTT,
|
||||
ENCRYPTION_TYPES,
|
||||
FLOWS,
|
||||
multiplexLevels,
|
||||
SECURITY,
|
||||
SS_CIPHERS,
|
||||
TRANSPORTS,
|
||||
TUIC_CONGESTION,
|
||||
TUIC_UDP_RELAY_MODES,
|
||||
XHTTP_MODES,
|
||||
} from "./constants";
|
||||
|
||||
const nullableString = z.string().nullish();
|
||||
const nullableBool = z.boolean().nullish();
|
||||
const nullablePort = z.number().int().min(0).max(65_535).nullish();
|
||||
const nullableRatio = z.number().min(0).nullish();
|
||||
|
||||
const ss = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("shadowsocks"),
|
||||
enable: nullableBool,
|
||||
port: nullablePort,
|
||||
cipher: z.enum(SS_CIPHERS).nullish(),
|
||||
server_key: nullableString,
|
||||
obfs: z.enum(["none", "http", "tls"] as const).nullish(),
|
||||
obfs_host: nullableString,
|
||||
obfs_path: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const vmess = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("vmess"),
|
||||
enable: nullableBool,
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
transport: z.enum(TRANSPORTS.vmess).nullish(),
|
||||
security: z.enum(SECURITY.vmess).nullish(),
|
||||
path: nullableString,
|
||||
service_name: nullableString,
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const vless = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("vless"),
|
||||
enable: nullableBool,
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
transport: z.enum(TRANSPORTS.vless).nullish(),
|
||||
security: z.enum(SECURITY.vless).nullish(),
|
||||
path: nullableString,
|
||||
service_name: nullableString,
|
||||
flow: z.enum(FLOWS.vless).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
reality_server_addr: nullableString,
|
||||
reality_server_port: nullablePort,
|
||||
reality_private_key: nullableString,
|
||||
reality_public_key: nullableString,
|
||||
reality_short_id: nullableString,
|
||||
xhttp_mode: z.enum(XHTTP_MODES).nullish(),
|
||||
xhttp_extra: nullableString,
|
||||
encryption: z.enum(ENCRYPTION_TYPES).nullish(),
|
||||
encryption_mode: z.enum(ENCRYPTION_MODES).nullish(),
|
||||
encryption_rtt: z.enum(ENCRYPTION_RTT).nullish(),
|
||||
encryption_ticket: nullableString,
|
||||
encryption_server_padding: nullableString,
|
||||
encryption_private_key: nullableString,
|
||||
encryption_client_padding: nullableString,
|
||||
encryption_password: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const trojan = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("trojan"),
|
||||
enable: nullableBool,
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
transport: z.enum(TRANSPORTS.trojan).nullish(),
|
||||
security: z.enum(SECURITY.trojan).nullish(),
|
||||
path: nullableString,
|
||||
service_name: nullableString,
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const hysteria = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("hysteria"),
|
||||
enable: nullableBool,
|
||||
hop_ports: nullableString,
|
||||
hop_interval: z.number().nullish(),
|
||||
obfs_password: nullableString,
|
||||
obfs: z.enum(["none", "salamander"] as const).nullish(),
|
||||
port: nullablePort,
|
||||
security: z.enum(SECURITY.hysteria).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
up_mbps: z.number().nullish(),
|
||||
down_mbps: z.number().nullish(),
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const tuic = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("tuic"),
|
||||
enable: nullableBool,
|
||||
host: nullableString,
|
||||
port: nullablePort,
|
||||
disable_sni: z.boolean().nullish(),
|
||||
reduce_rtt: z.boolean().nullish(),
|
||||
udp_relay_mode: z.enum(TUIC_UDP_RELAY_MODES).nullish(),
|
||||
congestion_controller: z.enum(TUIC_CONGESTION).nullish(),
|
||||
security: z.enum(SECURITY.tuic).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const anytls = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("anytls"),
|
||||
enable: nullableBool,
|
||||
port: nullablePort,
|
||||
security: z.enum(SECURITY.anytls).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
padding_scheme: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const socks = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("socks"),
|
||||
enable: nullableBool,
|
||||
port: nullablePort,
|
||||
});
|
||||
|
||||
const naive = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("naive"),
|
||||
enable: nullableBool,
|
||||
port: nullablePort,
|
||||
security: z.enum(SECURITY.naive).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const http = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("http"),
|
||||
enable: nullableBool,
|
||||
port: nullablePort,
|
||||
security: z.enum(SECURITY.http).nullish(),
|
||||
sni: nullableString,
|
||||
allow_insecure: nullableBool,
|
||||
fingerprint: nullableString,
|
||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||
cert_dns_provider: nullableString,
|
||||
cert_dns_env: nullableString,
|
||||
});
|
||||
|
||||
const mieru = z.object({
|
||||
ratio: nullableRatio,
|
||||
type: z.literal("mieru"),
|
||||
enable: nullableBool,
|
||||
port: nullablePort,
|
||||
multiplex: z.enum(multiplexLevels).nullish(),
|
||||
transport: z.enum(TRANSPORTS.mieru).nullish(),
|
||||
});
|
||||
|
||||
export const protocolApiScheme = z.discriminatedUnion("type", [
|
||||
ss,
|
||||
vmess,
|
||||
vless,
|
||||
trojan,
|
||||
hysteria,
|
||||
tuic,
|
||||
anytls,
|
||||
socks,
|
||||
naive,
|
||||
http,
|
||||
mieru,
|
||||
]);
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
country: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
protocols: z.array(protocolApiScheme),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { protocols } from "./constants";
|
||||
|
||||
export type FieldConfig = {
|
||||
name: string;
|
||||
type: "input" | "select" | "switch" | "number" | "textarea";
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
options?: readonly string[];
|
||||
defaultValue?: any;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
suffix?: string;
|
||||
generate?: {
|
||||
function?: () =>
|
||||
| Promise<string | Record<string, string>>
|
||||
| string
|
||||
| Record<string, string>;
|
||||
functions?: {
|
||||
label: string;
|
||||
function: () =>
|
||||
| Promise<string | Record<string, string>>
|
||||
| string
|
||||
| Record<string, string>;
|
||||
}[];
|
||||
updateFields?: Record<string, string>;
|
||||
};
|
||||
condition?: (protocol: any, values: any) => boolean;
|
||||
group?:
|
||||
| "basic"
|
||||
| "transport"
|
||||
| "security"
|
||||
| "reality"
|
||||
| "obfs"
|
||||
| "encryption";
|
||||
gridSpan?: 1 | 2;
|
||||
};
|
||||
|
||||
export type ProtocolType = (typeof protocols)[number];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
export { generateMLKEM768KeyPair } from "./mlkem768";
|
||||
export { generateRealityShortId } from "./short-id";
|
||||
export { generatePassword } from "./uid";
|
||||
export { generateRealityKeyPair } from "./x25519";
|
||||
@@ -0,0 +1,22 @@
|
||||
import mlkem from "mlkem-wasm";
|
||||
import { toB64Url } from "./util";
|
||||
|
||||
export async function generateMLKEM768KeyPair() {
|
||||
const mlkemKeyPair = await mlkem.generateKey({ name: "ML-KEM-768" }, true, [
|
||||
"encapsulateBits",
|
||||
"decapsulateBits",
|
||||
]);
|
||||
const mlkemPublicKeyRaw = await mlkem.exportKey(
|
||||
"raw-public",
|
||||
mlkemKeyPair.publicKey
|
||||
);
|
||||
const mlkemPrivateKeyRaw = await mlkem.exportKey(
|
||||
"raw-seed",
|
||||
mlkemKeyPair.privateKey
|
||||
);
|
||||
|
||||
return {
|
||||
publicKey: toB64Url(new Uint8Array(mlkemPublicKeyRaw)),
|
||||
privateKey: toB64Url(new Uint8Array(mlkemPrivateKeyRaw)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generate a short ID for Reality
|
||||
* @returns A random hexadecimal string of length 2, 4, 6, 8, 10, 12, 14, or 16
|
||||
*/
|
||||
export function generateRealityShortId() {
|
||||
const hex = "0123456789abcdef";
|
||||
const lengths = [2, 4, 6, 8, 10, 12, 14, 16];
|
||||
const idx = Math.floor(Math.random() * lengths.length);
|
||||
const len = lengths[idx] ?? 16;
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += hex.charAt(Math.floor(Math.random() * hex.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { uid } from "radash";
|
||||
|
||||
/**
|
||||
* Generate a random password
|
||||
* @param length Length of the password
|
||||
* @param charset Character set to use (defaults to alphanumeric)
|
||||
* @returns Randomly generated password
|
||||
*/
|
||||
export function generatePassword(length = 16, charset?: string) {
|
||||
return uid(length, charset).toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function toB64Url(bytes: Uint8Array) {
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { x25519 } from "@noble/curves/ed25519.js";
|
||||
import { toB64Url } from "./util";
|
||||
|
||||
/**
|
||||
* Generate a Reality key pair
|
||||
* @returns An object containing the private and public keys in base64url format
|
||||
*/
|
||||
export function generateRealityKeyPair() {
|
||||
const { secretKey, publicKey } = x25519.keygen();
|
||||
return { privateKey: toB64Url(secretKey), publicKey: toB64Url(publicKey) };
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import {
|
||||
createServer,
|
||||
deleteServer,
|
||||
filterServerList,
|
||||
resetSortWithServer,
|
||||
updateServer,
|
||||
} from "@workspace/ui/services/admin/server";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useNode } from "@/stores/node";
|
||||
import { useServer } from "@/stores/server";
|
||||
import DynamicMultiplier from "./dynamic-multiplier";
|
||||
import OnlineUsersCell from "./online-users-cell";
|
||||
import ServerConfig from "./server-config";
|
||||
import ServerForm from "./server-form";
|
||||
import ServerInstall from "./server-install";
|
||||
|
||||
function PctBar({ value }: { value: number }) {
|
||||
const v = value.toFixed(2);
|
||||
const widthClass =
|
||||
value >= 90
|
||||
? "w-[90%]"
|
||||
: value >= 80
|
||||
? "w-4/5"
|
||||
: value >= 70
|
||||
? "w-[70%]"
|
||||
: value >= 60
|
||||
? "w-3/5"
|
||||
: value >= 50
|
||||
? "w-1/2"
|
||||
: value >= 40
|
||||
? "w-2/5"
|
||||
: value >= 30
|
||||
? "w-[30%]"
|
||||
: value >= 20
|
||||
? "w-1/5"
|
||||
: value >= 10
|
||||
? "w-[10%]"
|
||||
: "w-0";
|
||||
return (
|
||||
<div className="min-w-24">
|
||||
<div className="text-xs leading-none">{v}%</div>
|
||||
<div className="h-1.5 w-full rounded bg-muted">
|
||||
<div className={cn("h-1.5 rounded bg-primary", widthClass)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionIpCell({
|
||||
country,
|
||||
city,
|
||||
ip,
|
||||
notAvailableText,
|
||||
}: {
|
||||
country?: string;
|
||||
city?: string;
|
||||
ip?: string;
|
||||
notAvailableText: string;
|
||||
}) {
|
||||
const region =
|
||||
[country, city].filter(Boolean).join(" / ") || notAvailableText;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline">{region}</Badge>
|
||||
<Badge variant="secondary">{ip || notAvailableText}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Servers() {
|
||||
const { t } = useTranslation("servers");
|
||||
const { isServerReferencedByNodes } = useNode();
|
||||
const { fetchServers } = useServer();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<DynamicMultiplier />
|
||||
<ServerConfig />
|
||||
</div>
|
||||
<ProTable<API.Server, { search: string }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<ServerForm
|
||||
initialValues={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateServer({
|
||||
id: row.id,
|
||||
...(values as unknown as Omit<
|
||||
API.UpdateServerRequest,
|
||||
"id"
|
||||
>),
|
||||
});
|
||||
toast.success(t("updated", "Updated"));
|
||||
ref.current?.refresh();
|
||||
fetchServers();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("drawerEditTitle", "Edit Server")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<ServerInstall key="install" server={row} />,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"confirmDeleteDesc",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteServer({ id: row.id } as API.DeleteServerRequest);
|
||||
toast.success(t("deleted", "Deleted"));
|
||||
ref.current?.refresh();
|
||||
fetchServers();
|
||||
}}
|
||||
title={t("confirmDeleteTitle", "Delete this server?")}
|
||||
trigger={
|
||||
<Button
|
||||
disabled={isServerReferencedByNodes(row.id)}
|
||||
variant="destructive"
|
||||
>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
}
|
||||
/>,
|
||||
<Button
|
||||
key="copy"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
const {
|
||||
id: _id,
|
||||
created_at: _created_at,
|
||||
updated_at: _updated_at,
|
||||
last_reported_at: _last_reported_at,
|
||||
status: _status,
|
||||
...others
|
||||
} = row as Record<string, unknown>;
|
||||
const body: API.CreateServerRequest = {
|
||||
name: others.name as string,
|
||||
country: others.country as string,
|
||||
city: others.city as string,
|
||||
address: others.address as string,
|
||||
protocols: (others.protocols as API.Protocol[]) || [],
|
||||
};
|
||||
await createServer(body);
|
||||
toast.success(t("copied", "Copied"));
|
||||
ref.current?.refresh();
|
||||
fetchServers();
|
||||
setLoading(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("copy", "Copy")}
|
||||
</Button>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
const hasReferencedServers = rows.some((row) =>
|
||||
isServerReferencedByNodes(row.id)
|
||||
);
|
||||
return [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"confirmDeleteDesc",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await Promise.all(
|
||||
rows.map((r) => deleteServer({ id: r.id }))
|
||||
);
|
||||
toast.success(t("deleted", "Deleted"));
|
||||
ref.current?.refresh();
|
||||
fetchServers();
|
||||
}}
|
||||
title={t("confirmDeleteTitle", "Delete this server?")}
|
||||
trigger={
|
||||
<Button disabled={hasReferencedServers} variant="destructive">
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: t("id", "ID"),
|
||||
cell: ({ row }) => <Badge>{row.getValue("id")}</Badge>,
|
||||
},
|
||||
{ accessorKey: "name", header: t("name", "Name") },
|
||||
{
|
||||
id: "region_ip",
|
||||
header: t("address", "Address"),
|
||||
cell: ({ row }) => (
|
||||
<RegionIpCell
|
||||
city={row.original.city as unknown as string}
|
||||
country={row.original.country as unknown as string}
|
||||
ip={row.original.address as unknown as string}
|
||||
notAvailableText={t("notAvailable", "Not Available")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "protocols",
|
||||
header: t("protocols", "Protocols"),
|
||||
cell: ({ row }) => {
|
||||
const list = row.original.protocols.filter(
|
||||
(p) => p.enable
|
||||
) as API.Protocol[];
|
||||
if (!list.length) return "—";
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{list.map((p, idx) => {
|
||||
const ratio = Number(p.ratio ?? 1) || 1;
|
||||
return (
|
||||
<div className="flex items-center gap-2" key={idx}>
|
||||
<Badge variant="outline">{ratio.toFixed(2)}x</Badge>
|
||||
<Badge variant="secondary">{p.type}</Badge>
|
||||
<Badge variant="secondary">{p.port}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "status",
|
||||
header: t("status", "Status"),
|
||||
cell: ({ row }) => {
|
||||
const offline = row.original.status.status === "offline";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2.5 w-2.5 rounded-full",
|
||||
offline ? "bg-zinc-400" : "bg-emerald-500"
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{offline ? t("offline", "Offline") : t("online", "Online")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
header: t("cpu", "CPU"),
|
||||
cell: ({ row }) => (
|
||||
<PctBar
|
||||
value={(row.original.status?.cpu as unknown as number) ?? 0}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mem",
|
||||
header: t("memory", "Memory"),
|
||||
cell: ({ row }) => (
|
||||
<PctBar
|
||||
value={(row.original.status?.mem as unknown as number) ?? 0}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "disk",
|
||||
header: t("disk", "Disk"),
|
||||
cell: ({ row }) => (
|
||||
<PctBar
|
||||
value={(row.original.status?.disk as unknown as number) ?? 0}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "online_users",
|
||||
header: t("onlineUsers", "Online Users"),
|
||||
cell: ({ row }) => (
|
||||
<OnlineUsersCell
|
||||
status={row.original.status as API.ServerStatus}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("pageTitle", "Servers"),
|
||||
toolbar: (
|
||||
<div className="flex gap-2">
|
||||
<ServerForm
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createServer(
|
||||
values as unknown as API.CreateServerRequest
|
||||
);
|
||||
toast.success(t("created", "Created"));
|
||||
ref.current?.refresh();
|
||||
fetchServers();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("drawerCreateTitle", "Create Server")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
onSort={async (source, target, items) => {
|
||||
const sourceIndex = items.findIndex(
|
||||
(item) => String(item.id) === source
|
||||
);
|
||||
const targetIndex = items.findIndex(
|
||||
(item) => String(item.id) === target
|
||||
);
|
||||
|
||||
const originalSorts = items.map((item) => item.sort);
|
||||
|
||||
const [movedItem] = items.splice(sourceIndex, 1);
|
||||
items.splice(targetIndex, 0, movedItem!);
|
||||
|
||||
const updatedItems = items.map((item, index) => {
|
||||
const originalSort = originalSorts[index];
|
||||
const newSort =
|
||||
originalSort !== undefined ? originalSort : item.sort;
|
||||
return { ...item, sort: newSort };
|
||||
});
|
||||
|
||||
const changedItems = updatedItems.filter(
|
||||
(item, index) => item.sort !== items[index]?.sort
|
||||
);
|
||||
|
||||
if (changedItems.length > 0) {
|
||||
resetSortWithServer({
|
||||
sort: changedItems.map((item) => ({
|
||||
id: item.id,
|
||||
sort: item.sort,
|
||||
})) as API.SortItem[],
|
||||
});
|
||||
toast.success(t("sorted_success", "Sorted successfully"));
|
||||
}
|
||||
return updatedItems;
|
||||
}}
|
||||
params={[{ key: "search" }]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await filterServerList({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
search: filter?.search || undefined,
|
||||
});
|
||||
const list = (data?.data?.list || []) as API.Server[];
|
||||
const total = (data?.data?.total ?? list.length) as number;
|
||||
return { list, total };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { getUserSubscribeById } from "@workspace/ui/services/admin/user";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IpLink } from "@/components/ip-link";
|
||||
import { UserDetail } from "@/sections/user/user-detail";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
function UserSubscribeInfo({
|
||||
subscribeId,
|
||||
open,
|
||||
type,
|
||||
expiredText,
|
||||
unlimitedText,
|
||||
}: {
|
||||
subscribeId: number;
|
||||
open: boolean;
|
||||
type:
|
||||
| "account"
|
||||
| "subscribeName"
|
||||
| "subscribeId"
|
||||
| "trafficUsage"
|
||||
| "expireTime";
|
||||
expiredText: string;
|
||||
unlimitedText: string;
|
||||
}) {
|
||||
const { data } = useQuery({
|
||||
enabled: subscribeId !== 0 && open,
|
||||
queryKey: ["getUserSubscribeById", subscribeId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserSubscribeById({ id: subscribeId });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return <span className="text-muted-foreground">--</span>;
|
||||
|
||||
switch (type) {
|
||||
case "account":
|
||||
if (!data.user_id)
|
||||
return <span className="text-muted-foreground">--</span>;
|
||||
return <UserDetail id={data.user_id} />;
|
||||
|
||||
case "subscribeName":
|
||||
if (!data.subscribe?.name)
|
||||
return <span className="text-muted-foreground">--</span>;
|
||||
return <span className="text-sm">{data.subscribe.name}</span>;
|
||||
|
||||
case "subscribeId":
|
||||
if (!data.id) return <span className="text-muted-foreground">--</span>;
|
||||
return <span className="font-mono text-sm">{data.id}</span>;
|
||||
|
||||
case "trafficUsage": {
|
||||
const usedTraffic = data.upload + data.download;
|
||||
const totalTraffic = data.traffic || 0;
|
||||
return (
|
||||
<div className="min-w-0 text-sm">
|
||||
<div className="wrap-break-word">
|
||||
{formatBytes(usedTraffic)} /{" "}
|
||||
{totalTraffic > 0 ? formatBytes(totalTraffic) : unlimitedText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "expireTime": {
|
||||
if (!data.expire_time)
|
||||
return <span className="text-muted-foreground">--</span>;
|
||||
const isExpired = data.expire_time < Date.now() / 1000;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-sm">{formatDate(data.expire_time)}</span>
|
||||
{isExpired && (
|
||||
<Badge className="w-fit px-1 py-0 text-xs" variant="destructive">
|
||||
{expiredText}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span className="text-muted-foreground">--</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function OnlineUsersCell({
|
||||
status,
|
||||
}: {
|
||||
status?: API.ServerStatus;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-2 bg-transparent p-0 text-muted-foreground text-sm hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<Users className="h-4 w-4" /> {status?.online.length}
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="h-screen w-screen max-w-none sm:h-auto sm:w-[900px] sm:max-w-[90vw]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("onlineUsers", "Online Users")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="h-[calc(100vh-48px-16px)] overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]">
|
||||
<ProTable<API.ServerOnlineUser, Record<string, unknown>>
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: t("ipAddresses", "IP Addresses"),
|
||||
cell: ({ row }) => {
|
||||
const ips = row.original.ip;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{ips.map((item) => (
|
||||
<div
|
||||
className="whitespace-nowrap text-sm"
|
||||
key={`${item.protocol}-${item.ip}`}
|
||||
>
|
||||
<Badge>{item.protocol}</Badge>
|
||||
<IpLink className="ml-1 font-medium" ip={item.ip} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("user", "User"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
expiredText={t("expired", "Expired")}
|
||||
open={open}
|
||||
subscribeId={Number(row.original.subscribe_id)}
|
||||
type="account"
|
||||
unlimitedText={t("unlimited", "Unlimited")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subscription",
|
||||
header: t("subscription", "Subscription"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
expiredText={t("expired", "Expired")}
|
||||
open={open}
|
||||
subscribeId={Number(row.original.subscribe_id)}
|
||||
type="subscribeName"
|
||||
unlimitedText={t("unlimited", "Unlimited")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subscribeId",
|
||||
header: t("subscribeId", "Subscribe ID"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
expiredText={t("expired", "Expired")}
|
||||
open={open}
|
||||
subscribeId={Number(row.original.subscribe_id)}
|
||||
type="subscribeId"
|
||||
unlimitedText={t("unlimited", "Unlimited")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "traffic",
|
||||
header: t("traffic", "Traffic"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
expiredText={t("expired", "Expired")}
|
||||
open={open}
|
||||
subscribeId={Number(row.original.subscribe_id)}
|
||||
type="trafficUsage"
|
||||
unlimitedText={t("unlimited", "Unlimited")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expireTime",
|
||||
header: t("expireTime", "Expire Time"),
|
||||
cell: ({ row }) => (
|
||||
<UserSubscribeInfo
|
||||
expiredText={t("expired", "Expired")}
|
||||
open={open}
|
||||
subscribeId={Number(row.original.subscribe_id)}
|
||||
type="expireTime"
|
||||
unlimitedText={t("unlimited", "Unlimited")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
header={{ hidden: true }}
|
||||
request={async () => ({
|
||||
list: status?.online || [],
|
||||
total: status?.online?.length || 0,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Card, CardContent } from "@workspace/ui/components/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getNodeConfig,
|
||||
updateNodeConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { DicesIcon } from "lucide-react";
|
||||
import { uid } from "radash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { SS_CIPHERS } from "./form-schema";
|
||||
|
||||
const dnsConfigSchema = z.object({
|
||||
proto: z.string(), // z.enum(['tcp', 'udp', 'tls', 'https', 'quic']),
|
||||
address: z.string(),
|
||||
domains: z.array(z.string()),
|
||||
});
|
||||
|
||||
const outboundConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
protocol: z.string(),
|
||||
address: z.string(),
|
||||
port: z.number(),
|
||||
cipher: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
rules: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const nodeConfigSchema = z.object({
|
||||
node_secret: z.string().optional(),
|
||||
node_pull_interval: z.number().optional(),
|
||||
node_push_interval: z.number().optional(),
|
||||
traffic_report_threshold: z.number().optional(),
|
||||
ip_strategy: z.enum(["prefer_ipv4", "prefer_ipv6"]).optional(),
|
||||
dns: z.array(dnsConfigSchema).optional(),
|
||||
block: z.array(z.string()).optional(),
|
||||
outbound: z.array(outboundConfigSchema).optional(),
|
||||
});
|
||||
type NodeConfigFormData = z.infer<typeof nodeConfigSchema>;
|
||||
|
||||
export default function ServerConfig() {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { data: cfgResp, refetch: refetchCfg } = useQuery({
|
||||
queryKey: ["getNodeConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeConfig();
|
||||
return data.data as API.NodeConfig | undefined;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<NodeConfigFormData>({
|
||||
resolver: zodResolver(nodeConfigSchema),
|
||||
defaultValues: {
|
||||
node_secret: "",
|
||||
node_pull_interval: undefined,
|
||||
node_push_interval: undefined,
|
||||
traffic_report_threshold: undefined,
|
||||
ip_strategy: "prefer_ipv4",
|
||||
dns: [],
|
||||
block: [],
|
||||
outbound: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cfgResp) {
|
||||
form.reset({
|
||||
node_secret: cfgResp.node_secret ?? "",
|
||||
node_pull_interval: cfgResp.node_pull_interval as number | undefined,
|
||||
node_push_interval: cfgResp.node_push_interval as number | undefined,
|
||||
traffic_report_threshold: cfgResp.traffic_report_threshold as
|
||||
| number
|
||||
| undefined,
|
||||
ip_strategy:
|
||||
(cfgResp.ip_strategy as "prefer_ipv4" | "prefer_ipv6" | undefined) ||
|
||||
"prefer_ipv4",
|
||||
dns: cfgResp.dns || [],
|
||||
block: cfgResp.block || [],
|
||||
outbound: cfgResp.outbound || [],
|
||||
});
|
||||
}
|
||||
}, [cfgResp, form]);
|
||||
|
||||
async function onSubmit(values: NodeConfigFormData) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateNodeConfig(values as API.NodeConfig);
|
||||
toast.success(t("server_config.saveSuccess", "Saved successfully"));
|
||||
await refetchCfg();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex cursor-pointer items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:resistor-nodes"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("server_config.title", "Node configuration")}
|
||||
</p>
|
||||
<p className="truncate text-muted-foreground text-sm">
|
||||
{t(
|
||||
"server_config.description",
|
||||
"Manage node communication keys, pull/push intervals."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="w-[720px] max-w-full md:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("server_config.title", "Node configuration")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6">
|
||||
<Tabs className="pt-4" defaultValue="basic">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">
|
||||
{t("server_config.tabs.basic", "Basic Configuration")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dns">
|
||||
{t("server_config.tabs.dns", "DNS Configuration")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="outbound">
|
||||
{t("server_config.tabs.outbound", "Outbound Rules")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="block">
|
||||
{t("server_config.tabs.block", "Block Rules")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4"
|
||||
id="server-config-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<TabsContent className="space-y-4" value="basic">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="node_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"server_config.fields.communication_key",
|
||||
"Communication key"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"server_config.fields.communication_key_placeholder",
|
||||
"Please enter"
|
||||
)}
|
||||
suffix={
|
||||
<div className="flex h-9 items-center bg-muted px-3">
|
||||
<DicesIcon
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
const id = uid(32).toLowerCase();
|
||||
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
||||
form.setValue("node_secret", formatted);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"server_config.fields.communication_key_desc",
|
||||
"Used for node authentication."
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="node_pull_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"server_config.fields.node_pull_interval",
|
||||
"Node pull interval"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={0}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"server_config.fields.communication_key_placeholder",
|
||||
"Please enter"
|
||||
)}
|
||||
suffix="S"
|
||||
type="number"
|
||||
value={field.value as number | undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"server_config.fields.node_pull_interval_desc",
|
||||
"How often the node pulls configuration (seconds)."
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="node_push_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"server_config.fields.node_push_interval",
|
||||
"Node push interval"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={0}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"server_config.fields.communication_key_placeholder",
|
||||
"Please enter"
|
||||
)}
|
||||
step={0.1}
|
||||
suffix="S"
|
||||
type="number"
|
||||
value={field.value as number | undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"server_config.fields.node_push_interval_desc",
|
||||
"How often the node pushes stats (seconds)."
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="traffic_report_threshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"server_config.fields.traffic_report_threshold",
|
||||
"Traffic Report Threshold"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(unitConversion("mbToBits", value));
|
||||
}}
|
||||
placeholder="1"
|
||||
suffix="MB"
|
||||
type="number"
|
||||
value={unitConversion(
|
||||
"bitsToMb",
|
||||
field.value as number | undefined
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"server_config.fields.traffic_report_threshold_desc",
|
||||
"Set the minimum threshold for traffic reporting."
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-4" value="dns">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ip_strategy"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("server_config.fields.ip_strategy", "IP Strategy")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"server_config.fields.ip_strategy_placeholder",
|
||||
"Select IP strategy"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="prefer_ipv4">
|
||||
{t(
|
||||
"server_config.fields.ip_strategy_ipv4",
|
||||
"Prefer IPv4"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="prefer_ipv6">
|
||||
{t(
|
||||
"server_config.fields.ip_strategy_ipv6",
|
||||
"Prefer IPv6"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"server_config.fields.ip_strategy_desc",
|
||||
"Choose IP version preference for network connections"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dns"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"server_config.fields.dns_config",
|
||||
"DNS Configuration"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ArrayInput
|
||||
className="grid grid-cols-2 gap-2"
|
||||
fields={[
|
||||
{
|
||||
name: "proto",
|
||||
type: "select",
|
||||
placeholder: t(
|
||||
"server_config.fields.dns_proto_placeholder",
|
||||
"Select type"
|
||||
),
|
||||
options: [
|
||||
{ label: "TCP", value: "tcp" },
|
||||
{ label: "UDP", value: "udp" },
|
||||
{ label: "TLS", value: "tls" },
|
||||
{ label: "HTTPS", value: "https" },
|
||||
{ label: "QUIC", value: "quic" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "address",
|
||||
type: "text",
|
||||
placeholder: "8.8.8.8:53",
|
||||
},
|
||||
{
|
||||
name: "domains",
|
||||
type: "textarea",
|
||||
className: "col-span-2",
|
||||
placeholder: t(
|
||||
"server_config.fields.dns_domains_placeholder",
|
||||
"One domain rule per line"
|
||||
),
|
||||
},
|
||||
]}
|
||||
onChange={(values) => {
|
||||
const converted = values.map((item: any) => ({
|
||||
proto: item.proto,
|
||||
address: item.address,
|
||||
domains:
|
||||
typeof item.domains === "string"
|
||||
? item.domains
|
||||
.split("\n")
|
||||
.map((d: string) => d.trim())
|
||||
: item.domains || [],
|
||||
}));
|
||||
field.onChange(converted);
|
||||
}}
|
||||
value={(field.value || []).map((item) => ({
|
||||
...item,
|
||||
domains: Array.isArray(item.domains)
|
||||
? item.domains.join("\n")
|
||||
: "",
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-4" value="outbound">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="outbound"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<ArrayInput
|
||||
className="grid grid-cols-2 gap-2"
|
||||
fields={[
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
className: "col-span-2",
|
||||
placeholder: t(
|
||||
"server_config.fields.outbound_name_placeholder",
|
||||
"Configuration name"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "protocol",
|
||||
type: "select",
|
||||
placeholder: t(
|
||||
"server_config.fields.outbound_protocol_placeholder",
|
||||
"Select protocol"
|
||||
),
|
||||
options: [
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "SOCKS", value: "socks" },
|
||||
{
|
||||
label: "Shadowsocks",
|
||||
value: "shadowsocks",
|
||||
},
|
||||
{ label: "Brook", value: "brook" },
|
||||
{ label: "Snell", value: "snell" },
|
||||
{ label: "VMess", value: "vmess" },
|
||||
{ label: "VLESS", value: "vless" },
|
||||
{ label: "Trojan", value: "trojan" },
|
||||
{ label: "WireGuard", value: "wireguard" },
|
||||
{ label: "Hysteria", value: "hysteria" },
|
||||
{ label: "TUIC", value: "tuic" },
|
||||
{ label: "AnyTLS", value: "anytls" },
|
||||
{ label: "Naive", value: "naive" },
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Reject", value: "reject" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "cipher",
|
||||
type: "select",
|
||||
options: SS_CIPHERS.map((cipher) => ({
|
||||
label: cipher,
|
||||
value: cipher,
|
||||
})),
|
||||
visible: (item: Record<string, unknown>) =>
|
||||
item.protocol === "shadowsocks",
|
||||
},
|
||||
{
|
||||
name: "address",
|
||||
type: "text",
|
||||
placeholder: t(
|
||||
"server_config.fields.outbound_address_placeholder",
|
||||
"Server address"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "port",
|
||||
type: "number",
|
||||
placeholder: t(
|
||||
"server_config.fields.outbound_port_placeholder",
|
||||
"Port number"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
type: "text",
|
||||
placeholder: t(
|
||||
"server_config.fields.outbound_password_placeholder",
|
||||
"Password (optional)"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "rules",
|
||||
type: "textarea",
|
||||
className: "col-span-2",
|
||||
placeholder: t(
|
||||
"server_config.fields.outbound_rules_placeholder",
|
||||
"One rule per line"
|
||||
),
|
||||
},
|
||||
]}
|
||||
onChange={(values) => {
|
||||
const converted = values.map((item: any) => ({
|
||||
name: item.name,
|
||||
protocol: item.protocol,
|
||||
address: item.address,
|
||||
port: item.port,
|
||||
cipher: item.cipher,
|
||||
password: item.password,
|
||||
rules:
|
||||
typeof item.rules === "string"
|
||||
? item.rules
|
||||
.split("\n")
|
||||
.map((r: string) => r.trim())
|
||||
: item.rules || [],
|
||||
}));
|
||||
field.onChange(converted);
|
||||
}}
|
||||
value={(field.value || []).map((item) => ({
|
||||
...item,
|
||||
rules: Array.isArray(item.rules)
|
||||
? item.rules.join("\n")
|
||||
: "",
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-4" value="block">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="block"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
onChange={(e) => {
|
||||
const lines = e.target.value
|
||||
.split("\n")
|
||||
.map((line) => line.trim());
|
||||
field.onChange(lines);
|
||||
}}
|
||||
placeholder={t(
|
||||
"server_config.fields.block_rules_placeholder",
|
||||
"One domain rule per line"
|
||||
)}
|
||||
rows={10}
|
||||
value={(field.value || []).join("\n")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</form>
|
||||
</Form>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={saving} form="server-config-form" type="submit">
|
||||
<Icon
|
||||
className={saving ? "mr-2 animate-spin" : "hidden"}
|
||||
icon="mdi:loading"
|
||||
/>
|
||||
{t("actions.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@workspace/ui/components/accordion";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useNode } from "@/stores/node";
|
||||
import {
|
||||
type FieldConfig,
|
||||
formSchema,
|
||||
getLabel,
|
||||
getProtocolDefaultConfig,
|
||||
protocols as PROTOCOLS,
|
||||
useProtocolFields,
|
||||
} from "./form-schema";
|
||||
|
||||
function DynamicField({
|
||||
field,
|
||||
control,
|
||||
form,
|
||||
protocolIndex,
|
||||
protocolData,
|
||||
}: {
|
||||
field: FieldConfig;
|
||||
control: any;
|
||||
form: any;
|
||||
protocolIndex: number;
|
||||
protocolData: any;
|
||||
}) {
|
||||
const fieldName = `protocols.${protocolIndex}.${field.name}` as const;
|
||||
|
||||
if (field.condition && !field.condition(protocolData, {})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
control,
|
||||
name: fieldName,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case "input":
|
||||
return (
|
||||
<FormField
|
||||
{...commonProps}
|
||||
render={({ field: fieldProps }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{field.label}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...fieldProps}
|
||||
onValueChange={(v) => fieldProps.onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
suffix={
|
||||
field.generate ? (
|
||||
field.generate.functions &&
|
||||
field.generate.functions.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" type="button" variant="ghost">
|
||||
<Icon className="h-4 w-4" icon="mdi:key" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{field.generate.functions.map((genFunc, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={idx}
|
||||
onClick={async () => {
|
||||
const result = await genFunc.function();
|
||||
if (typeof result === "string") {
|
||||
fieldProps.onChange(result);
|
||||
} else if (field.generate!.updateFields) {
|
||||
Object.entries(
|
||||
field.generate!.updateFields
|
||||
).forEach(([fieldName, resultKey]) => {
|
||||
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
|
||||
form.setValue(
|
||||
fullFieldName,
|
||||
(result as any)[resultKey]
|
||||
);
|
||||
});
|
||||
} else if (result.privateKey) {
|
||||
fieldProps.onChange(result.privateKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{genFunc.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : field.generate.function ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const result = await field.generate!.function!();
|
||||
if (typeof result === "string") {
|
||||
fieldProps.onChange(result);
|
||||
} else if (field.generate!.updateFields) {
|
||||
Object.entries(
|
||||
field.generate!.updateFields
|
||||
).forEach(([fieldName, resultKey]) => {
|
||||
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
|
||||
form.setValue(
|
||||
fullFieldName,
|
||||
(result as any)[resultKey]
|
||||
);
|
||||
});
|
||||
} else if (result.privateKey) {
|
||||
fieldProps.onChange(result.privateKey);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="h-4 w-4" icon="mdi:key" />
|
||||
</Button>
|
||||
) : null
|
||||
) : (
|
||||
field.suffix
|
||||
)
|
||||
}
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<FormField
|
||||
{...commonProps}
|
||||
render={({ field: fieldProps }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{field.label}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...fieldProps}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
onValueChange={(v) => fieldProps.onChange(v)}
|
||||
placeholder={field.placeholder}
|
||||
step={field.step || 1}
|
||||
suffix={field.suffix}
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
if (!field.options || field.options.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
{...commonProps}
|
||||
render={({ field: fieldProps }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{field.label}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(v) => fieldProps.onChange(v)}
|
||||
value={fieldProps.value ?? field.defaultValue}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{getLabel(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "switch":
|
||||
return (
|
||||
<FormField
|
||||
{...commonProps}
|
||||
render={({ field: fieldProps }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{field.label}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="pt-2">
|
||||
<Switch
|
||||
checked={!!fieldProps.value}
|
||||
onCheckedChange={(checked) => fieldProps.onChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<FormField
|
||||
{...commonProps}
|
||||
render={({ field: fieldProps }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>{field.label}</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
{...fieldProps}
|
||||
className="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onChange={(e) => fieldProps.onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
value={fieldProps.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFieldsByGroup(
|
||||
fields: FieldConfig[],
|
||||
group: string,
|
||||
control: any,
|
||||
form: any,
|
||||
protocolIndex: number,
|
||||
protocolData: any
|
||||
) {
|
||||
const groupFields = fields.filter((field) => field.group === group);
|
||||
if (groupFields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{groupFields.map((field) => (
|
||||
<DynamicField
|
||||
control={control}
|
||||
field={field}
|
||||
form={form}
|
||||
key={field.name}
|
||||
protocolData={protocolData}
|
||||
protocolIndex={protocolIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroupCard(
|
||||
title: string,
|
||||
fields: FieldConfig[],
|
||||
group: string,
|
||||
control: any,
|
||||
form: any,
|
||||
protocolIndex: number,
|
||||
protocolData: any
|
||||
) {
|
||||
const groupFields = fields.filter((field) => field.group === group);
|
||||
if (groupFields.length === 0) return null;
|
||||
|
||||
const visibleFields = groupFields.filter(
|
||||
(field) => !field.condition || field.condition(protocolData, {})
|
||||
);
|
||||
|
||||
if (visibleFields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<fieldset className="rounded-lg border border-border">
|
||||
<legend className="ml-3 bg-background px-1 py-1 font-medium text-foreground text-sm">
|
||||
{title}
|
||||
</legend>
|
||||
<div className="p-4 pt-2">
|
||||
{renderFieldsByGroup(
|
||||
fields,
|
||||
group,
|
||||
control,
|
||||
form,
|
||||
protocolIndex,
|
||||
protocolData
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerForm(props: {
|
||||
trigger: string;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
initialValues?: Partial<API.Server>;
|
||||
onSubmit: (values: Partial<API.Server>) => Promise<boolean> | boolean;
|
||||
}) {
|
||||
const { trigger, title, loading, initialValues, onSubmit } = props;
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [accordionValue, setAccordionValue] = useState<string>();
|
||||
|
||||
const { isProtocolUsedInNodes } = useNode();
|
||||
const PROTOCOL_FIELDS = useProtocolFields();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
address: "",
|
||||
country: "",
|
||||
city: "",
|
||||
protocols: [] as any[],
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
const { control } = form;
|
||||
|
||||
const protocolsValues = useWatch({ control, name: "protocols" });
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
form.reset({
|
||||
name: "",
|
||||
address: "",
|
||||
country: "",
|
||||
city: "",
|
||||
...initialValues,
|
||||
protocols: PROTOCOLS.map((type) => {
|
||||
const existingProtocol = initialValues.protocols?.find(
|
||||
(p) => p.type === type
|
||||
);
|
||||
const defaultConfig = getProtocolDefaultConfig(type);
|
||||
return existingProtocol
|
||||
? { ...defaultConfig, ...existingProtocol }
|
||||
: defaultConfig;
|
||||
}),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValues]);
|
||||
|
||||
async function handleSubmit(values: Record<string, any>) {
|
||||
const filteredProtocols = (values?.protocols || []).filter(
|
||||
(protocol: any) => {
|
||||
const port = Number(protocol?.port);
|
||||
return protocol && Number.isFinite(port) && port > 0 && port <= 65_535;
|
||||
}
|
||||
);
|
||||
|
||||
const result = {
|
||||
name: values.name,
|
||||
country: values.country,
|
||||
city: values.city,
|
||||
address: values.address,
|
||||
protocols: filteredProtocols,
|
||||
};
|
||||
|
||||
const ok = await onSubmit(result);
|
||||
if (ok) {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!initialValues) {
|
||||
const full = PROTOCOLS.map((t) => getProtocolDefaultConfig(t));
|
||||
form.reset({
|
||||
name: "",
|
||||
address: "",
|
||||
country: "",
|
||||
city: "",
|
||||
protocols: full,
|
||||
});
|
||||
}
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[700px] max-w-full gap-0 md:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form className="grid grid-cols-1 gap-2 px-6 pt-4">
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name", "Name")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("address", "Address")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
placeholder={t(
|
||||
"address_placeholder",
|
||||
"Server address"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("country", "Country")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("city", "City")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-3">
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
{t("protocol_configurations", "Protocol Configurations")}
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{t(
|
||||
"protocol_configurations_desc",
|
||||
"Enable and configure the required protocol types"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
className="w-full space-y-3"
|
||||
collapsible
|
||||
onValueChange={setAccordionValue}
|
||||
type="single"
|
||||
value={accordionValue}
|
||||
>
|
||||
{PROTOCOLS.map((type) => {
|
||||
const i = Math.max(0, PROTOCOLS.indexOf(type));
|
||||
const current = (protocolsValues[i] || {}) as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
const isEnabled = current?.enable;
|
||||
const fields = PROTOCOL_FIELDS[type] || [];
|
||||
return (
|
||||
<AccordionItem
|
||||
className="mb-2 rounded-lg border"
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium capitalize">
|
||||
{type}
|
||||
</span>
|
||||
{current.transport && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
{current.transport.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{current.security &&
|
||||
current.security !== "none" && (
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{current.security.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{current.port && (
|
||||
<Badge className="text-xs">
|
||||
{current.port}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isEnabled
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isEnabled
|
||||
? t("enabled", "Enabled")
|
||||
: t("disabled", "Disabled")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!isEnabled}
|
||||
className="mr-2"
|
||||
disabled={Boolean(
|
||||
initialValues?.id &&
|
||||
isProtocolUsedInNodes(
|
||||
initialValues?.id || 0,
|
||||
type
|
||||
) &&
|
||||
isEnabled
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(`protocols.${i}.enable`, checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pt-0 pb-4">
|
||||
<div className="-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4">
|
||||
{renderGroupCard(
|
||||
t("basic", "Basic Configuration"),
|
||||
fields,
|
||||
"basic",
|
||||
control,
|
||||
form,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("obfs", "Obfuscation"),
|
||||
fields,
|
||||
"obfs",
|
||||
control,
|
||||
form,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("transport", "Transport"),
|
||||
fields,
|
||||
"transport",
|
||||
control,
|
||||
form,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("security", "Security"),
|
||||
fields,
|
||||
"security",
|
||||
control,
|
||||
form,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("reality", "Reality"),
|
||||
fields,
|
||||
"reality",
|
||||
control,
|
||||
form,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("encryption", "Encryption"),
|
||||
fields,
|
||||
"encryption",
|
||||
control,
|
||||
form,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={form.handleSubmit(handleSubmit, (errors) => {
|
||||
const key = Object.keys(errors)[0] as keyof typeof errors;
|
||||
if (key) toast.error(String(errors[key]?.message));
|
||||
return false;
|
||||
})}
|
||||
>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import { getNodeConfig } from "@workspace/ui/services/admin/system";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Props = {
|
||||
server: API.Server;
|
||||
};
|
||||
|
||||
export default function ServerInstall({ server }: Props) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [domain, setDomain] = useState("");
|
||||
|
||||
const { data: cfgResp } = useQuery({
|
||||
queryKey: ["getNodeConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeConfig();
|
||||
return data.data as API.NodeConfig | undefined;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const host = localStorage.getItem("API_HOST") ?? window.location.origin;
|
||||
setDomain(host);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const installCommand = useMemo(() => {
|
||||
const secret = cfgResp?.node_secret ?? "";
|
||||
return `wget -N https://raw.githubusercontent.com/perfect-panel/ppanel-node/master/scripts/install.sh && bash install.sh --api-host ${domain} --server-id ${server.id} --secret-key ${secret}`;
|
||||
}, [domain, server.id, cfgResp?.node_secret]);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
} else {
|
||||
// fallback for environments without clipboard API
|
||||
const el = document.createElement("textarea");
|
||||
el.value = installCommand;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
toast.success(t("copied", "Copied"));
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast.error(t("copyFailed", "Copy failed"));
|
||||
}
|
||||
}
|
||||
|
||||
const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setDomain(e.target.value);
|
||||
localStorage.setItem("API_HOST", e.target.value);
|
||||
}, []);
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">{t("connect", "Connect")}</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="w-[720px] max-w-full md:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("oneClickInstall", "One-click Install")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>{t("apiHost", "API Host")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
onChange={onDomainChange}
|
||||
placeholder={t("apiHostPlaceholder", "http(s)://example.com")}
|
||||
value={domain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t("installCommand", "Install command")}</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
aria-label={t("installCommand", "Install command")}
|
||||
className="min-h-[88px] w-full rounded border p-2 font-mono text-sm"
|
||||
readOnly
|
||||
value={installCommand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button onClick={() => setOpen(false)} variant="outline">
|
||||
{t("close", "Close")}
|
||||
</Button>
|
||||
<Button onClick={handleCopy}>
|
||||
{t("copyAndClose", "Copy and Close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getSubscribeConfig,
|
||||
updateSubscribeConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const subscribeConfigSchema = z.object({
|
||||
single_model: z.boolean().optional(),
|
||||
pan_domain: z.boolean().optional(),
|
||||
subscribe_path: z.string().optional(),
|
||||
subscribe_domain: z.string().optional(),
|
||||
user_agent_limit: z.boolean().optional(),
|
||||
user_agent_list: z.string().optional(),
|
||||
});
|
||||
|
||||
type SubscribeConfigFormData = z.infer<typeof subscribeConfigSchema>;
|
||||
|
||||
export default function ConfigForm() {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getSubscribeConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<SubscribeConfigFormData>({
|
||||
resolver: zodResolver(subscribeConfigSchema),
|
||||
defaultValues: {
|
||||
single_model: false,
|
||||
pan_domain: false,
|
||||
subscribe_path: "",
|
||||
subscribe_domain: "",
|
||||
user_agent_limit: false,
|
||||
user_agent_list: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: SubscribeConfigFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribeConfig(values as API.SubscribeConfig);
|
||||
toast.success(t("config.updateSuccess", "Settings updated successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("config.updateError", "Update failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:cog" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("config.title", "Subscription Configuration")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("config.description", "Manage subscription system settings")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("config.title", "Subscription Configuration")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="subscribe-config-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="single_model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"config.singleSubscriptionMode",
|
||||
"Single Subscription Mode"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"config.singleSubscriptionModeDescription",
|
||||
"Limit users to one active subscription. Existing subscriptions unaffected"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pan_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("config.wildcardResolution", "Wildcard Resolution")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"config.wildcardResolutionDescription",
|
||||
"Enable wildcard domain resolution for subscriptions"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subscribe_path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("config.subscriptionPath", "Subscription Path")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"config.subscriptionPathPlaceholder",
|
||||
"Enter subscription path"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"config.subscriptionPathDescription",
|
||||
"Custom path for subscription endpoints (better performance after system restart)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subscribe_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("config.subscriptionDomain", "Subscription Domain")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
placeholder={`${t(
|
||||
"config.subscriptionDomainPlaceholder",
|
||||
"Enter subscription domain, one per line"
|
||||
)}\nexample.com\nwww.example.com`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"config.subscriptionDomainDescription",
|
||||
"Custom domain for subscription links"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_agent_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("config.userAgentLimit", "{userAgent} Restriction", {
|
||||
userAgent: "User-Agent",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"config.userAgentLimitDescription",
|
||||
"Enable access restrictions based on {userAgent}",
|
||||
{
|
||||
userAgent: "User-Agent",
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_agent_list"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("config.userAgentList", "{userAgent} Whitelist", {
|
||||
userAgent: "User-Agent",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
placeholder={`${t(
|
||||
"config.userAgentListPlaceholder",
|
||||
"Enter allowed {userAgent}, one per line",
|
||||
{ userAgent: "User-Agent" }
|
||||
)}\nClashX\nClashForAndroid\nClash-verge`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"config.userAgentListDescription",
|
||||
"Allowed {userAgent} for subscription access, one per line. Configured application {userAgent} will be automatically included",
|
||||
{
|
||||
userAgent: "User-Agent",
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="subscribe-config-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("actions.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@workspace/ui/components/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ConfigForm from "./config-form";
|
||||
import { ProtocolForm } from "./protocol-form";
|
||||
|
||||
export default function Subscribe() {
|
||||
const { t } = useTranslation("subscribe");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold text-lg">
|
||||
{t("config.title", "Subscription Configuration")}
|
||||
</h2>
|
||||
<Card className="py-3">
|
||||
<CardContent>
|
||||
<ConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProtocolForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@workspace/ui/components/tooltip";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import { GoTemplateEditor } from "@workspace/ui/composed/editor/index";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/index";
|
||||
import { UploadImage } from "@workspace/ui/composed/upload-image";
|
||||
import {
|
||||
createSubscribeApplication,
|
||||
deleteSubscribeApplication,
|
||||
getSubscribeApplicationList,
|
||||
updateSubscribeApplication,
|
||||
} from "@workspace/ui/services/admin/application";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { subscribeSchema } from "./schema";
|
||||
import { TemplatePreview } from "./template-preview";
|
||||
|
||||
const createClientFormSchema = (t: any) =>
|
||||
z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("form.validation.nameRequired", "Client name is required")),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
user_agent: z
|
||||
.string()
|
||||
.min(
|
||||
1,
|
||||
`User-Agent ${t("form.validation.userAgentRequiredSuffix", "is required")}`
|
||||
),
|
||||
scheme: z.string().optional(),
|
||||
template: z.string(),
|
||||
output_format: z.string(),
|
||||
download_link: z.object({
|
||||
windows: z.string().optional(),
|
||||
mac: z.string().optional(),
|
||||
linux: z.string().optional(),
|
||||
ios: z.string().optional(),
|
||||
android: z.string().optional(),
|
||||
harmony: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type ClientFormData = z.infer<ReturnType<typeof createClientFormSchema>>;
|
||||
|
||||
export function ProtocolForm() {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingClient, setEditingClient] =
|
||||
useState<API.SubscribeApplication | null>(null);
|
||||
const tableRef = useRef<ProTableActions>(null);
|
||||
|
||||
const clientFormSchema = createClientFormSchema(t);
|
||||
|
||||
const form = useForm<ClientFormData>({
|
||||
resolver: zodResolver(clientFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
user_agent: "",
|
||||
scheme: "",
|
||||
template: "",
|
||||
output_format: "",
|
||||
download_link: {
|
||||
windows: "",
|
||||
mac: "",
|
||||
linux: "",
|
||||
ios: "",
|
||||
android: "",
|
||||
harmony: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const request = async (
|
||||
pagination: { page: number; size: number },
|
||||
_filter: Record<string, unknown>
|
||||
) => {
|
||||
const { data } = await getSubscribeApplicationList({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
});
|
||||
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const columns: ColumnDef<API.SubscribeApplication, any>[] = [
|
||||
{
|
||||
accessorKey: "is_default",
|
||||
header: t("table.columns.default", "Default"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.is_default}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribeApplication({
|
||||
...row.original,
|
||||
is_default: checked,
|
||||
});
|
||||
tableRef.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("table.columns.name", "Client Name"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.icon && (
|
||||
<div className="relative h-6 w-6 flex-shrink-0">
|
||||
<img
|
||||
alt={row.original.name}
|
||||
className="h-full w-full rounded object-contain"
|
||||
height={24}
|
||||
onError={() => {
|
||||
console.log(`Failed to load image for ${row.original.name}`);
|
||||
}}
|
||||
src={row.original.icon}
|
||||
width={24}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
header: "User-Agent",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[150px] truncate font-mono text-muted-foreground text-sm">
|
||||
{row.original.user_agent}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "output_format",
|
||||
header: t("table.columns.outputFormat", "Output Format"),
|
||||
cell: ({ row }) => (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
{t(
|
||||
`outputFormats.${row.original.output_format}`,
|
||||
row.original.output_format
|
||||
) || row.original.output_format}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "download_link",
|
||||
header: t("table.columns.supportedPlatforms", "Supported Platforms"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(row.original.download_link || {}).map(
|
||||
([key, value]) => {
|
||||
if (value) {
|
||||
return (
|
||||
<Badge className="text-xs" key={key} variant="secondary">
|
||||
{t(`platforms.${key}`, key)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: t("table.columns.description", "Description"),
|
||||
cell: ({ row }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="max-w-[200px] truncate text-muted-foreground text-sm">
|
||||
{row.original.description}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{row.original.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingClient(null);
|
||||
form.reset({
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
user_agent: "",
|
||||
scheme: "",
|
||||
template: "",
|
||||
output_format: "",
|
||||
download_link: {
|
||||
windows: "",
|
||||
mac: "",
|
||||
linux: "",
|
||||
ios: "",
|
||||
android: "",
|
||||
harmony: "",
|
||||
},
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (client: API.SubscribeApplication) => {
|
||||
setEditingClient(client);
|
||||
form.reset({
|
||||
...client,
|
||||
download_link: client.download_link || {
|
||||
windows: "",
|
||||
mac: "",
|
||||
linux: "",
|
||||
ios: "",
|
||||
android: "",
|
||||
harmony: "",
|
||||
},
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (client: API.SubscribeApplication) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSubscribeApplication({ id: client.id });
|
||||
tableRef.current?.refresh();
|
||||
toast.success(t("actions.deleteSuccess", "Deleted successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete client:", error);
|
||||
toast.error(t("actions.deleteFailed", "Delete failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async (clients: API.SubscribeApplication[]) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
clients.map((client) => deleteSubscribeApplication({ id: client.id }))
|
||||
);
|
||||
tableRef.current?.refresh();
|
||||
toast.success(
|
||||
t(
|
||||
"actions.batchDeleteSuccess",
|
||||
"Successfully deleted {count} clients",
|
||||
{ count: clients.length }
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to batch delete clients:", error);
|
||||
toast.error(t("actions.deleteFailed", "Delete failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ClientFormData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (editingClient) {
|
||||
await updateSubscribeApplication({
|
||||
...data,
|
||||
is_default: editingClient.is_default,
|
||||
id: editingClient.id,
|
||||
});
|
||||
toast.success(t("actions.updateSuccess", "Updated successfully"));
|
||||
} else {
|
||||
await createSubscribeApplication({
|
||||
...data,
|
||||
is_default: false,
|
||||
});
|
||||
toast.success(t("actions.createSuccess", "Created successfully"));
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
tableRef.current?.refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to save client:", error);
|
||||
toast.error(t("actions.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProTable<API.SubscribeApplication, Record<string, unknown>>
|
||||
action={tableRef}
|
||||
actions={{
|
||||
render: (row: API.SubscribeApplication) => [
|
||||
<TemplatePreview
|
||||
applicationId={row.id}
|
||||
key="preview"
|
||||
output_format={row.output_format}
|
||||
/>,
|
||||
<Button
|
||||
key="edit"
|
||||
onClick={() =>
|
||||
handleEdit(row as unknown as API.SubscribeApplication)
|
||||
}
|
||||
>
|
||||
{t("actions.edit", "Edit")}
|
||||
</Button>,
|
||||
<ConfirmButton
|
||||
cancelText={t("actions.cancel", "Cancel")}
|
||||
confirmText={t("actions.confirm", "Confirm")}
|
||||
description={t(
|
||||
"actions.deleteWarning",
|
||||
"This operation cannot be undone. Are you sure you want to delete this client?"
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={() =>
|
||||
handleDelete(row as unknown as API.SubscribeApplication)
|
||||
}
|
||||
title={t("actions.confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button disabled={loading} variant="destructive">
|
||||
{t("actions.delete", "Delete")}
|
||||
</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
batchRender: (rows: API.SubscribeApplication[]) => [
|
||||
<ConfirmButton
|
||||
cancelText={t("actions.cancel", "Cancel")}
|
||||
confirmText={t("actions.confirm", "Confirm")}
|
||||
description={t(
|
||||
"actions.batchDeleteWarning",
|
||||
"Are you sure you want to delete the selected {count} clients?",
|
||||
{
|
||||
count: rows.length,
|
||||
}
|
||||
)}
|
||||
key="batchDelete"
|
||||
onConfirm={() =>
|
||||
handleBatchDelete(rows as unknown as API.SubscribeApplication[])
|
||||
}
|
||||
title={t("actions.confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">
|
||||
{t("actions.batchDelete", "Batch Delete")}
|
||||
</Button>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
columns={columns}
|
||||
header={{
|
||||
title: (
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-lg">
|
||||
{t("protocol.title", "Client Management")}
|
||||
</h2>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded-md px-3 py-1 font-medium text-primary text-sm hover:underline"
|
||||
href="https://github.com/perfect-panel/subscription-template"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon className="h-4 w-4" icon="mdi:github" />
|
||||
<span>Template Repo</span>
|
||||
<Icon
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
icon="mdi:open-in-new"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
toolbar: (
|
||||
<Button onClick={handleAdd}>{t("actions.add", "Add")}</Button>
|
||||
),
|
||||
}}
|
||||
request={request}
|
||||
/>
|
||||
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetContent className="w-[580px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{editingClient
|
||||
? t("form.editTitle", "Edit Client")
|
||||
: t("form.addTitle", "Add Client")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px)] px-6">
|
||||
<Form {...form}>
|
||||
<form className="space-y-6 py-4">
|
||||
<Tabs className="w-full" defaultValue="basic">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic">
|
||||
{t("form.tabs.basic", "Basic Info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="template">
|
||||
{t("form.tabs.template", "Templates")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="download">
|
||||
{t("form.tabs.download", "Downloads")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent className="space-y-4" value="basic">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.fields.icon", "Icon")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as string);
|
||||
}}
|
||||
placeholder="https://example.com/icon.png"
|
||||
suffix={
|
||||
<UploadImage
|
||||
className="h-9 rounded-none border-none bg-muted px-2"
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value as string);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
type="text"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"form.descriptions.icon",
|
||||
"Icon URL or base64 encoding"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("form.fields.name", "Name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Clash for Windows" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("form.descriptions.name", "Client display name")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_agent"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User-Agent</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Clash" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"form.descriptions.userAgentPrefix",
|
||||
"Client identifier for distinguishing different clients"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.fields.description", "Description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"form.descriptions.description",
|
||||
"Detailed client description"
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"form.descriptions.description",
|
||||
"Detailed client description"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-4" value="template">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="output_format"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("form.fields.outputFormat", "Output Format")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="base64">
|
||||
{t("outputFormats.base64", "Base64")}
|
||||
</SelectItem>
|
||||
<SelectItem value="yaml">
|
||||
{t("outputFormats.yaml", "YAML")}
|
||||
</SelectItem>
|
||||
<SelectItem value="json">
|
||||
{t("outputFormats.json", "JSON")}
|
||||
</SelectItem>
|
||||
<SelectItem value="conf">
|
||||
{t("outputFormats.conf", "CONF")}
|
||||
</SelectItem>
|
||||
<SelectItem value="plain">
|
||||
{t("outputFormats.plain", "Plain Text")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"form.descriptions.outputFormat",
|
||||
"Subscription configuration file format"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
{t("form.fields.scheme", "URL Scheme")}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
icon="mdi:help-circle-outline"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-md bg-secondary text-secondary-foreground"
|
||||
side="right"
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="font-medium">
|
||||
{t(
|
||||
"form.descriptions.scheme.title",
|
||||
"URL Scheme template"
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t(
|
||||
"form.descriptions.scheme.variables",
|
||||
"Supports variables:"
|
||||
)}
|
||||
</div>
|
||||
<ul className="ml-2 list-disc space-y-1 text-xs">
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"${url}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.scheme.urlVariable",
|
||||
"subscription URL"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"${name}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.scheme.nameVariable",
|
||||
"site name"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t(
|
||||
"form.descriptions.scheme.functions",
|
||||
"Supports functions:"
|
||||
)}
|
||||
</div>
|
||||
<ul className="ml-2 list-disc space-y-1 text-xs">
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"${encodeURIComponent(...)}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.scheme.urlEncoding",
|
||||
"URL encoding"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"${window.btoa(...)}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.scheme.base64Encoding",
|
||||
"Base64 encoding"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"${JSON.stringify(...)}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.scheme.jsonStringify",
|
||||
"JSON object to string"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="clash://install-config?url=${url}&name=${name}"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
{t(
|
||||
"form.fields.template",
|
||||
"Subscription File Template"
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
icon="mdi:help-circle-outline"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-md bg-secondary text-secondary-foreground"
|
||||
side="right"
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="font-medium">
|
||||
{t(
|
||||
"form.descriptions.template.title",
|
||||
"Go Template Syntax"
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t(
|
||||
"form.descriptions.template.variables",
|
||||
"Available variables:"
|
||||
)}
|
||||
</div>
|
||||
<ul className="ml-2 list-disc space-y-1 text-xs">
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
.SiteName
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.template.siteName",
|
||||
"site name"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
.SubscribeName
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.template.subscribeName",
|
||||
"subscription name"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
.Proxies
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.template.nodes",
|
||||
"proxy nodes list"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
.UserInfo
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.template.userInfo",
|
||||
"user info (traffic, expiry, etc.)"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t(
|
||||
"form.descriptions.template.functions",
|
||||
"Template functions:"
|
||||
)}
|
||||
</div>
|
||||
<ul className="ml-2 list-disc space-y-1 text-xs">
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"{{range .Proxies}}...{{end}}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.template.range",
|
||||
"iterate arrays"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"{{if .condition}}...{{end}}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.template.if",
|
||||
"conditional statements"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded px-1">
|
||||
{"{{sprig_func}}"}
|
||||
</code>{" "}
|
||||
-{" "}
|
||||
{t(
|
||||
"form.descriptions.template.sprig",
|
||||
"Sprig function library (string processing, dates, etc.)"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<GoTemplateEditor
|
||||
enableSprig
|
||||
onChange={(value: string | undefined) =>
|
||||
field.onChange(value || "")
|
||||
}
|
||||
schema={subscribeSchema}
|
||||
showLineNumbers
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-4" value="download">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{[
|
||||
"windows",
|
||||
"mac",
|
||||
"linux",
|
||||
"ios",
|
||||
"android",
|
||||
"harmony",
|
||||
].map((key) => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={key}
|
||||
name={
|
||||
`download_link.${key}` as `download_link.${keyof ClientFormData["download_link"]}`
|
||||
}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(`platforms.${key}`, key)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(`platforms.${key}`, key)}{" "}
|
||||
{t(
|
||||
"form.descriptions.downloadLink",
|
||||
"platform download URL"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button onClick={() => setOpen(false)} variant="outline">
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(onSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{editingClient
|
||||
? t("actions.update", "Update")
|
||||
: t("actions.add", "Add")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
export const subscribeSchema = {
|
||||
SiteName: { type: "string", description: "Site name" },
|
||||
SubscribeName: { type: "string", description: "Subscribe name" },
|
||||
Proxies: {
|
||||
type: "array",
|
||||
description: "Array of proxy nodes",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
Name: { type: "string", description: "Node name" },
|
||||
Server: { type: "string", description: "Server host" },
|
||||
Port: { type: "number", description: "Server port" },
|
||||
Type: { type: "string", description: "Proxy type" },
|
||||
Tags: {
|
||||
type: "array",
|
||||
description: "Node tags",
|
||||
items: { type: "string" },
|
||||
},
|
||||
Sort: { type: "number", description: "Node sort order" },
|
||||
// Security Options
|
||||
Security: {
|
||||
type: "string",
|
||||
description: "Security protocol",
|
||||
},
|
||||
SNI: {
|
||||
type: "string",
|
||||
description: "Server Name Indication for TLS",
|
||||
},
|
||||
AllowInsecure: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Allow insecure connections (skip certificate verification)",
|
||||
},
|
||||
Fingerprint: {
|
||||
type: "string",
|
||||
description: "Client fingerprint for TLS connections",
|
||||
},
|
||||
RealityServerAddr: {
|
||||
type: "string",
|
||||
description: "Reality server address",
|
||||
},
|
||||
RealityServerPort: {
|
||||
type: "number",
|
||||
description: "Reality server port",
|
||||
},
|
||||
RealityPrivateKey: {
|
||||
type: "string",
|
||||
description: "Reality private key for authentication",
|
||||
},
|
||||
RealityPublicKey: {
|
||||
type: "string",
|
||||
description: "Reality public key for authentication",
|
||||
},
|
||||
RealityShortId: {
|
||||
type: "string",
|
||||
description: "Reality short ID for authentication",
|
||||
},
|
||||
// Transport Options
|
||||
Transport: {
|
||||
type: "string",
|
||||
description: "Transport protocol (e.g., ws, http, grpc)",
|
||||
},
|
||||
Host: {
|
||||
type: "string",
|
||||
description: "For WebSocket/HTTP/HTTPS",
|
||||
},
|
||||
Path: { type: "string", description: "For HTTP/HTTPS" },
|
||||
ServiceName: {
|
||||
type: "string",
|
||||
description: "For gRPC",
|
||||
},
|
||||
// Shadowsocks Options
|
||||
Method: { type: "string", description: "Encryption method" },
|
||||
ServerKey: {
|
||||
type: "string",
|
||||
description: "For Shadowsocks 2022",
|
||||
},
|
||||
// Vmess/Vless/Trojan Options
|
||||
Flow: {
|
||||
type: "string",
|
||||
description: "Flow for Vmess/Vless/Trojan",
|
||||
},
|
||||
// Hysteria2 Options
|
||||
HopPorts: {
|
||||
type: "string",
|
||||
description: "Comma-separated list of hop ports",
|
||||
},
|
||||
HopInterval: {
|
||||
type: "number",
|
||||
description: "Interval for hop ports in seconds",
|
||||
},
|
||||
ObfsPassword: {
|
||||
type: "string",
|
||||
description: "Obfuscation password for Hysteria2",
|
||||
},
|
||||
// Tuic Options
|
||||
DisableSNI: {
|
||||
type: "boolean",
|
||||
description: "Disable SNI",
|
||||
},
|
||||
ReduceRtt: {
|
||||
type: "boolean",
|
||||
description: "Reduce RTT",
|
||||
},
|
||||
UDPRelayMode: {
|
||||
type: "string",
|
||||
description: 'UDP relay mode (e.g., "full", "partial")',
|
||||
},
|
||||
CongestionController: {
|
||||
type: "string",
|
||||
description: 'Congestion controller (e.g., "cubic", "bbr")',
|
||||
},
|
||||
// Hysteria2 additional options
|
||||
UpMbps: {
|
||||
type: "number",
|
||||
description: "Upload bandwidth in Mbps",
|
||||
},
|
||||
DownMbps: {
|
||||
type: "number",
|
||||
description: "Download bandwidth in Mbps",
|
||||
},
|
||||
// VLESS encryption options
|
||||
Encryption: {
|
||||
type: "string",
|
||||
description: "Encryption type for VLESS",
|
||||
},
|
||||
EncryptionMode: {
|
||||
type: "string",
|
||||
description: 'Encryption mode (e.g., "native", "xorpub", "random")',
|
||||
},
|
||||
EncryptionRtt: {
|
||||
type: "string",
|
||||
description: 'Encryption RTT (e.g., "0rtt", "1rtt")',
|
||||
},
|
||||
EncryptionTicket: {
|
||||
type: "string",
|
||||
description: "Encryption ticket",
|
||||
},
|
||||
EncryptionServerPadding: {
|
||||
type: "string",
|
||||
description: "Server padding for encryption",
|
||||
},
|
||||
EncryptionClientPadding: {
|
||||
type: "string",
|
||||
description: "Client padding for encryption",
|
||||
},
|
||||
EncryptionPassword: {
|
||||
type: "string",
|
||||
description: "Encryption password",
|
||||
},
|
||||
EncryptionPrivateKey: {
|
||||
type: "string",
|
||||
description: "Private key for encryption",
|
||||
},
|
||||
// XHTTP options
|
||||
XhttpMode: {
|
||||
type: "string",
|
||||
description:
|
||||
'XHTTP mode (e.g., "auto", "packet-up", "stream-up", "stream-one")',
|
||||
},
|
||||
XhttpExtra: {
|
||||
type: "string",
|
||||
description: "XHTTP extra parameters",
|
||||
},
|
||||
// Shadowsocks obfs options (combined with Hysteria2 obfs)
|
||||
ObfsHost: {
|
||||
type: "string",
|
||||
description: "Obfuscation host",
|
||||
},
|
||||
ObfsPath: {
|
||||
type: "string",
|
||||
description: "Obfuscation path",
|
||||
},
|
||||
// Shadowsocks cipher
|
||||
Cipher: {
|
||||
type: "string",
|
||||
description: "Shadowsocks cipher method",
|
||||
},
|
||||
// AnyTLS options
|
||||
PaddingScheme: {
|
||||
type: "string",
|
||||
description: "Padding scheme for AnyTLS",
|
||||
},
|
||||
// Mieru options
|
||||
Multiplex: {
|
||||
type: "string",
|
||||
description:
|
||||
'Multiplex level (e.g., "none", "low", "middle", "high")',
|
||||
},
|
||||
// General protocol field
|
||||
Enable: {
|
||||
type: "boolean",
|
||||
description: "Whether this protocol is enabled",
|
||||
},
|
||||
// UUID for vmess/vless
|
||||
UUID: {
|
||||
type: "string",
|
||||
description: "User UUID for vmess/vless protocols",
|
||||
},
|
||||
// Alternative ID for vmess
|
||||
AlterId: {
|
||||
type: "number",
|
||||
description: "Alternative ID for vmess (deprecated)",
|
||||
},
|
||||
// Password for trojan/tuic
|
||||
Password: {
|
||||
type: "string",
|
||||
description: "Password for authentication",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UserInfo: {
|
||||
type: "object",
|
||||
description: "User information",
|
||||
properties: {
|
||||
Password: { type: "string", description: "User password" },
|
||||
ExpiredAt: { type: "string", description: "Expiration date" },
|
||||
Download: { type: "number", description: "Downloaded bytes" },
|
||||
Upload: { type: "number", description: "Uploaded bytes" },
|
||||
Traffic: { type: "number", description: "Total traffic bytes" },
|
||||
SubscribeURL: {
|
||||
type: "string",
|
||||
description: "Subscription URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { MonacoEditor } from "@workspace/ui/composed/editor/monaco-editor";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { previewSubscribeTemplate } from "@workspace/ui/services/admin/application";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TemplatePreviewProps {
|
||||
applicationId: number;
|
||||
output_format?: string;
|
||||
}
|
||||
|
||||
export function TemplatePreview({
|
||||
applicationId,
|
||||
output_format,
|
||||
}: TemplatePreviewProps) {
|
||||
const { t } = useTranslation("subscribe");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["previewSubscribeTemplate", applicationId],
|
||||
queryFn: () =>
|
||||
previewSubscribeTemplate(
|
||||
{ id: applicationId },
|
||||
{ skipErrorHandler: true }
|
||||
),
|
||||
enabled: isOpen && !!applicationId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const originalContent = data?.data?.data?.template || "";
|
||||
const errorMessage =
|
||||
(error as any)?.data?.msg ||
|
||||
error?.message ||
|
||||
t("templatePreview.failed", "Failed to load template");
|
||||
|
||||
const getDecodedContent = () => {
|
||||
if (output_format === "base64" && originalContent) {
|
||||
try {
|
||||
return atob(originalContent);
|
||||
} catch {
|
||||
return t("templatePreview.base64.decodeError", "Base64 decode error");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getDisplayContent = () => {
|
||||
if (error) return errorMessage;
|
||||
if (!originalContent) return "";
|
||||
switch (output_format) {
|
||||
case "base64": {
|
||||
const decoded = getDecodedContent();
|
||||
return `${t("templatePreview.base64.originalContent", "Original Content")}:\n${originalContent}\n\n${t("templatePreview.base64.decodedContent", "Decoded Content")}:\n${decoded}`;
|
||||
}
|
||||
default:
|
||||
return originalContent;
|
||||
}
|
||||
};
|
||||
const mapLanguage = (fmt?: string) => {
|
||||
switch (fmt) {
|
||||
case "json":
|
||||
return "json";
|
||||
case "yaml":
|
||||
return "yaml";
|
||||
case "base64":
|
||||
return "ini";
|
||||
case "plain":
|
||||
return "ini";
|
||||
case "conf":
|
||||
return "ini";
|
||||
default:
|
||||
return "ini";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setIsOpen} open={isOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<Icon className="h-4 w-4" icon="mdi:eye" />
|
||||
{t("templatePreview.preview", "Preview")}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetHeader>
|
||||
<SheetTitle />
|
||||
</SheetHeader>
|
||||
<SheetContent className="w-[800px] max-w-[90vw] pt-10 md:max-w-screen-md">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon className="h-6 w-6 animate-spin" icon="mdi:loading" />
|
||||
<span className="ml-2">
|
||||
{t("templatePreview.loading", "Loading...")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<MonacoEditor
|
||||
language={mapLanguage(output_format)}
|
||||
readOnly
|
||||
showLineNumbers
|
||||
title={t("templatePreview.title", "Template Preview")}
|
||||
value={getDisplayContent()}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getCurrencyConfig,
|
||||
updateCurrencyConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
// Constants
|
||||
const EXCHANGE_RATE_HOST_URL = "https://exchangerate.host";
|
||||
|
||||
const currencySchema = z.object({
|
||||
access_key: z.string().optional(),
|
||||
currency_unit: z.string().min(1),
|
||||
currency_symbol: z.string().min(1),
|
||||
});
|
||||
|
||||
type CurrencyFormData = z.infer<typeof currencySchema>;
|
||||
|
||||
export default function CurrencyConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getCurrencyConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getCurrencyConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open, // Only request data when the modal is open
|
||||
});
|
||||
|
||||
const form = useForm<CurrencyFormData>({
|
||||
resolver: zodResolver(currencySchema),
|
||||
defaultValues: {
|
||||
access_key: "",
|
||||
currency_unit: "USD",
|
||||
currency_symbol: "$",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: CurrencyFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateCurrencyConfig(values as API.CurrencyConfig);
|
||||
toast.success(t("common.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:currency-usd" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("currency.title", "Currency Configuration")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"currency.description",
|
||||
"Configure currency units, symbols, and exchange rate API settings"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full gap-0 md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("currency.title", "Currency Configuration")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="currency-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="access_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("currency.accessKey", "API Key")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"currency.accessKeyPlaceholder",
|
||||
"Enter API key"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"currency.accessKeyDescription",
|
||||
"Free exchange rate API key provided by {{url}}",
|
||||
{
|
||||
url: EXCHANGE_RATE_HOST_URL,
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency_unit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("currency.currencyUnit", "Currency Unit")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"currency.currencyUnitPlaceholder",
|
||||
"USD"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"currency.currencyUnitDescription",
|
||||
"Used for display purposes only; changing this will affect all currency units in the system"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency_symbol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("currency.currencySymbol", "Currency Symbol")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"currency.currencySymbolPlaceholder",
|
||||
"$"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"currency.currencySymbolDescription",
|
||||
"Used for display purposes only; changing this will affect all currency units in the system"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="currency-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { MarkdownEditor } from "@workspace/ui/composed/editor/index";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getPrivacyPolicyConfig,
|
||||
updatePrivacyPolicyConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const privacyPolicySchema = z.object({
|
||||
privacy_policy: z.string().optional(),
|
||||
});
|
||||
|
||||
type PrivacyPolicyFormData = z.infer<typeof privacyPolicySchema>;
|
||||
|
||||
export default function PrivacyPolicyConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getPrivacyPolicyConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getPrivacyPolicyConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<PrivacyPolicyFormData>({
|
||||
resolver: zodResolver(privacyPolicySchema),
|
||||
defaultValues: {
|
||||
privacy_policy: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
privacy_policy: data.privacy_policy || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: PrivacyPolicyFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updatePrivacyPolicyConfig(values as API.PrivacyPolicyConfig);
|
||||
toast.success(t("common.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:shield-account-outline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("privacyPolicy.title", "Privacy Policy")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"privacyPolicy.description",
|
||||
"Edit and manage privacy policy content"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("privacyPolicy.title", "Privacy Policy")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="privacy-policy-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="privacy_policy"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("privacyPolicy.title", "Privacy Policy")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
onChange={field.onChange}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"privacyPolicy.description",
|
||||
"Edit and manage privacy policy content"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="privacy-policy-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { JSONEditor } from "@workspace/ui/composed/editor/json";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { UploadImage } from "@workspace/ui/composed/upload-image";
|
||||
import {
|
||||
getSiteConfig,
|
||||
updateSiteConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const siteSchema = z.object({
|
||||
site_logo: z.string().optional(),
|
||||
site_name: z.string().min(1),
|
||||
site_desc: z.string().optional(),
|
||||
keywords: z.string().optional(),
|
||||
custom_html: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
custom_data: z.any().optional(),
|
||||
});
|
||||
|
||||
type SiteFormData = z.infer<typeof siteSchema>;
|
||||
|
||||
export default function SiteConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getSiteConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSiteConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<SiteFormData>({
|
||||
resolver: zodResolver(siteSchema),
|
||||
defaultValues: {
|
||||
site_logo: "",
|
||||
site_name: "",
|
||||
site_desc: "",
|
||||
keywords: "",
|
||||
custom_html: "",
|
||||
host: "",
|
||||
custom_data: {},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: SiteFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSiteConfig(values as API.SiteConfig);
|
||||
toast.success(t("common.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:web" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("site.title", "Site Configuration")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"site.description",
|
||||
"Configure basic site information, logo, domain and other settings"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("site.title", "Site Configuration")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="site-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="site_logo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("site.logo", "Site Logo")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"site.logoPlaceholder",
|
||||
"Enter the URL of the logo, without ending with '/'"
|
||||
)}
|
||||
suffix={
|
||||
<UploadImage
|
||||
className="h-9 rounded-none border-none bg-muted px-2"
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"site.logoDescription",
|
||||
"Used for displaying the logo in designated locations"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="site_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("site.siteName", "Site Name")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"site.siteNamePlaceholder",
|
||||
"Enter site name"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"site.siteNameDescription",
|
||||
"Used for displaying the site name in designated locations"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="site_desc"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("site.siteDesc", "Site Description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"site.siteDescPlaceholder",
|
||||
"Enter site description"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"site.siteDescDescription",
|
||||
"Used for displaying the site description in designated locations"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("site.keywords", "Keywords")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"site.keywordsPlaceholder",
|
||||
"keyword1, keyword2, keyword3"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("site.keywordsDescription", "Used for SEO purposes")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_html"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("site.customHtml", "Custom HTML")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
placeholder={t(
|
||||
"site.customHtmlDescription",
|
||||
"Custom HTML code to be injected into the bottom of the site's body tag"
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"site.customHtmlDescription",
|
||||
"Custom HTML code to be injected into the bottom of the site's body tag"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("site.siteDomain", "Site Domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
placeholder={`${t("site.siteDomainPlaceholder", "Please enter the domain address. For multiple domains, please enter one per line.")}\nexample.com\nwww.example.com`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"site.siteDomainDescription",
|
||||
"Domain address of the current website, e.g., used in emails"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_data"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("site.customData", "Custom Data")}</FormLabel>
|
||||
<FormControl>
|
||||
<JSONEditor
|
||||
onBlur={(value) => field.onChange(value)}
|
||||
schema={{
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
website: { type: "string", title: "Website" },
|
||||
contacts: {
|
||||
type: "object",
|
||||
title: "Contacts",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
email: { type: "string", title: "Email" },
|
||||
telephone: {
|
||||
type: "string",
|
||||
title: "Telephone",
|
||||
},
|
||||
address: { type: "string", title: "Address" },
|
||||
},
|
||||
},
|
||||
community: {
|
||||
type: "object",
|
||||
title: "Community",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
telegram: { type: "string", title: "Telegram" },
|
||||
twitter: { type: "string", title: "Twitter" },
|
||||
discord: { type: "string", title: "Discord" },
|
||||
instagram: {
|
||||
type: "string",
|
||||
title: "Instagram",
|
||||
},
|
||||
linkedin: { type: "string", title: "Linkedin" },
|
||||
facebook: { type: "string", title: "Facebook" },
|
||||
github: { type: "string", title: "Github" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"site.customDataDescription",
|
||||
"Custom data for website customization"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="site-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getTosConfig,
|
||||
updateTosConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const tosSchema = z.object({
|
||||
tos_content: z.string().optional(),
|
||||
});
|
||||
|
||||
type TosFormData = z.infer<typeof tosSchema>;
|
||||
|
||||
export default function TosConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getTosConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getTosConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<TosFormData>({
|
||||
resolver: zodResolver(tosSchema),
|
||||
defaultValues: {
|
||||
tos_content: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
tos_content: data.tos_content || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: TosFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateTosConfig(values as API.TosConfig);
|
||||
toast.success(t("common.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:file-document-outline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("tos.title", "Terms of Service")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"tos.description",
|
||||
"Edit and manage terms of service content"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("tos.title", "Terms of Service")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="tos-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tos_content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("tos.title", "Terms of Service")}</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
onChange={field.onChange}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"tos.description",
|
||||
"Edit and manage terms of service content"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="tos-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@workspace/ui/components/table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CurrencyForm from "./basic-settings/currency-form";
|
||||
import PrivacyPolicyForm from "./basic-settings/privacy-policy-form";
|
||||
import SiteForm from "./basic-settings/site-form";
|
||||
import TosForm from "./basic-settings/tos-form";
|
||||
import LogCleanupForm from "./log-cleanup/log-cleanup-form";
|
||||
import InviteForm from "./user-security/invite-form";
|
||||
import RegisterForm from "./user-security/register-form";
|
||||
import VerifyCodeForm from "./user-security/verify-code-form";
|
||||
import VerifyForm from "./user-security/verify-form";
|
||||
|
||||
export default function System() {
|
||||
const { t } = useTranslation("system");
|
||||
|
||||
const formSections = [
|
||||
{
|
||||
title: t("basicSettings", "Basic Settings"),
|
||||
forms: [
|
||||
{ component: SiteForm },
|
||||
{ component: CurrencyForm },
|
||||
{ component: TosForm },
|
||||
{ component: PrivacyPolicyForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("userSecuritySettings", "User & Security"),
|
||||
forms: [
|
||||
{ component: RegisterForm },
|
||||
{ component: InviteForm },
|
||||
{ component: VerifyForm },
|
||||
{ component: VerifyCodeForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("logSettings", "Log Settings"),
|
||||
forms: [{ component: LogCleanupForm }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{formSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<h2 className="mb-4 font-semibold text-lg">{section.title}</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{section.forms.map((form, formIndex) => {
|
||||
const FormComponent = form.component;
|
||||
return (
|
||||
<TableRow key={formIndex}>
|
||||
<TableCell>
|
||||
<FormComponent />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getLogSetting,
|
||||
updateLogSetting,
|
||||
} from "@workspace/ui/services/admin/log";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const logCleanupSchema = z.object({
|
||||
auto_clear: z.boolean(),
|
||||
clear_days: z.number().min(1),
|
||||
});
|
||||
|
||||
type LogCleanupFormData = z.infer<typeof logCleanupSchema>;
|
||||
|
||||
export default function LogCleanupForm() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getLogSetting"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getLogSetting();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<LogCleanupFormData>({
|
||||
resolver: zodResolver(logCleanupSchema),
|
||||
defaultValues: {
|
||||
auto_clear: false,
|
||||
clear_days: 30,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: LogCleanupFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateLogSetting(values as API.LogSetting);
|
||||
toast.success(t("common.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:delete-sweep" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("logCleanup.title", "Log Cleanup Settings")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"logCleanup.description",
|
||||
"Configure automatic log cleanup rules and retention period"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("logCleanup.title", "Log Cleanup Settings")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="log-cleanup-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_clear"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("logCleanup.autoClear", "Enable Auto Cleanup")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"logCleanup.autoClearDescription",
|
||||
"When enabled, the system will automatically clear expired log records"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clear_days"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("logCleanup.clearDays", "Retention Days")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={!form.watch("auto_clear")}
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
placeholder={t(
|
||||
"logCleanup.clearDaysPlaceholder",
|
||||
"Enter retention days"
|
||||
)}
|
||||
type="number"
|
||||
value={field.value?.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"logCleanup.clearDaysDescription",
|
||||
"Number of days to retain logs; logs older than this will be cleaned up"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="log-cleanup-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getInviteConfig,
|
||||
updateInviteConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const inviteSchema = z.object({
|
||||
forced_invite: z.boolean().optional(),
|
||||
referral_percentage: z.number().optional(),
|
||||
only_first_purchase: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type InviteFormData = z.infer<typeof inviteSchema>;
|
||||
|
||||
export default function InviteConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getInviteConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getInviteConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<InviteFormData>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: {
|
||||
forced_invite: false,
|
||||
referral_percentage: 0,
|
||||
only_first_purchase: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: InviteFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateInviteConfig(values as API.InviteConfig);
|
||||
toast.success(t("invite.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("invite.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:account-multiple-plus-outline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("invite.title", "Invitation Settings")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"invite.description",
|
||||
"Configure user invitation and referral reward settings"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("invite.title", "Invitation Settings")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="invite-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="forced_invite"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"invite.forcedInvite",
|
||||
"Require Invitation to Register"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"invite.forcedInviteDescription",
|
||||
"When enabled, users must register through an invitation link"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referral_percentage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"invite.referralPercentage",
|
||||
"Referral Reward Percentage"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
max={100}
|
||||
min={0}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
placeholder={t(
|
||||
"invite.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
suffix="%"
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"invite.referralPercentageDescription",
|
||||
"Percentage of reward given to referrers"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="only_first_purchase"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"invite.onlyFirstPurchase",
|
||||
"First Purchase Reward Only"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"invite.onlyFirstPurchaseDescription",
|
||||
"When enabled, referrers only receive rewards for the first purchase by referred users"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="invite-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getRegisterConfig,
|
||||
updateRegisterConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
|
||||
const registerSchema = z.object({
|
||||
stop_register: z.boolean().optional(),
|
||||
enable_trial: z.boolean().optional(),
|
||||
trial_subscribe: z.number().optional(),
|
||||
trial_time: z.number().optional(),
|
||||
trial_time_unit: z.string().optional(),
|
||||
enable_ip_register_limit: z.boolean().optional(),
|
||||
ip_register_limit: z.number().optional(),
|
||||
ip_register_limit_duration: z.number().optional(),
|
||||
});
|
||||
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function RegisterConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getRegisterConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getRegisterConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
const form = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
stop_register: false,
|
||||
enable_trial: false,
|
||||
trial_subscribe: undefined,
|
||||
trial_time: 0,
|
||||
trial_time_unit: "day",
|
||||
enable_ip_register_limit: false,
|
||||
ip_register_limit: 1,
|
||||
ip_register_limit_duration: 1,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: RegisterFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateRegisterConfig(values as API.RegisterConfig);
|
||||
toast.success(t("register.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("register.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:account-plus-outline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("register.title", "Registration Settings")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"register.description",
|
||||
"Configure user registration related settings"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("register.title", "Registration Settings")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="register-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stop_register"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"register.stopNewUserRegistration",
|
||||
"Stop New User Registration"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.stopNewUserRegistrationDescription",
|
||||
"When enabled, new user registration will be disabled"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_ip_register_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"register.ipRegistrationLimit",
|
||||
"IP Registration Limit"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.ipRegistrationLimitDescription",
|
||||
"Limit the number of registrations from a single IP address"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("enable_ip_register_limit") && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ip_register_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"register.registrationLimitCount",
|
||||
"Registration Limit Count"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={1}
|
||||
onValueBlur={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder={t(
|
||||
"register.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.registrationLimitCountDescription",
|
||||
"Number of registrations allowed per IP within the limit period"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ip_register_limit_duration"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"register.registrationLimitExpire",
|
||||
"Limit Period"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={1}
|
||||
onValueBlur={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder={t(
|
||||
"register.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
suffix={t("register.minute", "Minute(s)")}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.registrationLimitExpireDescription",
|
||||
"Duration for IP registration limit (minutes)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_trial"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("register.enableTrial", "Enable Trial")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.enableTrialDescription",
|
||||
"When enabled, new users will receive a trial subscription upon registration"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("enable_trial") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("register.trialConfig", "Trial Configuration")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<EnhancedInput
|
||||
className="flex-1"
|
||||
min={0}
|
||||
onValueBlur={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder={t(
|
||||
"register.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
prefix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_subscribe"
|
||||
render={({ field }) => (
|
||||
<Combobox
|
||||
className="w-32 rounded-r-none bg-secondary"
|
||||
onChange={(value: number) => {
|
||||
if (value) {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
options={subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: item.id!,
|
||||
}))}
|
||||
placeholder={t(
|
||||
"register.selectPlaceholder",
|
||||
"Please select"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
suffix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trial_time_unit"
|
||||
render={({ field: unitField }) => (
|
||||
<Combobox
|
||||
className="w-32 rounded-l-none bg-secondary"
|
||||
onChange={(value: string) => {
|
||||
unitField.onChange(value);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: t("register.none", "None"),
|
||||
value: "None",
|
||||
},
|
||||
{
|
||||
label: t("register.year", "Year(s)"),
|
||||
value: "Year",
|
||||
},
|
||||
{
|
||||
label: t("register.month", "Month(s)"),
|
||||
value: "Month",
|
||||
},
|
||||
{
|
||||
label: t("register.day", "Day(s)"),
|
||||
value: "Day",
|
||||
},
|
||||
{
|
||||
label: t("register.hour", "Hour(s)"),
|
||||
value: "Hour",
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
"register.minute",
|
||||
"Minute(s)"
|
||||
),
|
||||
value: "Minute",
|
||||
},
|
||||
]}
|
||||
placeholder={t(
|
||||
"register.selectPlaceholder",
|
||||
"Please select"
|
||||
)}
|
||||
value={unitField.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"register.trialConfigDescription",
|
||||
"Configure trial subscription, duration and time unit for new users upon registration"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="register-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getVerifyCodeConfig,
|
||||
updateVerifyCodeConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const verifyCodeSchema = z.object({
|
||||
verify_code_expire_time: z.number().optional(),
|
||||
verify_code_interval: z.number().optional(),
|
||||
verify_code_limit: z.number().optional(),
|
||||
});
|
||||
|
||||
type VerifyCodeFormData = z.infer<typeof verifyCodeSchema>;
|
||||
|
||||
export default function VerifyCodeConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getVerifyCodeConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getVerifyCodeConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<VerifyCodeFormData>({
|
||||
resolver: zodResolver(verifyCodeSchema),
|
||||
defaultValues: {
|
||||
verify_code_expire_time: 300,
|
||||
verify_code_interval: 60,
|
||||
verify_code_limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: VerifyCodeFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateVerifyCodeConfig(values as API.VerifyCodeConfig);
|
||||
toast.success(t("verifyCode.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("verifyCode.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:message-text-clock-outline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("verifyCode.title", "Verification Code Settings")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"verifyCode.description",
|
||||
"Configure email verification code sending rules and limits"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("verifyCode.title", "Verification Code Settings")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="verify-code-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_code_expire_time"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("verifyCode.expireTime", "Verification Code Validity")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={60}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
placeholder={t(
|
||||
"verifyCode.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
suffix={t("verifyCode.seconds", "seconds")}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verifyCode.expireTimeDescription",
|
||||
"Validity period of verification codes (seconds)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_code_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("verifyCode.interval", "Sending Interval")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={30}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
placeholder={t(
|
||||
"verifyCode.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
suffix={t("verifyCode.seconds", "seconds")}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verifyCode.intervalDescription",
|
||||
"Minimum interval between two verification code sends (seconds)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_code_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("verifyCode.dailyLimit", "Daily Sending Limit")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
min={1}
|
||||
onValueBlur={(value) => field.onChange(Number(value))}
|
||||
placeholder={t(
|
||||
"verifyCode.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
suffix={t("verifyCode.times", "time(s)")}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verifyCode.dailyLimitDescription",
|
||||
"Maximum number of verification codes each user can send per day"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="verify-code-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getVerifyConfig,
|
||||
updateVerifyConfig,
|
||||
} from "@workspace/ui/services/admin/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const verifySchema = z.object({
|
||||
turnstile_site_key: z.string().optional(),
|
||||
turnstile_secret: z.string().optional(),
|
||||
enable_register_verify: z.boolean().optional(),
|
||||
enable_login_verify: z.boolean().optional(),
|
||||
enable_password_verify: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type VerifyFormData = z.infer<typeof verifySchema>;
|
||||
|
||||
export default function VerifyConfig() {
|
||||
const { t } = useTranslation("system");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getVerifyConfig"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getVerifyConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<VerifyFormData>({
|
||||
resolver: zodResolver(verifySchema),
|
||||
defaultValues: {
|
||||
turnstile_site_key: "",
|
||||
turnstile_secret: "",
|
||||
enable_register_verify: false,
|
||||
enable_login_verify: false,
|
||||
enable_password_verify: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: VerifyFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateVerifyConfig(values as API.VerifyConfig);
|
||||
toast.success(t("verify.saveSuccess", "Save Successful"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("verify.saveFailed", "Save Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:shield-check-outline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("verify.title", "Security Verification")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"verify.description",
|
||||
"Configure Turnstile CAPTCHA and verification settings"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("verify.title", "Security Verification")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="verify-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="turnstile_site_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"verify.turnstileSiteKeyPlaceholder",
|
||||
"Enter Turnstile site key"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verify.turnstileSiteKeyDescription",
|
||||
"Cloudflare Turnstile site key for frontend verification"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="turnstile_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("verify.turnstileSecret", "Turnstile Secret Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"verify.turnstileSecretPlaceholder",
|
||||
"Enter Turnstile secret key"
|
||||
)}
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verify.turnstileSecretDescription",
|
||||
"Cloudflare Turnstile secret key for backend verification"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_register_verify"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"verify.enableRegisterVerify",
|
||||
"Enable Verification on Registration"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verify.enableRegisterVerifyDescription",
|
||||
"When enabled, users must pass human verification during registration"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_login_verify"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"verify.enableLoginVerify",
|
||||
"Enable Verification on Login"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verify.enableLoginVerifyDescription",
|
||||
"When enabled, users must pass human verification during login"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_password_verify"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"verify.enablePasswordVerify",
|
||||
"Enable Verification on Password Reset"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"verify.enablePasswordVerifyDescription",
|
||||
"When enabled, users must pass human verification during password reset"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="verify-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save Settings")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@workspace/ui/components/drawer";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import {
|
||||
createTicketFollow,
|
||||
getTicket,
|
||||
getTicketList,
|
||||
updateTicketStatus,
|
||||
} from "@workspace/ui/services/admin/ticket";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import { UserDetail } from "../user/user-detail";
|
||||
|
||||
export default function Page() {
|
||||
const { t } = useTranslation("ticket");
|
||||
|
||||
// i18n status declarations for extraction
|
||||
// t("status.0", "Status")
|
||||
// t("status.1", "Pending Follow-up")
|
||||
// t("status.2", "Pending Reply")
|
||||
// t("status.3", "Resolved")
|
||||
// t("status.4", "Closed")
|
||||
|
||||
const [ticketId, setTicketId] = useState<any>(null);
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const { data: ticket, refetch: refetchTicket } = useQuery({
|
||||
queryKey: ["getTicket", ticketId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getTicket({
|
||||
id: ticketId,
|
||||
});
|
||||
return data.data as API.Ticket;
|
||||
},
|
||||
enabled: !!ticketId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.children[1]?.scrollTo({
|
||||
top: scrollRef.current.children[1].scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, 66);
|
||||
}, [ticket?.follow?.length]);
|
||||
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
return (
|
||||
<>
|
||||
<ProTable<API.Ticket, { status: number }>
|
||||
action={ref}
|
||||
actions={{
|
||||
render(row) {
|
||||
if (row.status !== 4) {
|
||||
return [
|
||||
<Button key="reply" onClick={() => setTicketId(row.id)}>
|
||||
{t("reply", "Reply")}
|
||||
</Button>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"closeWarning",
|
||||
"Once closed, the ticket cannot be operated on. Please proceed with caution."
|
||||
)}
|
||||
key="colse"
|
||||
onConfirm={async () => {
|
||||
await updateTicketStatus({
|
||||
id: row.id,
|
||||
status: 4,
|
||||
});
|
||||
toast.success(t("closeSuccess", "Closed successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmClose", "Are you sure you want to close?")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("close", "Close")}</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
return [
|
||||
<Button key="check" onClick={() => setTicketId(row.id)} size="sm">
|
||||
{t("check", "Check")}
|
||||
</Button>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: t("title", "Title"),
|
||||
},
|
||||
{
|
||||
accessorKey: "user_id",
|
||||
header: t("user", "User"),
|
||||
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status.0", "Status"),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50",
|
||||
{
|
||||
"before:bg-rose-500 before:ring-rose-500":
|
||||
row.original.status === 1,
|
||||
"before:bg-yellow-500 before:ring-yellow-500":
|
||||
row.original.status === 2,
|
||||
"before:bg-green-500 before:ring-green-500":
|
||||
row.original.status === 3,
|
||||
"before:bg-zinc-500 before:ring-zinc-500":
|
||||
row.original.status === 4,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{t(`status.${row.original.status}`)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: t("updatedAt", "Updated At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("updated_at")),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("ticketList", "Ticket List"),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: "status",
|
||||
placeholder: t("status.0", "Status"),
|
||||
options: [
|
||||
{
|
||||
label: t("close", "Close"),
|
||||
value: "4",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getTicketList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setTicketId(null);
|
||||
}}
|
||||
open={!!ticketId}
|
||||
>
|
||||
<DrawerContent className="container mx-auto h-screen *:select-text">
|
||||
<DrawerHeader className="border-b text-left">
|
||||
<DrawerTitle>{ticket?.title}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<ScrollArea className="h-full overflow-hidden" ref={scrollRef}>
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
{/* 显示工单描述作为第一条用户消息 */}
|
||||
{ticket?.description && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDate(ticket.created_at)}
|
||||
</p>
|
||||
<p className="w-fit rounded-lg bg-accent p-2 font-medium">
|
||||
{ticket.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示后续跟进消息 */}
|
||||
{ticket?.follow?.map((item) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-4", {
|
||||
"flex-row-reverse": item.from === "System",
|
||||
})}
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col gap-1", {
|
||||
"items-end": item.from === "System",
|
||||
})}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDate(item.created_at)}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"w-fit rounded-lg bg-accent p-2 font-medium",
|
||||
{
|
||||
"bg-primary text-primary-foreground":
|
||||
item.from === "System",
|
||||
}
|
||||
)}
|
||||
>
|
||||
{item.type === 1 && item.content}
|
||||
{item.type === 2 && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="attachment"
|
||||
className="!size-auto object-cover"
|
||||
height={300}
|
||||
src={item.content!}
|
||||
width={300}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{ticket?.status !== 4 && (
|
||||
<DrawerFooter>
|
||||
<form
|
||||
className="flex w-full flex-row items-center gap-2"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (message) {
|
||||
await createTicketFollow({
|
||||
ticket_id: ticketId,
|
||||
from: "System",
|
||||
type: 1,
|
||||
content: message,
|
||||
});
|
||||
refetchTicket();
|
||||
setMessage("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button className="p-0" type="button" variant="outline">
|
||||
<Label className="p-2" htmlFor="picture">
|
||||
<Icon className="text-2xl" icon="uil:image-upload" />
|
||||
</Label>
|
||||
<Input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
id="picture"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file?.type.startsWith("image/")) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const maxWidth = 300;
|
||||
const maxHeight = 300;
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(
|
||||
(maxWidth / width) * height
|
||||
);
|
||||
width = maxWidth;
|
||||
}
|
||||
} else if (height > maxHeight) {
|
||||
width = Math.round((maxHeight / height) * width);
|
||||
height = maxHeight;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob!);
|
||||
reader.onloadend = async () => {
|
||||
await createTicketFollow({
|
||||
ticket_id: ticketId,
|
||||
from: "System",
|
||||
type: 2,
|
||||
content: reader.result as string,
|
||||
});
|
||||
refetchTicket();
|
||||
};
|
||||
},
|
||||
"image/webp",
|
||||
0.8
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
</Button>
|
||||
<Input
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t(
|
||||
"inputPlaceholder",
|
||||
"Please enter your question, we will reply as soon as possible."
|
||||
)}
|
||||
value={message}
|
||||
/>
|
||||
<Button disabled={!message} type="submit">
|
||||
<Icon icon="uil:navigator" />
|
||||
</Button>
|
||||
</form>
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserDetail,
|
||||
getUserList,
|
||||
updateUserBasicInfo,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { 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 { UserDetail } from "./user-detail";
|
||||
import UserForm from "./user-form";
|
||||
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";
|
||||
|
||||
export default function User() {
|
||||
const { t } = useTranslation("user");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
const initialFilters = {
|
||||
search: sp.search || undefined,
|
||||
user_id: sp.user_id || undefined,
|
||||
subscribe_id: sp.subscribe_id || undefined,
|
||||
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<ProTable<API.User, API.GetUserListParams>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<ProfileSheet key="profile" userId={row.id} />,
|
||||
<SubscriptionSheet key="subscription" userId={row.id} />,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteDescription",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="edit"
|
||||
onConfirm={async () => {
|
||||
await deleteUser({ id: row.id });
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
<DropdownMenu key="more">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{t("more", "More")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/order"
|
||||
>
|
||||
{t("orderList", "Order List")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/login"
|
||||
>
|
||||
{t("loginLogs", "Login Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/balance"
|
||||
>
|
||||
{t("balanceLogs", "Balance Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/commission"
|
||||
>
|
||||
{t("commissionLogs", "Commission Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/gift"
|
||||
>
|
||||
{t("giftLogs", "Gift Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "enable",
|
||||
header: t("enable", "Enable"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("enable")}
|
||||
onCheckedChange={async (checked) => {
|
||||
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,
|
||||
enable: checked,
|
||||
} as unknown as API.UpdateUserBasiceInfoRequest);
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "auth_methods",
|
||||
header: t("userName", "Username"),
|
||||
cell: ({ row }) => {
|
||||
const method = row.original.auth_methods?.[0];
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
className="mr-1 uppercase"
|
||||
title={method?.verified ? t("verified", "Verified") : ""}
|
||||
>
|
||||
{method?.auth_type}
|
||||
</Badge>
|
||||
{method?.auth_identifier}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "balance",
|
||||
header: t("balance", "Balance"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.getValue("balance")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "gift_amount",
|
||||
header: t("giftAmount", "Gift Amount"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.getValue("gift_amount")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "commission",
|
||||
header: t("commission", "Commission"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.getValue("commission")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "refer_code",
|
||||
header: t("inviteCode", "Invite Code"),
|
||||
cell: ({ row }) => row.getValue("refer_code") || "--",
|
||||
},
|
||||
{
|
||||
accessorKey: "referer_id",
|
||||
header: t("referer", "Referer"),
|
||||
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("createdAt", "Created At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("userList", "User List"),
|
||||
toolbar: (
|
||||
<UserForm<API.CreateUserRequest>
|
||||
key="create"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createUser(values);
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createUser", "Create User")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
initialFilters={initialFilters}
|
||||
key={initialFilters.user_id}
|
||||
params={[
|
||||
{
|
||||
key: "subscribe_id",
|
||||
placeholder: t("subscription", "Subscription"),
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id!),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
placeholder: "Search",
|
||||
},
|
||||
{
|
||||
key: "user_id",
|
||||
placeholder: t("userId", "User ID"),
|
||||
},
|
||||
{
|
||||
key: "user_subscribe_id",
|
||||
placeholder: t("subscriptionId", "Subscription ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getUserList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSheet({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: user, refetch } = useQuery({
|
||||
enabled: open,
|
||||
queryKey: ["user", userId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserDetail({ id: userId });
|
||||
return data.data as API.User;
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="default">{t("edit", "Edit")}</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
className="w-[700px] max-w-full md:max-w-screen-lg"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("userProfile", "User Profile")} · ID: {userId}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{user && (
|
||||
<ScrollArea className="h-[calc(100dvh-140px)] p-2">
|
||||
<Tabs defaultValue="basic">
|
||||
<TabsList className="mb-3">
|
||||
<TabsTrigger value="basic">
|
||||
{t("basicInfoTitle", "Basic Info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notify">
|
||||
{t("notifySettingsTitle", "Notify Settings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth">
|
||||
{t("authMethodsTitle", "Auth Methods")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="mt-0" value="basic">
|
||||
<BasicInfoForm refetch={refetch as any} user={user} />
|
||||
</TabsContent>
|
||||
<TabsContent className="mt-0" value="notify">
|
||||
<NotifySettingsForm refetch={refetch as any} user={user} />
|
||||
</TabsContent>
|
||||
<TabsContent className="mt-0" value="auth">
|
||||
<AuthMethodsForm refetch={refetch as any} user={user} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionSheet({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="secondary">{t("subscription", "Subscription")}</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
className="w-[1000px] max-w-full md:max-w-screen-xl"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("subscriptionList", "Subscription List")} · ID: {userId}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-2">
|
||||
<UserSubscription userId={userId} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@workspace/ui/components/hover-card";
|
||||
import {
|
||||
getUserDetail,
|
||||
getUserSubscribeById,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export function UserSubscribeDetail({
|
||||
id,
|
||||
enabled,
|
||||
hoverCard = false,
|
||||
}: {
|
||||
id: number;
|
||||
enabled: boolean;
|
||||
hoverCard?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: id !== 0 && enabled,
|
||||
queryKey: ["getUserSubscribeById", id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserSubscribeById({ id });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!id) return "--";
|
||||
|
||||
const usedTraffic = data ? data.upload + data.download : 0;
|
||||
const totalTraffic = data?.traffic || 0;
|
||||
|
||||
const subscribeContent = (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-sm">{t("subscriptionInfo")}</h3>
|
||||
<div className="rounded-lg bg-muted/30 p-3">
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{t("subscriptionId")}
|
||||
</span>
|
||||
<span>{data?.id || "--"}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("subscriptionName")}
|
||||
</span>
|
||||
<span>{data?.subscribe?.name || "--"}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("token")}</span>
|
||||
<div className="font-mono text-xs" title={data?.token || ""}>
|
||||
{data?.token || "--"}
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("trafficUsage")}</span>
|
||||
<span>
|
||||
{data
|
||||
? totalTraffic === 0
|
||||
? `${formatBytes(usedTraffic)} / ${t("unlimited")}`
|
||||
: `${formatBytes(usedTraffic)} / ${formatBytes(totalTraffic)}`
|
||||
: "--"}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("startTime")}</span>
|
||||
<span>
|
||||
{data?.start_time ? formatDate(data.start_time) : "--"}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("expireTime")}</span>
|
||||
<span>
|
||||
{data?.expire_time ? formatDate(data.expire_time) : "--"}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hoverCard && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-sm">
|
||||
{t("userInfo")}
|
||||
{/* Removed link to legacy user detail page */}
|
||||
</h3>
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">{t("userId")}</span>
|
||||
<span>{data?.user_id}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">{t("balance")}</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.user.balance} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("giftAmount")}</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.user?.gift_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("commission")}</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.user?.commission} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("createdAt")}</span>
|
||||
<span>
|
||||
{data?.user?.created_at && formatDate(data?.user?.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hoverCard) {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button className="p-0" variant="link">
|
||||
{data?.subscribe?.name || t("loading")}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96">{subscribeContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return subscribeContent;
|
||||
}
|
||||
|
||||
export function UserDetail({ id }: { id: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: id !== 0,
|
||||
queryKey: ["getUserDetail", id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserDetail({ id });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!id) return "--";
|
||||
|
||||
const identifier =
|
||||
data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier ||
|
||||
data?.auth_methods[0]?.auth_identifier;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button asChild className="p-0" variant="link">
|
||||
<Link search={{ user_id: id }} to="/dashboard/user">
|
||||
{identifier || t("loading", "Loading...")}
|
||||
</Link>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="grid gap-3">
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">ID</span>
|
||||
<span>{data?.id}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{t("balance", "Balance")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.balance} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("giftAmount", "Gift Amount")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.gift_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("commission", "Commission")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.commission} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("createdAt", "Created At")}
|
||||
</span>
|
||||
<span>{data?.created_at && formatDate(data?.created_at)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
interface UserFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
update?: boolean;
|
||||
}
|
||||
|
||||
export default function UserForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: Readonly<UserFormProps<T>>) {
|
||||
const { t } = useTranslation("user");
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const formSchema = z.object({
|
||||
email: z.email(t("invalidEmailFormat", "Invalid email format")),
|
||||
telephone_area_code: z.string().optional(),
|
||||
telephone: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
referer_id: z.number().optional(),
|
||||
refer_code: z.string().optional(),
|
||||
referral_percentage: z.number().optional(),
|
||||
only_first_purchase: z.boolean().optional(),
|
||||
is_admin: z.boolean().optional(),
|
||||
balance: z.number().optional(),
|
||||
gift_amount: z.number().optional(),
|
||||
commission: z.number().optional(),
|
||||
});
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full gap-0 md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4 px-6 pt-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("userEmail", "Email")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t("userEmailPlaceholder", "Enter email")}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="telephone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("telephone", "Phone")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"telephonePlaceholder",
|
||||
"Enter phone number"
|
||||
)}
|
||||
prefix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="telephone_area_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<AreaCodeSelect
|
||||
className="w-32 rounded-none border-y-0 border-l-0"
|
||||
onChange={(value) => {
|
||||
form.setValue(
|
||||
field.name,
|
||||
value.phone as string
|
||||
);
|
||||
}}
|
||||
placeholder={t(
|
||||
"areaCodePlaceholder",
|
||||
"Area code"
|
||||
)}
|
||||
simple
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password", "Password")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder", "Enter password")}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referer_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("refererId", "Referer ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"refererIdPlaceholder",
|
||||
"Enter referer ID"
|
||||
)}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="refer_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("inviteCode", "Invite Code")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"inviteCodePlaceholder",
|
||||
"Enter invite code"
|
||||
)}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referral_percentage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("referralPercentage", "Referral Percentage")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
max={100}
|
||||
min={0}
|
||||
placeholder={t(
|
||||
"referralPercentagePlaceholder",
|
||||
"Enter percentage"
|
||||
)}
|
||||
type="number"
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, Number(value));
|
||||
}}
|
||||
suffix="%"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="only_first_purchase"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("onlyFirstPurchase", "First Purchase Only")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="balance"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("balance", "Balance")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t("balancePlaceholder", "Enter balance")}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gift_amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"giftAmountPlaceholder",
|
||||
"Enter gift amount"
|
||||
)}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="commission"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("commission", "Commission")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"commissionPlaceholder",
|
||||
"Enter commission"
|
||||
)}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_admin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("manager", "Administrator")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="pt-2">
|
||||
<Switch
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}{" "}
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import {
|
||||
createUserAuthMethod,
|
||||
deleteUserAuthMethod,
|
||||
updateUserAuthMethod,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AuthMethodsForm({
|
||||
user,
|
||||
refetch,
|
||||
}: {
|
||||
user: API.User;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const [emailChanges, setEmailChanges] = useState<Record<string, string>>({});
|
||||
|
||||
const handleRemoveAuth = async (authType: string) => {
|
||||
await deleteUserAuthMethod({
|
||||
user_id: user.id,
|
||||
auth_type: authType,
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
};
|
||||
|
||||
const handleUpdateEmail = async (email: string) => {
|
||||
await updateUserAuthMethod({
|
||||
user_id: user.id,
|
||||
auth_type: "email",
|
||||
auth_identifier: email,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleCreateEmail = async (email: string) => {
|
||||
await createUserAuthMethod({
|
||||
user_id: user.id,
|
||||
auth_type: "email",
|
||||
auth_identifier: email,
|
||||
});
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleEmailChange = (authType: string, value: string) => {
|
||||
setEmailChanges((prev) => ({
|
||||
...prev,
|
||||
[authType]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const emailMethod = user.auth_methods.find(
|
||||
(method) => method.auth_type === "email"
|
||||
);
|
||||
const otherMethods = user.auth_methods.filter(
|
||||
(method) => method.auth_type !== "email"
|
||||
);
|
||||
|
||||
const defaultEmailMethod = {
|
||||
auth_type: "email",
|
||||
auth_identifier: "",
|
||||
verified: false,
|
||||
...emailMethod,
|
||||
};
|
||||
|
||||
const isEmailExists = !!emailMethod;
|
||||
const handleEmailAction = () => {
|
||||
const email = emailChanges.email;
|
||||
if (isEmailExists) {
|
||||
handleUpdateEmail(email as string);
|
||||
} else {
|
||||
handleCreateEmail(email as string);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("authMethodsTitle", "Auth Methods")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<Card className="border-none shadow-none">
|
||||
<CardContent className="space-y-3 p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium uppercase">email</div>
|
||||
<Badge
|
||||
variant={
|
||||
defaultEmailMethod.verified ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{defaultEmailMethod.verified
|
||||
? t("verified", "Verified")
|
||||
: t("unverified", "Unverified")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
handleEmailChange("email", value as string)
|
||||
}
|
||||
placeholder={t("pleaseEnterEmail", "Enter email")}
|
||||
value={defaultEmailMethod.auth_identifier}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={
|
||||
!emailChanges.email ||
|
||||
(isEmailExists &&
|
||||
emailChanges.email === defaultEmailMethod.auth_identifier)
|
||||
}
|
||||
onClick={handleEmailAction}
|
||||
>
|
||||
{isEmailExists ? t("update", "Update") : t("add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{otherMethods.map((method) => (
|
||||
<Card className="border-none shadow-none" key={method.auth_type}>
|
||||
<CardContent className="space-y-3 p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium uppercase">
|
||||
{method.auth_type}
|
||||
</div>
|
||||
<Badge variant={method.verified ? "default" : "destructive"}>
|
||||
{method.verified
|
||||
? t("verified", "Verified")
|
||||
: t("unverified", "Unverified")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{method.auth_identifier}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleRemoveAuth(method.auth_type)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{t("remove", "Remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { UploadImage } from "@workspace/ui/composed/upload-image";
|
||||
import { updateUserBasicInfo } from "@workspace/ui/services/admin/user";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
const basicInfoSchema = z.object({
|
||||
avatar: z.string().optional(),
|
||||
balance: z.number().optional(),
|
||||
commission: z.number().optional(),
|
||||
gift_amount: z.number().optional(),
|
||||
refer_code: z.string().optional(),
|
||||
referer_id: z.number().optional(),
|
||||
referral_percentage: z.number().optional(),
|
||||
only_first_purchase: z.boolean().optional(),
|
||||
is_admin: z.boolean().optional(),
|
||||
password: z.string().optional(),
|
||||
enable: z.boolean(),
|
||||
});
|
||||
|
||||
type BasicInfoValues = z.infer<typeof basicInfoSchema>;
|
||||
|
||||
export function BasicInfoForm({
|
||||
user,
|
||||
refetch,
|
||||
}: {
|
||||
user: API.User;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
const form = useForm<BasicInfoValues>({
|
||||
resolver: zodResolver(basicInfoSchema),
|
||||
defaultValues: {
|
||||
avatar: user.avatar,
|
||||
balance: user.balance,
|
||||
commission: user.commission,
|
||||
gift_amount: user.gift_amount,
|
||||
refer_code: user.refer_code,
|
||||
referer_id: user.referer_id,
|
||||
referral_percentage: user.referral_percentage,
|
||||
only_first_purchase: user.only_first_purchase,
|
||||
is_admin: user.is_admin,
|
||||
enable: user.enable,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: BasicInfoValues) {
|
||||
await updateUserBasicInfo({
|
||||
user_id: user.id,
|
||||
telegram: user.telegram,
|
||||
...data,
|
||||
} as API.UpdateUserBasiceInfoRequest);
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{t("basicInfoTitle", "Basic Info")}</CardTitle>
|
||||
<Button size="sm" type="submit">
|
||||
{t("save", "Save")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("accountEnable", "Account Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_admin"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("administrator", "Administrator")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="balance"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("balance", "Balance")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="commission"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("commission", "Commission")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gift_amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="refer_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("referralCode", "Referral Code")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as string);
|
||||
}}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referer_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("referrerUserId", "Referrer User ID")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referral_percentage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("referralPercentage", "Referral Percentage")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
max={100}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="%"
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="only_first_purchase"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("onlyFirstPurchase", "First Purchase Only")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("avatar", "Avatar")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as string);
|
||||
}}
|
||||
suffix={
|
||||
<UploadImage
|
||||
className="h-9 rounded-none border-none bg-muted px-2"
|
||||
onChange={(value) =>
|
||||
form.setValue("avatar", value as string)
|
||||
}
|
||||
returnType="base64"
|
||||
/>
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password", "Password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"passwordPlaceholder",
|
||||
"Enter new password"
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { updateUserNotifySetting } from "@workspace/ui/services/admin/user";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
const notifySettingsSchema = z.object({
|
||||
enable_balance_notify: z.boolean(),
|
||||
enable_login_notify: z.boolean(),
|
||||
enable_subscribe_notify: z.boolean(),
|
||||
enable_trade_notify: z.boolean(),
|
||||
});
|
||||
|
||||
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
|
||||
|
||||
export function NotifySettingsForm({
|
||||
user,
|
||||
refetch,
|
||||
}: {
|
||||
user: API.User;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const form = useForm<NotifySettingsValues>({
|
||||
resolver: zodResolver(notifySettingsSchema),
|
||||
defaultValues: {
|
||||
enable_balance_notify: user.enable_balance_notify,
|
||||
enable_login_notify: user.enable_login_notify,
|
||||
enable_subscribe_notify: user.enable_subscribe_notify,
|
||||
enable_trade_notify: user.enable_trade_notify,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: NotifySettingsValues) {
|
||||
await updateUserNotifySetting({
|
||||
...data,
|
||||
user_id: user.id,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{t("notifySettingsTitle", "Notify Settings")}</CardTitle>
|
||||
<Button size="sm" type="submit">
|
||||
{t("save", "Save")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_balance_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("balanceNotifications", "Balance Notifications")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_login_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("loginNotifications", "Login Notifications")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_subscribe_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"subscriptionNotifications",
|
||||
"Subscription Notifications"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_trade_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("tradeNotifications", "Trade Notifications")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createUserSubscribe,
|
||||
deleteUserSubscribe,
|
||||
getUserSubscribe,
|
||||
updateUserSubscribe,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Display } from "@/components/display";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import { SubscriptionDetail } from "./subscription-detail";
|
||||
import { SubscriptionForm } from "./subscription-form";
|
||||
|
||||
export default function UserSubscription({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
|
||||
|
||||
return (
|
||||
<ProTable<API.UserSubscribe, Record<string, unknown>>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<SubscriptionForm
|
||||
initialData={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
await updateUserSubscribe({
|
||||
user_id: Number(userId),
|
||||
user_subscribe_id: row.id,
|
||||
...values,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
}}
|
||||
title={t("editSubscription", "Edit Subscription")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<Button
|
||||
key="copy"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
getUserSubscribeUrls(row.token)[0] || ""
|
||||
);
|
||||
toast.success(t("copySuccess", "Copied successfully"));
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{t("copySubscription", "Copy Subscription")}
|
||||
</Button>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteSubscriptionDescription",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteUserSubscribe({ user_subscribe_id: row.id });
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
<RowMoreActions key="more" subId={row.id} userId={userId} />,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("subscriptionName", "Subscription Name"),
|
||||
cell: ({ row }) => row.original.subscribe.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "upload",
|
||||
header: t("upload", "Upload"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" value={row.getValue("upload")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: t("download", "Download"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" value={row.getValue("download")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "traffic",
|
||||
header: t("totalTraffic", "Total Traffic"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" unlimited value={row.getValue("traffic")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "speed_limit",
|
||||
header: t("speedLimit", "Speed Limit"),
|
||||
cell: ({ row }) => {
|
||||
const speed = row.original?.subscribe?.speed_limit;
|
||||
return <Display type="trafficSpeed" value={speed} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "device_limit",
|
||||
header: t("deviceLimit", "Device Limit"),
|
||||
cell: ({ row }) => {
|
||||
const limit = row.original?.subscribe?.device_limit;
|
||||
return <Display type="number" unlimited value={limit} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reset_time",
|
||||
header: t("resetTime", "Reset Time"),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type="number"
|
||||
unlimited
|
||||
value={row.getValue("reset_time")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expire_time",
|
||||
header: t("expireTime", "Expire Time"),
|
||||
cell: ({ row }) =>
|
||||
row.getValue("expire_time")
|
||||
? formatDate(row.getValue("expire_time"))
|
||||
: t("permanent", "Permanent"),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("createdAt", "Created At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("subscriptionList", "Subscription List"),
|
||||
toolbar: (
|
||||
<SubscriptionForm
|
||||
key="create"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
await createUserSubscribe({
|
||||
user_id: Number(userId),
|
||||
...values,
|
||||
});
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
}}
|
||||
title={t("createSubscription", "Create Subscription")}
|
||||
trigger={t("add", "Add")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
request={async (pagination) => {
|
||||
const { data } = await getUserSubscribe({
|
||||
user_id: userId,
|
||||
...pagination,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const { t } = useTranslation("user");
|
||||
return (
|
||||
<div className="inline-flex">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{t("more", "More")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/subscribe?user_id=${userId}&user_subscribe_id=${subId}`}
|
||||
>
|
||||
{t("subscriptionLogs", "Subscription Logs")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/reset-subscribe?user_id=${userId}&user_subscribe_id=${subId}`}
|
||||
>
|
||||
{t("resetLogs", "Reset Logs")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/subscribe-traffic?user_id=${userId}&user_subscribe_id=${subId}`}
|
||||
>
|
||||
{t("trafficStats", "Traffic Stats")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/traffic-details?user_id=${userId}&subscribe_id=${subId}`}
|
||||
>
|
||||
{t("trafficDetails", "Traffic Details")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
triggerRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{t("onlineDevices", "Online Devices")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SubscriptionDetail
|
||||
subscriptionId={subId}
|
||||
trigger={<Button className="hidden" ref={triggerRef} />}
|
||||
userId={userId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
getUserSubscribeDevices,
|
||||
kickOfflineByUserDevice,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IpLink } from "@/components/ip-link";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export function SubscriptionDetail({
|
||||
trigger,
|
||||
userId,
|
||||
subscriptionId,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
userId: number;
|
||||
subscriptionId: number;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
||||
<SheetContent
|
||||
className="w-[700px] max-w-full md:max-w-screen-md"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto">
|
||||
<ProTable<API.UserDevice, Record<string, unknown>>
|
||||
actions={{
|
||||
render: (row) => {
|
||||
if (!row.identifier) return [];
|
||||
return [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"kickOfflineConfirm",
|
||||
`Kick device ${row.ip} offline?`
|
||||
)}
|
||||
key="offline"
|
||||
onConfirm={async () => {
|
||||
await kickOfflineByUserDevice({ id: row.id });
|
||||
toast.success(
|
||||
t("kickOfflineSuccess", "Device kicked offline")
|
||||
);
|
||||
}}
|
||||
title={t("confirmOffline", "Confirm Offline")}
|
||||
trigger={
|
||||
<Button variant="destructive">
|
||||
{t("confirmOffline", "Confirm Offline")}
|
||||
</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: t("enable", "Enable"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.getValue("enabled")}
|
||||
onChange={(checked) => {
|
||||
console.log("Switch:", checked);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "id", header: "ID" },
|
||||
{ accessorKey: "identifier", header: "IMEI" },
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
header: t("userAgent", "User Agent"),
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP",
|
||||
cell: ({ row }) => <IpLink ip={row.getValue("ip")} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: t("loginStatus", "Login Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.getValue("online") ? "default" : "destructive"}
|
||||
>
|
||||
{row.getValue("online")
|
||||
? t("online", "Online")
|
||||
: t("offline", "Offline")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: t("lastSeen", "Last Seen"),
|
||||
cell: ({ row }) => formatDate(row.getValue("updated_at")),
|
||||
},
|
||||
]}
|
||||
request={async (pagination) => {
|
||||
const { data } = await getUserSubscribeDevices({
|
||||
user_id: userId,
|
||||
subscribe_id: subscriptionId,
|
||||
...pagination,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { DatePicker } from "@workspace/ui/composed/date-picker";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
|
||||
interface Props {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
initialData?: API.UserSubscribe;
|
||||
onSubmit: (values: any) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
subscribe_id: z.number().optional(),
|
||||
traffic: z.number().optional(),
|
||||
speed_limit: z.number().optional(),
|
||||
device_limit: z.number().optional(),
|
||||
expired_at: z.number().nullish().optional(),
|
||||
upload: z.number().optional(),
|
||||
download: z.number().optional(),
|
||||
id: z.number().optional(),
|
||||
});
|
||||
|
||||
export function SubscriptionForm({
|
||||
trigger,
|
||||
title,
|
||||
loading,
|
||||
initialData,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
subscribe_id: initialData?.subscribe_id || 0,
|
||||
traffic: initialData?.traffic || 0,
|
||||
upload: initialData?.upload || 0,
|
||||
download: initialData?.download || 0,
|
||||
expired_at: initialData?.expire_time || 0,
|
||||
...(initialData && { id: initialData.id }),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const success = await onSubmit(values);
|
||||
if (success) {
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subscribe_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("subscription", "Subscription")}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
options={subscribes?.map((item) => ({
|
||||
value: item.id!,
|
||||
label: item.name!,
|
||||
}))}
|
||||
placeholder="Select Subscription"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="traffic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("trafficLimit", "Traffic Limit")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t("unlimited", "Unlimited")}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("bytesToGb", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("gbToBytes", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="GB"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="upload"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("uploadTraffic", "Upload Traffic")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder="0"
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("bytesToGb", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("gbToBytes", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="GB"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="download"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("downloadTraffic", "Download Traffic")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder="0"
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("bytesToGb", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("gbToBytes", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="GB"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expired_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("expiredAt", "Expired At")}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
onChange={(value: number | null | undefined) => {
|
||||
if (value === field.value) {
|
||||
form.setValue(field.name, 0);
|
||||
} else {
|
||||
form.setValue(field.name, value!);
|
||||
}
|
||||
}}
|
||||
placeholder={t("permanent", "Permanent")}
|
||||
value={field.value ?? undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user