feat: 自定义版本功能更新
Build and Release / Build (push) Has been cancelled

- 新增家庭共享订阅管理
- 新增用户邀请统计
- 新增签名和订阅模式设置表单
- 更新 API 服务层和国际化文件
- UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
2026-03-19 01:56:13 -07:00
parent 31803a1d24
commit 6b92979c7c
82 changed files with 3266 additions and 739 deletions
+187 -43
View File
@@ -8,7 +8,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
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,
@@ -23,6 +31,7 @@ import {
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
@@ -35,14 +44,16 @@ import {
getUserList,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import FamilyManagement from "./family";
import { UserDetail } from "./user-detail";
import UserForm from "./user-form";
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
import { AuthMethodsForm } from "./user-profile/auth-methods-form";
import { BasicInfoForm } from "./user-profile/basic-info-form";
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
@@ -56,13 +67,15 @@ export default function User() {
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,
short_code: sp.short_code || undefined,
};
const searchRef = useRef({
type: sp.user_id ? "user_id" : "email",
value: sp.search || sp.user_id || "",
});
const handleSearch = useCallback((type: string, value: string) => {
searchRef.current = { type, value };
ref.current?.refresh();
}, []);
return (
<ProTable<API.User, API.GetUserListParams>
@@ -75,6 +88,11 @@ export default function User() {
userId={row.id}
/>,
<SubscriptionSheet key="subscription" userId={row.id} />,
<DeviceGroupSheet
key="device-group"
onChanged={() => ref.current?.refresh()}
userId={row.id}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
@@ -98,6 +116,7 @@ export default function User() {
<Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<InviteStatsMenuItem userId={row.id} />
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
@@ -194,6 +213,10 @@ export default function User() {
header: t("userName", "Username"),
cell: ({ row }) => {
const method = row.original.auth_methods?.[0];
const identifier = method?.auth_identifier || "";
const isDevice = method?.auth_type === "device";
const deviceNo = (row.original.user_devices?.[0] as any)?.device_no;
const display = isDevice ? deviceNo || identifier : identifier;
return (
<div>
<Badge
@@ -202,7 +225,7 @@ export default function User() {
>
{method?.auth_type}
</Badge>
{method?.auth_identifier}
<span title={isDevice ? display : undefined}>{display}</span>
</div>
);
},
@@ -245,7 +268,14 @@ export default function User() {
},
]}
header={{
title: t("userList", "User List"),
title: (
<UserSearchBar
initialType={searchRef.current.type}
initialValue={searchRef.current.value}
onSearch={handleSearch}
subscribes={subscribes}
/>
),
toolbar: (
<UserForm<API.CreateUserRequest>
key="create"
@@ -270,39 +300,19 @@ export default function User() {
/>
),
}}
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"),
},
{
key: "short_code",
placeholder: t("shortCode", "Short Code"),
},
]}
request={async (pagination, filter) => {
const { data } = await getUserList({
...pagination,
...filter,
});
request={async (pagination) => {
const { type, value } = searchRef.current;
const params: Record<string, unknown> = { ...pagination };
if (value) {
if (type === "user_id") {
params.user_id = value;
} else if (type === "subscribe_id") {
params.subscribe_id = value;
} else {
params.search = value;
}
}
const { data } = await getUserList(params as API.GetUserListParams);
return {
list: data.data?.list || [],
total: data.data?.total || 0,
@@ -401,3 +411,137 @@ function SubscriptionSheet({ userId }: { userId: number }) {
</Sheet>
);
}
function InviteStatsMenuItem({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
return (
<>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setOpen(true);
}}
>
{t("inviteStats", "Invite Statistics")}
</DropdownMenuItem>
<UserInviteStatsSheet
onOpenChange={setOpen}
open={open}
userId={userId}
/>
</>
);
}
function DeviceGroupSheet({
userId,
onChanged,
}: {
userId: number;
onChanged?: () => void;
}) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button variant="outline">{t("deviceGroup", "Device Group")}</Button>
</SheetTrigger>
<SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
<SheetHeader>
<SheetTitle>
{t("deviceGroup", "Device Group")} · ID: {userId}
</SheetTitle>
</SheetHeader>
<div className="mt-2 px-4">
<FamilyManagement initialUserId={userId} onChanged={onChanged} />
</div>
</SheetContent>
</Sheet>
);
}
function UserSearchBar({
initialType,
initialValue,
onSearch,
subscribes,
}: {
initialType: string;
initialValue: string;
onSearch: (type: string, value: string) => void;
subscribes?: API.SubscribeItem[];
}) {
const { t } = useTranslation("user");
const [searchType, setSearchType] = useState(initialType);
const [searchValue, setSearchValue] = useState(initialValue);
return (
<div className="flex items-center gap-2">
<Select
onValueChange={(v) => {
setSearchType(v);
setSearchValue("");
}}
value={searchType}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">{t("email", "Email")}</SelectItem>
<SelectItem value="device">{t("deviceSearch", "Device")}</SelectItem>
<SelectItem value="user_id">{t("userId", "User ID")}</SelectItem>
<SelectItem value="subscribe_id">
{t("subscription", "Subscription")}
</SelectItem>
</SelectContent>
</Select>
{searchType === "subscribe_id" ? (
<Combobox
className="w-48"
onChange={(value) => {
setSearchValue(value);
onSearch("subscribe_id", value);
}}
options={subscribes?.map((item) => ({
label: item.name!,
value: String(item.id!),
}))}
placeholder={t("subscription", "Subscription")}
value={searchValue}
/>
) : (
<>
<Input
className="w-48"
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && onSearch(searchType, searchValue)
}
placeholder={t("searchInputPlaceholder", "Enter search term")}
value={searchValue}
/>
<Button
onClick={() => onSearch(searchType, searchValue)}
variant="default"
>
{t("search", "Search")}
</Button>
{searchValue && (
<Button
onClick={() => {
setSearchValue("");
onSearch(searchType, "");
}}
variant="outline"
>
{t("resetSearch", "Reset")}
</Button>
)}
</>
)}
</div>
);
}