优化布局

This commit is contained in:
zhx 2026-05-09 13:36:49 +08:00
parent 3698b05159
commit 0fd67199b6
74 changed files with 12372 additions and 10005 deletions

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,7 @@ export function AdminLayout() {
<Header <Header
activeLabel={activeNav?.label || "总览"} activeLabel={activeNav?.label || "总览"}
isSidebarCollapsed={sidebarCollapsed} isSidebarCollapsed={sidebarCollapsed}
menuItems={menus}
onRefresh={refresh} onRefresh={refresh}
onToggleSidebar={() => setSidebarCollapsed((current) => !current)} onToggleSidebar={() => setSidebarCollapsed((current) => !current)}
/> />

View File

@ -26,7 +26,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog }))); const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
const emptyPasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" }; const emptyPasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) { export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefresh, onToggleSidebar }) {
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [searchMounted, setSearchMounted] = useState(false); const [searchMounted, setSearchMounted] = useState(false);
const [userMenuAnchor, setUserMenuAnchor] = useState(null); const [userMenuAnchor, setUserMenuAnchor] = useState(null);
@ -186,7 +186,7 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
</TextField> </TextField>
<button className="search-trigger" type="button" onClick={openSearch}> <button className="search-trigger" type="button" onClick={openSearch}>
<Search fontSize="small" /> <Search fontSize="small" />
<span>搜索用户角色...</span> <span>搜索菜单用户角色...</span>
</button> </button>
<IconButton label="刷新" onClick={refresh}> <IconButton label="刷新" onClick={refresh}>
<Refresh fontSize="small" /> <Refresh fontSize="small" />
@ -223,7 +223,7 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
{searchMounted ? ( {searchMounted ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} /> <SearchDialog menuItems={menuItems} open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
</Suspense> </Suspense>
) : null} ) : null}
<AdminFormDialog <AdminFormDialog

View File

@ -94,6 +94,11 @@ function NavGroup({
return; return;
} }
if (hasChildren && children.length === 1 && children[0].path && !isChildActive) {
onNavigate(children[0].path);
return;
}
if (hasChildren) { if (hasChildren) {
onToggleExpanded(); onToggleExpanded();
return; return;

View File

@ -23,6 +23,10 @@ import WalletOutlined from "@mui/icons-material/WalletOutlined";
import { getRouteByMenuCode } from "@/app/router/routeConfig"; import { getRouteByMenuCode } from "@/app/router/routeConfig";
const deprecatedMenuCodes = new Set(["services"]); const deprecatedMenuCodes = new Set(["services"]);
const menuLabelOverrides = {
system: "后台设置",
"system-users": "后台用户"
};
const iconMap = { const iconMap = {
apartment: ApartmentOutlined, apartment: ApartmentOutlined,
@ -55,7 +59,7 @@ export const fallbackNavigation = [
code: "system", code: "system",
icon: SettingsApplicationsOutlined, icon: SettingsApplicationsOutlined,
id: "system", id: "system",
label: "系统管理", label: "后台设置",
children: [ children: [
routeNavItem("system-users", { icon: ManageAccountsOutlined }), routeNavItem("system-users", { icon: ManageAccountsOutlined }),
routeNavItem("system-roles", { icon: ShieldOutlined }), routeNavItem("system-roles", { icon: ShieldOutlined }),
@ -162,7 +166,7 @@ export function mapBackendMenus(menus = []) {
code: item.code, code: item.code,
icon: iconMap[item.icon] || MenuOpenOutlined, icon: iconMap[item.icon] || MenuOpenOutlined,
id: item.code || String(item.id), id: item.code || String(item.id),
label: item.label, label: menuLabelOverrides[item.code] || route?.label || item.label,
path: route?.path || item.path, path: route?.path || item.path,
permissionCode: route?.permission || item.permissionCode permissionCode: route?.permission || item.permissionCode
}; };

View File

@ -24,6 +24,7 @@ export const PERMISSIONS = {
coinSellerView: "coin-seller:view", coinSellerView: "coin-seller:view",
coinSellerCreate: "coin-seller:create", coinSellerCreate: "coin-seller:create",
coinSellerUpdate: "coin-seller:update", coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
paymentBillView: "payment-bill:view", paymentBillView: "payment-bill:view",
roleView: "role:view", roleView: "role:view",
roleCreate: "role:create", roleCreate: "role:create",
@ -73,7 +74,7 @@ export const PERMISSIONS = {
uploadCreate: "upload:create", uploadCreate: "upload:create",
jobView: "job:view", jobView: "job:view",
jobCancel: "job:cancel", jobCancel: "job:cancel",
exportCreate: "export:create" exportCreate: "export:create",
} as const; } as const;
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
@ -113,7 +114,7 @@ export const MENU_CODES = {
hostOrgCoinSellers: "host-org-coin-sellers", hostOrgCoinSellers: "host-org-coin-sellers",
payment: "payment", payment: "payment",
paymentBillList: "payment-bill-list", paymentBillList: "payment-bill-list",
jobs: "jobs" jobs: "jobs",
} as const; } as const;
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES]; export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];

View File

@ -20,7 +20,7 @@ const emptyForm = () => ({
platform: "android", platform: "android",
regionId: "", regionId: "",
sortOrder: "0", sortOrder: "0",
status: "active" status: "active",
}); });
export function useBannerConfigPage() { export function useBannerConfigPage() {
@ -45,15 +45,20 @@ export function useBannerConfigPage() {
keyword, keyword,
platform, platform,
regionId, regionId,
status status,
}), }),
[countryCode, keyword, platform, regionId, status] [countryCode, keyword, platform, regionId, status],
); );
const queryFn = useCallback(() => listBanners(filters), [filters]); const queryFn = useCallback(() => listBanners(filters), [filters]);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, { const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(queryFn, {
errorMessage: "加载 BANNER 配置失败", errorMessage: "加载 BANNER 配置失败",
initialData: emptyData, initialData: emptyData,
queryKey: ["app-config", "banners", filters] queryKey: ["app-config", "banners", filters],
}); });
const setForm = (patch) => { const setForm = (patch) => {
@ -78,6 +83,14 @@ export function useBannerConfigPage() {
setFormState(emptyForm()); setFormState(emptyForm());
}; };
const resetFilters = () => {
setKeyword("");
setStatus("");
setPlatform("");
setRegionId("");
setCountryCode("");
};
const submitBanner = async (event) => { const submitBanner = async (event) => {
event.preventDefault(); event.preventDefault();
const payload = parseForm(appBannerSchema, normalizeForm(form)); const payload = parseForm(appBannerSchema, normalizeForm(form));
@ -105,7 +118,7 @@ export function useBannerConfigPage() {
confirmText: "删除", confirmText: "删除",
message: item.description || item.param || `BANNER ${item.id}`, message: item.description || item.param || `BANNER ${item.id}`,
title: "删除BANNER", title: "删除BANNER",
tone: "danger" tone: "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -144,6 +157,7 @@ export function useBannerConfigPage() {
regionOptions, regionOptions,
reload, reload,
removeBanner, removeBanner,
resetFilters,
setCountryCode, setCountryCode,
setForm, setForm,
setKeyword, setKeyword,
@ -151,7 +165,7 @@ export function useBannerConfigPage() {
setRegionId, setRegionId,
setStatus, setStatus,
status, status,
submitBanner submitBanner,
}; };
} }
@ -160,7 +174,7 @@ function normalizeForm(form) {
...form, ...form,
countryCode: form.countryCode || "", countryCode: form.countryCode || "",
regionId: form.regionId || 0, regionId: form.regionId || 0,
sortOrder: form.sortOrder || 0 sortOrder: form.sortOrder || 0,
}; };
} }
@ -174,6 +188,6 @@ function formFromBanner(item) {
platform: item.platform || "android", platform: item.platform || "android",
regionId: item.regionId ? String(item.regionId) : "", regionId: item.regionId ? String(item.regionId) : "",
sortOrder: String(item.sortOrder || 0), sortOrder: String(item.sortOrder || 0),
status: item.status || "active" status: item.status || "active",
}; };
} }

View File

@ -11,31 +11,30 @@ import { Button } from "@/shared/ui/Button.jsx";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminRowActions, AdminRowActions,
AdminSearchBox,
adminListClasses
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
const statusOptions = [ const statusOptions = [
["", "全部状态"], ["", "全部状态"],
["active", "启用"], ["active", "启用"],
["disabled", "关闭"] ["disabled", "关闭"],
]; ];
const platformOptions = [ const platformOptions = [
["", "全部平台"], ["", "全部平台"],
["android", "安卓"], ["android", "安卓"],
["ios", "iOS"] ["ios", "iOS"],
]; ];
export function BannerConfigPage() { export function BannerConfigPage() {
@ -48,32 +47,20 @@ export function BannerConfigPage() {
<AdminListToolbar <AdminListToolbar
actions={ actions={
page.abilities.canUpdate ? ( page.abilities.canUpdate ? (
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}> <Button
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={page.openCreate}
>
新增BANNER 新增BANNER
</Button> </Button>
) : null ) : null
} }
filters={ filters={
<> <AdminFilterResetButton
<AdminSearchBox value={page.keyword} onChange={page.setKeyword} placeholder="搜索参数、国家、描述..." /> disabled={!page.keyword && !page.platform && !page.status && !page.regionId && !page.countryCode}
<AdminFilterSelect label="平台" options={platformOptions} value={page.platform} onChange={page.setPlatform} /> onClick={page.resetFilters}
<AdminFilterSelect label="状态" options={statusOptions} value={page.status} onChange={page.setStatus} />
<RegionFilterControl
emptyLabel="全部区域"
loading={page.loadingRegions}
options={page.regionOptions}
value={page.regionId}
onChange={page.setRegionId}
/> />
<CountrySelect
className={adminListClasses.statusSelect}
disabled={page.loadingCountries}
emptyLabel="全部国家"
options={page.countryOptions}
value={page.countryCode}
onChange={page.setCountryCode}
/>
</>
} }
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -89,7 +76,9 @@ export function BannerConfigPage() {
loading={page.loadingAction === "create" || page.loadingAction === "edit"} loading={page.loadingAction === "create" || page.loadingAction === "edit"}
open={Boolean(page.activeAction)} open={Boolean(page.activeAction)}
size="narrow" size="narrow"
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"} submitDisabled={
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
}
title={page.editingItem ? "编辑BANNER" : "新增BANNER"} title={page.editingItem ? "编辑BANNER" : "新增BANNER"}
onClose={page.closeDialog} onClose={page.closeDialog}
onSubmit={page.submitBanner} onSubmit={page.submitBanner}
@ -100,7 +89,14 @@ export function BannerConfigPage() {
value={page.form.coverUrl} value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })} onChange={(coverUrl) => page.setForm({ coverUrl })}
/> />
<TextField disabled={!page.abilities.canUpdate} label="类型" required select value={page.form.bannerType} onChange={(event) => page.setForm({ bannerType: event.target.value })}> <TextField
disabled={!page.abilities.canUpdate}
label="类型"
required
select
value={page.form.bannerType}
onChange={(event) => page.setForm({ bannerType: event.target.value })}
>
<MenuItem value="h5">H5</MenuItem> <MenuItem value="h5">H5</MenuItem>
<MenuItem value="app">APP</MenuItem> <MenuItem value="app">APP</MenuItem>
</TextField> </TextField>
@ -111,11 +107,25 @@ export function BannerConfigPage() {
value={page.form.param} value={page.form.param}
onChange={(event) => page.setForm({ param: event.target.value })} onChange={(event) => page.setForm({ param: event.target.value })}
/> />
<TextField disabled={!page.abilities.canUpdate} label="状态" required select value={page.form.status} onChange={(event) => page.setForm({ status: event.target.value })}> <TextField
disabled={!page.abilities.canUpdate}
label="状态"
required
select
value={page.form.status}
onChange={(event) => page.setForm({ status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem> <MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">关闭</MenuItem> <MenuItem value="disabled">关闭</MenuItem>
</TextField> </TextField>
<TextField disabled={!page.abilities.canUpdate} label="平台" required select value={page.form.platform} onChange={(event) => page.setForm({ platform: event.target.value })}> <TextField
disabled={!page.abilities.canUpdate}
label="平台"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="android">安卓</MenuItem> <MenuItem value="android">安卓</MenuItem>
<MenuItem value="ios">iOS</MenuItem> <MenuItem value="ios">iOS</MenuItem>
</TextField> </TextField>
@ -162,49 +172,86 @@ function bannerColumns(page) {
key: "cover", key: "cover",
label: "封面图", label: "封面图",
render: (item) => <BannerCover src={item.coverUrl} />, render: (item) => <BannerCover src={item.coverUrl} />,
width: "minmax(112px, 0.55fr)" width: "minmax(112px, 0.55fr)",
}, },
{ {
key: "target", key: "target",
label: "类型 / 参数", label: "类型 / 参数",
render: (item) => <Stack primary={typeLabel(item.bannerType)} secondary={item.param || "-"} />, render: (item) => <Stack primary={typeLabel(item.bannerType)} secondary={item.param || "-"} />,
width: "minmax(260px, 1.3fr)" filter: createTextColumnFilter({
placeholder: "搜索参数、国家、描述",
value: page.keyword,
onChange: page.setKeyword,
}),
width: "minmax(260px, 1.3fr)",
}, },
{ {
key: "state", key: "platform",
label: "平台 / 状态", label: "平台",
render: (item) => <Stack primary={platformLabel(item.platform)} secondary={<StatusBadge status={item.status} />} />, render: (item) => platformLabel(item.platform),
width: "minmax(140px, 0.75fr)" filter: createOptionsColumnFilter({
options: platformOptions,
placeholder: "搜索平台",
value: page.platform,
onChange: page.setPlatform,
}),
width: "minmax(110px, 0.6fr)",
}, },
{ {
key: "scope", key: "status",
label: "区域 / 国家", label: "状态",
render: (item) => <StatusBadge status={item.status} />,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
width: "minmax(100px, 0.55fr)",
},
{
key: "region",
label: "区域",
render: (item) => regionLabel(page.regionOptions, item.regionId),
filter: createRegionColumnFilter({ filter: createRegionColumnFilter({
loading: page.loadingRegions, loading: page.loadingRegions,
options: page.regionOptions, options: page.regionOptions,
value: page.regionId, value: page.regionId,
onChange: page.setRegionId onChange: page.setRegionId,
}), }),
render: (item) => <Stack primary={regionLabel(page.regionOptions, item.regionId)} secondary={countryLabel(page.countryOptions, item.countryCode)} />, width: "minmax(150px, 0.8fr)",
width: "minmax(180px, 0.95fr)" },
{
key: "country",
label: "国家",
render: (item) => countryLabel(page.countryOptions, item.countryCode),
filter: createOptionsColumnFilter({
emptyLabel: "全部国家",
loading: page.loadingCountries,
options: page.countryOptions,
placeholder: "搜索国家",
value: page.countryCode,
onChange: page.setCountryCode,
}),
width: "minmax(150px, 0.8fr)",
}, },
{ {
key: "sortOrder", key: "sortOrder",
label: "排序", label: "排序",
width: "minmax(80px, 0.4fr)" width: "minmax(80px, 0.4fr)",
}, },
{ {
key: "description", key: "description",
label: "描述", label: "描述",
render: (item) => item.description || "-", render: (item) => item.description || "-",
width: "minmax(180px, 0.9fr)" width: "minmax(180px, 0.9fr)",
}, },
{ {
key: "updatedAtMs", key: "updatedAtMs",
label: "更新时间", label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs), render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(160px, 0.8fr)" width: "minmax(160px, 0.8fr)",
} },
]; ];
if (!page.abilities.canUpdate) { if (!page.abilities.canUpdate) {
@ -217,8 +264,8 @@ function bannerColumns(page) {
key: "actions", key: "actions",
label: "操作", label: "操作",
render: (item) => <BannerActions item={item} page={page} />, render: (item) => <BannerActions item={item} page={page} />,
width: "minmax(96px, 0.45fr)" width: "minmax(96px, 0.45fr)",
} },
]; ];
} }
@ -237,10 +284,18 @@ function BannerActions({ item, page }) {
const deleting = page.loadingAction === `delete:${item.id}`; const deleting = page.loadingAction === `delete:${item.id}`;
return ( return (
<AdminRowActions> <AdminRowActions>
<AdminActionIconButton disabled={Boolean(page.loadingAction)} label="编辑" onClick={() => page.openEdit(item)}> <AdminActionIconButton
disabled={Boolean(page.loadingAction)}
label="编辑"
onClick={() => page.openEdit(item)}
>
<EditOutlined fontSize="small" /> <EditOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
<AdminActionIconButton disabled={deleting || Boolean(page.loadingAction)} label="删除" onClick={() => page.removeBanner(item)}> <AdminActionIconButton
disabled={deleting || Boolean(page.loadingAction)}
label="删除"
onClick={() => page.removeBanner(item)}
>
<DeleteOutlineOutlined fontSize="small" /> <DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
</AdminRowActions> </AdminRowActions>
@ -249,7 +304,15 @@ function BannerActions({ item, page }) {
function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) { function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) {
return ( return (
<TextField className={className} disabled={disabled} label={label} select size="small" value={value || ""} onChange={(event) => onChange(event.target.value)}> <TextField
className={className}
disabled={disabled}
label={label}
select
size="small"
value={value || ""}
onChange={(event) => onChange(event.target.value)}
>
<MenuItem value="">{emptyLabel}</MenuItem> <MenuItem value="">{emptyLabel}</MenuItem>
{options.map((option) => ( {options.map((option) => (
<MenuItem key={option.value} value={option.value}> <MenuItem key={option.value} value={option.value}>

View File

@ -3,13 +3,7 @@ import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
banAppUser,
listAppUsers,
setAppUserPassword,
unbanAppUser,
updateAppUser
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js"; import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema"; import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js"; import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
@ -37,17 +31,22 @@ export function useAppUsersPage() {
const filters = useMemo( const filters = useMemo(
() => ({ () => ({
keyword: query, keyword: query,
status status,
}), }),
[query, status] [query, status],
); );
const { data = emptyData, error, loading, reload } = usePaginatedQuery({ const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载用户列表失败", errorMessage: "加载用户列表失败",
fetcher: listAppUsers, fetcher: listAppUsers,
filters, filters,
page, page,
pageSize, pageSize,
queryKey: ["app-users", filters, page] queryKey: ["app-users", filters, page],
}); });
const changeQuery = (value) => { const changeQuery = (value) => {
@ -60,12 +59,18 @@ export function useAppUsersPage() {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setPage(1);
};
const openEdit = (user) => { const openEdit = (user) => {
setActiveUser(user); setActiveUser(user);
setEditForm({ setEditForm({
avatar: user.avatar || "", avatar: user.avatar || "",
gender: user.gender || "", gender: user.gender || "",
username: user.username || "" username: user.username || "",
}); });
setActiveAction("edit"); setActiveAction("edit");
}; };
@ -131,7 +136,7 @@ export function useAppUsersPage() {
confirmText: banned ? "解封" : "封禁", confirmText: banned ? "解封" : "封禁",
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`, message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
title: banned ? "解封用户" : "封禁用户", title: banned ? "解封用户" : "封禁用户",
tone: banned ? "primary" : "danger" tone: banned ? "primary" : "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -180,6 +185,7 @@ export function useAppUsersPage() {
passwordForm, passwordForm,
query, query,
reload, reload,
resetFilters,
setCountryForm, setCountryForm,
setEditForm, setEditForm,
setPage, setPage,
@ -188,7 +194,7 @@ export function useAppUsersPage() {
submitCountry, submitCountry,
submitEdit, submitEdit,
submitPassword, submitPassword,
toggleBan toggleBan,
}; };
} }

View File

@ -10,17 +10,13 @@ import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
appUserStatusFilters,
appUserStatusLabels,
genderOptions
} from "@/features/app-users/constants.js";
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js"; import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
import styles from "@/features/app-users/app-users.module.css"; import styles from "@/features/app-users/app-users.module.css";
@ -29,7 +25,7 @@ const columns = [
key: "identity", key: "identity",
label: "用户", label: "用户",
width: "minmax(240px, 1.5fr)", width: "minmax(240px, 1.5fr)",
render: (user) => <UserIdentity user={user} /> render: (user) => <UserIdentity user={user} />,
}, },
{ key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) }, { key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) },
{ key: "diamond", label: "钻石", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.diamond) }, { key: "diamond", label: "钻石", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.diamond) },
@ -37,7 +33,7 @@ const columns = [
key: "status", key: "status",
label: "状态", label: "状态",
width: "minmax(92px, 0.6fr)", width: "minmax(92px, 0.6fr)",
render: (user) => <UserStatus status={user.status} /> render: (user) => <UserStatus status={user.status} />,
}, },
{ {
key: "time", key: "time",
@ -48,8 +44,8 @@ const columns = [
<span>{formatMillis(user.createdAtMs)}</span> <span>{formatMillis(user.createdAtMs)}</span>
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span> <span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
</div> </div>
) ),
} },
]; ];
export function AppUserListPage() { export function AppUserListPage() {
@ -57,20 +53,39 @@ export function AppUserListPage() {
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const tableColumns = [ const tableColumns = [
columns[0], {
...columns[0],
filter: createTextColumnFilter({
placeholder: "搜索名称、短 ID、用户 ID",
value: page.query,
onChange: page.changeQuery,
}),
},
{ {
key: "location", key: "location",
label: "国家 / 区域", label: "国家 / 区域",
width: "minmax(170px, 1fr)", width: "minmax(170px, 1fr)",
render: (user) => <UserLocation page={page} user={user} /> render: (user) => <UserLocation page={page} user={user} />,
}, },
...columns.slice(1), ...columns.slice(1).map((column) =>
column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
options: appUserStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
}
: column,
),
{ {
key: "actions", key: "actions",
label: "操作", label: "操作",
width: "minmax(148px, 0.8fr)", width: "minmax(148px, 0.8fr)",
render: (user) => <UserActions page={page} user={user} /> render: (user) => <UserActions page={page} user={user} />,
} },
]; ];
return ( return (
@ -78,20 +93,26 @@ export function AppUserListPage() {
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<section className={styles.filters}> <section className={styles.filters}>
<SearchBox className={styles.search} value={page.query} onChange={page.changeQuery} placeholder="搜索名称、短 ID、用户 ID..." /> <AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
<StatusSelect
className={styles.statusSelect}
options={appUserStatusFilters}
value={page.status}
onChange={page.changeStatus}
/>
</section> </section>
</div> </div>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}> <div className={styles.listBlock}>
<DataTable columns={tableColumns} items={items} minWidth="1220px" rowKey={(user) => user.userId} /> <DataTable
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null} columns={tableColumns}
items={items}
minWidth="1220px"
rowKey={(user) => user.userId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div> </div>
</DataState> </DataState>
</div> </div>
@ -104,11 +125,29 @@ export function AppUserListPage() {
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitEdit} onSubmit={page.submitEdit}
> >
<TextField disabled={!page.abilities.canUpdate} label="用户名称" value={page.editForm.username} onChange={(event) => page.setEditForm({ ...page.editForm, username: event.target.value })} /> <TextField
<UploadField disabled={!page.abilities.canUpdate || !page.abilities.canUpload} label="头像" value={page.editForm.avatar} onChange={(avatar) => page.setEditForm({ ...page.editForm, avatar })} /> disabled={!page.abilities.canUpdate}
<TextField disabled={!page.abilities.canUpdate} label="性别" select value={page.editForm.gender} onChange={(event) => page.setEditForm({ ...page.editForm, gender: event.target.value })}> label="用户名称"
value={page.editForm.username}
onChange={(event) => page.setEditForm({ ...page.editForm, username: event.target.value })}
/>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="头像"
value={page.editForm.avatar}
onChange={(avatar) => page.setEditForm({ ...page.editForm, avatar })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="性别"
select
value={page.editForm.gender}
onChange={(event) => page.setEditForm({ ...page.editForm, gender: event.target.value })}
>
{genderOptions.map(([value, label]) => ( {genderOptions.map(([value, label]) => (
<MenuItem key={value || "empty"} value={value}>{label}</MenuItem> <MenuItem key={value || "empty"} value={value}>
{label}
</MenuItem>
))} ))}
</TextField> </TextField>
</ActionModal> </ActionModal>
@ -142,7 +181,13 @@ export function AppUserListPage() {
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitPassword} onSubmit={page.submitPassword}
> >
<TextField disabled={!page.abilities.canPassword} label="新密码" type="password" value={page.passwordForm.password} onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })} /> <TextField
disabled={!page.abilities.canPassword}
label="新密码"
type="password"
value={page.passwordForm.password}
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
/>
</ActionModal> </ActionModal>
</section> </section>
); );
@ -201,7 +246,12 @@ function UserActions({ page, user }) {
{page.abilities.canStatus ? ( {page.abilities.canStatus ? (
<Tooltip arrow title={banned ? "解封" : "封禁"}> <Tooltip arrow title={banned ? "解封" : "封禁"}>
<span> <span>
<IconButton disabled={page.loadingAction === `status-${user.userId}`} label={banned ? "解封" : "封禁"} tone={banned ? "success" : "danger"} onClick={() => page.toggleBan(user)}> <IconButton
disabled={page.loadingAction === `status-${user.userId}`}
label={banned ? "解封" : "封禁"}
tone={banned ? "success" : "danger"}
onClick={() => page.toggleBan(user)}
>
{banned ? <LockOpenOutlined fontSize="small" /> : <BlockOutlined fontSize="small" />} {banned ? <LockOpenOutlined fontSize="small" /> : <BlockOutlined fontSize="small" />}
</IconButton> </IconButton>
</span> </span>

View File

@ -4,6 +4,7 @@ import {
createCountry, createCountry,
createCoinSeller, createCoinSeller,
createRegion, createRegion,
creditCoinSellerStock,
disableRegion, disableRegion,
listAgencies, listAgencies,
listBDs, listBDs,
@ -13,7 +14,7 @@ import {
listRegions, listRegions,
replaceRegionCountries, replaceRegionCountries,
setCoinSellerStatus, setCoinSellerStatus,
updateCountry updateCountry,
} from "./api"; } from "./api";
afterEach(() => { afterEach(() => {
@ -24,7 +25,9 @@ afterEach(() => {
test("host org list APIs use generated admin paths and filters", async () => { test("host org list APIs use generated admin paths and filters", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } }))) vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })),
),
); );
await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" }); await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" });
@ -33,6 +36,12 @@ test("host org list APIs use generated admin paths and filters", async () => {
await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" }); await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 }); await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" }); await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
await creditCoinSellerStock(1001, {
coinAmount: 8000000,
commandId: "coin-seller-stock-test",
rechargeAmount: "100.000000",
type: "usdt_purchase",
});
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0]; const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
const [agencyUrl] = vi.mocked(fetch).mock.calls[1]; const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
@ -40,6 +49,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3]; const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[4]; const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[4];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[5]; const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[5];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[6];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?"); expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21"); expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -58,16 +68,23 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(coinSellerCreateInit?.method).toBe("POST"); expect(coinSellerCreateInit?.method).toBe("POST");
expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status"); expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status");
expect(coinSellerStatusInit?.method).toBe("PATCH"); expect(coinSellerStatusInit?.method).toBe("PATCH");
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
expect(coinSellerStockInit?.method).toBe("POST");
}); });
test("country and region APIs use generated admin paths", async () => { test("country and region APIs use generated admin paths", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], total: 0 } }))) vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], total: 0 } }))),
); );
await listCountries({ enabled: true }); await listCountries({ enabled: true });
await createCountry({ countryCode: "US", countryDisplayName: "United States", countryName: "United States", enabled: true }); await createCountry({
countryCode: "US",
countryDisplayName: "United States",
countryName: "United States",
enabled: true,
});
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" }); await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
await listRegions({ status: "active" }); await listRegions({ status: "active" });
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" }); await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });

View File

@ -9,6 +9,8 @@ import type {
BDProfileDto, BDProfileDto,
BDStatusPayload, BDStatusPayload,
CoinSellerDto, CoinSellerDto,
CoinSellerStockCreditDto,
CoinSellerStockCreditPayload,
CoinSellerStatusPayload, CoinSellerStatusPayload,
CountryDto, CountryDto,
CountryPayload, CountryPayload,
@ -25,21 +27,21 @@ import type {
RegionCountriesPayload, RegionCountriesPayload,
RegionDto, RegionDto,
RegionPayload, RegionPayload,
RegionUpdatePayload RegionUpdatePayload,
} from "@/shared/api/types"; } from "@/shared/api/types";
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> { export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
const endpoint = API_ENDPOINTS.listCountries; const endpoint = API_ENDPOINTS.listCountries;
return apiRequest<ApiList<CountryDto>>(apiEndpointPath(API_OPERATIONS.listCountries), { return apiRequest<ApiList<CountryDto>>(apiEndpointPath(API_OPERATIONS.listCountries), {
method: endpoint.method, method: endpoint.method,
query query,
}); });
} }
export function getCountry(countryId: EntityId): Promise<CountryDto> { export function getCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.getCountry; const endpoint = API_ENDPOINTS.getCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.getCountry, { country_id: countryId }), { return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.getCountry, { country_id: countryId }), {
method: endpoint.method method: endpoint.method,
}); });
} }
@ -47,43 +49,46 @@ export function createCountry(payload: CountryPayload): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.createCountry; const endpoint = API_ENDPOINTS.createCountry;
return apiRequest<CountryDto, CountryPayload>(apiEndpointPath(API_OPERATIONS.createCountry), { return apiRequest<CountryDto, CountryPayload>(apiEndpointPath(API_OPERATIONS.createCountry), {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); });
} }
export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload): Promise<CountryDto> { export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.updateCountry; const endpoint = API_ENDPOINTS.updateCountry;
return apiRequest<CountryDto, CountryUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateCountry, { country_id: countryId }), { return apiRequest<CountryDto, CountryUpdatePayload>(
apiEndpointPath(API_OPERATIONS.updateCountry, { country_id: countryId }),
{
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); },
);
} }
export function enableCountry(countryId: EntityId): Promise<CountryDto> { export function enableCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.enableCountry; const endpoint = API_ENDPOINTS.enableCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), { return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), {
method: endpoint.method method: endpoint.method,
}); });
} }
export function disableCountry(countryId: EntityId): Promise<CountryDto> { export function disableCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.disableCountry; const endpoint = API_ENDPOINTS.disableCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.disableCountry, { country_id: countryId }), { return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.disableCountry, { country_id: countryId }), {
method: endpoint.method method: endpoint.method,
}); });
} }
export function deleteCountry(countryId: EntityId): Promise<CountryDto> { export function deleteCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.deleteCountry; const endpoint = API_ENDPOINTS.deleteCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.deleteCountry, { country_id: countryId }), { return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.deleteCountry, { country_id: countryId }), {
method: endpoint.method method: endpoint.method,
}); });
} }
export function getRegion(regionId: EntityId): Promise<RegionDto> { export function getRegion(regionId: EntityId): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.getRegion; const endpoint = API_ENDPOINTS.getRegion;
return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.getRegion, { region_id: regionId }), { return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.getRegion, { region_id: regionId }), {
method: endpoint.method method: endpoint.method,
}); });
} }
@ -91,16 +96,19 @@ export function createRegion(payload: RegionPayload): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.createRegion; const endpoint = API_ENDPOINTS.createRegion;
return apiRequest<RegionDto, RegionPayload>(apiEndpointPath(API_OPERATIONS.createRegion), { return apiRequest<RegionDto, RegionPayload>(apiEndpointPath(API_OPERATIONS.createRegion), {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); });
} }
export function updateRegion(regionId: EntityId, payload: RegionUpdatePayload): Promise<RegionDto> { export function updateRegion(regionId: EntityId, payload: RegionUpdatePayload): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.updateRegion; const endpoint = API_ENDPOINTS.updateRegion;
return apiRequest<RegionDto, RegionUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateRegion, { region_id: regionId }), { return apiRequest<RegionDto, RegionUpdatePayload>(
apiEndpointPath(API_OPERATIONS.updateRegion, { region_id: regionId }),
{
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); },
);
} }
export function replaceRegionCountries(regionId: EntityId, payload: RegionCountriesPayload): Promise<RegionDto> { export function replaceRegionCountries(regionId: EntityId, payload: RegionCountriesPayload): Promise<RegionDto> {
@ -109,22 +117,22 @@ export function replaceRegionCountries(regionId: EntityId, payload: RegionCountr
apiEndpointPath(API_OPERATIONS.replaceRegionCountries, { region_id: regionId }), apiEndpointPath(API_OPERATIONS.replaceRegionCountries, { region_id: regionId }),
{ {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
} },
); );
} }
export function enableRegion(regionId: EntityId): Promise<RegionDto> { export function enableRegion(regionId: EntityId): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.enableRegion; const endpoint = API_ENDPOINTS.enableRegion;
return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.enableRegion, { region_id: regionId }), { return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.enableRegion, { region_id: regionId }), {
method: endpoint.method method: endpoint.method,
}); });
} }
export function disableRegion(regionId: EntityId): Promise<RegionDto> { export function disableRegion(regionId: EntityId): Promise<RegionDto> {
const endpoint = API_ENDPOINTS.disableRegion; const endpoint = API_ENDPOINTS.disableRegion;
return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.disableRegion, { region_id: regionId }), { return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.disableRegion, { region_id: regionId }), {
method: endpoint.method method: endpoint.method,
}); });
} }
@ -132,7 +140,7 @@ export function listBDLeaders(query: PageQuery = {}): Promise<ApiPage<BDProfileD
const endpoint = API_ENDPOINTS.listBDLeaders; const endpoint = API_ENDPOINTS.listBDLeaders;
return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDLeaders), { return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDLeaders), {
method: endpoint.method, method: endpoint.method,
query query,
}); });
} }
@ -140,7 +148,7 @@ export function listBDs(query: PageQuery = {}): Promise<ApiPage<BDProfileDto>> {
const endpoint = API_ENDPOINTS.listBDs; const endpoint = API_ENDPOINTS.listBDs;
return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDs), { return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDs), {
method: endpoint.method, method: endpoint.method,
query query,
}); });
} }
@ -148,7 +156,7 @@ export function listAgencies(query: PageQuery = {}): Promise<ApiPage<AgencyDto>>
const endpoint = API_ENDPOINTS.listAgencies; const endpoint = API_ENDPOINTS.listAgencies;
return apiRequest<ApiPage<AgencyDto>>(apiEndpointPath(API_OPERATIONS.listAgencies), { return apiRequest<ApiPage<AgencyDto>>(apiEndpointPath(API_OPERATIONS.listAgencies), {
method: endpoint.method, method: endpoint.method,
query query,
}); });
} }
@ -156,7 +164,7 @@ export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto
const endpoint = API_ENDPOINTS.listHosts; const endpoint = API_ENDPOINTS.listHosts;
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), { return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
method: endpoint.method, method: endpoint.method,
query query,
}); });
} }
@ -164,7 +172,7 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
const endpoint = API_ENDPOINTS.listCoinSellers; const endpoint = API_ENDPOINTS.listCoinSellers;
return apiRequest<ApiPage<CoinSellerDto>>(apiEndpointPath(API_OPERATIONS.listCoinSellers), { return apiRequest<ApiPage<CoinSellerDto>>(apiEndpointPath(API_OPERATIONS.listCoinSellers), {
method: endpoint.method, method: endpoint.method,
query query,
}); });
} }
@ -172,7 +180,7 @@ export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfil
const endpoint = API_ENDPOINTS.createBDLeader; const endpoint = API_ENDPOINTS.createBDLeader;
return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), { return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); });
} }
@ -180,23 +188,32 @@ export function createBD(payload: CreateBDPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.createBD; const endpoint = API_ENDPOINTS.createBD;
return apiRequest<BDProfileDto, CreateBDPayload>(apiEndpointPath(API_OPERATIONS.createBD), { return apiRequest<BDProfileDto, CreateBDPayload>(apiEndpointPath(API_OPERATIONS.createBD), {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
});
}
export function deleteBDLeader(userId: EntityId): Promise<BDProfileDto> {
return apiRequest<BDProfileDto>(`/v1/admin/bd-leaders/${encodeURIComponent(String(userId))}`, {
method: "DELETE",
}); });
} }
export function setBDLeaderStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> { export function setBDLeaderStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.setBDLeaderStatus; const endpoint = API_ENDPOINTS.setBDLeaderStatus;
return apiRequest<BDProfileDto, BDStatusPayload>(apiEndpointPath(API_OPERATIONS.setBDLeaderStatus, { user_id: userId }), { return apiRequest<BDProfileDto, BDStatusPayload>(
apiEndpointPath(API_OPERATIONS.setBDLeaderStatus, { user_id: userId }),
{
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); },
);
} }
export function setBDStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> { export function setBDStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.setBDStatus; const endpoint = API_ENDPOINTS.setBDStatus;
return apiRequest<BDProfileDto, BDStatusPayload>(apiEndpointPath(API_OPERATIONS.setBDStatus, { user_id: userId }), { return apiRequest<BDProfileDto, BDStatusPayload>(apiEndpointPath(API_OPERATIONS.setBDStatus, { user_id: userId }), {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); });
} }
@ -204,7 +221,7 @@ export function createCoinSeller(payload: CreateCoinSellerPayload): Promise<Coin
const endpoint = API_ENDPOINTS.createCoinSeller; const endpoint = API_ENDPOINTS.createCoinSeller;
return apiRequest<CoinSellerDto, CreateCoinSellerPayload>(apiEndpointPath(API_OPERATIONS.createCoinSeller), { return apiRequest<CoinSellerDto, CreateCoinSellerPayload>(apiEndpointPath(API_OPERATIONS.createCoinSeller), {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); });
} }
@ -214,8 +231,22 @@ export function setCoinSellerStatus(userId: EntityId, payload: CoinSellerStatusP
apiEndpointPath(API_OPERATIONS.setCoinSellerStatus, { user_id: userId }), apiEndpointPath(API_OPERATIONS.setCoinSellerStatus, { user_id: userId }),
{ {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
} },
);
}
export function creditCoinSellerStock(
userId: EntityId,
payload: CoinSellerStockCreditPayload,
): Promise<CoinSellerStockCreditDto> {
const endpoint = API_ENDPOINTS.creditCoinSellerStock;
return apiRequest<CoinSellerStockCreditDto, CoinSellerStockCreditPayload>(
apiEndpointPath(API_OPERATIONS.creditCoinSellerStock, { user_id: userId }),
{
body: payload,
method: endpoint.method,
},
); );
} }
@ -223,16 +254,19 @@ export function createAgency(payload: CreateAgencyPayload): Promise<CreateAgency
const endpoint = API_ENDPOINTS.createAgency; const endpoint = API_ENDPOINTS.createAgency;
return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), { return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); });
} }
export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> { export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.closeAgency; const endpoint = API_ENDPOINTS.closeAgency;
return apiRequest<AgencyDto, HostCommandPayload>(apiEndpointPath(API_OPERATIONS.closeAgency, { agency_id: agencyId }), { return apiRequest<AgencyDto, HostCommandPayload>(
apiEndpointPath(API_OPERATIONS.closeAgency, { agency_id: agencyId }),
{
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
}); },
);
} }
export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> { export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> {
@ -241,7 +275,7 @@ export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnab
apiEndpointPath(API_OPERATIONS.setAgencyJoinEnabled, { agency_id: agencyId }), apiEndpointPath(API_OPERATIONS.setAgencyJoinEnabled, { agency_id: agencyId }),
{ {
body: payload, body: payload,
method: endpoint.method method: endpoint.method,
} },
); );
} }

View File

@ -7,6 +7,7 @@ export function HostOrgActionModal({
onClose, onClose,
onSubmit, onSubmit,
open, open,
size = "compact",
submitLabel = "提交", submitLabel = "提交",
submitVariant = "primary", submitVariant = "primary",
title title
@ -15,7 +16,7 @@ export function HostOrgActionModal({
<AdminFormDialog <AdminFormDialog
loading={loading} loading={loading}
open={open} open={open}
size="compact" size={size}
submitDisabled={disabled || loading} submitDisabled={disabled || loading}
submitLabel={submitLabel} submitLabel={submitLabel}
submitVariant={submitVariant} submitVariant={submitVariant}

View File

@ -1,50 +1,20 @@
import TextField from "@mui/material/TextField"; import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
export function HostOrgFilters({ export function HostOrgFilters({
extraFilters = [], extraFilters = [],
loadingRegions = false, onReset,
onQueryChange,
onRegionIdChange,
onStatusChange,
query, query,
regionId, regionId,
regionOptions = [],
searchPlaceholder,
status, status,
statusOptions
}) { }) {
const hasActiveFilters = [query, regionId, status, ...extraFilters.map((filter) => filter.value)].some(
(value) => String(value || "").trim() !== "",
);
return ( return (
<div className={styles.filters}> <div className={styles.filters}>
<SearchBox className={styles.search} value={query} onChange={onQueryChange} placeholder={searchPlaceholder} /> {onReset ? <AdminFilterResetButton disabled={!hasActiveFilters} onClick={onReset} /> : null}
<StatusSelect
className={styles.statusSelect}
options={statusOptions}
value={status}
onChange={onStatusChange}
/>
<div className={styles.filterFields}>
<RegionFilterControl
emptyLabel="全部区域"
loading={loadingRegions}
options={regionOptions}
value={regionId}
onChange={onRegionIdChange}
/>
{extraFilters.map((filter) => (
<TextField
key={filter.name}
label={filter.label}
size="small"
type="number"
value={filter.value}
onChange={(event) => filter.onChange(event.target.value)}
/>
))}
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,47 @@
import styles from "@/features/host-org/host-org.module.css";
export function HostOrgPerson({ avatar, displayUserId, fallbackId, username }) {
const name = username || "-";
const meta = displayUserId || fallbackId || "-";
return (
<div className={styles.sellerIdentity}>
{avatar ? (
<img alt="" className={styles.sellerAvatar} src={avatar} />
) : (
<span className={styles.sellerAvatar}>
{String(name || meta)
.slice(0, 1)
.toUpperCase()}
</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
);
}
export function HostOrgEntity({ id, name, prefix = "" }) {
if (!id && !name) {
return "-";
}
return (
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name || "-"}</span>
<span className={styles.sellerMeta}>
{prefix}
{id || "-"}
</span>
</span>
);
}
export function hostOrgRegionName(item, regionOptions = []) {
if (item.regionName) {
return item.regionName;
}
const option = regionOptions.find((region) => String(region.value) === String(item.regionId));
return option?.name || item.regionId || "-";
}

View File

@ -2,19 +2,10 @@ import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation"; import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { import { closeAgency, createAgency, listAgencies, setAgencyJoinEnabled } from "@/features/host-org/api";
closeAgency,
createAgency,
listAgencies,
setAgencyJoinEnabled
} from "@/features/host-org/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useAgencyAbilities } from "@/features/host-org/permissions.js"; import { useAgencyAbilities } from "@/features/host-org/permissions.js";
import { import { agencyCloseSchema, agencyJoinEnabledSchema, createAgencySchema } from "@/features/host-org/schema";
agencyCloseSchema,
agencyJoinEnabledSchema,
createAgencySchema
} from "@/features/host-org/schema";
const emptyAgencyForm = () => ({ const emptyAgencyForm = () => ({
commandId: makeCommandId("agency"), commandId: makeCommandId("agency"),
@ -23,14 +14,14 @@ const emptyAgencyForm = () => ({
name: "", name: "",
ownerUserId: "", ownerUserId: "",
parentBdUserId: "", parentBdUserId: "",
reason: "" reason: "",
}); });
const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" }); const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" });
const emptyJoinEnabledForm = () => ({ const emptyJoinEnabledForm = () => ({
agencyId: "", agencyId: "",
commandId: makeCommandId("agency-join"), commandId: makeCommandId("agency-join"),
joinEnabled: true, joinEnabled: true,
reason: "" reason: "",
}); });
const pageSize = 10; const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyData = { items: [], page: 1, pageSize, total: 0 };
@ -54,17 +45,22 @@ export function useHostAgenciesPage() {
keyword: query, keyword: query,
parent_bd_user_id: parentBdUserId, parent_bd_user_id: parentBdUserId,
region_id: regionId, region_id: regionId,
status status,
}), }),
[parentBdUserId, query, regionId, status] [parentBdUserId, query, regionId, status],
); );
const { data = emptyData, error, loading, reload } = usePaginatedQuery({ const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载 Agency 列表失败", errorMessage: "加载 Agency 列表失败",
fetcher: listAgencies, fetcher: listAgencies,
filters, filters,
page, page,
pageSize, pageSize,
queryKey: ["host-org", "agencies", filters, page] queryKey: ["host-org", "agencies", filters, page],
}); });
const changeQuery = (value) => { const changeQuery = (value) => {
@ -87,6 +83,14 @@ export function useHostAgenciesPage() {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setParentBdUserId("");
setPage(1);
};
const submitAgency = async (event) => { const submitAgency = async (event) => {
event.preventDefault(); event.preventDefault();
await runAction("agency", "Agency 已创建", async () => { await runAction("agency", "Agency 已创建", async () => {
@ -158,6 +162,7 @@ export function useHostAgenciesPage() {
regionId, regionId,
regionOptions, regionOptions,
reload, reload,
resetFilters,
closeAction: () => setActiveAction(""), closeAction: () => setActiveAction(""),
openAgencyForm: () => setActiveAction("agency"), openAgencyForm: () => setActiveAction("agency"),
openCloseForm: () => setActiveAction("agency-close"), openCloseForm: () => setActiveAction("agency-close"),
@ -169,7 +174,7 @@ export function useHostAgenciesPage() {
status, status,
submitAgency, submitAgency,
submitClose, submitClose,
submitJoinEnabled submitJoinEnabled,
}; };
} }

View File

@ -1,27 +1,38 @@
import { useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation"; import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { import {
createBD, createBD,
createBDLeader, createBDLeader,
deleteBDLeader,
listBDLeaders, listBDLeaders,
listBDs, listBDs,
setBDLeaderStatus, setBDLeaderStatus,
setBDStatus setBDStatus,
} from "@/features/host-org/api"; } from "@/features/host-org/api";
import { listAppUsers } from "@/features/app-users/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useBDAbilities } from "@/features/host-org/permissions.js"; import { useBDAbilities } from "@/features/host-org/permissions.js";
import { bdStatusSchema, createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema"; import { bdStatusSchema, createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" }); const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" });
const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" }); const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" });
const emptyBDStatusForm = (targetType = "bd") => ({ commandId: makeCommandId("bd-status"), reason: "", status: "active", targetType, targetUserId: "" }); const emptyBDStatusForm = (targetType = "bd") => ({
commandId: makeCommandId("bd-status"),
reason: "",
status: "active",
targetType,
targetUserId: "",
});
const pageSize = 10; const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyLookup = () => ({ error: "", loading: false, user: null });
export function useHostBdsPage({ profileType = "bd" } = {}) { export function useHostBdsPage({ profileType = "bd" } = {}) {
const abilities = useBDAbilities(); const abilities = useBDAbilities();
const confirm = useConfirm();
const { showToast } = useToast(); const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions(); const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -32,6 +43,11 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm); const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
const [bdForm, setBDForm] = useState(emptyBDForm); const [bdForm, setBDForm] = useState(emptyBDForm);
const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType)); const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType));
const [bdUserLookup, setBDUserLookup] = useState(emptyLookup);
const [leaderBDs, setLeaderBDs] = useState(emptyData);
const [leaderBDsError, setLeaderBDsError] = useState("");
const [leaderBDsLoading, setLeaderBDsLoading] = useState(false);
const [selectedLeader, setSelectedLeader] = useState(null);
const [loadingAction, setLoadingAction] = useState(""); const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const filters = useMemo( const filters = useMemo(
@ -39,20 +55,80 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
keyword: query, keyword: query,
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "", parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
region_id: regionId, region_id: regionId,
status status,
}), }),
[parentLeaderUserId, profileType, query, regionId, status] [parentLeaderUserId, profileType, query, regionId, status],
); );
const fetcher = profileType === "bd" ? listBDs : listBDLeaders; const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
const { data = emptyData, error, loading, reload } = usePaginatedQuery({ const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: profileType === "leader" ? "加载 BD Leader 列表失败" : "加载 BD 列表失败", errorMessage: profileType === "leader" ? "加载 BD Leader 列表失败" : "加载 BD 列表失败",
fetcher, fetcher,
filters, filters,
page, page,
pageSize, pageSize,
queryKey: ["host-org", "bds", profileType, filters, page] queryKey: ["host-org", "bds", profileType, filters, page],
}); });
const loadLeaderBDs = useCallback(async (leader) => {
if (!leader?.userId) {
setLeaderBDs(emptyData);
return;
}
setLeaderBDsError("");
setLeaderBDsLoading(true);
try {
const data = await listBDs({
page: 1,
page_size: 100,
parent_leader_user_id: leader.userId,
});
setLeaderBDs(data || emptyData);
} catch (err) {
setLeaderBDsError(err.message || "加载下级 BD 失败");
} finally {
setLeaderBDsLoading(false);
}
}, []);
useEffect(() => {
if (activeAction !== "leader-add-bd") {
return undefined;
}
const keyword = String(bdForm.targetUserId || "").trim();
if (!keyword) {
setBDUserLookup(emptyLookup());
return undefined;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
setBDUserLookup({ error: "", loading: true, user: null });
try {
const data = await listAppUsers({ keyword, page: 1, page_size: 5 });
if (cancelled) {
return;
}
setBDUserLookup({ error: "", loading: false, user: pickUserMatch(data?.items || [], keyword) });
} catch (err) {
if (cancelled) {
return;
}
setBDUserLookup({ error: err.message || "搜索用户失败", loading: false, user: null });
}
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activeAction, bdForm.targetUserId]);
const changeQuery = (value) => { const changeQuery = (value) => {
setQuery(value); setQuery(value);
setPage(1); setPage(1);
@ -73,6 +149,14 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setParentLeaderUserId("");
setPage(1);
};
const submitBDLeader = async (event) => { const submitBDLeader = async (event) => {
event.preventDefault(); event.preventDefault();
await runAction("bd-leader", "BD Leader 已创建", async () => { await runAction("bd-leader", "BD Leader 已创建", async () => {
@ -97,12 +181,26 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
}); });
}; };
const submitLeaderBD = async (event) => {
event.preventDefault();
await runAction("leader-add-bd", "下级 BD 已添加", async () => {
const payload = parseForm(createBDSchema, bdForm);
const data = await createBD(payload);
setBDForm(emptyBDForm());
setBDUserLookup(emptyLookup());
setActiveAction("");
await reload();
return data;
});
};
const submitBDStatus = async (event) => { const submitBDStatus = async (event) => {
event.preventDefault(); event.preventDefault();
await runAction("bd-status", profileType === "leader" ? "BD Leader 状态已更新" : "BD 状态已更新", async () => { await runAction("bd-status", profileType === "leader" ? "BD Leader 状态已更新" : "BD 状态已更新", async () => {
const payload = parseForm(bdStatusSchema, { ...bdStatusForm, targetType: profileType }); const payload = parseForm(bdStatusSchema, { ...bdStatusForm, targetType: profileType });
const { targetType, targetUserId, ...body } = payload; const { targetType, targetUserId, ...body } = payload;
const data = targetType === "leader" const data =
targetType === "leader"
? await setBDLeaderStatus(targetUserId, body) ? await setBDLeaderStatus(targetUserId, body)
: await setBDStatus(targetUserId, body); : await setBDStatus(targetUserId, body);
setBDStatusForm(emptyBDStatusForm(profileType)); setBDStatusForm(emptyBDStatusForm(profileType));
@ -112,6 +210,57 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
}); });
}; };
const toggleBDLeader = async (leader, enabled) => {
const status = enabled ? "active" : "disabled";
await runAction(
`bd-leader-status-${leader.userId}`,
enabled ? "BD Leader 已启用" : "BD Leader 已停用",
async () => {
const data = await setBDLeaderStatus(leader.userId, {
commandId: makeCommandId("bd-leader-status"),
reason: "",
status,
});
await reload();
return data;
},
);
};
const removeBDLeader = async (leader) => {
const ok = await confirm({
confirmText: "删除",
message: `确认删除 BD Leader ${leader.displayUserId || leader.userId}?删除后该身份会从列表移除。`,
title: "删除 BD Leader",
tone: "danger",
});
if (!ok) {
return;
}
await runAction(`bd-leader-delete-${leader.userId}`, "BD Leader 已删除", async () => {
const data = await deleteBDLeader(leader.userId);
await reload();
return data;
});
};
const openLeaderBDs = (leader) => {
setSelectedLeader(leader);
setActiveAction("leader-bds");
void loadLeaderBDs(leader);
};
const openAddLeaderBD = (leader) => {
setSelectedLeader(leader);
setBDUserLookup(emptyLookup());
setBDForm({
...emptyBDForm(),
parentLeaderUserId: leader.displayUserId || String(leader.userId || ""),
});
setActiveAction("leader-add-bd");
};
const runAction = async (action, successMessage, submitter) => { const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action); setLoadingAction(action);
try { try {
@ -124,12 +273,19 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
} }
}; };
const closeAction = () => {
setActiveAction("");
setBDUserLookup(emptyLookup());
setLeaderBDsError("");
};
return { return {
abilities, abilities,
activeAction, activeAction,
bdForm, bdForm,
bdLeaderForm, bdLeaderForm,
bdStatusForm, bdStatusForm,
bdUserLookup,
changeParentLeaderUserId, changeParentLeaderUserId,
changeQuery, changeQuery,
changeRegionId, changeRegionId,
@ -139,6 +295,9 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
loading, loading,
loadingAction, loadingAction,
loadingRegions, loadingRegions,
leaderBDs,
leaderBDsError,
leaderBDsLoading,
page, page,
parentLeaderUserId, parentLeaderUserId,
profileType, profileType,
@ -146,10 +305,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
regionId, regionId,
regionOptions, regionOptions,
reload, reload,
closeAction: () => setActiveAction(""), removeBDLeader,
resetFilters,
closeAction,
loadLeaderBDs: () => loadLeaderBDs(selectedLeader),
openAddLeaderBD,
openBDForm: () => setActiveAction("bd"), openBDForm: () => setActiveAction("bd"),
openBDLeaderForm: () => setActiveAction("bd-leader"), openBDLeaderForm: () => setActiveAction("bd-leader"),
openBDStatusForm: () => setActiveAction("bd-status"), openBDStatusForm: () => setActiveAction("bd-status"),
openLeaderBDs,
selectedLeader,
setBDForm, setBDForm,
setBDLeaderForm, setBDLeaderForm,
setBDStatusForm, setBDStatusForm,
@ -157,10 +322,23 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
status, status,
submitBD, submitBD,
submitBDLeader, submitBDLeader,
submitBDStatus submitLeaderBD,
submitBDStatus,
toggleBDLeader,
}; };
} }
function makeCommandId(prefix) { function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`; return `${prefix}-${Date.now()}`;
} }
function pickUserMatch(items, keyword) {
const normalized = String(keyword || "").trim();
return (
items.find(
(item) => String(item.displayUserId || "") === normalized || String(item.userId || "") === normalized,
) ||
items[0] ||
null
);
}

View File

@ -2,20 +2,35 @@ import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation"; import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createCoinSeller, listCoinSellers, setCoinSellerStatus } from "@/features/host-org/api"; import { createCoinSeller, creditCoinSellerStock, listCoinSellers, setCoinSellerStatus } from "@/features/host-org/api";
import { useCoinSellerAbilities } from "@/features/host-org/permissions.js"; import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
import { coinSellerStatusSchema, createCoinSellerSchema } from "@/features/host-org/schema"; import {
coinSellerStatusSchema,
coinSellerStockCreditSchema,
createCoinSellerSchema,
} from "@/features/host-org/schema";
const pageSize = 10; const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), reason: "", targetUserId: "" }); const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), contact: "", reason: "", targetUserId: "" });
const emptyEditForm = () => ({ commandId: makeCommandId("coin-seller-status"), reason: "", status: "active", targetUserId: "" }); const emptyEditForm = () => ({
commandId: makeCommandId("coin-seller-status"),
contact: "",
reason: "",
status: "active",
targetUserId: "",
});
const emptyStockForm = () => ({
coinAmount: "",
commandId: makeCommandId("coin-seller-stock"),
reason: "",
rechargeAmount: "",
type: "usdt_purchase",
});
export function useHostCoinSellersPage() { export function useHostCoinSellersPage() {
const abilities = useCoinSellerAbilities(); const abilities = useCoinSellerAbilities();
const confirm = useConfirm();
const { showToast } = useToast(); const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions(); const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -25,23 +40,29 @@ export function useHostCoinSellersPage() {
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const [createForm, setCreateForm] = useState(emptyCreateForm); const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editForm, setEditForm] = useState(emptyEditForm); const [editForm, setEditForm] = useState(emptyEditForm);
const [stockForm, setStockForm] = useState(emptyStockForm);
const [selectedSeller, setSelectedSeller] = useState(null); const [selectedSeller, setSelectedSeller] = useState(null);
const [loadingAction, setLoadingAction] = useState(""); const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo( const filters = useMemo(
() => ({ () => ({
keyword: query, keyword: query,
region_id: regionId, region_id: regionId,
status status,
}), }),
[query, regionId, status] [query, regionId, status],
); );
const { data = emptyData, error, loading, reload } = usePaginatedQuery({ const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载币商列表失败", errorMessage: "加载币商列表失败",
fetcher: listCoinSellers, fetcher: listCoinSellers,
filters, filters,
page, page,
pageSize, pageSize,
queryKey: ["host-org", "coin-sellers", filters, page] queryKey: ["host-org", "coin-sellers", filters, page],
}); });
const changeQuery = (value) => { const changeQuery = (value) => {
@ -59,6 +80,13 @@ export function useHostCoinSellersPage() {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setPage(1);
};
const openCreateSeller = () => { const openCreateSeller = () => {
setCreateForm(emptyCreateForm()); setCreateForm(emptyCreateForm());
setSelectedSeller(null); setSelectedSeller(null);
@ -72,13 +100,23 @@ export function useHostCoinSellersPage() {
setSelectedSeller(seller); setSelectedSeller(seller);
setEditForm({ setEditForm({
commandId: makeCommandId("coin-seller-status"), commandId: makeCommandId("coin-seller-status"),
reason: "", contact: seller.contact || "",
reason: "update coin seller contact",
status: seller.status || "active", status: seller.status || "active",
targetUserId: seller.userId targetUserId: seller.userId,
}); });
setActiveAction("edit"); setActiveAction("edit");
}; };
const openStockCredit = (seller) => {
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
return;
}
setSelectedSeller(seller);
setStockForm(emptyStockForm());
setActiveAction("stock");
};
const closeAction = () => { const closeAction = () => {
setActiveAction(""); setActiveAction("");
setSelectedSeller(null); setSelectedSeller(null);
@ -97,13 +135,31 @@ export function useHostCoinSellersPage() {
const submitEditSeller = async (event) => { const submitEditSeller = async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`, "币商已更新", async () => { await runAction(
`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`,
"币商已更新",
async () => {
const payload = parseForm(coinSellerStatusSchema, editForm); const payload = parseForm(coinSellerStatusSchema, editForm);
const { targetUserId, ...body } = payload; const { targetUserId, ...body } = payload;
await setCoinSellerStatus(targetUserId, body); await setCoinSellerStatus(targetUserId, body);
setEditForm(emptyEditForm()); setEditForm(emptyEditForm());
closeAction(); closeAction();
await reload(); await reload();
},
);
};
const submitStockCredit = async (event) => {
event.preventDefault();
if (!selectedSeller?.userId) {
return;
}
await runAction(`coin-seller-stock-${selectedSeller.userId}`, "币商库存已入账", async () => {
const payload = parseForm(coinSellerStockCreditSchema, stockForm);
await creditCoinSellerStock(selectedSeller.userId, payload);
setStockForm(emptyStockForm());
closeAction();
await reload();
}); });
}; };
@ -119,30 +175,7 @@ export function useHostCoinSellersPage() {
await setCoinSellerStatus(seller.userId, { await setCoinSellerStatus(seller.userId, {
commandId: makeCommandId("coin-seller-status"), commandId: makeCommandId("coin-seller-status"),
reason: "", reason: "",
status: nextStatus status: nextStatus,
});
await reload();
});
};
const deleteSeller = async (seller) => {
if (!abilities.canUpdate || !seller?.userId) {
return;
}
const ok = await confirm({
confirmText: "删除",
message: seller.username || seller.displayUserId || String(seller.userId),
title: "删除币商",
tone: "danger"
});
if (!ok) {
return;
}
await runAction(`coin-seller-delete-${seller.userId}`, "币商已删除", async () => {
await setCoinSellerStatus(seller.userId, {
commandId: makeCommandId("coin-seller-delete"),
reason: "delete coin seller",
status: "disabled"
}); });
await reload(); await reload();
}); });
@ -169,7 +202,6 @@ export function useHostCoinSellersPage() {
closeAction, closeAction,
createForm, createForm,
data, data,
deleteSeller,
editForm, editForm,
error, error,
loading, loading,
@ -177,19 +209,24 @@ export function useHostCoinSellersPage() {
loadingRegions, loadingRegions,
openCreateSeller, openCreateSeller,
openEditSeller, openEditSeller,
openStockCredit,
page, page,
query, query,
regionId, regionId,
regionOptions, regionOptions,
reload, reload,
resetFilters,
selectedSeller, selectedSeller,
setCreateForm, setCreateForm,
setEditForm, setEditForm,
setPage, setPage,
setStockForm,
status, status,
stockForm,
submitCreateSeller, submitCreateSeller,
submitEditSeller, submitEditSeller,
toggleSeller submitStockCredit,
toggleSeller,
}; };
} }

View File

@ -2,13 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation"; import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { import { createCountry, disableCountry, enableCountry, listCountries, updateCountry } from "@/features/host-org/api";
createCountry,
disableCountry,
enableCountry,
listCountries,
updateCountry
} from "@/features/host-org/api";
import { useCountryAbilities } from "@/features/host-org/permissions.js"; import { useCountryAbilities } from "@/features/host-org/permissions.js";
import { countryCreateSchema, countryUpdateSchema } from "@/features/host-org/schema"; import { countryCreateSchema, countryUpdateSchema } from "@/features/host-org/schema";
@ -21,7 +15,7 @@ const emptyCountryForm = () => ({
isoAlpha3: "", isoAlpha3: "",
isoNumeric: "", isoNumeric: "",
phoneCountryCode: "", phoneCountryCode: "",
sortOrder: 0 sortOrder: 0,
}); });
const emptyData = { items: [], total: 0 }; const emptyData = { items: [], total: 0 };
@ -36,16 +30,21 @@ export function useHostCountriesPage() {
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const filters = useMemo( const filters = useMemo(
() => ({ () => ({
enabled: enabledStatus === "enabled" ? true : enabledStatus === "disabled" ? false : undefined enabled: enabledStatus === "enabled" ? true : enabledStatus === "disabled" ? false : undefined,
}), }),
[enabledStatus] [enabledStatus],
); );
const queryFn = useCallback(() => listCountries(filters), [filters]); const queryFn = useCallback(() => listCountries(filters), [filters]);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, { const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(queryFn, {
errorMessage: "加载国家列表失败", errorMessage: "加载国家列表失败",
initialData: emptyData, initialData: emptyData,
keepPreviousData: true, keepPreviousData: true,
queryKey: ["host-org", "countries", filters] queryKey: ["host-org", "countries", filters],
}); });
const items = useMemo(() => filterCountries(data?.items || [], query), [data?.items, query]); const items = useMemo(() => filterCountries(data?.items || [], query), [data?.items, query]);
@ -57,6 +56,11 @@ export function useHostCountriesPage() {
setEnabledStatus(value); setEnabledStatus(value);
}; };
const resetFilters = () => {
setQuery("");
setEnabledStatus("");
};
const openCreateCountry = () => { const openCreateCountry = () => {
setEditingCountry(null); setEditingCountry(null);
setCountryForm(emptyCountryForm()); setCountryForm(emptyCountryForm());
@ -74,7 +78,7 @@ export function useHostCountriesPage() {
isoAlpha3: country.isoAlpha3 || "", isoAlpha3: country.isoAlpha3 || "",
isoNumeric: country.isoNumeric || "", isoNumeric: country.isoNumeric || "",
phoneCountryCode: country.phoneCountryCode || "", phoneCountryCode: country.phoneCountryCode || "",
sortOrder: country.sortOrder || 0 sortOrder: country.sortOrder || 0,
}); });
setActiveAction("edit"); setActiveAction("edit");
}; };
@ -84,9 +88,7 @@ export function useHostCountriesPage() {
const isEdit = activeAction === "edit"; const isEdit = activeAction === "edit";
await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => { await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => {
const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm); const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm);
const data = isEdit const data = isEdit ? await updateCountry(editingCountry.countryId, payload) : await createCountry(payload);
? await updateCountry(editingCountry.countryId, payload)
: await createCountry(payload);
setCountryForm(emptyCountryForm()); setCountryForm(emptyCountryForm());
setEditingCountry(null); setEditingCountry(null);
setActiveAction(""); setActiveAction("");
@ -135,9 +137,10 @@ export function useHostCountriesPage() {
openEditCountry, openEditCountry,
query, query,
reload, reload,
resetFilters,
setCountryForm, setCountryForm,
submitCountry, submitCountry,
toggleCountry toggleCountry,
}; };
} }
@ -153,7 +156,7 @@ function filterCountries(items, query) {
item.countryName, item.countryName,
item.isoAlpha3, item.isoAlpha3,
item.isoNumeric, item.isoNumeric,
item.phoneCountryCode item.phoneCountryCode,
] ]
.filter(Boolean) .filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword)); .some((value) => String(value).toLowerCase().includes(keyword));

View File

@ -20,17 +20,22 @@ export function useHostHostsPage() {
agency_id: agencyId, agency_id: agencyId,
keyword: query, keyword: query,
region_id: regionId, region_id: regionId,
status status,
}), }),
[agencyId, query, regionId, status] [agencyId, query, regionId, status],
); );
const { data = emptyData, error, loading, reload } = usePaginatedQuery({ const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载主播列表失败", errorMessage: "加载主播列表失败",
fetcher: listHosts, fetcher: listHosts,
filters, filters,
page, page,
pageSize, pageSize,
queryKey: ["host-org", "hosts", filters, page] queryKey: ["host-org", "hosts", filters, page],
}); });
const changeQuery = (value) => { const changeQuery = (value) => {
@ -53,6 +58,14 @@ export function useHostHostsPage() {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setAgencyId("");
setPage(1);
};
return { return {
abilities, abilities,
agencyId, agencyId,
@ -69,7 +82,8 @@ export function useHostHostsPage() {
regionId, regionId,
regionOptions, regionOptions,
reload, reload,
resetFilters,
setPage, setPage,
status status,
}; };
} }

View File

@ -9,7 +9,7 @@ import {
listCountries, listCountries,
listRegions, listRegions,
replaceRegionCountries, replaceRegionCountries,
updateRegion updateRegion,
} from "@/features/host-org/api"; } from "@/features/host-org/api";
import { useRegionAbilities } from "@/features/host-org/permissions.js"; import { useRegionAbilities } from "@/features/host-org/permissions.js";
import { regionCreateSchema, regionUpdateSchema } from "@/features/host-org/schema"; import { regionCreateSchema, regionUpdateSchema } from "@/features/host-org/schema";
@ -18,7 +18,7 @@ const emptyRegionForm = () => ({
countries: [], countries: [],
name: "", name: "",
regionCode: "", regionCode: "",
sortOrder: 0 sortOrder: 0,
}); });
const emptyData = { items: [], total: 0 }; const emptyData = { items: [], total: 0 };
const globalRegionCode = "GLOBAL"; const globalRegionCode = "GLOBAL";
@ -33,25 +33,34 @@ export function useHostRegionsPage() {
const [loadingAction, setLoadingAction] = useState(""); const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const queryFn = useCallback(() => listRegions(), []); const queryFn = useCallback(() => listRegions(), []);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, { const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(queryFn, {
errorMessage: "加载区域列表失败", errorMessage: "加载区域列表失败",
initialData: emptyData, initialData: emptyData,
keepPreviousData: true, keepPreviousData: true,
queryKey: ["host-org", "regions"] queryKey: ["host-org", "regions"],
}); });
const countryQueryFn = useCallback(() => listCountries(), []); const countryQueryFn = useCallback(() => listCountries(), []);
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, { const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
errorMessage: "加载国家列表失败", errorMessage: "加载国家列表失败",
initialData: emptyData, initialData: emptyData,
queryKey: ["host-org", "countries", "region-options"], queryKey: ["host-org", "countries", "region-options"],
staleTime: 5 * 60 * 1000 staleTime: 5 * 60 * 1000,
}); });
const items = useMemo(() => filterRegions(data?.items || [], query, status), [data?.items, query, status]); const items = useMemo(() => filterRegions(data?.items || [], query, status), [data?.items, query, status]);
const assignedCountries = useMemo(() => { const assignedCountries = useMemo(() => {
const currentRegionId = editingRegion?.regionId; const currentRegionId = editingRegion?.regionId;
const assigned = new Set(); const assigned = new Set();
(data?.items || []).forEach((region) => { (data?.items || []).forEach((region) => {
if (region.status !== "active" || region.regionId === currentRegionId || isGlobalRegionCode(region.regionCode)) { if (
region.status !== "active" ||
region.regionId === currentRegionId ||
isGlobalRegionCode(region.regionCode)
) {
return; return;
} }
(region.countries || []).forEach((country) => assigned.add(country)); (region.countries || []).forEach((country) => assigned.add(country));
@ -66,7 +75,9 @@ export function useHostRegionsPage() {
const countryLabels = useMemo(() => { const countryLabels = useMemo(() => {
return (countriesData?.items || []).reduce((labels, country) => { return (countriesData?.items || []).reduce((labels, country) => {
if (country.countryCode) { if (country.countryCode) {
labels[country.countryCode] = [country.countryCode, country.countryDisplayName || country.countryName].filter(Boolean).join(" · "); labels[country.countryCode] = [country.countryCode, country.countryDisplayName || country.countryName]
.filter(Boolean)
.join(" · ");
} }
return labels; return labels;
}, {}); }, {});
@ -86,6 +97,11 @@ export function useHostRegionsPage() {
setStatus(value); setStatus(value);
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
};
const openCreateRegion = () => { const openCreateRegion = () => {
setEditingRegion(null); setEditingRegion(null);
setRegionFormState(emptyRegionForm()); setRegionFormState(emptyRegionForm());
@ -98,7 +114,7 @@ export function useHostRegionsPage() {
countries: region.countries || [], countries: region.countries || [],
name: region.name || "", name: region.name || "",
regionCode: region.regionCode || "", regionCode: region.regionCode || "",
sortOrder: region.sortOrder || 0 sortOrder: region.sortOrder || 0,
}); });
setActiveAction("edit"); setActiveAction("edit");
}; };
@ -118,7 +134,11 @@ export function useHostRegionsPage() {
}; };
const toggleRegion = async (region, nextActive = region.status !== "active") => { const toggleRegion = async (region, nextActive = region.status !== "active") => {
if (isGlobalRegionCode(region.regionCode) || !abilities.canStatus || (region.status === "active") === nextActive) { if (
isGlobalRegionCode(region.regionCode) ||
!abilities.canStatus ||
(region.status === "active") === nextActive
) {
return; return;
} }
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => { await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
@ -160,10 +180,11 @@ export function useHostRegionsPage() {
query, query,
regionForm, regionForm,
reload, reload,
resetFilters,
setRegionForm, setRegionForm,
status, status,
submitRegion, submitRegion,
toggleRegion toggleRegion,
}; };
} }
@ -198,5 +219,9 @@ function filterRegions(items, query, status) {
} }
function isGlobalRegionCode(regionCode) { function isGlobalRegionCode(regionCode) {
return String(regionCode || "").trim().toUpperCase() === globalRegionCode; return (
String(regionCode || "")
.trim()
.toUpperCase() === globalRegionCode
);
} }

View File

@ -165,6 +165,62 @@
white-space: nowrap; white-space: nowrap;
} }
.contactButton {
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--primary);
cursor: pointer;
font: inherit;
font-weight: 700;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.contactButton:hover {
color: var(--primary-strong);
text-decoration: underline;
}
.contactPlaceholder {
color: var(--text-tertiary);
}
.childBdList {
display: grid;
gap: var(--space-3);
}
.childBdItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-input);
}
.childBdStatus {
flex: 0 0 auto;
}
.lookupPanel {
display: grid;
gap: var(--space-2);
min-height: 44px;
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
color: var(--text-secondary);
font-size: var(--admin-font-size);
}
.positive { .positive {
color: var(--success); color: var(--success);
} }

View File

@ -7,30 +7,64 @@ import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { agencyStatusFilters } from "@/features/host-org/constants.js"; import { agencyStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js"; import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
const agencyColumns = [ const agencyColumns = [
{ key: "agencyId", label: "Agency ID", width: "minmax(110px, 0.8fr)" }, {
{ key: "name", label: "名称", width: "minmax(160px, 1.2fr)" }, key: "agency",
{ key: "ownerUserId", label: "Owner 用户 ID" }, label: "Agency",
{ key: "parentBdUserId", label: "上级 BD 用户 ID" }, render: (item) => <HostOrgEntity id={item.agencyId} name={item.name} prefix="Agency " />,
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" }, width: "minmax(190px, 1fr)",
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" }, },
{
key: "ownerUserId",
label: "Owner",
render: (item) => <AgencyOwner item={item} />,
width: "minmax(220px, 1.2fr)",
},
{
key: "parentBdUserId",
label: "上级 BD",
render: (item) => <ParentBD item={item} />,
width: "minmax(220px, 1.2fr)",
},
{
key: "regionId",
label: "区域",
render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)",
},
{
key: "status",
label: "状态",
render: (item) => <HostOrgStatus value={item.status} />,
width: "minmax(90px, 0.7fr)",
},
{ {
key: "joinEnabled", key: "joinEnabled",
label: "入会", label: "入会",
render: (item) => <span className={item.joinEnabled ? styles.positive : styles.negative}>{item.joinEnabled ? "允许" : "禁止"}</span>, render: (item) => (
width: "minmax(80px, 0.6fr)" <span className={item.joinEnabled ? styles.positive : styles.negative}>
{item.joinEnabled ? "允许" : "禁止"}
</span>
),
width: "minmax(80px, 0.6fr)",
},
{ key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" },
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
}, },
{ key: "maxHosts", label: "最大主播数", width: "minmax(90px, 0.7fr)" },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
]; ];
export function HostAgenciesPage() { export function HostAgenciesPage() {
@ -44,12 +78,52 @@ export function HostAgenciesPage() {
? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm } ? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm }
: null, : null,
page.abilities.canStatus page.abilities.canStatus
? { icon: <BlockOutlined fontSize="small" />, label: "关闭 Agency", onClick: page.openCloseForm, tone: "danger" } ? {
icon: <BlockOutlined fontSize="small" />,
label: "关闭 Agency",
onClick: page.openCloseForm,
tone: "danger",
}
: null, : null,
page.abilities.canCreate page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" } ? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
: null : null,
].filter(Boolean); ].filter(Boolean);
const columns = agencyColumns.map((column) => {
if (column.key === "agency") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索 Agency、Owner、用户",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "parentBdUserId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索上级 BD 用户 ID",
value: page.parentBdUserId,
onChange: page.changeParentBdUserId,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: agencyStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return ( return (
<section className={styles.root}> <section className={styles.root}>
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
@ -61,8 +135,8 @@ export function HostAgenciesPage() {
label: "上级 BD 用户 ID", label: "上级 BD 用户 ID",
name: "parentBdUserId", name: "parentBdUserId",
onChange: page.changeParentBdUserId, onChange: page.changeParentBdUserId,
value: page.parentBdUserId value: page.parentBdUserId,
} },
], ],
loadingRegions: page.loadingRegions, loadingRegions: page.loadingRegions,
query: page.query, query: page.query,
@ -73,24 +147,28 @@ export function HostAgenciesPage() {
statusOptions: agencyStatusFilters, statusOptions: agencyStatusFilters,
onQueryChange: page.changeQuery, onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId, onRegionIdChange: page.changeRegionId,
onStatusChange: page.changeStatus onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}} }}
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}> <div className={styles.listBlock}>
<HostOrgTable <HostOrgTable
columns={agencyColumns} columns={columns}
context={{ regionOptions: page.regionOptions }}
items={items} items={items}
regionFilter={createRegionColumnFilter({ minWidth="1250px"
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.changeRegionId
})}
rowKey={(item) => item.agencyId} rowKey={(item) => item.agencyId}
/> />
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null} {total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div> </div>
</DataState> </DataState>
</div> </div>
@ -103,15 +181,56 @@ export function HostAgenciesPage() {
onSubmit={page.submitAgency} onSubmit={page.submitAgency}
title="创建 Agency" title="创建 Agency"
> >
<TextField disabled={createDisabled} label="Agency 名称" required value={page.agencyForm.name} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, name: event.target.value })} /> <TextField
<TextField disabled={createDisabled} label="Owner 短 ID" required type="number" value={page.agencyForm.ownerUserId} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, ownerUserId: event.target.value })} /> disabled={createDisabled}
<TextField disabled={createDisabled} label="上级 BD 短 ID" required type="number" value={page.agencyForm.parentBdUserId} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })} /> label="Agency 名称"
<TextField disabled={createDisabled} label="最大主播数" type="number" value={page.agencyForm.maxHosts} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })} /> required
value={page.agencyForm.name}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, name: event.target.value })}
/>
<TextField
disabled={createDisabled}
label="Owner 短 ID"
required
type="number"
value={page.agencyForm.ownerUserId}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, ownerUserId: event.target.value })}
/>
<TextField
disabled={createDisabled}
label="上级 BD 短 ID"
required
type="number"
value={page.agencyForm.parentBdUserId}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })}
/>
<TextField
disabled={createDisabled}
label="最大主播数"
type="number"
value={page.agencyForm.maxHosts}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
/>
<FormControlLabel <FormControlLabel
control={<Switch checked={page.agencyForm.joinEnabled} disabled={createDisabled} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })} />} control={
<Switch
checked={page.agencyForm.joinEnabled}
disabled={createDisabled}
onChange={(event) =>
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
}
/>
}
label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"} label={page.agencyForm.joinEnabled ? "允许入会" : "禁止入会"}
/> />
<TextField disabled={createDisabled} label="原因" multiline minRows={2} value={page.agencyForm.reason} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })} /> <TextField
disabled={createDisabled}
label="原因"
multiline
minRows={2}
value={page.agencyForm.reason}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
/>
</HostOrgActionModal> </HostOrgActionModal>
<HostOrgActionModal <HostOrgActionModal
@ -122,12 +241,38 @@ export function HostAgenciesPage() {
onSubmit={page.submitJoinEnabled} onSubmit={page.submitJoinEnabled}
title="入会开关" title="入会开关"
> >
<TextField disabled={statusDisabled} label="Agency ID" required type="number" value={page.joinEnabledForm.agencyId} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })} /> <TextField
disabled={statusDisabled}
label="Agency ID"
required
type="number"
value={page.joinEnabledForm.agencyId}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
}
/>
<FormControlLabel <FormControlLabel
control={<Switch checked={page.joinEnabledForm.joinEnabled} disabled={statusDisabled} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })} />} control={
<Switch
checked={page.joinEnabledForm.joinEnabled}
disabled={statusDisabled}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
}
/>
}
label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"} label={page.joinEnabledForm.joinEnabled ? "允许入会" : "禁止入会"}
/> />
<TextField disabled={statusDisabled} label="原因" multiline minRows={2} value={page.joinEnabledForm.reason} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value })} /> <TextField
disabled={statusDisabled}
label="原因"
multiline
minRows={2}
value={page.joinEnabledForm.reason}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value })
}
/>
</HostOrgActionModal> </HostOrgActionModal>
<HostOrgActionModal <HostOrgActionModal
@ -140,10 +285,46 @@ export function HostAgenciesPage() {
submitVariant="danger" submitVariant="danger"
title="关闭 Agency" title="关闭 Agency"
> >
<TextField disabled={statusDisabled} label="Agency ID" required type="number" value={page.closeForm.agencyId} onChange={(event) => page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })} /> <TextField
<TextField disabled={statusDisabled} label="原因" multiline minRows={2} required value={page.closeForm.reason} onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })} /> disabled={statusDisabled}
label="Agency ID"
required
type="number"
value={page.closeForm.agencyId}
onChange={(event) => page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })}
/>
<TextField
disabled={statusDisabled}
label="原因"
multiline
minRows={2}
required
value={page.closeForm.reason}
onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })}
/>
</HostOrgActionModal> </HostOrgActionModal>
</section> </section>
); );
} }
function AgencyOwner({ item }) {
return (
<HostOrgPerson
avatar={item.ownerAvatar}
displayUserId={item.ownerDisplayUserId}
fallbackId={item.ownerUserId}
username={item.ownerUsername || "Owner"}
/>
);
}
function ParentBD({ item }) {
return (
<HostOrgPerson
avatar={item.parentBdAvatar}
displayUserId={item.parentBdDisplayUserId}
fallbackId={item.parentBdUserId}
username={item.parentBdUsername || "BD"}
/>
);
}

View File

@ -1,11 +1,13 @@
import Add from "@mui/icons-material/Add"; import Add from "@mui/icons-material/Add";
import SyncAltOutlined from "@mui/icons-material/SyncAltOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import MenuItem from "@mui/material/MenuItem"; import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { bdStatusFilters } from "@/features/host-org/constants.js"; import { bdStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
@ -15,30 +17,72 @@ import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js"; import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
const bdLeaderColumns = [ const bdLeaderBaseColumns = [
{ key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" }, {
{ key: "role", label: "角色", width: "minmax(110px, 0.8fr)" }, key: "user",
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" }, label: "用户",
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" }, render: (item) => <PersonIdentity item={item} />,
{ key: "createdByUserId", label: "创建人用户 ID" }, width: "minmax(220px, 1.25fr)",
{ key: "createdAtMs", label: "创建时间", render: (item) => formatMillis(item.createdAtMs), width: "minmax(170px, 1fr)" }, },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } {
key: "regionId",
label: "区域",
render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)",
},
{
key: "status",
label: "状态",
render: (item, _index, context) => <BDLeaderStatusSwitch item={item} page={context?.page} />,
width: "minmax(90px, 0.7fr)",
},
{
key: "createdByUserId",
label: "创建人",
render: (item) => <CreatorIdentity item={item} />,
width: "minmax(180px, 1fr)",
},
{
key: "subBdCount",
label: "下级 BD 数量",
render: (item, _index, context) => <SubBDCount item={item} page={context?.page} />,
width: "minmax(130px, 0.75fr)",
},
{
key: "createdAtMs",
label: "创建时间",
render: (item) => formatMillis(item.createdAtMs),
width: "minmax(170px, 1fr)",
},
]; ];
export function HostBdLeadersPage() { export function HostBdLeadersPage() {
const page = useHostBdsPage({ profileType: "leader" }); const page = useHostBdsPage({ profileType: "leader" });
const createDisabled = !page.abilities.canCreate; const createDisabled = !page.abilities.canCreate;
const updateDisabled = !page.abilities.canUpdate;
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const toolbarActions = [ const bdLeaderColumns = [
page.abilities.canUpdate ...bdLeaderBaseColumns,
? { icon: <SyncAltOutlined fontSize="small" />, label: "BD Leader 状态", onClick: page.openBDStatusForm } page.abilities.canCreate || page.abilities.canUpdate
? {
key: "actions",
label: "操作",
render: (item) => <BDLeaderActions item={item} page={page} />,
width: "minmax(118px, 0.7fr)",
}
: null, : null,
page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 BD Leader", onClick: page.openBDLeaderForm, variant: "primary" }
: null
].filter(Boolean); ].filter(Boolean);
const toolbarActions = page.abilities.canCreate
? [
{
icon: <Add fontSize="small" />,
label: "创建 BD Leader",
onClick: page.openBDLeaderForm,
variant: "primary",
},
]
: [];
return ( return (
<section className={styles.root}> <section className={styles.root}>
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
@ -54,7 +98,8 @@ export function HostBdLeadersPage() {
statusOptions: bdStatusFilters, statusOptions: bdStatusFilters,
onQueryChange: page.changeQuery, onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId, onRegionIdChange: page.changeRegionId,
onStatusChange: page.changeStatus onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}} }}
/> />
@ -62,16 +107,19 @@ export function HostBdLeadersPage() {
<div className={styles.listBlock}> <div className={styles.listBlock}>
<HostOrgTable <HostOrgTable
columns={bdLeaderColumns} columns={bdLeaderColumns}
context={{ page, regionOptions: page.regionOptions }}
items={items} items={items}
regionFilter={createRegionColumnFilter({ minWidth="1120px"
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.changeRegionId
})}
rowKey={(item) => `bd-leader-${item.userId}`} rowKey={(item) => `bd-leader-${item.userId}`}
/> />
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null} {total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div> </div>
</DataState> </DataState>
</div> </div>
@ -84,27 +132,208 @@ export function HostBdLeadersPage() {
onSubmit={page.submitBDLeader} onSubmit={page.submitBDLeader}
title="创建 BD Leader" title="创建 BD Leader"
> >
<TextField disabled={createDisabled} label="用户短 ID" required type="number" value={page.bdLeaderForm.targetUserId} onChange={(event) => page.setBDLeaderForm({ ...page.bdLeaderForm, targetUserId: event.target.value })} /> <TextField
<RegionSelect disabled={createDisabled} emptyLabel="选择区域" loading={page.loadingRegions} options={page.regionOptions} required value={page.bdLeaderForm.regionId} onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })} /> disabled={createDisabled}
<TextField disabled={createDisabled} label="联系方式" value={page.bdLeaderForm.reason} onChange={(event) => page.setBDLeaderForm({ ...page.bdLeaderForm, reason: event.target.value })} /> label="用户短 ID"
required
type="number"
value={page.bdLeaderForm.targetUserId}
onChange={(event) =>
page.setBDLeaderForm({ ...page.bdLeaderForm, targetUserId: event.target.value })
}
/>
<RegionSelect
disabled={createDisabled}
emptyLabel="选择区域"
loading={page.loadingRegions}
options={page.regionOptions}
required
value={page.bdLeaderForm.regionId}
onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })}
/>
<TextField
disabled={createDisabled}
label="联系方式"
value={page.bdLeaderForm.reason}
onChange={(event) => page.setBDLeaderForm({ ...page.bdLeaderForm, reason: event.target.value })}
/>
</HostOrgActionModal> </HostOrgActionModal>
<HostOrgActionModal <HostOrgActionModal
disabled={updateDisabled} disabled={createDisabled}
loading={page.loadingAction === "bd-status"} loading={page.loadingAction === "leader-add-bd"}
open={page.activeAction === "bd-status"} open={page.activeAction === "leader-add-bd"}
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitBDStatus} onSubmit={page.submitLeaderBD}
title="BD Leader 状态" title="增加下级 BD"
> >
<TextField disabled={updateDisabled} label="用户 ID" required type="number" value={page.bdStatusForm.targetUserId} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })} /> <TextField
<TextField disabled={updateDisabled} label="状态" required select value={page.bdStatusForm.status} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}> disabled
<MenuItem value="active">启用</MenuItem> label="上级 Leader"
<MenuItem value="disabled">停用</MenuItem> value={page.selectedLeader?.displayUserId || page.selectedLeader?.userId || ""}
</TextField> />
<TextField disabled={updateDisabled} label="原因" multiline minRows={2} value={page.bdStatusForm.reason} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} /> <TextField
disabled={createDisabled}
label="下级用户短 ID"
required
type="number"
value={page.bdForm.targetUserId}
onChange={(event) => page.setBDForm({ ...page.bdForm, targetUserId: event.target.value })}
/>
<UserLookupPreview keyword={page.bdForm.targetUserId} lookup={page.bdUserLookup} />
<TextField
disabled={createDisabled}
label="原因"
multiline
minRows={2}
value={page.bdForm.reason}
onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })}
/>
</HostOrgActionModal> </HostOrgActionModal>
<SideDrawer
open={page.activeAction === "leader-bds"}
title={`下级 BD ${page.selectedLeader?.displayUserId || page.selectedLeader?.userId || ""}`}
width="wide"
onClose={page.closeAction}
>
<DataState error={page.leaderBDsError} loading={page.leaderBDsLoading} onRetry={page.loadLeaderBDs}>
<div className={styles.childBdList}>
{(page.leaderBDs.items || []).length ? (
page.leaderBDs.items.map((item) => (
<div className={styles.childBdItem} key={item.userId}>
<PersonIdentity item={item} />
<span className={styles.childBdStatus}>
<HostOrgStatus value={item.status} />
</span>
</div>
))
) : (
<div className="empty-state">暂无下级 BD</div>
)}
</div>
</DataState>
</SideDrawer>
</section> </section>
); );
} }
function PersonIdentity({ item }) {
const name = item.username || "-";
const meta = item.displayUserId || item.userId || "-";
return (
<div className={styles.sellerIdentity}>
{item.avatar ? (
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
) : (
<span className={styles.sellerAvatar}>
{String(name || meta)
.slice(0, 1)
.toUpperCase()}
</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
);
}
function CreatorIdentity({ item }) {
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
return "-";
}
return (
<PersonIdentity
item={{
avatar: item.createdByAvatar,
displayUserId: item.createdByDisplayUserId,
userId: item.createdByUserId,
username: item.createdByUsername || (item.createdByUserId ? `用户 ${item.createdByUserId}` : "-"),
}}
/>
);
}
function BDLeaderStatusSwitch({ item, page }) {
return (
<Switch
checked={item.status === "active"}
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-leader-status-${item.userId}`}
inputProps={{ "aria-label": item.status === "active" ? "停用 BD Leader" : "启用 BD Leader" }}
onChange={(event) => page.toggleBDLeader(item, event.target.checked)}
/>
);
}
function SubBDCount({ item, page }) {
return (
<button className={styles.contactButton} type="button" onClick={() => page.openLeaderBDs(item)}>
{Number(item.subBdCount || 0).toLocaleString("zh-CN")}
</button>
);
}
function BDLeaderActions({ item, page }) {
return (
<AdminRowActions>
{page.abilities.canCreate ? (
<AdminActionIconButton label="增加下级 BD" onClick={() => page.openAddLeaderBD(item)}>
<PersonAddAlt1Outlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canUpdate ? (
<AdminActionIconButton
disabled={page.loadingAction === `bd-leader-delete-${item.userId}`}
label="删除 BD Leader"
sx={dangerActionSx}
onClick={() => page.removeBDLeader(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</AdminRowActions>
);
}
function UserLookupPreview({ keyword, lookup }) {
const hasKeyword = String(keyword || "").trim();
if (!hasKeyword) {
return <div className={styles.lookupPanel}>输入用户短 ID 后自动搜索用户</div>;
}
if (lookup.loading) {
return <div className={styles.lookupPanel}>搜索中...</div>;
}
if (lookup.error) {
return <div className={`${styles.lookupPanel} ${styles.negative}`}>{lookup.error}</div>;
}
if (!lookup.user) {
return <div className={styles.lookupPanel}>未找到匹配用户提交时后端会再次校验</div>;
}
return (
<div className={styles.lookupPanel}>
<PersonIdentity item={lookup.user} />
<span className={styles.sellerMeta}>{lookup.user.regionName || "未分配区域"}</span>
</div>
);
}
function regionName(item, regionOptions = []) {
if (item.regionName) {
return item.regionName;
}
const option = regionOptions.find((region) => String(region.value) === String(item.regionId));
return option?.name || item.regionId || "-";
}
const dangerActionSx = {
borderColor: "rgba(239, 68, 68, 0.3)",
backgroundColor: "rgba(239, 68, 68, 0.08)",
color: "var(--danger)",
"&:hover": {
borderColor: "rgba(239, 68, 68, 0.5)",
backgroundColor: "rgba(239, 68, 68, 0.14)",
color: "var(--danger)",
},
};

View File

@ -5,24 +5,60 @@ import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { bdStatusFilters } from "@/features/host-org/constants.js"; import { bdStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js"; import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
const bdColumns = [ const bdColumns = [
{ key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" }, {
{ key: "role", label: "角色", width: "minmax(110px, 0.8fr)" }, key: "user",
{ key: "parentLeaderUserId", label: "上级 Leader 用户 ID", render: (item) => item.parentLeaderUserId || "-" }, label: "用户",
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" }, render: (item) => <BDUser item={item} />,
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" }, width: "minmax(220px, 1.25fr)",
{ key: "createdByUserId", label: "创建人用户 ID" }, },
{ key: "createdAtMs", label: "创建时间", render: (item) => formatMillis(item.createdAtMs), width: "minmax(170px, 1fr)" }, { key: "role", label: "角色", width: "minmax(100px, 0.65fr)" },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } {
key: "parentLeaderUserId",
label: "上级 Leader",
render: (item) => <ParentLeader item={item} />,
width: "minmax(220px, 1.25fr)",
},
{
key: "regionId",
label: "区域",
render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)",
},
{
key: "status",
label: "状态",
render: (item) => <HostOrgStatus value={item.status} />,
width: "minmax(90px, 0.7fr)",
},
{
key: "createdByUserId",
label: "创建人",
render: (item) => <Creator item={item} />,
width: "minmax(180px, 1fr)",
},
{
key: "createdAtMs",
label: "创建时间",
render: (item) => formatMillis(item.createdAtMs),
width: "minmax(170px, 1fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
]; ];
export function HostBdsPage() { export function HostBdsPage() {
@ -37,8 +73,43 @@ export function HostBdsPage() {
: null, : null,
page.abilities.canCreate page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" } ? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
: null : null,
].filter(Boolean); ].filter(Boolean);
const columns = bdColumns.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID、展示 ID、用户名",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "parentLeaderUserId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索 Leader 用户 ID",
value: page.parentLeaderUserId,
onChange: page.changeParentLeaderUserId,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: bdStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return ( return (
<section className={styles.root}> <section className={styles.root}>
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
@ -50,8 +121,8 @@ export function HostBdsPage() {
label: "上级 Leader 用户 ID", label: "上级 Leader 用户 ID",
name: "parentLeaderUserId", name: "parentLeaderUserId",
onChange: page.changeParentLeaderUserId, onChange: page.changeParentLeaderUserId,
value: page.parentLeaderUserId value: page.parentLeaderUserId,
} },
], ],
loadingRegions: page.loadingRegions, loadingRegions: page.loadingRegions,
query: page.query, query: page.query,
@ -62,24 +133,28 @@ export function HostBdsPage() {
statusOptions: bdStatusFilters, statusOptions: bdStatusFilters,
onQueryChange: page.changeQuery, onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId, onRegionIdChange: page.changeRegionId,
onStatusChange: page.changeStatus onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}} }}
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}> <div className={styles.listBlock}>
<HostOrgTable <HostOrgTable
columns={bdColumns} columns={columns}
context={{ regionOptions: page.regionOptions }}
items={items} items={items}
regionFilter={createRegionColumnFilter({ minWidth="1260px"
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.changeRegionId
})}
rowKey={(item) => `bd-${item.userId}`} rowKey={(item) => `bd-${item.userId}`}
/> />
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null} {total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div> </div>
</DataState> </DataState>
</div> </div>
@ -92,9 +167,30 @@ export function HostBdsPage() {
onSubmit={page.submitBD} onSubmit={page.submitBD}
title="创建 BD" title="创建 BD"
> >
<TextField disabled={createDisabled} label="用户短 ID" required type="number" value={page.bdForm.targetUserId} onChange={(event) => page.setBDForm({ ...page.bdForm, targetUserId: event.target.value })} /> <TextField
<TextField disabled={createDisabled} label="上级 Leader 短 ID" required type="number" value={page.bdForm.parentLeaderUserId} onChange={(event) => page.setBDForm({ ...page.bdForm, parentLeaderUserId: event.target.value })} /> disabled={createDisabled}
<TextField disabled={createDisabled} label="原因" multiline minRows={2} value={page.bdForm.reason} onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })} /> label="用户短 ID"
required
type="number"
value={page.bdForm.targetUserId}
onChange={(event) => page.setBDForm({ ...page.bdForm, targetUserId: event.target.value })}
/>
<TextField
disabled={createDisabled}
label="上级 Leader 短 ID"
required
type="number"
value={page.bdForm.parentLeaderUserId}
onChange={(event) => page.setBDForm({ ...page.bdForm, parentLeaderUserId: event.target.value })}
/>
<TextField
disabled={createDisabled}
label="原因"
multiline
minRows={2}
value={page.bdForm.reason}
onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })}
/>
</HostOrgActionModal> </HostOrgActionModal>
<HostOrgActionModal <HostOrgActionModal
@ -105,14 +201,75 @@ export function HostBdsPage() {
onSubmit={page.submitBDStatus} onSubmit={page.submitBDStatus}
title="BD 状态" title="BD 状态"
> >
<TextField disabled={updateDisabled} label="用户 ID" required type="number" value={page.bdStatusForm.targetUserId} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })} /> <TextField
<TextField disabled={updateDisabled} label="状态" required select value={page.bdStatusForm.status} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}> disabled={updateDisabled}
label="用户 ID"
required
type="number"
value={page.bdStatusForm.targetUserId}
onChange={(event) =>
page.setBDStatusForm({ ...page.bdStatusForm, targetUserId: event.target.value })
}
/>
<TextField
disabled={updateDisabled}
label="状态"
required
select
value={page.bdStatusForm.status}
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem> <MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem> <MenuItem value="disabled">停用</MenuItem>
</TextField> </TextField>
<TextField disabled={updateDisabled} label="原因" multiline minRows={2} value={page.bdStatusForm.reason} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} /> <TextField
disabled={updateDisabled}
label="原因"
multiline
minRows={2}
value={page.bdStatusForm.reason}
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })}
/>
</HostOrgActionModal> </HostOrgActionModal>
</section> </section>
); );
} }
function BDUser({ item }) {
return (
<HostOrgPerson
avatar={item.avatar}
displayUserId={item.displayUserId}
fallbackId={item.userId}
username={item.username}
/>
);
}
function ParentLeader({ item }) {
if (!item.parentLeaderUserId) {
return "-";
}
return (
<HostOrgPerson
avatar={item.parentLeaderAvatar}
displayUserId={item.parentLeaderDisplayUserId}
fallbackId={item.parentLeaderUserId}
username={item.parentLeaderUsername || "Leader"}
/>
);
}
function Creator({ item }) {
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
return "-";
}
return (
<HostOrgPerson
avatar={item.createdByAvatar}
displayUserId={item.createdByDisplayUserId}
fallbackId={item.createdByUserId}
username={item.createdByUsername || "创建人"}
/>
);
}

View File

@ -1,19 +1,25 @@
import Add from "@mui/icons-material/Add"; import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import Switch from "@mui/material/Switch"; import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import {
AdminFormAmountField,
AdminFormFieldGrid,
AdminFormReadOnlyField,
AdminFormSection,
} from "@/shared/ui/AdminFormDialog.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { coinSellerStatusFilters } from "@/features/host-org/constants.js"; import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSellersPage.js"; import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSellersPage.js";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
const baseColumns = [ const baseColumns = [
@ -21,13 +27,44 @@ const baseColumns = [
key: "seller", key: "seller",
label: "币商", label: "币商",
render: (item) => <SellerIdentity item={item} />, render: (item) => <SellerIdentity item={item} />,
width: "minmax(220px, 1.3fr)" width: "minmax(220px, 1.3fr)",
},
{
key: "regionId",
label: "区域",
render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(140px, 0.8fr)",
},
{
key: "contact",
label: "联系方式",
render: (item, _index, context) => <SellerContact item={item} page={context?.page} />,
width: "minmax(170px, 1fr)",
},
{
key: "merchantAssetType",
label: "资产类型",
render: (item) => item.merchantAssetType || "-",
width: "minmax(150px, 0.9fr)",
},
{
key: "merchantBalance",
label: "币商余额",
render: (item) => formatNumber(item.merchantBalance),
width: "minmax(120px, 0.8fr)",
},
{
key: "createdByUserId",
label: "创建人 ID",
render: (item) => item.createdByUserId || "-",
width: "minmax(110px, 0.7fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
}, },
{ key: "regionId", label: "区域", render: (item, _index, context) => regionName(item, context?.regionOptions), width: "minmax(140px, 0.8fr)" },
{ key: "merchantAssetType", label: "资产类型", render: (item) => item.merchantAssetType || "-", width: "minmax(150px, 0.9fr)" },
{ key: "merchantBalance", label: "币商余额", render: (item) => formatNumber(item.merchantBalance), width: "minmax(120px, 0.8fr)" },
{ key: "createdByUserId", label: "创建人 ID", render: (item) => item.createdByUserId || "-", width: "minmax(110px, 0.7fr)" },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
]; ];
export function HostCoinSellersPage() { export function HostCoinSellersPage() {
@ -42,18 +79,49 @@ export function HostCoinSellersPage() {
key: "status", key: "status",
label: "状态", label: "状态",
render: (item) => <SellerStatusSwitch item={item} page={page} />, render: (item) => <SellerStatusSwitch item={item} page={page} />,
width: "minmax(90px, 0.6fr)" width: "minmax(90px, 0.6fr)",
}, },
...baseColumns.slice(2), ...baseColumns.slice(2),
page.abilities.canUpdate page.abilities.canStockCredit
? { ? {
key: "actions", key: "actions",
label: "操作", label: "操作",
render: (item) => <SellerActions item={item} page={page} />, render: (item) => <SellerActions item={item} page={page} />,
width: "minmax(120px, 0.8fr)" width: "minmax(90px, 0.5fr)",
} }
: null : null,
].filter(Boolean); ]
.filter(Boolean)
.map((column) => {
if (column.key === "seller") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID、展示 ID、用户名",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: coinSellerStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
const regionFilter = createRegionColumnFilter({
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.changeRegionId,
});
const toolbarActions = page.abilities.canCreate const toolbarActions = page.abilities.canCreate
? [{ icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }] ? [{ icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }]
: []; : [];
@ -73,7 +141,8 @@ export function HostCoinSellersPage() {
statusOptions: coinSellerStatusFilters, statusOptions: coinSellerStatusFilters,
onQueryChange: page.changeQuery, onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId, onRegionIdChange: page.changeRegionId,
onStatusChange: page.changeStatus onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}} }}
/> />
@ -81,18 +150,20 @@ export function HostCoinSellersPage() {
<div className={styles.listBlock}> <div className={styles.listBlock}>
<HostOrgTable <HostOrgTable
columns={columns} columns={columns}
context={{ regionOptions: page.regionOptions }} context={{ page, regionOptions: page.regionOptions }}
items={items} items={items}
minWidth="1040px" minWidth="1040px"
regionFilter={createRegionColumnFilter({ regionFilter={regionFilter}
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.changeRegionId
})}
rowKey={(item) => item.userId} rowKey={(item) => item.userId}
/> />
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null} {total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div> </div>
</DataState> </DataState>
</div> </div>
@ -105,8 +176,20 @@ export function HostCoinSellersPage() {
onSubmit={page.submitCreateSeller} onSubmit={page.submitCreateSeller}
title="添加币商" title="添加币商"
> >
<TextField disabled={createDisabled} label="用户短 ID" required type="number" value={page.createForm.targetUserId} onChange={(event) => page.setCreateForm({ ...page.createForm, targetUserId: event.target.value })} /> <TextField
<TextField disabled={createDisabled} label="联系方式" value={page.createForm.reason} onChange={(event) => page.setCreateForm({ ...page.createForm, reason: event.target.value })} /> disabled={createDisabled}
label="用户短 ID"
required
type="number"
value={page.createForm.targetUserId}
onChange={(event) => page.setCreateForm({ ...page.createForm, targetUserId: event.target.value })}
/>
<TextField
disabled={createDisabled}
label="联系方式"
value={page.createForm.contact}
onChange={(event) => page.setCreateForm({ ...page.createForm, contact: event.target.value })}
/>
</HostOrgActionModal> </HostOrgActionModal>
<HostOrgActionModal <HostOrgActionModal
@ -115,14 +198,90 @@ export function HostCoinSellersPage() {
open={page.activeAction === "edit"} open={page.activeAction === "edit"}
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitEditSeller} onSubmit={page.submitEditSeller}
title="编辑币商" title="编辑联系方式"
> >
<TextField disabled label="用户 ID" required type="number" value={page.editForm.targetUserId} /> <TextField disabled label="用户 ID" required type="number" value={page.editForm.targetUserId} />
<TextField disabled={updateDisabled} label="状态" required select value={page.editForm.status} onChange={(event) => page.setEditForm({ ...page.editForm, status: event.target.value })}> <TextField
<MenuItem value="active">启用</MenuItem> disabled={updateDisabled}
<MenuItem value="disabled">停用</MenuItem> label="联系方式"
value={page.editForm.contact}
onChange={(event) => page.setEditForm({ ...page.editForm, contact: event.target.value })}
/>
</HostOrgActionModal>
<HostOrgActionModal
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
open={page.activeAction === "stock"}
size="standard"
submitLabel="确认入账"
onClose={page.closeAction}
onSubmit={page.submitStockCredit}
title="币商进货"
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<AdminFormReadOnlyField label="币商用户 ID" value={page.selectedSeller?.userId || ""} />
<AdminFormReadOnlyField
label="当前库存余额"
value={formatNumber(page.selectedSeller?.merchantBalance)}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="充值信息">
<TextField
disabled={!page.abilities.canStockCredit}
label="类型"
required
select
value={page.stockForm.type}
onChange={(event) =>
page.setStockForm({
...page.stockForm,
type: event.target.value,
rechargeAmount: "",
})
}
>
<MenuItem value="usdt_purchase">USDT进货</MenuItem>
<MenuItem value="coin_compensation">金币补偿</MenuItem>
</TextField> </TextField>
<TextField disabled={updateDisabled} label="联系方式" value={page.editForm.reason} onChange={(event) => page.setEditForm({ ...page.editForm, reason: event.target.value })} /> <AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<AdminFormAmountField
disabled={!page.abilities.canStockCredit}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="充值金币"
required
unit="金币"
value={page.stockForm.coinAmount}
onChange={(event) =>
page.setStockForm({ ...page.stockForm, coinAmount: event.target.value })
}
/>
{page.stockForm.type === "usdt_purchase" ? (
<AdminFormAmountField
disabled={!page.abilities.canStockCredit}
label="充值金额"
required
unit="USDT"
value={page.stockForm.rechargeAmount}
onChange={(event) =>
page.setStockForm({ ...page.stockForm, rechargeAmount: event.target.value })
}
/>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="备注信息">
<TextField
disabled={!page.abilities.canStockCredit}
label="原因"
multiline
minRows={2}
value={page.stockForm.reason}
onChange={(event) => page.setStockForm({ ...page.stockForm, reason: event.target.value })}
/>
</AdminFormSection>
</HostOrgActionModal> </HostOrgActionModal>
</section> </section>
); );
@ -134,7 +293,11 @@ function SellerIdentity({ item }) {
return ( return (
<div className={styles.sellerIdentity}> <div className={styles.sellerIdentity}>
{item.avatar ? <img alt="" className={styles.sellerAvatar} src={item.avatar} /> : <span className={styles.sellerAvatar}>{name.slice(0, 1).toUpperCase()}</span>} {item.avatar ? (
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
) : (
<span className={styles.sellerAvatar}>{name.slice(0, 1).toUpperCase()}</span>
)}
<span className={styles.sellerText}> <span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span> <span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span> <span className={styles.sellerMeta}>{meta}</span>
@ -154,17 +317,32 @@ function SellerStatusSwitch({ item, page }) {
); );
} }
function SellerActions({ item, page }) { function SellerContact({ item, page }) {
const deleting = page.loadingAction === `coin-seller-delete-${item.userId}`; const contact = String(item.contact || "").trim();
if (!page?.abilities.canUpdate) {
return <span className={contact ? styles.inlineValue : styles.contactPlaceholder}>{contact || "未填写"}</span>;
}
return (
<button className={styles.contactButton} type="button" onClick={() => page.openEditSeller(item)}>
{contact || "填写联系方式"}
</button>
);
}
function SellerActions({ item, page }) {
return ( return (
<AdminRowActions> <AdminRowActions>
<AdminActionIconButton label="编辑币商" onClick={() => page.openEditSeller(item)}> {page.abilities.canStockCredit ? (
<EditOutlined fontSize="small" /> <AdminActionIconButton
</AdminActionIconButton> disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-${item.userId}`}
<AdminActionIconButton disabled={deleting || item.status === "disabled"} label="删除币商" tone="danger" onClick={() => page.deleteSeller(item)}> label="加金币"
<DeleteOutlineOutlined fontSize="small" /> sx={coinCreditActionSx}
onClick={() => page.openStockCredit(item)}
>
<MonetizationOnOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
) : null}
</AdminRowActions> </AdminRowActions>
); );
} }
@ -180,3 +358,17 @@ function regionName(item, regionOptions = []) {
function formatNumber(value) { function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN"); return Number(value || 0).toLocaleString("zh-CN");
} }
const coinCreditActionSx = {
borderColor: "rgba(245, 158, 11, 0.45)",
backgroundColor: "rgba(251, 191, 36, 0.16)",
color: "#b45309",
"&:hover": {
borderColor: "rgba(245, 158, 11, 0.72)",
backgroundColor: "rgba(251, 191, 36, 0.24)",
color: "#92400e",
},
"& .MuiSvgIcon-root": {
color: "#f59e0b",
},
};

View File

@ -7,28 +7,38 @@ import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminRowActions, AdminRowActions,
AdminSearchBox
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { countryEnabledFilters } from "@/features/host-org/constants.js"; import { countryEnabledFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { useHostCountriesPage } from "@/features/host-org/hooks/useHostCountriesPage.js"; import { useHostCountriesPage } from "@/features/host-org/hooks/useHostCountriesPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
const baseCountryColumns = [ const baseCountryColumns = [
{ key: "countryId", label: "国家 ID", width: "minmax(90px, 0.6fr)" }, { key: "countryId", label: "国家 ID", width: "minmax(90px, 0.6fr)" },
{ key: "flag", label: "国旗", render: (item) => <span className={styles.flagCell}>{item.flag || "-"}</span>, width: "minmax(70px, 0.4fr)" }, {
key: "flag",
label: "国旗",
render: (item) => <span className={styles.flagCell}>{item.flag || "-"}</span>,
width: "minmax(70px, 0.4fr)",
},
{ key: "countryCode", label: "国家码", width: "minmax(90px, 0.7fr)" }, { key: "countryCode", label: "国家码", width: "minmax(90px, 0.7fr)" },
{ key: "countryDisplayName", label: "展示名称", width: "minmax(150px, 1fr)" }, { key: "countryDisplayName", label: "展示名称", width: "minmax(150px, 1fr)" },
{ key: "countryName", label: "国家名称", width: "minmax(150px, 1fr)" }, { key: "countryName", label: "国家名称", width: "minmax(150px, 1fr)" },
{ key: "phoneCountryCode", label: "电话区号", width: "minmax(90px, 0.7fr)" }, { key: "phoneCountryCode", label: "电话区号", width: "minmax(90px, 0.7fr)" },
{ key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" }, { key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } {
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
]; ];
export function HostCountriesPage() { export function HostCountriesPage() {
@ -43,10 +53,27 @@ export function HostCountriesPage() {
key: "enabled", key: "enabled",
label: "状态", label: "状态",
render: (item) => <CountryStatusSwitch item={item} page={page} />, render: (item) => <CountryStatusSwitch item={item} page={page} />,
width: "minmax(90px, 0.6fr)" filter: createOptionsColumnFilter({
options: countryEnabledFilters,
placeholder: "搜索状态",
value: page.enabledStatus,
onChange: page.changeEnabledStatus,
}),
width: "minmax(90px, 0.6fr)",
}, },
...baseCountryColumns.slice(6) ...baseCountryColumns.slice(6),
]; ].map((column) =>
column.key === "countryCode"
? {
...column,
filter: createTextColumnFilter({
placeholder: "搜索国家码、国家名称",
value: page.query,
onChange: page.changeQuery,
}),
}
: column,
);
const columns = page.abilities.canUpdate const columns = page.abilities.canUpdate
? [ ? [
...countryColumns, ...countryColumns,
@ -54,37 +81,28 @@ export function HostCountriesPage() {
key: "actions", key: "actions",
label: "操作", label: "操作",
render: (item) => <CountryActions item={item} page={page} />, render: (item) => <CountryActions item={item} page={page} />,
width: "minmax(104px, 0.7fr)" width: "minmax(104px, 0.7fr)",
} },
] ]
: countryColumns; : countryColumns;
return ( return (
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={( filters={
<> <AdminFilterResetButton disabled={!page.query && !page.enabledStatus} onClick={page.resetFilters} />
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索国家码、国家名称..." /> }
<AdminFilterSelect actions={
options={countryEnabledFilters} page.abilities.canCreate ? (
value={page.enabledStatus}
onChange={page.changeEnabledStatus}
/>
</>
)}
actions={page.abilities.canCreate ? (
<AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}> <AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}>
<Add fontSize="small" /> <Add fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
) : null} ) : null
}
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody> <AdminListBody>
<HostOrgTable <HostOrgTable columns={columns} items={items} rowKey={(item) => item.countryId} />
columns={columns}
items={items}
rowKey={(item) => item.countryId}
/>
<div className="pagination-bar"> <div className="pagination-bar">
<span>{total} </span> <span>{total} </span>
</div> </div>
@ -99,22 +117,77 @@ export function HostCountriesPage() {
onSubmit={page.submitCountry} onSubmit={page.submitCountry}
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"} title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
> >
<TextField disabled={page.activeAction === "edit" || createDisabled} label="国家码" required value={page.countryForm.countryCode} onChange={(event) => page.setCountryForm({ ...page.countryForm, countryCode: event.target.value })} /> <TextField
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="展示名称" required value={page.countryForm.countryDisplayName} onChange={(event) => page.setCountryForm({ ...page.countryForm, countryDisplayName: event.target.value })} /> disabled={page.activeAction === "edit" || createDisabled}
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="国家名称" required value={page.countryForm.countryName} onChange={(event) => page.setCountryForm({ ...page.countryForm, countryName: event.target.value })} /> label="国家码"
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="ISO Alpha-3" value={page.countryForm.isoAlpha3} onChange={(event) => page.setCountryForm({ ...page.countryForm, isoAlpha3: event.target.value })} /> required
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="ISO Numeric" value={page.countryForm.isoNumeric} onChange={(event) => page.setCountryForm({ ...page.countryForm, isoNumeric: event.target.value })} /> value={page.countryForm.countryCode}
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="电话区号" value={page.countryForm.phoneCountryCode} onChange={(event) => page.setCountryForm({ ...page.countryForm, phoneCountryCode: event.target.value })} /> onChange={(event) => page.setCountryForm({ ...page.countryForm, countryCode: event.target.value })}
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="国旗" value={page.countryForm.flag} onChange={(event) => page.setCountryForm({ ...page.countryForm, flag: event.target.value })} /> />
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="排序" type="number" value={page.countryForm.sortOrder} onChange={(event) => page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })} /> <TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="展示名称"
required
value={page.countryForm.countryDisplayName}
onChange={(event) =>
page.setCountryForm({ ...page.countryForm, countryDisplayName: event.target.value })
}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="国家名称"
required
value={page.countryForm.countryName}
onChange={(event) => page.setCountryForm({ ...page.countryForm, countryName: event.target.value })}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="ISO Alpha-3"
value={page.countryForm.isoAlpha3}
onChange={(event) => page.setCountryForm({ ...page.countryForm, isoAlpha3: event.target.value })}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="ISO Numeric"
value={page.countryForm.isoNumeric}
onChange={(event) => page.setCountryForm({ ...page.countryForm, isoNumeric: event.target.value })}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="电话区号"
value={page.countryForm.phoneCountryCode}
onChange={(event) =>
page.setCountryForm({ ...page.countryForm, phoneCountryCode: event.target.value })
}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="国旗"
value={page.countryForm.flag}
onChange={(event) => page.setCountryForm({ ...page.countryForm, flag: event.target.value })}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="排序"
type="number"
value={page.countryForm.sortOrder}
onChange={(event) => page.setCountryForm({ ...page.countryForm, sortOrder: event.target.value })}
/>
{page.activeAction === "create" ? ( {page.activeAction === "create" ? (
<FormControlLabel <FormControlLabel
control={<Switch checked={page.countryForm.enabled} disabled={createDisabled} onChange={(event) => page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })} />} control={
<Switch
checked={page.countryForm.enabled}
disabled={createDisabled}
onChange={(event) =>
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
}
/>
}
label={page.countryForm.enabled ? "启用" : "停用"} label={page.countryForm.enabled ? "启用" : "停用"}
/> />
) : null} ) : null}
</HostOrgActionModal> </HostOrgActionModal>
</AdminListPage> </AdminListPage>
); );
} }

View File

@ -1,29 +1,111 @@
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js"; import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js"; import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
const hostColumns = [ const hostColumns = [
{ key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" }, {
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" }, key: "user",
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" }, label: "用户",
{ key: "currentAgencyId", label: "当前 Agency ID", render: (item) => item.currentAgencyId || "-" }, render: (item) => <HostUser item={item} />,
{ key: "currentMembershipId", label: "当前 Membership ID", render: (item) => item.currentMembershipId || "-" }, width: "minmax(220px, 1.25fr)",
{ key: "source", label: "来源", render: (item) => hostSourceLabels[item.source] || item.source || "-", width: "minmax(150px, 1fr)" }, },
{ key: "firstBecameHostAtMs", label: "首次成为主播", render: (item) => formatMillis(item.firstBecameHostAtMs), width: "minmax(170px, 1fr)" }, {
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } key: "status",
label: "状态",
render: (item) => <HostOrgStatus value={item.status} />,
width: "minmax(90px, 0.7fr)",
},
{
key: "regionId",
label: "区域",
render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)",
},
{
key: "currentAgencyId",
label: "当前 Agency",
render: (item) => (
<HostOrgEntity id={item.currentAgencyId || ""} name={item.currentAgencyName || ""} prefix="Agency " />
),
width: "minmax(190px, 1fr)",
},
{
key: "currentMembershipId",
label: "当前 Membership",
render: (item) =>
item.currentMembershipId ? (
<HostOrgEntity id={item.currentMembershipId} name="Membership" prefix="" />
) : (
"-"
),
width: "minmax(170px, 0.9fr)",
},
{
key: "source",
label: "来源",
render: (item) => hostSourceLabels[item.source] || item.source || "-",
width: "minmax(150px, 1fr)",
},
{
key: "firstBecameHostAtMs",
label: "首次成为主播",
render: (item) => formatMillis(item.firstBecameHostAtMs),
width: "minmax(170px, 1fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
]; ];
export function HostHostsPage() { export function HostHostsPage() {
const page = useHostHostsPage(); const page = useHostHostsPage();
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const columns = hostColumns.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID、展示 ID、用户名、Agency",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "currentAgencyId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索 Agency ID",
value: page.agencyId,
onChange: page.changeAgencyId,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: hostStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return ( return (
<section className={styles.root}> <section className={styles.root}>
@ -35,8 +117,8 @@ export function HostHostsPage() {
label: "Agency ID", label: "Agency ID",
name: "agencyId", name: "agencyId",
onChange: page.changeAgencyId, onChange: page.changeAgencyId,
value: page.agencyId value: page.agencyId,
} },
], ],
loadingRegions: page.loadingRegions, loadingRegions: page.loadingRegions,
query: page.query, query: page.query,
@ -47,27 +129,42 @@ export function HostHostsPage() {
statusOptions: hostStatusFilters, statusOptions: hostStatusFilters,
onQueryChange: page.changeQuery, onQueryChange: page.changeQuery,
onRegionIdChange: page.changeRegionId, onRegionIdChange: page.changeRegionId,
onStatusChange: page.changeStatus onReset: page.resetFilters,
onStatusChange: page.changeStatus,
}} }}
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}> <div className={styles.listBlock}>
<HostOrgTable <HostOrgTable
columns={hostColumns} columns={columns}
context={{ regionOptions: page.regionOptions }}
items={items} items={items}
regionFilter={createRegionColumnFilter({ minWidth="1240px"
loading: page.loadingRegions,
options: page.regionOptions,
value: page.regionId,
onChange: page.changeRegionId
})}
rowKey={(item) => item.userId} rowKey={(item) => item.userId}
/> />
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null} {total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div> </div>
</DataState> </DataState>
</div> </div>
</section> </section>
); );
} }
function HostUser({ item }) {
return (
<HostOrgPerson
avatar={item.avatar}
displayUserId={item.displayUserId}
fallbackId={item.userId}
username={item.username}
/>
);
}

View File

@ -5,23 +5,33 @@ import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx"; import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
import { regionStatusFilters } from "@/features/host-org/constants.js"; import { regionStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { useHostRegionsPage } from "@/features/host-org/hooks/useHostRegionsPage.js"; import { useHostRegionsPage } from "@/features/host-org/hooks/useHostRegionsPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
const baseRegionColumns = [ const baseRegionColumns = [
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.6fr)" }, { key: "regionId", label: "区域 ID", width: "minmax(90px, 0.6fr)" },
{ key: "regionCode", label: "区域编码", width: "minmax(110px, 0.8fr)" }, { key: "regionCode", label: "区域编码", width: "minmax(110px, 0.8fr)" },
{ key: "name", label: "区域名称", width: "minmax(150px, 1fr)" }, { key: "name", label: "区域名称", width: "minmax(150px, 1fr)" },
{ key: "countries", label: "国家码", render: (item) => <CountryCodes countries={item.countries} />, width: "minmax(220px, 1.5fr)" }, {
key: "countries",
label: "国家码",
render: (item) => <CountryCodes countries={item.countries} />,
width: "minmax(220px, 1.5fr)",
},
{ key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" }, { key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" },
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" } {
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
]; ];
export function HostRegionsPage() { export function HostRegionsPage() {
@ -36,10 +46,27 @@ export function HostRegionsPage() {
key: "status", key: "status",
label: "状态", label: "状态",
render: (item) => <RegionStatusSwitch item={item} page={page} />, render: (item) => <RegionStatusSwitch item={item} page={page} />,
width: "minmax(90px, 0.6fr)" filter: createOptionsColumnFilter({
options: regionStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
width: "minmax(90px, 0.6fr)",
}, },
...baseRegionColumns.slice(3) ...baseRegionColumns.slice(3),
]; ].map((column) =>
column.key === "regionCode"
? {
...column,
filter: createTextColumnFilter({
placeholder: "搜索区域、国家码",
value: page.query,
onChange: page.changeQuery,
}),
}
: column,
);
const columns = page.abilities.canUpdate const columns = page.abilities.canUpdate
? [ ? [
...regionColumns, ...regionColumns,
@ -47,8 +74,8 @@ export function HostRegionsPage() {
key: "actions", key: "actions",
label: "操作", label: "操作",
render: (item) => <RegionActions item={item} page={page} />, render: (item) => <RegionActions item={item} page={page} />,
width: "minmax(80px, 0.5fr)" width: "minmax(80px, 0.5fr)",
} },
] ]
: regionColumns; : regionColumns;
return ( return (
@ -57,12 +84,9 @@ export function HostRegionsPage() {
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div className={styles.toolbarLeft}> <div className={styles.toolbarLeft}>
<section className={styles.filters}> <section className={styles.filters}>
<SearchBox className={styles.search} value={page.query} onChange={page.changeQuery} placeholder="搜索区域、国家码..." /> <AdminFilterResetButton
<StatusSelect disabled={!page.query && !page.status}
className={styles.statusSelect} onClick={page.resetFilters}
options={regionStatusFilters}
value={page.status}
onChange={page.changeStatus}
/> />
</section> </section>
</div> </div>
@ -82,11 +106,7 @@ export function HostRegionsPage() {
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}> <div className={styles.listBlock}>
<HostOrgTable <HostOrgTable columns={columns} items={items} rowKey={(item) => item.regionId} />
columns={columns}
items={items}
rowKey={(item) => item.regionId}
/>
<div className="pagination-bar"> <div className="pagination-bar">
<span>{total} </span> <span>{total} </span>
</div> </div>
@ -102,11 +122,31 @@ export function HostRegionsPage() {
onSubmit={page.submitRegion} onSubmit={page.submitRegion}
title={page.activeAction === "edit" ? "编辑区域" : "创建区域"} title={page.activeAction === "edit" ? "编辑区域" : "创建区域"}
> >
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="区域编码" required value={page.regionForm.regionCode} onChange={(event) => page.setRegionForm({ ...page.regionForm, regionCode: event.target.value })} /> <TextField
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="区域名称" required value={page.regionForm.name} onChange={(event) => page.setRegionForm({ ...page.regionForm, name: event.target.value })} /> disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
<TextField disabled={page.activeAction === "edit" ? updateDisabled : createDisabled} label="排序" type="number" value={page.regionForm.sortOrder} onChange={(event) => page.setRegionForm({ ...page.regionForm, sortOrder: event.target.value })} /> label="区域编码"
required
value={page.regionForm.regionCode}
onChange={(event) => page.setRegionForm({ ...page.regionForm, regionCode: event.target.value })}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="区域名称"
required
value={page.regionForm.name}
onChange={(event) => page.setRegionForm({ ...page.regionForm, name: event.target.value })}
/>
<TextField
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="排序"
type="number"
value={page.regionForm.sortOrder}
onChange={(event) => page.setRegionForm({ ...page.regionForm, sortOrder: event.target.value })}
/>
<CountryCodeSelect <CountryCodeSelect
disabled={page.isGlobalRegionForm || (page.activeAction === "edit" ? updateDisabled : createDisabled)} disabled={
page.isGlobalRegionForm || (page.activeAction === "edit" ? updateDisabled : createDisabled)
}
label={page.isGlobalRegionForm ? "国家码" : "国家码 *"} label={page.isGlobalRegionForm ? "国家码" : "国家码 *"}
labels={page.countryLabels} labels={page.countryLabels}
loading={page.loadingCountries} loading={page.loadingCountries}
@ -115,7 +155,6 @@ export function HostRegionsPage() {
onChange={(countries) => page.setRegionForm({ ...page.regionForm, countries })} onChange={(countries) => page.setRegionForm({ ...page.regionForm, countries })}
/> />
</HostOrgActionModal> </HostOrgActionModal>
</section> </section>
); );
} }
@ -191,6 +230,6 @@ const primaryActionSx = {
"&:hover": { "&:hover": {
borderColor: "var(--primary-strong)", borderColor: "var(--primary-strong)",
backgroundColor: "var(--primary-strong)", backgroundColor: "var(--primary-strong)",
color: "var(--active-contrast)" color: "var(--active-contrast)",
} },
}; };

View File

@ -8,7 +8,7 @@ export function useCountryAbilities() {
canCreate: can(PERMISSIONS.countryCreate), canCreate: can(PERMISSIONS.countryCreate),
canStatus: can(PERMISSIONS.countryStatus), canStatus: can(PERMISSIONS.countryStatus),
canUpdate: can(PERMISSIONS.countryUpdate), canUpdate: can(PERMISSIONS.countryUpdate),
canView: can(PERMISSIONS.countryView) canView: can(PERMISSIONS.countryView),
}; };
} }
@ -19,7 +19,7 @@ export function useRegionAbilities() {
canCreate: can(PERMISSIONS.regionCreate), canCreate: can(PERMISSIONS.regionCreate),
canStatus: can(PERMISSIONS.regionStatus), canStatus: can(PERMISSIONS.regionStatus),
canUpdate: can(PERMISSIONS.regionUpdate), canUpdate: can(PERMISSIONS.regionUpdate),
canView: can(PERMISSIONS.regionView) canView: can(PERMISSIONS.regionView),
}; };
} }
@ -29,7 +29,7 @@ export function useAgencyAbilities() {
return { return {
canCreate: can(PERMISSIONS.agencyCreate), canCreate: can(PERMISSIONS.agencyCreate),
canStatus: can(PERMISSIONS.agencyStatus), canStatus: can(PERMISSIONS.agencyStatus),
canView: can(PERMISSIONS.agencyView) canView: can(PERMISSIONS.agencyView),
}; };
} }
@ -37,7 +37,7 @@ export function useHostAbilities() {
const { can } = useAuth(); const { can } = useAuth();
return { return {
canView: can(PERMISSIONS.hostView) canView: can(PERMISSIONS.hostView),
}; };
} }
@ -47,7 +47,7 @@ export function useBDAbilities() {
return { return {
canCreate: can(PERMISSIONS.bdCreate), canCreate: can(PERMISSIONS.bdCreate),
canUpdate: can(PERMISSIONS.bdUpdate), canUpdate: can(PERMISSIONS.bdUpdate),
canView: can(PERMISSIONS.bdView) canView: can(PERMISSIONS.bdView),
}; };
} }
@ -56,7 +56,8 @@ export function useCoinSellerAbilities() {
return { return {
canCreate: can(PERMISSIONS.coinSellerCreate), canCreate: can(PERMISSIONS.coinSellerCreate),
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
canUpdate: can(PERMISSIONS.coinSellerUpdate), canUpdate: can(PERMISSIONS.coinSellerUpdate),
canView: can(PERMISSIONS.coinSellerView) canView: can(PERMISSIONS.coinSellerView),
}; };
} }

View File

@ -2,12 +2,13 @@ import { z } from "zod";
const commandBaseSchema = z.object({ const commandBaseSchema = z.object({
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"), commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default("") reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default(""),
}); });
const commandContactSchema = z.object({ const commandContactSchema = z.object({
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"), commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
reason: z.string().trim().max(200, "联系方式不能超过 200 个字符").optional().default("") contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default(""),
}); });
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID"); const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
@ -16,9 +17,7 @@ const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").d
const optionalTextSchema = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); const optionalTextSchema = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
const codeSchema = (label: string) => z.string().trim().min(1, `请输入${label}`).max(40, `${label}不能超过 40 个字符`); const codeSchema = (label: string) => z.string().trim().min(1, `请输入${label}`).max(40, `${label}不能超过 40 个字符`);
const countryCodesSchema = z.array(z.string()).transform((values, ctx) => { const countryCodesSchema = z.array(z.string()).transform((values, ctx) => {
const countries = values const countries = values.map((item) => item.trim().toUpperCase()).filter(Boolean);
.map((item) => item.trim().toUpperCase())
.filter(Boolean);
if (new Set(countries).size !== countries.length) { if (new Set(countries).size !== countries.length) {
ctx.addIssue({ code: "custom", message: "国家码不能重复" }); ctx.addIssue({ code: "custom", message: "国家码不能重复" });
} }
@ -31,19 +30,21 @@ const retiredRegionCodes = new Set([
"MICRONESIA", "MICRONESIA",
"POLYNESIA", "POLYNESIA",
"SOUTHERN AFRICA", "SOUTHERN AFRICA",
"UNSPECIFIED" "UNSPECIFIED",
]); ]);
const requiredCountryCodesSchema = countryCodesSchema.superRefine((countries, ctx) => { const requiredCountryCodesSchema = countryCodesSchema.superRefine((countries, ctx) => {
if (!countries.length) { if (!countries.length) {
ctx.addIssue({ code: "custom", message: "请选择国家码" }); ctx.addIssue({ code: "custom", message: "请选择国家码" });
} }
}); });
const regionBaseSchema = z.object({ const regionBaseSchema = z
.object({
countries: countryCodesSchema, countries: countryCodesSchema,
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"), name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()), regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
sortOrder: sortOrderSchema sortOrder: sortOrderSchema,
}).superRefine((value, ctx) => { })
.superRefine((value, ctx) => {
if (retiredRegionCodes.has(value.regionCode)) { if (retiredRegionCodes.has(value.regionCode)) {
ctx.addIssue({ code: "custom", message: "该区域已移除", path: ["regionCode"] }); ctx.addIssue({ code: "custom", message: "该区域已移除", path: ["regionCode"] });
} }
@ -53,52 +54,76 @@ const regionBaseSchema = z.object({
if (value.regionCode === "GLOBAL" && value.countries.length) { if (value.regionCode === "GLOBAL" && value.countries.length) {
ctx.addIssue({ code: "custom", message: "GLOBAL 区域不需要选择国家", path: ["countries"] }); ctx.addIssue({ code: "custom", message: "GLOBAL 区域不需要选择国家", path: ["countries"] });
} }
}); });
export const createBDLeaderSchema = commandContactSchema.extend({ export const createBDLeaderSchema = commandContactSchema.extend({
regionId: regionIdSchema, regionId: regionIdSchema,
targetUserId: userIdSchema targetUserId: userIdSchema,
}); });
export const createBDSchema = commandBaseSchema.extend({ export const createBDSchema = commandBaseSchema.extend({
parentLeaderUserId: userIdSchema, parentLeaderUserId: userIdSchema,
targetUserId: userIdSchema targetUserId: userIdSchema,
}); });
export const bdStatusSchema = commandBaseSchema.extend({ export const bdStatusSchema = commandBaseSchema.extend({
status: z.string().trim().min(1, "请输入状态").max(40, "状态不能超过 40 个字符"), status: z.string().trim().min(1, "请输入状态").max(40, "状态不能超过 40 个字符"),
targetType: z.enum(["bd", "leader"]), targetType: z.enum(["bd", "leader"]),
targetUserId: userIdSchema targetUserId: userIdSchema,
}); });
export const createCoinSellerSchema = commandContactSchema.extend({ export const createCoinSellerSchema = commandContactSchema.extend({
targetUserId: userIdSchema targetUserId: userIdSchema,
}); });
export const coinSellerStatusSchema = commandContactSchema.extend({ export const coinSellerStatusSchema = commandContactSchema.extend({
status: z.enum(["active", "disabled"]), status: z.enum(["active", "disabled"]),
targetUserId: userIdSchema targetUserId: userIdSchema,
}); });
export const coinSellerStockCreditSchema = z
.object({
coinAmount: z.coerce.number().int().positive("请输入充值金币"),
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
reason: z.string().trim().max(512, "原因不能超过 512 个字符").optional().default(""),
rechargeAmount: z.string().trim().max(32, "充值金额不能超过 32 个字符").optional().default(""),
type: z.enum(["usdt_purchase", "coin_compensation"]),
})
.superRefine((value, ctx) => {
if (value.type === "usdt_purchase") {
if (!/^(0|[1-9]\d*)(\.\d{1,6})?$/.test(value.rechargeAmount) || Number(value.rechargeAmount) <= 0) {
ctx.addIssue({ code: "custom", message: "请输入最多 6 位小数的 USDT 金额", path: ["rechargeAmount"] });
}
return;
}
if (value.rechargeAmount) {
ctx.addIssue({ code: "custom", message: "金币补偿不能提交充值金额", path: ["rechargeAmount"] });
}
});
export const createAgencySchema = commandBaseSchema.extend({ export const createAgencySchema = commandBaseSchema.extend({
joinEnabled: z.boolean().default(true), joinEnabled: z.boolean().default(true),
maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0), maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0),
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"), name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
ownerUserId: userIdSchema, ownerUserId: userIdSchema,
parentBdUserId: userIdSchema parentBdUserId: userIdSchema,
}); });
export const agencyCloseSchema = commandBaseSchema.extend({ export const agencyCloseSchema = commandBaseSchema.extend({
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID") agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
}); });
export const agencyJoinEnabledSchema = commandBaseSchema.extend({ export const agencyJoinEnabledSchema = commandBaseSchema.extend({
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"), agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
joinEnabled: z.boolean().default(true) joinEnabled: z.boolean().default(true),
}); });
export const countryCreateSchema = z.object({ export const countryCreateSchema = z.object({
countryCode: z.string().trim().regex(/^[A-Za-z]{2,3}$/, "国家码必须是 2-3 位字母").transform((value) => value.toUpperCase()), countryCode: z
.string()
.trim()
.regex(/^[A-Za-z]{2,3}$/, "国家码必须是 2-3 位字母")
.transform((value) => value.toUpperCase()),
countryDisplayName: z.string().trim().min(1, "请输入展示名称").max(80, "展示名称不能超过 80 个字符"), countryDisplayName: z.string().trim().min(1, "请输入展示名称").max(80, "展示名称不能超过 80 个字符"),
countryName: z.string().trim().min(1, "请输入国家名称").max(80, "国家名称不能超过 80 个字符"), countryName: z.string().trim().min(1, "请输入国家名称").max(80, "国家名称不能超过 80 个字符"),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
@ -106,12 +131,12 @@ export const countryCreateSchema = z.object({
isoAlpha3: optionalTextSchema(8, "ISO Alpha-3 不能超过 8 个字符").transform((value) => value.toUpperCase()), isoAlpha3: optionalTextSchema(8, "ISO Alpha-3 不能超过 8 个字符").transform((value) => value.toUpperCase()),
isoNumeric: optionalTextSchema(8, "ISO Numeric 不能超过 8 个字符"), isoNumeric: optionalTextSchema(8, "ISO Numeric 不能超过 8 个字符"),
phoneCountryCode: optionalTextSchema(16, "电话区号不能超过 16 个字符"), phoneCountryCode: optionalTextSchema(16, "电话区号不能超过 16 个字符"),
sortOrder: sortOrderSchema sortOrder: sortOrderSchema,
}); });
export const countryUpdateSchema = countryCreateSchema.omit({ export const countryUpdateSchema = countryCreateSchema.omit({
countryCode: true, countryCode: true,
enabled: true enabled: true,
}); });
export const regionCreateSchema = regionBaseSchema; export const regionCreateSchema = regionBaseSchema;
@ -119,7 +144,7 @@ export const regionCreateSchema = regionBaseSchema;
export const regionUpdateSchema = regionBaseSchema; export const regionUpdateSchema = regionBaseSchema;
export const regionCountriesSchema = z.object({ export const regionCountriesSchema = z.object({
countries: requiredCountryCodesSchema countries: requiredCountryCodesSchema,
}); });
export type CreateBDLeaderForm = z.infer<typeof createBDLeaderSchema>; export type CreateBDLeaderForm = z.infer<typeof createBDLeaderSchema>;
@ -127,6 +152,7 @@ export type CreateBDForm = z.infer<typeof createBDSchema>;
export type BDStatusForm = z.infer<typeof bdStatusSchema>; export type BDStatusForm = z.infer<typeof bdStatusSchema>;
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>; export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>; export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
export type CreateAgencyForm = z.infer<typeof createAgencySchema>; export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>; export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>; export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;

View File

@ -1,9 +1,9 @@
import { SearchBox } from "@/shared/ui/SearchBox.jsx"; import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
export function LogFilters({ onQueryChange, query }) { export function LogFilters({ onReset, query }) {
return ( return (
<section className="filters-panel"> <section className="filters-panel">
<SearchBox value={query} onChange={onQueryChange} placeholder="搜索用户、IP、动作、资源..." /> <AdminFilterResetButton disabled={!query} onClick={onReset} />
</section> </section>
); );
} }

View File

@ -1,10 +1,20 @@
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
export function LogTable({ data, isLogin }) { export function LogTable({ data, isLogin, onQueryChange, query }) {
const items = data.items || []; const items = data.items || [];
const columns = isLogin const columns = isLogin
? [ ? [
{ key: "username", label: "用户", width: "minmax(140px, 1fr)" }, {
key: "username",
label: "用户",
width: "minmax(140px, 1fr)",
filter: createTextColumnFilter({
placeholder: "搜索用户、IP、动作、资源",
value: query,
onChange: onQueryChange
})
},
{ key: "ip", label: "IP", width: "minmax(140px, 1fr)", render: (item) => <span className="muted">{item.ip || "-"}</span> }, { key: "ip", label: "IP", width: "minmax(140px, 1fr)", render: (item) => <span className="muted">{item.ip || "-"}</span> },
{ key: "status", label: "状态", width: "110px" }, { key: "status", label: "状态", width: "110px" },
{ {
@ -21,7 +31,16 @@ export function LogTable({ data, isLogin }) {
} }
] ]
: [ : [
{ key: "username", label: "用户", width: "minmax(140px, 1fr)" }, {
key: "username",
label: "用户",
width: "minmax(140px, 1fr)",
filter: createTextColumnFilter({
placeholder: "搜索用户、IP、动作、资源",
value: query,
onChange: onQueryChange
})
},
{ key: "action", label: "动作", width: "minmax(120px, 1fr)" }, { key: "action", label: "动作", width: "minmax(120px, 1fr)" },
{ {
key: "resource", key: "resource",

View File

@ -24,7 +24,7 @@ export function useLogsPage(type) {
errorMessage: "加载日志失败", errorMessage: "加载日志失败",
initialData: emptyData, initialData: emptyData,
keepPreviousData: true, keepPreviousData: true,
queryKey: ["logs", type, requestQuery] queryKey: ["logs", type, requestQuery],
}); });
const changeQuery = (value) => { const changeQuery = (value) => {
@ -32,6 +32,11 @@ export function useLogsPage(type) {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setPage(1);
};
const downloadLogs = () => { const downloadLogs = () => {
const exporter = isLogin ? exportLoginLogs : exportOperationLogs; const exporter = isLogin ? exportLoginLogs : exportOperationLogs;
const filename = isLogin ? "hyapp-login-logs.csv" : "hyapp-operation-logs.csv"; const filename = isLogin ? "hyapp-login-logs.csv" : "hyapp-operation-logs.csv";
@ -49,6 +54,7 @@ export function useLogsPage(type) {
page, page,
query, query,
reload, reload,
setPage resetFilters,
setPage,
}; };
} }

View File

@ -11,11 +11,16 @@ export function LogsPage({ type }) {
return ( return (
<> <>
<LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} /> <LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} />
<LogFilters query={page.query} onQueryChange={page.changeQuery} /> <LogFilters query={page.query} onReset={page.resetFilters} />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<LogTable data={page.data} isLogin={page.isLogin} /> <LogTable data={page.data} isLogin={page.isLogin} query={page.query} onQueryChange={page.changeQuery} />
<PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={page.data.total || 0} onPageChange={page.setPage} /> <PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={page.data.total || 0}
onPageChange={page.setPage}
/>
</DataState> </DataState>
</> </>
); );

View File

@ -10,8 +10,9 @@ import {
adminListClasses adminListClasses
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
export function MenuTable({ abilities, flatMenus, onEdit, onReorder, onRemove, onToggleVisible }) { export function MenuTable({ abilities, flatMenus, onEdit, onQueryChange, onReorder, onRemove, onToggleVisible, query }) {
const [draggingMenu, setDraggingMenu] = useState(null); const [draggingMenu, setDraggingMenu] = useState(null);
const [dragOverId, setDragOverId] = useState(null); const [dragOverId, setDragOverId] = useState(null);
const columns = [ const columns = [
@ -25,6 +26,11 @@ export function MenuTable({ abilities, flatMenus, onEdit, onReorder, onRemove, o
key: "label", key: "label",
label: "菜单", label: "菜单",
width: "minmax(180px, 1.2fr)", width: "minmax(180px, 1.2fr)",
filter: createTextColumnFilter({
placeholder: "搜索菜单、权限码",
value: query,
onChange: onQueryChange
}),
render: (menu) => <span style={{ paddingLeft: menu.depth * 18 }}>{menu.label}</span> render: (menu) => <span style={{ paddingLeft: menu.depth * 18 }}>{menu.label}</span>
}, },
{ key: "path", label: "路由", width: "minmax(180px, 1.2fr)", render: (menu) => <span className="muted">{menu.path || "-"}</span> }, { key: "path", label: "路由", width: "minmax(180px, 1.2fr)", render: (menu) => <span className="muted">{menu.path || "-"}</span> },

View File

@ -3,14 +3,24 @@ import EditOutlined from "@mui/icons-material/EditOutlined";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { kindLabel } from "@/features/menus/constants.js"; import { kindLabel } from "@/features/menus/constants.js";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
export function PermissionTable({ abilities, onEdit, onRemove, permissions }) { export function PermissionTable({ abilities, onEdit, onQueryChange, onRemove, permissions, query }) {
if (!abilities.canLoadPermissions) { if (!abilities.canLoadPermissions) {
return null; return null;
} }
const columns = [ const columns = [
{ key: "name", label: "权限", width: "minmax(160px, 1fr)" }, {
key: "name",
label: "权限",
width: "minmax(160px, 1fr)",
filter: createTextColumnFilter({
placeholder: "搜索权限、权限码",
value: query,
onChange: onQueryChange
})
},
{ key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (permission) => <span className="muted">{permission.code}</span> }, { key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (permission) => <span className="muted">{permission.code}</span> },
{ key: "kind", label: "类型", width: "110px", render: (permission) => kindLabel(permission.kind) }, { key: "kind", label: "类型", width: "110px", render: (permission) => kindLabel(permission.kind) },
{ {

View File

@ -14,7 +14,7 @@ import {
syncPermissions, syncPermissions,
updateMenu, updateMenu,
updateMenuVisible, updateMenuVisible,
updatePermission updatePermission,
} from "@/features/menus/api"; } from "@/features/menus/api";
import { useMenuAbilities } from "@/features/menus/permissions.js"; import { useMenuAbilities } from "@/features/menus/permissions.js";
import { menuFormSchema, permissionFormSchema } from "@/features/menus/schema"; import { menuFormSchema, permissionFormSchema } from "@/features/menus/schema";
@ -27,7 +27,7 @@ const emptyMenu = {
path: "", path: "",
permissionCode: "", permissionCode: "",
sort: 0, sort: 0,
visible: true visible: true,
}; };
const emptyPermission = { code: "", description: "", kind: "button", name: "" }; const emptyPermission = { code: "", description: "", kind: "button", name: "" };
@ -55,7 +55,7 @@ export function useMenusPage() {
errorMessage: "加载菜单权限失败", errorMessage: "加载菜单权限失败",
initialData: emptyData, initialData: emptyData,
keepPreviousData: true, keepPreviousData: true,
queryKey: ["menus", abilities.canLoadPermissions] queryKey: ["menus", abilities.canLoadPermissions],
}); });
const flatMenus = useMemo(() => flattenMenus(data.menus || []), [data.menus]); const flatMenus = useMemo(() => flattenMenus(data.menus || []), [data.menus]);
@ -65,7 +65,11 @@ export function useMenusPage() {
return flatMenus; return flatMenus;
} }
return flatMenus.filter((menu) => { return flatMenus.filter((menu) => {
return [menu.label, menu.code, menu.path, menu.permissionCode].some((value) => String(value || "").toLowerCase().includes(keyword)); return [menu.label, menu.code, menu.path, menu.permissionCode].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword),
);
}); });
}, [flatMenus, query]); }, [flatMenus, query]);
const filteredPermissions = useMemo(() => { const filteredPermissions = useMemo(() => {
@ -75,7 +79,11 @@ export function useMenusPage() {
return items; return items;
} }
return items.filter((permission) => { return items.filter((permission) => {
return [permission.name, permission.code, permission.kind, permission.description].some((value) => String(value || "").toLowerCase().includes(keyword)); return [permission.name, permission.code, permission.kind, permission.description].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword),
);
}); });
}, [data.permissions, query]); }, [data.permissions, query]);
@ -83,6 +91,10 @@ export function useMenusPage() {
setQuery(value); setQuery(value);
}; };
const resetFilters = () => {
setQuery("");
};
const openCreateMenu = () => { const openCreateMenu = () => {
setEditingMenu(null); setEditingMenu(null);
setMenuForm(emptyMenu); setMenuForm(emptyMenu);
@ -99,7 +111,7 @@ export function useMenusPage() {
path: menu.path || "", path: menu.path || "",
permissionCode: menu.permissionCode || "", permissionCode: menu.permissionCode || "",
sort: menu.sort || 0, sort: menu.sort || 0,
visible: Boolean(menu.visible) visible: Boolean(menu.visible),
}); });
setMenuDrawerOpen(true); setMenuDrawerOpen(true);
}; };
@ -116,7 +128,7 @@ export function useMenusPage() {
code: permission.code, code: permission.code,
description: permission.description || "", description: permission.description || "",
kind: permission.kind, kind: permission.kind,
name: permission.name name: permission.name,
}); });
setPermissionDrawerOpen(true); setPermissionDrawerOpen(true);
}; };
@ -158,7 +170,12 @@ export function useMenusPage() {
}; };
const removeMenu = async (menu) => { const removeMenu = async (menu) => {
const ok = await confirm({ confirmText: "删除", message: `${menu.label} 删除前需要先删除子菜单。`, title: "删除菜单", tone: "danger" }); const ok = await confirm({
confirmText: "删除",
message: `${menu.label} 删除前需要先删除子菜单。`,
title: "删除菜单",
tone: "danger",
});
if (!ok) { if (!ok) {
return; return;
} }
@ -172,7 +189,12 @@ export function useMenusPage() {
}; };
const removePermission = async (permission) => { const removePermission = async (permission) => {
const ok = await confirm({ confirmText: "删除", message: `${permission.name} 删除前不能被角色或菜单引用。`, title: "删除权限", tone: "danger" }); const ok = await confirm({
confirmText: "删除",
message: `${permission.name} 删除前不能被角色或菜单引用。`,
title: "删除权限",
tone: "danger",
});
if (!ok) { if (!ok) {
return; return;
} }
@ -256,6 +278,7 @@ export function useMenusPage() {
permissions: filteredPermissions, permissions: filteredPermissions,
query, query,
reload, reload,
resetFilters,
removeMenu, removeMenu,
removePermission, removePermission,
reorderMenu, reorderMenu,
@ -264,13 +287,10 @@ export function useMenusPage() {
submitMenu, submitMenu,
submitPermission, submitPermission,
syncDefaultPermissions, syncDefaultPermissions,
toggleVisible toggleVisible,
}; };
} }
function flattenMenus(menus, depth = 0) { function flattenMenus(menus, depth = 0) {
return menus.flatMap((menu) => [ return menus.flatMap((menu) => [{ ...menu, depth }, ...flattenMenus(menu.children || [], depth + 1)]);
{ ...menu, depth },
...flattenMenus(menu.children || [], depth + 1)
]);
} }

View File

@ -3,10 +3,10 @@ import SyncOutlined from "@mui/icons-material/SyncOutlined";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminSearchBox
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { MenuFormDrawer } from "@/features/menus/components/MenuFormDrawer.jsx"; import { MenuFormDrawer } from "@/features/menus/components/MenuFormDrawer.jsx";
import { MenuTable } from "@/features/menus/components/MenuTable.jsx"; import { MenuTable } from "@/features/menus/components/MenuTable.jsx";
@ -21,8 +21,10 @@ export function MenuManagementPage() {
<> <>
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索菜单、权限码..." />} filters={
actions={( <AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
}
actions={
<> <>
{page.abilities.canSyncPermission ? ( {page.abilities.canSyncPermission ? (
<AdminActionIconButton label="同步权限" onClick={page.syncDefaultPermissions}> <AdminActionIconButton label="同步权限" onClick={page.syncDefaultPermissions}>
@ -40,7 +42,7 @@ export function MenuManagementPage() {
</AdminActionIconButton> </AdminActionIconButton>
) : null} ) : null}
</> </>
)} }
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -49,14 +51,18 @@ export function MenuManagementPage() {
<MenuTable <MenuTable
abilities={page.abilities} abilities={page.abilities}
flatMenus={page.flatMenus} flatMenus={page.flatMenus}
query={page.query}
onEdit={page.openEditMenu} onEdit={page.openEditMenu}
onQueryChange={page.changeQuery}
onReorder={page.reorderMenu} onReorder={page.reorderMenu}
onRemove={page.removeMenu} onRemove={page.removeMenu}
onToggleVisible={page.toggleVisible} onToggleVisible={page.toggleVisible}
/> />
<PermissionTable <PermissionTable
abilities={page.abilities} abilities={page.abilities}
query={page.query}
onEdit={page.openEditPermission} onEdit={page.openEditPermission}
onQueryChange={page.changeQuery}
onRemove={page.removePermission} onRemove={page.removePermission}
permissions={page.permissions} permissions={page.permissions}
/> />

View File

@ -1,28 +1,26 @@
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { listRechargeBills } from "@/features/payment/api"; import { listRechargeBills } from "@/features/payment/api";
import { import {
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminSearchBox,
adminListClasses
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
const pageSize = 20; const pageSize = 20;
const statusOptions = [ const statusOptions = [
["", "全部状态"], ["", "全部状态"],
["succeeded", "成功"] ["succeeded", "成功"],
]; ];
const rechargeTypeOptions = [ const rechargeTypeOptions = [
["", "全部途径"], ["", "全部途径"],
["coin_seller_transfer", "币商充值"] ["coin_seller_transfer", "币商充值"],
]; ];
const columns = [ const columns = [
@ -30,25 +28,30 @@ const columns = [
key: "bill", key: "bill",
label: "账单", label: "账单",
width: "minmax(240px, 1.4fr)", width: "minmax(240px, 1.4fr)",
render: (bill) => ( render: (bill) => <Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />,
<Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />
)
}, },
{ {
key: "users", key: "userId",
label: "用户 / 来源", label: "用户 ID",
width: "minmax(180px, 0.9fr)", width: "minmax(120px, 0.65fr)",
render: (bill) => ( render: (bill) => bill.userId || "-",
<Stack primary={`用户 ${bill.userId || "-"}`} secondary={bill.sellerUserId ? `币商 ${bill.sellerUserId}` : "-"} /> },
) {
key: "sellerUserId",
label: "币商 ID",
width: "minmax(120px, 0.65fr)",
render: (bill) => bill.sellerUserId || "-",
}, },
{ {
key: "amount", key: "amount",
label: "金币 / 金额", label: "金币 / 金额",
width: "minmax(150px, 0.75fr)", width: "minmax(150px, 0.75fr)",
render: (bill) => ( render: (bill) => (
<Stack primary={formatNumber(bill.coinAmount)} secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)} /> <Stack
) primary={formatNumber(bill.coinAmount)}
secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)}
/>
),
}, },
{ {
key: "policy", key: "policy",
@ -59,28 +62,37 @@ const columns = [
primary={bill.policyVersion || "-"} primary={bill.policyVersion || "-"}
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`} secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
/> />
) ),
}, },
{ {
key: "region", key: "region",
label: "区域", label: "区域",
width: "minmax(110px, 0.55fr)", width: "minmax(110px, 0.55fr)",
render: (bill) => <Stack primary={bill.targetRegionId ? `区域 ${bill.targetRegionId}` : "-"} secondary={bill.sellerRegionId ? `来源 ${bill.sellerRegionId}` : "-"} /> render: (bill) => (
<Stack
primary={bill.targetRegionId ? `区域 ${bill.targetRegionId}` : "-"}
secondary={bill.sellerRegionId ? `来源 ${bill.sellerRegionId}` : "-"}
/>
),
}, },
{ {
key: "state", key: "rechargeType",
label: "途径 / 状态", label: "充值途径",
width: "minmax(150px, 0.75fr)", width: "minmax(130px, 0.7fr)",
render: (bill) => ( render: (bill) => rechargeTypeLabel(bill.rechargeType),
<Stack primary={rechargeTypeLabel(bill.rechargeType)} secondary={<StatusBadge status={bill.status} />} /> },
) {
key: "status",
label: "状态",
width: "minmax(100px, 0.55fr)",
render: (bill) => <StatusBadge status={bill.status} />,
}, },
{ {
key: "time", key: "time",
label: "创建时间", label: "创建时间",
width: "minmax(160px, 0.8fr)", width: "minmax(160px, 0.8fr)",
render: (bill) => formatMillis(bill.createdAtMs) render: (bill) => formatMillis(bill.createdAtMs),
} },
]; ];
export function PaymentBillListPage() { export function PaymentBillListPage() {
@ -97,9 +109,9 @@ export function PaymentBillListPage() {
recharge_type: rechargeType, recharge_type: rechargeType,
seller_user_id: sellerUserId, seller_user_id: sellerUserId,
status, status,
user_id: userId user_id: userId,
}), }),
[keyword, rechargeType, sellerUserId, status, userId] [keyword, rechargeType, sellerUserId, status, userId],
); );
const query = usePaginatedQuery({ const query = usePaginatedQuery({
errorMessage: "获取账单列表失败", errorMessage: "获取账单列表失败",
@ -107,7 +119,7 @@ export function PaymentBillListPage() {
filters, filters,
page, page,
pageSize, pageSize,
queryKey: ["payment-bills", filters, page] queryKey: ["payment-bills", filters, page],
}); });
const data = query.data || { items: [], total: 0, page, pageSize }; const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || []; const items = data.items || [];
@ -118,35 +130,96 @@ export function PaymentBillListPage() {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setKeyword("");
setStatus("");
setRechargeType("");
setUserId("");
setSellerUserId("");
setPage(1);
};
const tableColumns = columns.map((column) => {
if (column.key === "bill") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索账单号、命令号、外部单号",
value: keyword,
onChange: resetPage(setKeyword),
}),
};
}
if (column.key === "userId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID",
value: userId,
onChange: (value) => resetPage(setUserId)(digitsOnly(value)),
}),
};
}
if (column.key === "sellerUserId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索币商 ID",
value: sellerUserId,
onChange: (value) => resetPage(setSellerUserId)(digitsOnly(value)),
}),
};
}
if (column.key === "rechargeType") {
return {
...column,
filter: createOptionsColumnFilter({
options: rechargeTypeOptions,
placeholder: "搜索充值途径",
value: rechargeType,
onChange: resetPage(setRechargeType),
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: status,
onChange: resetPage(setStatus),
}),
};
}
return column;
});
return ( return (
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={ filters={
<> <AdminFilterResetButton
<AdminSearchBox value={keyword} onChange={resetPage(setKeyword)} placeholder="搜索账单号、命令号、外部单号..." /> disabled={!keyword && !status && !rechargeType && !userId && !sellerUserId}
<TextField onClick={resetFilters}
className={adminListClasses.statusSelect}
label="用户 ID"
size="small"
value={userId}
onChange={(event) => resetPage(setUserId)(digitsOnly(event.target.value))}
/> />
<TextField
className={adminListClasses.statusSelect}
label="币商 ID"
size="small"
value={sellerUserId}
onChange={(event) => resetPage(setSellerUserId)(digitsOnly(event.target.value))}
/>
<AdminFilterSelect label="充值途径" options={rechargeTypeOptions} value={rechargeType} onChange={resetPage(setRechargeType)} />
<AdminFilterSelect label="状态" options={statusOptions} value={status} onChange={resetPage(setStatus)} />
</>
} }
/> />
<DataState error={query.error} loading={query.loading} onRetry={query.reload}> <DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody> <AdminListBody>
<DataTable columns={columns} items={items} minWidth="1180px" rowKey={(bill) => bill.transactionId} /> <DataTable
{total > 0 ? <PaginationBar page={page} pageSize={data.pageSize || pageSize} total={total} onPageChange={setPage} /> : null} columns={tableColumns}
items={items}
minWidth="1260px"
rowKey={(bill) => bill.transactionId}
/>
{total > 0 ? (
<PaginationBar
page={page}
pageSize={data.pageSize || pageSize}
total={total}
onPageChange={setPage}
/>
) : null}
</AdminListBody> </AdminListBody>
</DataState> </DataState>
</AdminListPage> </AdminListPage>
@ -199,7 +272,7 @@ function formatMoney(value, currency = "USD") {
} }
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", { return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
})}`; })}`;
} }

View File

@ -221,6 +221,13 @@ export function useResourceListPage() {
} }
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setResourceType("");
setPage(1);
};
return { return {
...sharedPageState({ page, query, result, setPage, setQuery }), ...sharedPageState({ page, query, result, setPage, setQuery }),
abilities, abilities,
@ -234,6 +241,7 @@ export function useResourceListPage() {
openEditResource, openEditResource,
resourceType, resourceType,
selectedResource, selectedResource,
resetFilters,
setForm, setForm,
status, status,
submitResource, submitResource,
@ -385,6 +393,12 @@ export function useResourceGroupListPage() {
} }
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setPage(1);
};
return { return {
...sharedPageState({ page, query, result, setPage, setQuery }), ...sharedPageState({ page, query, result, setPage, setQuery }),
abilities, abilities,
@ -401,6 +415,7 @@ export function useResourceGroupListPage() {
resourceOptions, resourceOptions,
resourceOptionsLoading, resourceOptionsLoading,
selectedGroup, selectedGroup,
resetFilters,
setForm, setForm,
status, status,
submitGroup, submitGroup,
@ -534,6 +549,13 @@ export function useGiftListPage() {
} }
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setPage(1);
};
return { return {
...sharedPageState({ page, query, result, setPage, setQuery }), ...sharedPageState({ page, query, result, setPage, setQuery }),
abilities, abilities,
@ -551,6 +573,7 @@ export function useGiftListPage() {
resourceOptions, resourceOptions,
resourceOptionsLoading, resourceOptionsLoading,
selectedGift, selectedGift,
resetFilters,
setForm, setForm,
status, status,
submitGift, submitGift,
@ -650,6 +673,12 @@ export function useResourceGrantListPage() {
} }
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setPage(1);
};
return { return {
...sharedPageState({ page, query, result, setPage, setQuery }), ...sharedPageState({ page, query, result, setPage, setQuery }),
abilities, abilities,
@ -662,6 +691,7 @@ export function useResourceGrantListPage() {
openCreateGrant, openCreateGrant,
optionsLoading, optionsLoading,
resourceOptions, resourceOptions,
resetFilters,
setForm, setForm,
status, status,
submitGrant, submitGrant,

View File

@ -17,17 +17,17 @@ import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx"; import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminRowActions, AdminRowActions,
AdminListToolbar, AdminListToolbar,
AdminSearchBox
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { resourceStatusFilters } from "@/features/resources/constants.js"; import { resourceStatusFilters } from "@/features/resources/constants.js";
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js"; import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
@ -43,16 +43,16 @@ const giftTypeOptions = [
["flag", "国旗礼物"], ["flag", "国旗礼物"],
["activity", "活动礼物"], ["activity", "活动礼物"],
["magic", "魔法礼物"], ["magic", "魔法礼物"],
["custom", "定制礼物"] ["custom", "定制礼物"],
]; ];
const chargeAssetOptions = [ const chargeAssetOptions = [
["COIN", "金币"], ["COIN", "金币"],
["DIAMOND", "钻石"] ["DIAMOND", "钻石"],
]; ];
const effectOptions = [ const effectOptions = [
["animation", "动画"], ["animation", "动画"],
["music", "音乐"], ["music", "音乐"],
["global_broadcast", "全局广播"] ["global_broadcast", "全局广播"],
]; ];
const effectLabels = Object.fromEntries(effectOptions); const effectLabels = Object.fromEntries(effectOptions);
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"]; const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
@ -62,13 +62,13 @@ const baseColumns = [
key: "gift", key: "gift",
label: "礼物", label: "礼物",
width: "minmax(260px, 1.4fr)", width: "minmax(260px, 1.4fr)",
render: (gift) => <GiftIdentity gift={gift} /> render: (gift) => <GiftIdentity gift={gift} />,
}, },
{ {
key: "resource", key: "resource",
label: "资源", label: "资源",
width: "minmax(220px, 1.1fr)", width: "minmax(220px, 1.1fr)",
render: (gift) => <ResourceRef gift={gift} /> render: (gift) => <ResourceRef gift={gift} />,
}, },
{ {
key: "price", key: "price",
@ -77,9 +77,11 @@ const baseColumns = [
render: (gift) => ( render: (gift) => (
<div className={styles.stack}> <div className={styles.stack}>
<span>{giftTypeLabel(gift.giftTypeCode)}</span> <span>{giftTypeLabel(gift.giftTypeCode)}</span>
<span className={styles.meta}>{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}</span> <span className={styles.meta}>
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
</span>
</div> </div>
) ),
}, },
{ {
key: "income", key: "income",
@ -90,12 +92,12 @@ const baseColumns = [
<span>{formatNumber(gift.giftPointAmount)}</span> <span>{formatNumber(gift.giftPointAmount)}</span>
<span className={styles.meta}>{formatNumber(gift.heatValue)}</span> <span className={styles.meta}>{formatNumber(gift.heatValue)}</span>
</div> </div>
) ),
}, },
{ {
key: "regions", key: "regions",
label: "区域", label: "区域",
width: "minmax(160px, 0.8fr)" width: "minmax(160px, 0.8fr)",
}, },
{ {
key: "effects", key: "effects",
@ -106,24 +108,24 @@ const baseColumns = [
<span>{effectiveRangeLabel(gift)}</span> <span>{effectiveRangeLabel(gift)}</span>
<span className={styles.meta}>{effectTypesLabel(gift.effectTypes)}</span> <span className={styles.meta}>{effectTypesLabel(gift.effectTypes)}</span>
</div> </div>
) ),
}, },
{ {
key: "status", key: "status",
label: "状态", label: "状态",
width: "minmax(92px, 0.55fr)" width: "minmax(92px, 0.55fr)",
}, },
{ {
key: "time", key: "time",
label: "更新时间", label: "更新时间",
width: "minmax(170px, 0.9fr)", width: "minmax(170px, 0.9fr)",
render: (gift) => formatMillis(gift.updatedAtMs || gift.createdAtMs) render: (gift) => formatMillis(gift.updatedAtMs || gift.createdAtMs),
}, },
{ {
key: "actions", key: "actions",
label: "操作", label: "操作",
width: "minmax(76px, 0.4fr)" width: "minmax(76px, 0.4fr)",
} },
]; ];
export function GiftListPage() { export function GiftListPage() {
@ -141,21 +143,37 @@ export function GiftListPage() {
loading: page.loadingRegions, loading: page.loadingRegions,
options: giftRegionOptions, options: giftRegionOptions,
value: page.regionId, value: page.regionId,
onChange: page.changeRegionId onChange: page.changeRegionId,
}), }),
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} /> render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
}; };
} }
if (column.key === "status") { if (column.key === "status") {
return { return {
...column, ...column,
render: (gift) => <GiftStatusSwitch gift={gift} page={page} /> filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
};
}
if (column.key === "gift") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索礼物名称、礼物 ID",
value: page.query,
onChange: page.changeQuery,
}),
}; };
} }
if (column.key === "actions") { if (column.key === "actions") {
return { return {
...column, ...column,
render: (gift) => <GiftRowActions gift={gift} page={page} /> render: (gift) => <GiftRowActions gift={gift} page={page} />,
}; };
} }
return column; return column;
@ -165,17 +183,10 @@ export function GiftListPage() {
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={ filters={
<> <AdminFilterResetButton
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索礼物名称、礼物 ID..." /> disabled={!page.query && !page.status && !page.regionId}
<AdminFilterSelect options={resourceStatusFilters} value={page.status} onChange={page.changeStatus} /> onClick={page.resetFilters}
<RegionFilterControl
emptyLabel="全部区域"
loading={page.loadingRegions}
options={giftRegionOptions}
value={page.regionId}
onChange={page.changeRegionId}
/> />
</>
} }
actions={ actions={
page.abilities.canCreateGift ? ( page.abilities.canCreateGift ? (
@ -275,7 +286,9 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })} onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
> >
{giftTypeOptions.map(([value, label]) => ( {giftTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>{label}</MenuItem> <MenuItem key={value} value={value}>
{label}
</MenuItem>
))} ))}
</TextField> </TextField>
<TextField <TextField
@ -301,7 +314,9 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })} onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
> >
{chargeAssetOptions.map(([value, label]) => ( {chargeAssetOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>{label}</MenuItem> <MenuItem key={value} value={value}>
{label}
</MenuItem>
))} ))}
</TextField> </TextField>
<TextField <TextField
@ -396,13 +411,10 @@ function DateTimeField({ disabled, label, onChange, value }) {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1)); setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
}; };
const changeDay = (day) => { const changeDay = (day) => {
setDraftDate((current) => new Date( setDraftDate(
day.getFullYear(), (current) =>
day.getMonth(), new Date(day.getFullYear(), day.getMonth(), day.getDate(), current.getHours(), current.getMinutes()),
day.getDate(), );
current.getHours(),
current.getMinutes()
));
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1)); setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
}; };
const changeTime = (unit, nextValue) => { const changeTime = (unit, nextValue) => {
@ -428,7 +440,7 @@ function DateTimeField({ disabled, label, onChange, value }) {
<InputAdornment position="end"> <InputAdornment position="end">
<CalendarMonthOutlined fontSize="small" /> <CalendarMonthOutlined fontSize="small" />
</InputAdornment> </InputAdornment>
) ),
}} }}
label={label} label={label}
placeholder="长期有效" placeholder="长期有效"
@ -454,7 +466,9 @@ function DateTimeField({ disabled, label, onChange, value }) {
</div> </div>
<div className={styles.calendarGrid}> <div className={styles.calendarGrid}>
{weekLabels.map((weekday) => ( {weekLabels.map((weekday) => (
<span className={styles.weekday} key={weekday}>{weekday}</span> <span className={styles.weekday} key={weekday}>
{weekday}
</span>
))} ))}
{days.map((day) => { {days.map((day) => {
const isOutside = day.getMonth() !== viewDate.getMonth(); const isOutside = day.getMonth() !== viewDate.getMonth();
@ -464,8 +478,10 @@ function DateTimeField({ disabled, label, onChange, value }) {
className={[ className={[
styles.dayButton, styles.dayButton,
isOutside ? styles.dayOutside : "", isOutside ? styles.dayOutside : "",
isSelected ? styles.daySelected : "" isSelected ? styles.daySelected : "",
].filter(Boolean).join(" ")} ]
.filter(Boolean)
.join(" ")}
key={day.toISOString()} key={day.toISOString()}
type="button" type="button"
onClick={() => changeDay(day)} onClick={() => changeDay(day)}
@ -484,7 +500,9 @@ function DateTimeField({ disabled, label, onChange, value }) {
onChange={(event) => changeTime("hour", event.target.value)} onChange={(event) => changeTime("hour", event.target.value)}
> >
{Array.from({ length: 24 }, (_, hour) => ( {Array.from({ length: 24 }, (_, hour) => (
<MenuItem key={hour} value={String(hour)}>{twoDigits(hour)}</MenuItem> <MenuItem key={hour} value={String(hour)}>
{twoDigits(hour)}
</MenuItem>
))} ))}
</TextField> </TextField>
<TextField <TextField
@ -495,7 +513,9 @@ function DateTimeField({ disabled, label, onChange, value }) {
onChange={(event) => changeTime("minute", event.target.value)} onChange={(event) => changeTime("minute", event.target.value)}
> >
{Array.from({ length: 60 }, (_, minute) => ( {Array.from({ length: 60 }, (_, minute) => (
<MenuItem key={minute} value={String(minute)}>{twoDigits(minute)}</MenuItem> <MenuItem key={minute} value={String(minute)}>
{twoDigits(minute)}
</MenuItem>
))} ))}
</TextField> </TextField>
</div> </div>
@ -527,7 +547,7 @@ function DateTimeField({ disabled, label, onChange, value }) {
function GiftRegionSelect({ disabled, labels, onChange, options, value }) { function GiftRegionSelect({ disabled, labels, onChange, options, value }) {
const selected = value || []; const selected = value || [];
const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort( const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort(
(left, right) => Number(left) - Number(right) (left, right) => Number(left) - Number(right),
); );
return ( return (
<MultiValueAutocomplete <MultiValueAutocomplete
@ -564,7 +584,9 @@ function RegionTags({ regionIds = [], regionLabelById }) {
return ( return (
<div className="admin-tag-list"> <div className="admin-tag-list">
{items.map((regionId) => ( {items.map((regionId) => (
<span className="admin-tag" key={regionId}>{regionLabelById[String(regionId)] || regionId}</span> <span className="admin-tag" key={regionId}>
{regionLabelById[String(regionId)] || regionId}
</span>
))} ))}
</div> </div>
); );
@ -619,8 +641,9 @@ function effectTypeLabel(value) {
return effectOptions.find(([optionValue]) => optionValue === value)?.[1] || value; return effectOptions.find(([optionValue]) => optionValue === value)?.[1] || value;
} }
function effectTypesLabel(values = []) { function effectTypesLabel(values) {
return values.length ? values.map(effectTypeLabel).join("、") : "无特效"; const list = values || [];
return list.length ? list.map(effectTypeLabel).join("、") : "无特效";
} }
function parseLocalDateTime(value) { function parseLocalDateTime(value) {
@ -663,9 +686,11 @@ function calendarDays(viewDate) {
} }
function sameDay(left, right) { function sameDay(left, right) {
return left.getFullYear() === right.getFullYear() return (
&& left.getMonth() === right.getMonth() left.getFullYear() === right.getFullYear() &&
&& left.getDate() === right.getDate(); left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
} }
function monthTitle(date) { function monthTitle(date) {
@ -691,7 +716,7 @@ function shortDateTime(value) {
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
hour: "2-digit", hour: "2-digit",
minute: "2-digit" minute: "2-digit",
}).format(new Date(Number(value))); }).format(new Date(Number(value)));
} }

View File

@ -6,13 +6,13 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { import {
resourceGrantStatusFilters, resourceGrantStatusFilters,
@ -86,19 +86,36 @@ export function ResourceGrantListPage() {
const page = useResourceGrantListPage(); const page = useResourceGrantListPage();
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const columns = grantColumns.map((column) => {
if (column.key === "target") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: resourceGrantStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return ( return (
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={ filters={
<> <AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索用户 ID..." />
<AdminFilterSelect
options={resourceGrantStatusFilters}
value={page.status}
onChange={page.changeStatus}
/>
</>
} }
actions={ actions={
page.abilities.canCreateGrant ? ( page.abilities.canCreateGrant ? (
@ -110,7 +127,12 @@ export function ResourceGrantListPage() {
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody> <AdminListBody>
<DataTable columns={grantColumns} items={items} minWidth="1190px" rowKey={(grant) => grant.grantId} /> <DataTable
columns={columns}
items={items}
minWidth="1190px"
rowKey={(grant) => grant.grantId}
/>
{total > 0 ? ( {total > 0 ? (
<PaginationBar <PaginationBar
page={page.page} page={page.page}

View File

@ -22,20 +22,16 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminRowActions, AdminRowActions,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
resourceGroupAssetLabels,
resourceStatusFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js"; import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css"; import styles from "@/features/resources/resources.module.css";
@ -83,9 +79,24 @@ export function ResourceGroupListPage() {
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const columns = baseColumns.map((column) => const columns = baseColumns.map((column) =>
column.key === "status" column.key === "group"
? { ? {
...column, ...column,
filter: createTextColumnFilter({
placeholder: "搜索资源组名称、资源组编码",
value: page.query,
onChange: page.changeQuery,
}),
}
: column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (group) => <ResourceGroupStatusSwitch group={group} page={page} />, render: (group) => <ResourceGroupStatusSwitch group={group} page={page} />,
} }
: column.key === "actions" : column.key === "actions"
@ -100,18 +111,7 @@ export function ResourceGroupListPage() {
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={ filters={
<> <AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
<AdminSearchBox
value={page.query}
onChange={page.changeQuery}
placeholder="搜索资源组名称、资源组编码..."
/>
<AdminFilterSelect
options={resourceStatusFilters}
value={page.status}
onChange={page.changeStatus}
/>
</>
} }
actions={ actions={
page.abilities.canCreateGroup ? ( page.abilities.canCreateGroup ? (
@ -135,7 +135,9 @@ export function ResourceGroupListPage() {
</AdminListBody> </AdminListBody>
</DataState> </DataState>
<ResourceGroupFormDialog <ResourceGroupFormDialog
disabled={page.activeAction === "edit" ? !page.abilities.canUpdateGroup : !page.abilities.canCreateGroup} disabled={
page.activeAction === "edit" ? !page.abilities.canUpdateGroup : !page.abilities.canCreateGroup
}
form={page.form} form={page.form}
loading={ loading={
page.activeAction === "edit" page.activeAction === "edit"
@ -230,13 +232,25 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
title="组内资源" title="组内资源"
actions={ actions={
<AdminFormInlineActions> <AdminFormInlineActions>
<Button disabled={loading} onClick={page.addResourceItem} startIcon={<Inventory2Outlined fontSize="small" />}> <Button
disabled={loading}
onClick={page.addResourceItem}
startIcon={<Inventory2Outlined fontSize="small" />}
>
资源 资源
</Button> </Button>
<Button disabled={loading} onClick={() => page.addWalletAssetItem("COIN")} startIcon={<MonetizationOnOutlined fontSize="small" />}> <Button
disabled={loading}
onClick={() => page.addWalletAssetItem("COIN")}
startIcon={<MonetizationOnOutlined fontSize="small" />}
>
金币 金币
</Button> </Button>
<Button disabled={loading} onClick={() => page.addWalletAssetItem("DIAMOND")} startIcon={<DiamondOutlined fontSize="small" />}> <Button
disabled={loading}
onClick={() => page.addWalletAssetItem("DIAMOND")}
startIcon={<DiamondOutlined fontSize="small" />}
>
钻石 钻石
</Button> </Button>
</AdminFormInlineActions> </AdminFormInlineActions>

View File

@ -11,14 +11,14 @@ import { DataTable } from "@/shared/ui/DataTable.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminRowActions, AdminRowActions,
AdminListToolbar, AdminListToolbar,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { import {
grantStrategyLabels, grantStrategyLabels,
@ -80,9 +80,34 @@ export function ResourceListPage() {
const total = page.data.total || 0; const total = page.data.total || 0;
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload; const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
const columns = baseColumns.map((column) => const columns = baseColumns.map((column) =>
column.key === "status" column.key === "resource"
? { ? {
...column, ...column,
filter: createTextColumnFilter({
placeholder: "搜索资源名称、资源编码",
value: page.query,
onChange: page.changeQuery,
}),
}
: column.key === "type"
? {
...column,
filter: createOptionsColumnFilter({
options: resourceTypeFilters,
placeholder: "搜索类型",
value: page.resourceType,
onChange: page.changeResourceType,
}),
}
: column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />, render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
} }
: column.key === "actions" : column.key === "actions"
@ -97,24 +122,10 @@ export function ResourceListPage() {
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={ filters={
<> <AdminFilterResetButton
<AdminSearchBox disabled={!page.query && !page.status && !page.resourceType}
value={page.query} onClick={page.resetFilters}
onChange={page.changeQuery}
placeholder="搜索资源名称、资源编码..."
/> />
<AdminFilterSelect
options={resourceStatusFilters}
value={page.status}
onChange={page.changeStatus}
/>
<AdminFilterSelect
label="类型"
options={resourceTypeFilters}
value={page.resourceType}
onChange={page.changeResourceType}
/>
</>
} }
actions={ actions={
page.abilities.canCreate ? ( page.abilities.canCreate ? (

View File

@ -2,10 +2,20 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
export function RoleTable({ abilities, onEdit, onRemove, roles }) { export function RoleTable({ abilities, onEdit, onQueryChange, onRemove, query, roles }) {
const columns = [ const columns = [
{ key: "name", label: "角色", width: "minmax(160px, 1fr)" }, {
key: "name",
label: "角色",
width: "minmax(160px, 1fr)",
filter: createTextColumnFilter({
placeholder: "搜索角色、编码",
value: query,
onChange: onQueryChange
})
},
{ key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (role) => <span className="muted">{role.code}</span> }, { key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (role) => <span className="muted">{role.code}</span> },
{ key: "permissionCount", label: "权限数量", width: "110px", render: (role) => role.permissions?.length || 0 }, { key: "permissionCount", label: "权限数量", width: "110px", render: (role) => role.permissions?.length || 0 },
{ {

View File

@ -9,7 +9,7 @@ import {
listPermissions, listPermissions,
listRoles, listRoles,
replaceRolePermissions, replaceRolePermissions,
updateRole updateRole,
} from "@/features/roles/api"; } from "@/features/roles/api";
import { useRoleAbilities } from "@/features/roles/permissions.js"; import { useRoleAbilities } from "@/features/roles/permissions.js";
import { roleFormSchema } from "@/features/roles/schema"; import { roleFormSchema } from "@/features/roles/schema";
@ -31,7 +31,7 @@ const permissionGroupDefinitions = [
{ key: "logs", prefixes: ["log"], title: "日志审计" }, { key: "logs", prefixes: ["log"], title: "日志审计" },
{ key: "notifications", prefixes: ["notification"], title: "通知中心" }, { key: "notifications", prefixes: ["notification"], title: "通知中心" },
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" }, { key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
{ key: "common", prefixes: ["upload"], title: "通用能力" } { key: "common", prefixes: ["upload"], title: "通用能力" },
]; ];
const fallbackPermissionGroup = { key: "other", title: "其他权限" }; const fallbackPermissionGroup = { key: "other", title: "其他权限" };
const permissionKindOrder = { menu: 1, button: 2, api: 3 }; const permissionKindOrder = { menu: 1, button: 2, api: 3 };
@ -55,7 +55,7 @@ export function useRolesPage() {
errorMessage: "加载角色失败", errorMessage: "加载角色失败",
initialData: emptyData, initialData: emptyData,
keepPreviousData: true, keepPreviousData: true,
queryKey: ["roles", abilities.canLoadPermissions] queryKey: ["roles", abilities.canLoadPermissions],
}); });
const permissionGroups = useMemo(() => { const permissionGroups = useMemo(() => {
@ -69,7 +69,11 @@ export function useRolesPage() {
return items; return items;
} }
return items.filter((role) => { return items.filter((role) => {
return [role.name, role.code, role.description].some((value) => String(value || "").toLowerCase().includes(keyword)); return [role.name, role.code, role.description].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword),
);
}); });
}, [data.roles, query]); }, [data.roles, query]);
@ -77,6 +81,10 @@ export function useRolesPage() {
setQuery(value); setQuery(value);
}; };
const resetFilters = () => {
setQuery("");
};
const openCreate = () => { const openCreate = () => {
setEditingRole(null); setEditingRole(null);
setForm(emptyRole); setForm(emptyRole);
@ -89,7 +97,7 @@ export function useRolesPage() {
code: role.code, code: role.code,
description: role.description || "", description: role.description || "",
name: role.name, name: role.name,
permissionIds: role.permissions?.map((permission) => permission.id) || [] permissionIds: role.permissions?.map((permission) => permission.id) || [],
}); });
setDrawerOpen(true); setDrawerOpen(true);
}; };
@ -107,7 +115,7 @@ export function useRolesPage() {
await updateRole(editingRole.id, { await updateRole(editingRole.id, {
code: payload.code, code: payload.code,
description: payload.description, description: payload.description,
name: payload.name name: payload.name,
}); });
} }
if (abilities.canGrant) { if (abilities.canGrant) {
@ -119,7 +127,7 @@ export function useRolesPage() {
code: payload.code, code: payload.code,
description: payload.description, description: payload.description,
name: payload.name, name: payload.name,
permissionIds: abilities.canGrant ? payload.permissionIds : [] permissionIds: abilities.canGrant ? payload.permissionIds : [],
}); });
showToast("角色已创建", "success"); showToast("角色已创建", "success");
} }
@ -135,7 +143,7 @@ export function useRolesPage() {
confirmText: "删除", confirmText: "删除",
message: `${role.name} 删除前需要先解绑用户。`, message: `${role.name} 删除前需要先解绑用户。`,
title: "删除角色", title: "删除角色",
tone: "danger" tone: "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -157,7 +165,7 @@ export function useRolesPage() {
...current, ...current,
permissionIds: checked permissionIds: checked
? Array.from(new Set([...current.permissionIds, permissionId])) ? Array.from(new Set([...current.permissionIds, permissionId]))
: current.permissionIds.filter((id) => id !== permissionId) : current.permissionIds.filter((id) => id !== permissionId),
})); }));
}; };
@ -172,7 +180,7 @@ export function useRolesPage() {
...current, ...current,
permissionIds: checked permissionIds: checked
? Array.from(new Set([...current.permissionIds, ...permissionIds])) ? Array.from(new Set([...current.permissionIds, ...permissionIds]))
: current.permissionIds.filter((id) => !targetIds.has(id)) : current.permissionIds.filter((id) => !targetIds.has(id)),
}; };
}); });
}; };
@ -193,18 +201,23 @@ export function useRolesPage() {
query, query,
reload, reload,
remove, remove,
resetFilters,
roles, roles,
setForm, setForm,
submit, submit,
togglePermission, togglePermission,
togglePermissionGroup togglePermissionGroup,
}; };
} }
function groupPermissionsByMenu(permissions) { function groupPermissionsByMenu(permissions) {
const groups = new Map(permissionGroupDefinitions.map((definition) => [definition.key, { ...definition, items: [] }])); const groups = new Map(
permissionGroupDefinitions.map((definition) => [definition.key, { ...definition, items: [] }]),
);
const prefixToGroup = new Map( const prefixToGroup = new Map(
permissionGroupDefinitions.flatMap((definition) => definition.prefixes.map((prefix) => [prefix, definition.key])) permissionGroupDefinitions.flatMap((definition) =>
definition.prefixes.map((prefix) => [prefix, definition.key]),
),
); );
const fallbackGroup = { ...fallbackPermissionGroup, items: [] }; const fallbackGroup = { ...fallbackPermissionGroup, items: [] };
@ -219,7 +232,7 @@ function groupPermissionsByMenu(permissions) {
.filter((group) => group.items.length > 0) .filter((group) => group.items.length > 0)
.map((group) => ({ .map((group) => ({
...group, ...group,
items: [...group.items].sort(comparePermissions) items: [...group.items].sort(comparePermissions),
})); }));
} }

View File

@ -2,10 +2,10 @@ import Add from "@mui/icons-material/Add";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminSearchBox
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { RoleFormDrawer } from "@/features/roles/components/RoleFormDrawer.jsx"; import { RoleFormDrawer } from "@/features/roles/components/RoleFormDrawer.jsx";
import { RoleTable } from "@/features/roles/components/RoleTable.jsx"; import { RoleTable } from "@/features/roles/components/RoleTable.jsx";
@ -18,17 +18,28 @@ export function RoleManagementPage() {
<> <>
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索角色、编码..." />} filters={
actions={page.abilities.canCreate ? ( <AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
}
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}> <AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>
<Add fontSize="small" /> <Add fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
) : null} ) : null
}
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody> <AdminListBody>
<RoleTable abilities={page.abilities} onEdit={page.openEdit} onRemove={page.remove} roles={page.roles} /> <RoleTable
abilities={page.abilities}
query={page.query}
onEdit={page.openEdit}
onQueryChange={page.changeQuery}
onRemove={page.remove}
roles={page.roles}
/>
<div className="pagination-bar"> <div className="pagination-bar">
<span>{page.roles.length} </span> <span>{page.roles.length} </span>
</div> </div>

View File

@ -0,0 +1,120 @@
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { roomStatusLabels } from "@/features/rooms/constants.js";
import styles from "@/features/rooms/rooms.module.css";
export function RoomDetailDrawer({ onClose, open, room }) {
const owner = room?.owner || {};
const normalizedStatus = room?.status === "active" ? "active" : "closed";
const ownerDisplayId = owner.displayUserId || room?.ownerUserId || "-";
return (
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
{room ? (
<>
<header className={styles.detailHero}>
<span className={styles.detailCover}>
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="medium" />}
</span>
<div className={styles.detailHeroText}>
<div className={styles.detailTitleRow}>
<h3>{room.title || "-"}</h3>
<StatusBadge status={normalizedStatus} />
</div>
<span className={styles.detailMeta}>房间 ID {room.roomId}</span>
</div>
</header>
<DetailSection
items={[
["房间名称", room.title],
["房间 ID", room.roomId],
["房间状态", roomStatusLabels[normalizedStatus]],
["房间模式", room.mode],
["区域", room.regionName || formatRegion(room.visibleRegionId)],
["可见区域 ID", room.visibleRegionId],
["创建时间", formatMillis(room.createdAtMs)],
["更新时间", formatMillis(room.updatedAtMs)]
]}
title="基础信息"
/>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>房主信息</h3>
<div className={styles.detailOwner}>
<span className={styles.detailOwnerAvatar}>
{owner.avatar ? <img src={owner.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.detailOwnerText}>
<strong>{owner.username || "-"}</strong>
<span>{ownerDisplayId}</span>
</div>
</div>
<div className={styles.detailGrid}>
<DetailItem label="房主用户 ID" value={room.ownerUserId || owner.userId} />
<DetailItem label="主持用户 ID" value={room.hostUserId} />
</div>
</section>
<DetailSection
items={[
["在线人数", `${formatNumber(room.onlineCount)}`],
["座位数", `${formatNumber(room.seatCount)}`],
["已占座位", `${formatNumber(room.occupiedSeatCount)}`],
["热度", formatNumber(room.heat)],
["排序分", formatNumber(room.sortScore)]
]}
title="房间数据"
/>
</>
) : null}
</SideDrawer>
);
}
function DetailSection({ items, title }) {
return (
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>{title}</h3>
<div className={styles.detailGrid}>
{items.map(([label, value]) => (
<DetailItem key={label} label={label} value={value} />
))}
</div>
</section>
);
}
function DetailItem({ label, value }) {
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<strong className={styles.detailValue}>{displayValue(value)}</strong>
</div>
);
}
function StatusBadge({ status }) {
const tone = status === "active" ? "running" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{roomStatusLabels[status]}
</span>
);
}
function displayValue(value) {
return value === 0 || value ? value : "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function formatRegion(value) {
return value ? `区域 ${value}` : "-";
}

View File

@ -14,7 +14,7 @@ const emptyForm = () => ({
coverUrl: "", coverUrl: "",
description: "", description: "",
title: "", title: "",
visibleRegionId: 0 visibleRegionId: 0,
}); });
const emptyStatusForm = () => ({ status: "active" }); const emptyStatusForm = () => ({ status: "active" });
@ -36,17 +36,22 @@ export function useRoomsPage() {
() => ({ () => ({
keyword: query, keyword: query,
region_id: regionId, region_id: regionId,
status status,
}), }),
[query, regionId, status] [query, regionId, status],
); );
const { data = emptyData, error, loading, reload } = usePaginatedQuery({ const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载房间列表失败", errorMessage: "加载房间列表失败",
fetcher: listRooms, fetcher: listRooms,
filters, filters,
page, page,
pageSize, pageSize,
queryKey: ["rooms", filters, page] queryKey: ["rooms", filters, page],
}); });
const changeQuery = (value) => { const changeQuery = (value) => {
@ -64,17 +69,29 @@ export function useRoomsPage() {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setPage(1);
};
const openEdit = (room) => { const openEdit = (room) => {
setActiveRoom(room); setActiveRoom(room);
setForm({ setForm({
coverUrl: room.coverUrl || "", coverUrl: room.coverUrl || "",
description: "", description: "",
title: room.title || "", title: room.title || "",
visibleRegionId: room.visibleRegionId || 0 visibleRegionId: room.visibleRegionId || 0,
}); });
setActiveAction("edit"); setActiveAction("edit");
}; };
const openDetail = (room) => {
setActiveRoom(room);
setActiveAction("detail");
};
const openStatus = (room) => { const openStatus = (room) => {
setActiveRoom(room); setActiveRoom(room);
setStatusForm({ status: room.status === "closed" ? "closed" : "active" }); setStatusForm({ status: room.status === "closed" ? "closed" : "active" });
@ -120,7 +137,7 @@ export function useRoomsPage() {
confirmText: "删除", confirmText: "删除",
message: `${room.title || room.roomId} 会从房间列表和房间状态中删除。`, message: `${room.title || room.roomId} 会从房间列表和房间状态中删除。`,
title: "删除房间", title: "删除房间",
tone: "danger" tone: "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -157,6 +174,7 @@ export function useRoomsPage() {
loading, loading,
loadingAction, loadingAction,
loadingRegions, loadingRegions,
openDetail,
openEdit, openEdit,
openStatus, openStatus,
page, page,
@ -165,12 +183,13 @@ export function useRoomsPage() {
regionOptions, regionOptions,
reload, reload,
removeRoom, removeRoom,
resetFilters,
setForm, setForm,
setPage, setPage,
setStatusForm, setStatusForm,
status, status,
statusForm, statusForm,
submitStatus, submitStatus,
submitEdit submitEdit,
}; };
} }

View File

@ -10,12 +10,13 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx"; import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { RoomDetailDrawer } from "@/features/rooms/components/RoomDetailDrawer.jsx";
import { roomStatusFilters, roomStatusLabels } from "@/features/rooms/constants.js"; import { roomStatusFilters, roomStatusLabels } from "@/features/rooms/constants.js";
import { useRoomsPage } from "@/features/rooms/hooks/useRoomsPage.js"; import { useRoomsPage } from "@/features/rooms/hooks/useRoomsPage.js";
import styles from "@/features/rooms/rooms.module.css"; import styles from "@/features/rooms/rooms.module.css";
@ -25,38 +26,38 @@ const columns = [
key: "identity", key: "identity",
label: "房间", label: "房间",
width: "minmax(240px, 1.5fr)", width: "minmax(240px, 1.5fr)",
render: (room) => <RoomIdentity room={room} /> render: (room) => <RoomIdentity room={room} />,
}, },
{ {
key: "owner", key: "owner",
label: "房主", label: "房主",
width: "minmax(210px, 1.2fr)", width: "minmax(210px, 1.2fr)",
render: (room) => <OwnerIdentity room={room} /> render: (room) => <OwnerIdentity room={room} />,
}, },
{ {
key: "region", key: "region",
label: "区域", label: "区域",
width: "minmax(140px, 0.8fr)", width: "minmax(140px, 0.8fr)",
render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-") render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-"),
}, },
{ {
key: "onlineCount", key: "onlineCount",
label: "房间人数", label: "房间人数",
width: "minmax(110px, 0.7fr)", width: "minmax(110px, 0.7fr)",
render: (room) => `${formatNumber(room.onlineCount)}` render: (room) => `${formatNumber(room.onlineCount)}`,
}, },
{ {
key: "status", key: "status",
label: "状态", label: "状态",
width: "minmax(100px, 0.7fr)", width: "minmax(100px, 0.7fr)",
render: (room) => <RoomStatus status={room.status} /> render: (room) => <RoomStatus status={room.status} />,
}, },
{ {
key: "time", key: "time",
label: "创建时间", label: "创建时间",
width: "minmax(170px, 1fr)", width: "minmax(170px, 1fr)",
render: (room) => formatMillis(room.createdAtMs) render: (room) => formatMillis(room.createdAtMs),
} },
]; ];
export function RoomListPage() { export function RoomListPage() {
@ -72,24 +73,44 @@ export function RoomListPage() {
loading: page.loadingRegions, loading: page.loadingRegions,
options: page.regionOptions, options: page.regionOptions,
value: page.regionId, value: page.regionId,
onChange: page.changeRegionId onChange: page.changeRegionId,
}) }),
}
: column.key === "identity"
? {
...column,
filter: createTextColumnFilter({
placeholder: "搜索房间、房主",
value: page.query,
onChange: page.changeQuery,
}),
render: (room) => <RoomIdentity room={room} onClick={() => page.openDetail(room)} />,
} }
: column.key === "status" : column.key === "status"
? { ? {
...column, ...column,
filter: createOptionsColumnFilter({
options: roomStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (room) => ( render: (room) => (
<RoomStatus disabled={!page.abilities.canUpdate} status={room.status} onClick={() => page.openStatus(room)} /> <RoomStatus
) disabled={!page.abilities.canUpdate}
status={room.status}
onClick={() => page.openStatus(room)}
/>
),
} }
: column : column,
), ),
{ {
key: "actions", key: "actions",
label: "操作", label: "操作",
width: "minmax(104px, 0.6fr)", width: "minmax(104px, 0.6fr)",
render: (room) => <RoomActions page={page} room={room} /> render: (room) => <RoomActions page={page} room={room} />,
} },
]; ];
return ( return (
@ -97,32 +118,35 @@ export function RoomListPage() {
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<section className={styles.filters}> <section className={styles.filters}>
<SearchBox className={styles.search} value={page.query} onChange={page.changeQuery} placeholder="搜索房间、房主..." /> <AdminFilterResetButton
<StatusSelect disabled={!page.query && !page.status && !page.regionId}
className={styles.statusSelect} onClick={page.resetFilters}
options={roomStatusFilters}
value={page.status}
onChange={page.changeStatus}
/>
<RegionFilterControl
className={styles.regionInput}
emptyLabel="全部区域"
loading={page.loadingRegions}
options={page.regionOptions}
value={page.regionId}
onChange={page.changeRegionId}
/> />
</section> </section>
</div> </div>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}> <div className={styles.listBlock}>
<DataTable columns={tableColumns} items={items} minWidth="1180px" rowKey={(room) => room.roomId} /> <DataTable
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null} columns={tableColumns}
items={items}
minWidth="1180px"
rowKey={(room) => room.roomId}
/>
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div> </div>
</DataState> </DataState>
</div> </div>
<RoomDetailDrawer open={page.activeAction === "detail"} room={page.activeRoom} onClose={page.closeAction} />
<ActionModal <ActionModal
disabled={!page.abilities.canUpdate} disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "edit"} loading={page.loadingAction === "edit"}
@ -131,10 +155,36 @@ export function RoomListPage() {
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitEdit} onSubmit={page.submitEdit}
> >
<TextField disabled={!page.abilities.canUpdate} label="房间名称" required value={page.form.title} onChange={(event) => page.setForm({ ...page.form, title: event.target.value })} /> <TextField
<UploadField disabled={!page.abilities.canUpdate || !page.abilities.canUpload} label="房间头像" value={page.form.coverUrl} onChange={(coverUrl) => page.setForm({ ...page.form, coverUrl })} /> disabled={!page.abilities.canUpdate}
<RegionSelect disabled={!page.abilities.canUpdate} emptyLabel="选择区域" loading={page.loadingRegions} options={page.regionOptions} required value={page.form.visibleRegionId} onChange={(visibleRegionId) => page.setForm({ ...page.form, visibleRegionId })} /> label="房间名称"
<TextField disabled={!page.abilities.canUpdate} label="房间简介" multiline minRows={2} value={page.form.description} onChange={(event) => page.setForm({ ...page.form, description: event.target.value })} /> required
value={page.form.title}
onChange={(event) => page.setForm({ ...page.form, title: event.target.value })}
/>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="房间头像"
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ ...page.form, coverUrl })}
/>
<RegionSelect
disabled={!page.abilities.canUpdate}
emptyLabel="选择区域"
loading={page.loadingRegions}
options={page.regionOptions}
required
value={page.form.visibleRegionId}
onChange={(visibleRegionId) => page.setForm({ ...page.form, visibleRegionId })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="房间简介"
multiline
minRows={2}
value={page.form.description}
onChange={(event) => page.setForm({ ...page.form, description: event.target.value })}
/>
</ActionModal> </ActionModal>
<ActionModal <ActionModal
@ -145,7 +195,14 @@ export function RoomListPage() {
onClose={page.closeAction} onClose={page.closeAction}
onSubmit={page.submitStatus} onSubmit={page.submitStatus}
> >
<TextField disabled={!page.abilities.canUpdate} label="状态" required select value={page.statusForm.status} onChange={(event) => page.setStatusForm({ status: event.target.value })}> <TextField
disabled={!page.abilities.canUpdate}
label="状态"
required
select
value={page.statusForm.status}
onChange={(event) => page.setStatusForm({ status: event.target.value })}
>
<MenuItem value="active">正常</MenuItem> <MenuItem value="active">正常</MenuItem>
<MenuItem value="closed">关闭</MenuItem> <MenuItem value="closed">关闭</MenuItem>
</TextField> </TextField>
@ -154,8 +211,8 @@ export function RoomListPage() {
); );
} }
function RoomIdentity({ room }) { function RoomIdentity({ onClick, room }) {
return ( const content = (
<div className={styles.identity}> <div className={styles.identity}>
<span className={styles.cover}> <span className={styles.cover}>
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="small" />} {room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="small" />}
@ -166,6 +223,21 @@ function RoomIdentity({ room }) {
</div> </div>
</div> </div>
); );
if (!onClick) {
return content;
}
return (
<button
aria-label={`查看房间详情:${room.title || room.roomId}`}
className={styles.identityButton}
type="button"
onClick={onClick}
>
{content}
</button>
);
} }
function OwnerIdentity({ room }) { function OwnerIdentity({ room }) {
@ -201,7 +273,12 @@ function RoomActions({ page, room }) {
{page.abilities.canDelete ? ( {page.abilities.canDelete ? (
<Tooltip arrow title="删除"> <Tooltip arrow title="删除">
<span> <span>
<IconButton disabled={page.loadingAction === `delete-${room.roomId}`} label="删除" tone="danger" onClick={() => page.removeRoom(room)}> <IconButton
disabled={page.loadingAction === `delete-${room.roomId}`}
label="删除"
tone="danger"
onClick={() => page.removeRoom(room)}
>
<DeleteOutlineOutlined fontSize="small" /> <DeleteOutlineOutlined fontSize="small" />
</IconButton> </IconButton>
</span> </span>

View File

@ -78,6 +78,34 @@
gap: var(--space-3); gap: var(--space-3);
} }
.identityButton {
display: block;
width: 100%;
padding: 2px;
border: 0;
border-radius: var(--radius-control);
background: transparent;
text-align: left;
cursor: pointer;
transition:
background var(--motion-base) var(--ease-standard),
box-shadow var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.identityButton:hover {
background: var(--bg-hover);
}
.identityButton:hover .name {
color: var(--primary);
}
.identityButton:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.cover { .cover {
display: inline-flex; display: inline-flex;
width: 36px; width: 36px;
@ -170,6 +198,156 @@
border-color: var(--primary-border); border-color: var(--primary-border);
} }
.detailHero {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-4);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.detailCover {
display: inline-flex;
width: 72px;
height: 72px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.detailCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detailHeroText {
display: grid;
min-width: 0;
gap: var(--space-2);
}
.detailTitleRow {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.detailTitleRow h3 {
overflow: hidden;
margin: 0;
color: var(--text-primary);
font-size: 18px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailMeta {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.detailSection {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card-strong);
}
.detailSectionTitle {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.detailItem {
display: grid;
min-width: 0;
gap: 4px;
}
.detailLabel {
color: var(--text-tertiary);
font-size: 12px;
}
.detailValue {
overflow: hidden;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailOwner {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.detailOwnerAvatar {
display: inline-flex;
width: 40px;
height: 40px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card);
color: var(--text-secondary);
}
.detailOwnerAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detailOwnerText {
display: grid;
min-width: 0;
gap: 2px;
}
.detailOwnerText strong,
.detailOwnerText span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailOwnerText strong {
color: var(--text-primary);
font-size: var(--admin-font-size);
}
.detailOwnerText span {
color: var(--text-tertiary);
font-size: 12px;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.toolbar, .toolbar,
.filters { .filters {
@ -183,4 +361,8 @@
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
} }
.detailGrid {
grid-template-columns: 1fr;
}
} }

View File

@ -2,14 +2,18 @@ import Dialog from "@mui/material/Dialog";
import List from "@mui/material/List"; import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton"; import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { searchGlobal } from "@/features/search/api"; import { searchGlobal } from "@/features/search/api";
import { SearchBox } from "@/shared/ui/SearchBox.jsx"; import { SearchBox } from "@/shared/ui/SearchBox.jsx";
export function SearchDialog({ onClose, onSelect, open }) { const maxMenuResults = 8;
export function SearchDialog({ menuItems = [], onClose, onSelect, open }) {
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const menuResults = useMemo(() => searchMenus(menuItems, keyword), [keyword, menuItems]);
const displayResults = useMemo(() => [...menuResults, ...results], [menuResults, results]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -53,11 +57,11 @@ export function SearchDialog({ onClose, onSelect, open }) {
slotProps={{ paper: { className: "search-dialog" } }} slotProps={{ paper: { className: "search-dialog" } }}
> >
<div className="search-dialog__head"> <div className="search-dialog__head">
<SearchBox autoFocus className="search-dialog__input" value={keyword} onChange={setKeyword} placeholder="搜索用户、角色..." /> <SearchBox autoFocus className="search-dialog__input" value={keyword} onChange={setKeyword} placeholder="搜索菜单、用户、角色..." />
</div> </div>
<List className="search-dialog__list" disablePadding> <List className="search-dialog__list" disablePadding>
{results.map((item) => ( {displayResults.map((item) => (
<ListItemButton className="search-dialog__item" key={`${item.type}-${item.id}`} onClick={() => selectResult(item)}> <ListItemButton className="search-dialog__item" key={`${item.type}-${item.id}`} onClick={() => selectResult(item)}>
<ListItemText <ListItemText
primary={item.title} primary={item.title}
@ -69,8 +73,61 @@ export function SearchDialog({ onClose, onSelect, open }) {
/> />
</ListItemButton> </ListItemButton>
))} ))}
{!results.length ? <div className="search-dialog__empty">{loading ? "搜索中..." : "暂无匹配结果"}</div> : null} {!displayResults.length ? <div className="search-dialog__empty">{loading ? "搜索中..." : "暂无匹配结果"}</div> : null}
</List> </List>
</Dialog> </Dialog>
); );
} }
export function searchMenus(items = [], keyword = "") {
const normalizedKeyword = normalizeSearchText(keyword);
if (!normalizedKeyword) {
return [];
}
return flattenMenuResults(items)
.filter((item) => item.searchText.includes(normalizedKeyword))
.slice(0, maxMenuResults)
.map(({ searchText: _searchText, ...item }) => item);
}
function flattenMenuResults(items = [], ancestors = []) {
return items.flatMap((item) => {
const label = item.label || item.title || item.code || "";
const breadcrumb = [...ancestors, label].filter(Boolean);
const target = item.path || findFirstMenuPath(item.children);
const current = target
? [
{
id: item.code || item.id || breadcrumb.join("/"),
searchText: normalizeSearchText([label, item.code, item.path].filter(Boolean).join(" ")),
subtitle: breadcrumb.join(" / "),
target,
title: label,
type: "菜单"
}
]
: [];
return [...current, ...flattenMenuResults(item.children || [], breadcrumb)];
});
}
function findFirstMenuPath(items = []) {
for (const item of items) {
if (item.path) {
return item.path;
}
const childPath = findFirstMenuPath(item.children || []);
if (childPath) {
return childPath;
}
}
return "";
}
function normalizeSearchText(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { searchMenus } from "./SearchDialog.jsx";
describe("searchMenus", () => {
const menus = [
{
code: "overview",
label: "系统概览",
path: "/overview"
},
{
code: "app-users",
label: "用户管理",
children: [{ code: "app-users-list", label: "用户列表", path: "/app/users" }]
},
{
code: "system",
label: "后台设置",
children: [
{ code: "system-users", label: "后台用户", path: "/system/users" },
{ code: "system-roles", label: "角色配置", path: "/system/roles" }
]
}
];
test("matches visible menu labels and returns navigable targets", () => {
expect(searchMenus(menus, "后台")).toEqual([
expect.objectContaining({ target: "/system/users", title: "后台设置", type: "菜单" }),
expect.objectContaining({ target: "/system/users", title: "后台用户", type: "菜单" })
]);
});
test("matches menu code and keeps breadcrumb for context", () => {
expect(searchMenus(menus, "app-users-list")).toEqual([
expect.objectContaining({ subtitle: "用户管理 / 用户列表", target: "/app/users", title: "用户列表" })
]);
});
test("returns nothing for blank keyword", () => {
expect(searchMenus(menus, " ")).toEqual([]);
});
});

View File

@ -1,44 +0,0 @@
import { Button } from "@/shared/ui/Button.jsx";
import { FilterChip } from "@/shared/ui/FilterChip.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
const statusFilters = [
["all", "全部"],
["active", "启用"],
["locked", "锁定"],
["disabled", "停用"]
];
export function UserFilters({
canBatchStatus,
onBatchStatus,
onQueryChange,
onRoleFilterChange,
onStatusFilterChange,
query,
roleFilter,
roleFilters,
statusFilter
}) {
return (
<section className="filters-panel user-filters-panel">
<SearchBox value={query} onChange={onQueryChange} placeholder="搜索用户、账号、角色、团队..." />
<div className="filter-row" aria-label="角色筛选">
{roleFilters.map(([value, label]) => (
<FilterChip active={roleFilter === value} key={value} label={label} onClick={() => onRoleFilterChange(value)} />
))}
</div>
<div className="filter-row" aria-label="状态筛选">
{statusFilters.map(([value, label]) => (
<FilterChip active={statusFilter === value} key={value} label={label} onClick={() => onStatusFilterChange(value)} />
))}
</div>
{canBatchStatus ? (
<div className="batch-actions">
<Button onClick={() => onBatchStatus("active")}>批量启用</Button>
<Button variant="danger" onClick={() => onBatchStatus("disabled")}>批量停用</Button>
</div>
) : null}
</section>
);
}

View File

@ -8,6 +8,7 @@ import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
import Checkbox from "@mui/material/Checkbox"; import Checkbox from "@mui/material/Checkbox";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { UserStatusBadge } from "./UserStatusBadge.jsx"; import { UserStatusBadge } from "./UserStatusBadge.jsx";
export function UserTable({ export function UserTable({
@ -16,11 +17,19 @@ export function UserTable({
canStatus, canStatus,
onEdit, onEdit,
onOpenProfile = () => {}, onOpenProfile = () => {},
onQueryChange,
onResetPassword, onResetPassword,
onRoleFilterChange,
onStatusFilterChange,
onToggleDisabled, onToggleDisabled,
onToggleLocked, onToggleLocked,
query,
roleFilter,
roleFilters = [],
selectedIds, selectedIds,
setSelectedIds, setSelectedIds,
statusFilter,
statusFilters = [],
users users
}) { }) {
const allChecked = users.length > 0 && users.every((user) => selectedIds.includes(user.id)); const allChecked = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
@ -51,28 +60,55 @@ export function UserTable({
key: "user", key: "user",
label: "用户", label: "用户",
width: "minmax(180px, 1.4fr)", width: "minmax(180px, 1.4fr)",
filter: createTextColumnFilter({
placeholder: "搜索用户、账号、角色、团队",
value: query,
onChange: onQueryChange
}),
render: (user) => ( render: (user) => (
<div className="user-main">
<button <button
aria-label={`查看 ${user.name || user.account || user.id} 信息`} aria-label={`查看 ${user.name || user.account || user.id} 信息`}
className="user-avatar-button" className="user-main-button"
type="button" type="button"
onClick={() => onOpenProfile(user)} onClick={() => onOpenProfile(user)}
> >
<span className="user-main">
<span className="user-avatar"> <span className="user-avatar">
<Person fontSize="small" /> <Person fontSize="small" />
</span> </span>
<span className="user-main__text">
<span className="service-name">{user.name}</span>
<span className="service-desc">{user.account}</span>
</span>
</span>
</button> </button>
<div>
<div className="service-name">{user.name}</div>
<div className="service-desc">{user.account}</div>
</div>
</div>
) )
}, },
{ key: "role", label: "角色", width: "minmax(120px, 1fr)", render: (user) => user.role || "-" }, {
key: "role",
label: "角色",
width: "minmax(120px, 1fr)",
filter: createOptionsColumnFilter({
options: roleFilters,
placeholder: "搜索角色",
value: roleFilter,
onChange: onRoleFilterChange
}),
render: (user) => user.role || "-"
},
{ key: "team", label: "团队", width: "minmax(120px, 1fr)", render: (user) => user.team || "-" }, { key: "team", label: "团队", width: "minmax(120px, 1fr)", render: (user) => user.team || "-" },
{ key: "status", label: "状态", width: "minmax(96px, 0.72fr)", render: (user) => <UserStatusBadge status={user.status} /> }, {
key: "status",
label: "状态",
width: "minmax(96px, 0.72fr)",
filter: createOptionsColumnFilter({
options: statusFilters,
placeholder: "搜索状态",
value: statusFilter,
onChange: onStatusFilterChange
}),
render: (user) => <UserStatusBadge status={user.status} />
},
{ key: "mfa", label: "MFA", width: "minmax(80px, 0.72fr)" }, { key: "mfa", label: "MFA", width: "minmax(80px, 0.72fr)" },
{ key: "lastLogin", label: "最后登录", width: "minmax(120px, 0.9fr)", className: "muted" }, { key: "lastLogin", label: "最后登录", width: "minmax(120px, 0.9fr)", className: "muted" },
{ {

View File

@ -14,7 +14,7 @@ const baseUser = {
team: "平台" team: "平台"
}; };
test("opens user profile from avatar", async () => { test("opens user profile from user identity", async () => {
const onOpenProfile = vi.fn(); const onOpenProfile = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();

View File

@ -14,7 +14,7 @@ import {
listUsers, listUsers,
resetUserPassword, resetUserPassword,
updateUser, updateUser,
updateUserStatus updateUserStatus,
} from "@/features/users/api"; } from "@/features/users/api";
import { useUserAbilities } from "@/features/users/permissions.js"; import { useUserAbilities } from "@/features/users/permissions.js";
import { userCreateFormSchema, userUpdateFormSchema } from "@/features/users/schema"; import { userCreateFormSchema, userUpdateFormSchema } from "@/features/users/schema";
@ -30,7 +30,7 @@ const emptyForm = {
roleIds: [], roleIds: [],
status: "active", status: "active",
team: "", team: "",
username: "" username: "",
}; };
export function useUsersPage() { export function useUsersPage() {
@ -50,13 +50,13 @@ export function useUsersPage() {
const requestQuery = useMemo( const requestQuery = useMemo(
() => toPageQuery({ keyword: query, page, pageSize, role: roleFilter, status: statusFilter }), () => toPageQuery({ keyword: query, page, pageSize, role: roleFilter, status: statusFilter }),
[page, query, roleFilter, statusFilter] [page, query, roleFilter, statusFilter],
); );
const queryFn = useCallback(async () => { const queryFn = useCallback(async () => {
const [userData, roleData] = await Promise.all([ const [userData, roleData] = await Promise.all([
listUsers(requestQuery), listUsers(requestQuery),
abilities.canViewRoles ? listRoles() : [] abilities.canViewRoles ? listRoles() : [],
]); ]);
setSelectedIds([]); setSelectedIds([]);
return { roles: roleData || [], users: userData || emptyUsers }; return { roles: roleData || [], users: userData || emptyUsers };
@ -66,7 +66,7 @@ export function useUsersPage() {
errorMessage: "加载用户失败", errorMessage: "加载用户失败",
initialData: emptyData, initialData: emptyData,
keepPreviousData: true, keepPreviousData: true,
queryKey: ["users", requestQuery, abilities.canViewRoles] queryKey: ["users", requestQuery, abilities.canViewRoles],
}); });
const users = data?.users || emptyUsers; const users = data?.users || emptyUsers;
@ -97,6 +97,13 @@ export function useUsersPage() {
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setQuery("");
setRoleFilter("");
setStatusFilter("");
setPage(1);
};
const openCreate = () => { const openCreate = () => {
setEditingUser(null); setEditingUser(null);
setForm(emptyForm); setForm(emptyForm);
@ -112,7 +119,7 @@ export function useUsersPage() {
roleIds: user.roleIds || [], roleIds: user.roleIds || [],
status: user.status, status: user.status,
team: user.team || "", team: user.team || "",
username: user.account username: user.account,
}); });
setFormOpen(true); setFormOpen(true);
}; };
@ -154,7 +161,7 @@ export function useUsersPage() {
confirmText: user.status === "locked" ? "解锁" : "锁定", confirmText: user.status === "locked" ? "解锁" : "锁定",
message: `${user.name} 的登录状态会立即变更。`, message: `${user.name} 的登录状态会立即变更。`,
title: user.status === "locked" ? "解锁用户" : "锁定用户", title: user.status === "locked" ? "解锁用户" : "锁定用户",
tone: user.status === "locked" ? "primary" : "danger" tone: user.status === "locked" ? "primary" : "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -170,7 +177,7 @@ export function useUsersPage() {
confirmText: user.status === "disabled" ? "启用" : "停用", confirmText: user.status === "disabled" ? "启用" : "停用",
message: `${user.name} 的账号状态会立即变更。`, message: `${user.name} 的账号状态会立即变更。`,
title: user.status === "disabled" ? "启用用户" : "停用用户", title: user.status === "disabled" ? "启用用户" : "停用用户",
tone: user.status === "disabled" ? "primary" : "danger" tone: user.status === "disabled" ? "primary" : "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -185,7 +192,7 @@ export function useUsersPage() {
confirmText: "重置", confirmText: "重置",
message: `${user.name} 的密码会重置为系统生成的初始密码。`, message: `${user.name} 的密码会重置为系统生成的初始密码。`,
title: "重置密码", title: "重置密码",
tone: "danger" tone: "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -203,7 +210,7 @@ export function useUsersPage() {
confirmText: "确认", confirmText: "确认",
message: `将更新 ${selectedIds.length} 个用户状态。`, message: `将更新 ${selectedIds.length} 个用户状态。`,
title: "批量更新状态", title: "批量更新状态",
tone: status === "active" ? "primary" : "danger" tone: status === "active" ? "primary" : "danger",
}); });
if (!ok) { if (!ok) {
return; return;
@ -216,14 +223,14 @@ export function useUsersPage() {
const downloadUsers = () => { const downloadUsers = () => {
return downloadCsv( return downloadCsv(
() => exportUsers({ keyword: query, role: roleFilter, status: statusFilter }), () => exportUsers({ keyword: query, role: roleFilter, status: statusFilter }),
"hyapp-users.csv" "hyapp-users.csv",
); );
}; };
const setRoleChecked = (roleId, checked) => { const setRoleChecked = (roleId, checked) => {
setForm((current) => ({ setForm((current) => ({
...current, ...current,
roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId) roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId),
})); }));
}; };
@ -250,6 +257,7 @@ export function useUsersPage() {
query, query,
reload, reload,
resetPassword, resetPassword,
resetFilters,
roleFilter, roleFilter,
roleFilters, roleFilters,
roles, roles,
@ -263,6 +271,6 @@ export function useUsersPage() {
submitUser, submitUser,
toggleDisabled, toggleDisabled,
toggleLocked, toggleLocked,
users users,
}; };
} }

View File

@ -7,11 +7,10 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminFilterSelect, AdminFilterResetButton,
AdminListBody, AdminListBody,
AdminListPage, AdminListPage,
AdminListToolbar, AdminListToolbar,
AdminSearchBox
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { UserFormDrawer } from "@/features/users/components/UserFormDrawer.jsx"; import { UserFormDrawer } from "@/features/users/components/UserFormDrawer.jsx";
import { UserProfileDrawer } from "@/features/users/components/UserProfileDrawer.jsx"; import { UserProfileDrawer } from "@/features/users/components/UserProfileDrawer.jsx";
@ -26,14 +25,13 @@ export function UserManagementPage() {
<> <>
<AdminListPage> <AdminListPage>
<AdminListToolbar <AdminListToolbar
filters={( filters={
<> <AdminFilterResetButton
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索用户、账号、角色、团队..." /> disabled={!page.query && !page.roleFilter && !page.statusFilter}
<AdminFilterSelect label="角色" options={page.roleFilters} value={page.roleFilter} onChange={page.changeRoleFilter} /> onClick={page.resetFilters}
<AdminFilterSelect options={userStatusFilters} value={page.statusFilter} onChange={page.changeStatusFilter} /> />
</> }
)} actions={
actions={(
<> <>
{page.abilities.canViewRoles ? ( {page.abilities.canViewRoles ? (
<AdminActionIconButton label="角色配置" onClick={() => page.navigate("/system/roles")}> <AdminActionIconButton label="角色配置" onClick={() => page.navigate("/system/roles")}>
@ -45,7 +43,11 @@ export function UserManagementPage() {
<AdminActionIconButton label="批量启用" onClick={() => page.batchStatus("active")}> <AdminActionIconButton label="批量启用" onClick={() => page.batchStatus("active")}>
<VerifiedUserOutlined fontSize="small" /> <VerifiedUserOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
<AdminActionIconButton label="批量停用" tone="danger" onClick={() => page.batchStatus("disabled")}> <AdminActionIconButton
label="批量停用"
tone="danger"
onClick={() => page.batchStatus("disabled")}
>
<PersonRemoveOutlined fontSize="small" /> <PersonRemoveOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
</> </>
@ -61,7 +63,7 @@ export function UserManagementPage() {
</AdminActionIconButton> </AdminActionIconButton>
) : null} ) : null}
</> </>
)} }
/> />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -75,11 +77,24 @@ export function UserManagementPage() {
onResetPassword={page.resetPassword} onResetPassword={page.resetPassword}
onToggleDisabled={page.toggleDisabled} onToggleDisabled={page.toggleDisabled}
onToggleLocked={page.toggleLocked} onToggleLocked={page.toggleLocked}
query={page.query}
roleFilter={page.roleFilter}
roleFilters={page.roleFilters}
selectedIds={page.selectedIds} selectedIds={page.selectedIds}
setSelectedIds={page.setSelectedIds} setSelectedIds={page.setSelectedIds}
statusFilter={page.statusFilter}
statusFilters={userStatusFilters}
onQueryChange={page.changeQuery}
onRoleFilterChange={page.changeRoleFilter}
onStatusFilterChange={page.changeStatusFilter}
users={page.users.items || []} users={page.users.items || []}
/> />
<PaginationBar page={page.page} pageSize={page.users.pageSize || 10} total={page.users.total || 0} onPageChange={page.setPage} /> <PaginationBar
page={page.page}
pageSize={page.users.pageSize || 10}
total={page.users.total || 0}
onPageChange={page.setPage}
/>
</AdminListBody> </AdminListBody>
</DataState> </DataState>
</AdminListPage> </AdminListPage>
@ -94,11 +109,7 @@ export function UserManagementPage() {
roles={page.roles} roles={page.roles}
setForm={page.setForm} setForm={page.setForm}
/> />
<UserProfileDrawer <UserProfileDrawer onClose={page.closeProfile} open={Boolean(page.profileUser)} user={page.profileUser} />
onClose={page.closeProfile}
open={Boolean(page.profileUser)}
user={page.profileUser}
/>
</> </>
); );
} }

View File

@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const usersRoutes = [ export const usersRoutes = [
{ {
label: "用户管理", label: "后台用户",
loader: () => import("./pages/UserManagementPage.jsx").then((module) => module.UserManagementPage), loader: () => import("./pages/UserManagementPage.jsx").then((module) => module.UserManagementPage),
menuCode: MENU_CODES.systemUsers, menuCode: MENU_CODES.systemUsers,
pageKey: "users", pageKey: "users",

View File

@ -19,6 +19,59 @@
gap: var(--space-3); gap: var(--space-3);
} }
.user-main-button {
display: block;
width: 100%;
padding: 2px;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background var(--motion-base) var(--ease-standard),
box-shadow var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.user-main-button:hover {
background: var(--bg-hover);
}
.user-main-button:hover .service-name {
color: var(--primary);
}
.user-main-button:focus-visible {
outline: 2px solid var(--primary-border-strong);
outline-offset: 2px;
}
.user-main__text {
display: grid;
min-width: 0;
gap: 2px;
}
.user-main__text .service-name,
.user-main__text .service-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-main__text .service-name {
color: var(--text-primary);
font-weight: 700;
}
.user-main__text .service-desc {
color: var(--text-secondary);
font-size: 12px;
}
.user-avatar { .user-avatar {
display: grid; display: grid;
flex: 0 0 auto; flex: 0 0 auto;
@ -33,24 +86,6 @@
transform var(--motion-base) var(--ease-emphasized); transform var(--motion-base) var(--ease-emphasized);
} }
.user-avatar-button {
display: grid;
flex: 0 0 auto;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: inherit;
cursor: pointer;
}
.user-avatar-button:focus-visible {
outline: 2px solid var(--primary-border-strong);
outline-offset: 2px;
}
.admin-row:hover .user-avatar { .admin-row:hover .user-avatar {
background: var(--primary-surface-strong); background: var(--primary-surface-strong);
transform: scale(1.08); transform: scale(1.08);

View File

@ -38,6 +38,7 @@ export const API_OPERATIONS = {
createRole: "createRole", createRole: "createRole",
createUser: "createUser", createUser: "createUser",
createUserExportJob: "createUserExportJob", createUserExportJob: "createUserExportJob",
creditCoinSellerStock: "creditCoinSellerStock",
dashboardOverview: "dashboardOverview", dashboardOverview: "dashboardOverview",
deleteBanner: "deleteBanner", deleteBanner: "deleteBanner",
deleteCountry: "deleteCountry", deleteCountry: "deleteCountry",
@ -126,7 +127,7 @@ export const API_OPERATIONS = {
updateUser: "updateUser", updateUser: "updateUser",
updateUserStatus: "updateUserStatus", updateUserStatus: "updateUserStatus",
uploadFile: "uploadFile", uploadFile: "uploadFile",
uploadImage: "uploadImage" uploadImage: "uploadImage",
} as const; } as const;
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS]; export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
@ -137,783 +138,790 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
operationId: API_OPERATIONS.appBanUser, operationId: API_OPERATIONS.appBanUser,
path: "/v1/app/users/{id}/ban", path: "/v1/app/users/{id}/ban",
permission: "app-user:status", permission: "app-user:status",
permissions: ["app-user:status"] permissions: ["app-user:status"],
}, },
appGetUser: { appGetUser: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.appGetUser, operationId: API_OPERATIONS.appGetUser,
path: "/v1/app/users/{id}", path: "/v1/app/users/{id}",
permission: "app-user:view", permission: "app-user:view",
permissions: ["app-user:view"] permissions: ["app-user:view"],
}, },
appListUsers: { appListUsers: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.appListUsers, operationId: API_OPERATIONS.appListUsers,
path: "/v1/app/users", path: "/v1/app/users",
permission: "app-user:view", permission: "app-user:view",
permissions: ["app-user:view"] permissions: ["app-user:view"],
}, },
appSetPassword: { appSetPassword: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.appSetPassword, operationId: API_OPERATIONS.appSetPassword,
path: "/v1/app/users/{id}/password", path: "/v1/app/users/{id}/password",
permission: "app-user:password", permission: "app-user:password",
permissions: ["app-user:password"] permissions: ["app-user:password"],
}, },
appUnbanUser: { appUnbanUser: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.appUnbanUser, operationId: API_OPERATIONS.appUnbanUser,
path: "/v1/app/users/{id}/unban", path: "/v1/app/users/{id}/unban",
permission: "app-user:status", permission: "app-user:status",
permissions: ["app-user:status"] permissions: ["app-user:status"],
}, },
appUpdateUser: { appUpdateUser: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.appUpdateUser, operationId: API_OPERATIONS.appUpdateUser,
path: "/v1/app/users/{id}", path: "/v1/app/users/{id}",
permission: "app-user:update", permission: "app-user:update",
permissions: ["app-user:update"] permissions: ["app-user:update"],
}, },
batchUpdateUserStatus: { batchUpdateUserStatus: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.batchUpdateUserStatus, operationId: API_OPERATIONS.batchUpdateUserStatus,
path: "/v1/users/batch/status", path: "/v1/users/batch/status",
permission: "user:status", permission: "user:status",
permissions: ["user:status"] permissions: ["user:status"],
}, },
cancelJob: { cancelJob: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.cancelJob, operationId: API_OPERATIONS.cancelJob,
path: "/v1/jobs/{id}/cancel", path: "/v1/jobs/{id}/cancel",
permission: "job:cancel", permission: "job:cancel",
permissions: ["job:cancel"] permissions: ["job:cancel"],
}, },
changePassword: { changePassword: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.changePassword, operationId: API_OPERATIONS.changePassword,
path: "/v1/auth/change-password" path: "/v1/auth/change-password",
}, },
closeAgency: { closeAgency: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.closeAgency, operationId: API_OPERATIONS.closeAgency,
path: "/v1/admin/agencies/{agency_id}/close", path: "/v1/admin/agencies/{agency_id}/close",
permission: "agency:status", permission: "agency:status",
permissions: ["agency:status"] permissions: ["agency:status"],
}, },
createAgency: { createAgency: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createAgency, operationId: API_OPERATIONS.createAgency,
path: "/v1/admin/agencies", path: "/v1/admin/agencies",
permission: "agency:create", permission: "agency:create",
permissions: ["agency:create"] permissions: ["agency:create"],
}, },
createBanner: { createBanner: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createBanner, operationId: API_OPERATIONS.createBanner,
path: "/v1/admin/app-config/banners", path: "/v1/admin/app-config/banners",
permission: "app-config:update", permission: "app-config:update",
permissions: ["app-config:update"] permissions: ["app-config:update"],
}, },
createBD: { createBD: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createBD, operationId: API_OPERATIONS.createBD,
path: "/v1/admin/bds", path: "/v1/admin/bds",
permission: "bd:create", permission: "bd:create",
permissions: ["bd:create"] permissions: ["bd:create"],
}, },
createBDLeader: { createBDLeader: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createBDLeader, operationId: API_OPERATIONS.createBDLeader,
path: "/v1/admin/bd-leaders", path: "/v1/admin/bd-leaders",
permission: "bd:create", permission: "bd:create",
permissions: ["bd:create"] permissions: ["bd:create"],
}, },
createCoinSeller: { createCoinSeller: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createCoinSeller, operationId: API_OPERATIONS.createCoinSeller,
path: "/v1/admin/coin-sellers", path: "/v1/admin/coin-sellers",
permission: "coin-seller:create", permission: "coin-seller:create",
permissions: ["coin-seller:create"] permissions: ["coin-seller:create"],
}, },
createCountry: { createCountry: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createCountry, operationId: API_OPERATIONS.createCountry,
path: "/v1/admin/countries", path: "/v1/admin/countries",
permission: "country:create", permission: "country:create",
permissions: ["country:create"] permissions: ["country:create"],
}, },
createGift: { createGift: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createGift, operationId: API_OPERATIONS.createGift,
path: "/v1/admin/gifts", path: "/v1/admin/gifts",
permission: "gift:create", permission: "gift:create",
permissions: ["gift:create"] permissions: ["gift:create"],
}, },
createMenu: { createMenu: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createMenu, operationId: API_OPERATIONS.createMenu,
path: "/v1/system/menus", path: "/v1/system/menus",
permission: "menu:create", permission: "menu:create",
permissions: ["menu:create"] permissions: ["menu:create"],
}, },
createPermission: { createPermission: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createPermission, operationId: API_OPERATIONS.createPermission,
path: "/v1/permissions", path: "/v1/permissions",
permission: "permission:create", permission: "permission:create",
permissions: ["permission:create"] permissions: ["permission:create"],
}, },
createRegion: { createRegion: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createRegion, operationId: API_OPERATIONS.createRegion,
path: "/v1/admin/regions", path: "/v1/admin/regions",
permission: "region:create", permission: "region:create",
permissions: ["region:create"] permissions: ["region:create"],
}, },
createResource: { createResource: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createResource, operationId: API_OPERATIONS.createResource,
path: "/v1/admin/resources", path: "/v1/admin/resources",
permission: "resource:create", permission: "resource:create",
permissions: ["resource:create"] permissions: ["resource:create"],
}, },
createResourceGroup: { createResourceGroup: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createResourceGroup, operationId: API_OPERATIONS.createResourceGroup,
path: "/v1/admin/resource-groups", path: "/v1/admin/resource-groups",
permission: "resource-group:create", permission: "resource-group:create",
permissions: ["resource-group:create"] permissions: ["resource-group:create"],
}, },
createRole: { createRole: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createRole, operationId: API_OPERATIONS.createRole,
path: "/v1/roles", path: "/v1/roles",
permission: "role:create", permission: "role:create",
permissions: ["role:create","role:manage"] permissions: ["role:create", "role:manage"],
}, },
createUser: { createUser: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createUser, operationId: API_OPERATIONS.createUser,
path: "/v1/users", path: "/v1/users",
permission: "user:create", permission: "user:create",
permissions: ["user:create"] permissions: ["user:create"],
}, },
createUserExportJob: { createUserExportJob: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createUserExportJob, operationId: API_OPERATIONS.createUserExportJob,
path: "/v1/exports/users", path: "/v1/exports/users",
permission: "export:create", permission: "export:create",
permissions: ["export:create"] permissions: ["export:create"],
},
creditCoinSellerStock: {
method: "POST",
operationId: API_OPERATIONS.creditCoinSellerStock,
path: "/v1/admin/coin-sellers/{user_id}/stock-credits",
permission: "coin-seller:stock-credit",
permissions: ["coin-seller:stock-credit"],
}, },
dashboardOverview: { dashboardOverview: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.dashboardOverview, operationId: API_OPERATIONS.dashboardOverview,
path: "/v1/dashboard/overview", path: "/v1/dashboard/overview",
permission: "overview:view", permission: "overview:view",
permissions: ["overview:view"] permissions: ["overview:view"],
}, },
deleteBanner: { deleteBanner: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteBanner, operationId: API_OPERATIONS.deleteBanner,
path: "/v1/admin/app-config/banners/{banner_id}", path: "/v1/admin/app-config/banners/{banner_id}",
permission: "app-config:update", permission: "app-config:update",
permissions: ["app-config:update"] permissions: ["app-config:update"],
}, },
deleteCountry: { deleteCountry: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteCountry, operationId: API_OPERATIONS.deleteCountry,
path: "/v1/admin/countries/{country_id}", path: "/v1/admin/countries/{country_id}",
permission: "country:status", permission: "country:status",
permissions: ["country:status"] permissions: ["country:status"],
}, },
deleteMenu: { deleteMenu: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteMenu, operationId: API_OPERATIONS.deleteMenu,
path: "/v1/system/menus/{id}", path: "/v1/system/menus/{id}",
permission: "menu:delete", permission: "menu:delete",
permissions: ["menu:delete"] permissions: ["menu:delete"],
}, },
deleteNotification: { deleteNotification: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteNotification, operationId: API_OPERATIONS.deleteNotification,
path: "/v1/notifications/{id}", path: "/v1/notifications/{id}",
permission: "notification:delete", permission: "notification:delete",
permissions: ["notification:delete"] permissions: ["notification:delete"],
}, },
deletePermission: { deletePermission: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deletePermission, operationId: API_OPERATIONS.deletePermission,
path: "/v1/permissions/{id}", path: "/v1/permissions/{id}",
permission: "permission:delete", permission: "permission:delete",
permissions: ["permission:delete"] permissions: ["permission:delete"],
}, },
deleteRole: { deleteRole: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteRole, operationId: API_OPERATIONS.deleteRole,
path: "/v1/roles/{id}", path: "/v1/roles/{id}",
permission: "role:delete", permission: "role:delete",
permissions: ["role:delete","role:manage"] permissions: ["role:delete", "role:manage"],
}, },
deleteRoom: { deleteRoom: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteRoom, operationId: API_OPERATIONS.deleteRoom,
path: "/v1/admin/rooms/{room_id}", path: "/v1/admin/rooms/{room_id}",
permission: "room:delete", permission: "room:delete",
permissions: ["room:delete"] permissions: ["room:delete"],
}, },
disableCountry: { disableCountry: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.disableCountry, operationId: API_OPERATIONS.disableCountry,
path: "/v1/admin/countries/{country_id}/disable", path: "/v1/admin/countries/{country_id}/disable",
permission: "country:status", permission: "country:status",
permissions: ["country:status"] permissions: ["country:status"],
}, },
disableGift: { disableGift: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.disableGift, operationId: API_OPERATIONS.disableGift,
path: "/v1/admin/gifts/{gift_id}/disable", path: "/v1/admin/gifts/{gift_id}/disable",
permission: "gift:status", permission: "gift:status",
permissions: ["gift:status"] permissions: ["gift:status"],
}, },
disableRegion: { disableRegion: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.disableRegion, operationId: API_OPERATIONS.disableRegion,
path: "/v1/admin/regions/{region_id}", path: "/v1/admin/regions/{region_id}",
permission: "region:status", permission: "region:status",
permissions: ["region:status"] permissions: ["region:status"],
}, },
disableResource: { disableResource: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.disableResource, operationId: API_OPERATIONS.disableResource,
path: "/v1/admin/resources/{resource_id}/disable", path: "/v1/admin/resources/{resource_id}/disable",
permission: "resource:update", permission: "resource:update",
permissions: ["resource:update"] permissions: ["resource:update"],
}, },
disableResourceGroup: { disableResourceGroup: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.disableResourceGroup, operationId: API_OPERATIONS.disableResourceGroup,
path: "/v1/admin/resource-groups/{group_id}/disable", path: "/v1/admin/resource-groups/{group_id}/disable",
permission: "resource-group:update", permission: "resource-group:update",
permissions: ["resource-group:update"] permissions: ["resource-group:update"],
}, },
downloadJobArtifact: { downloadJobArtifact: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.downloadJobArtifact, operationId: API_OPERATIONS.downloadJobArtifact,
path: "/v1/jobs/{id}/artifact", path: "/v1/jobs/{id}/artifact",
permission: "job:view", permission: "job:view",
permissions: ["job:view"] permissions: ["job:view"],
}, },
enableCountry: { enableCountry: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.enableCountry, operationId: API_OPERATIONS.enableCountry,
path: "/v1/admin/countries/{country_id}/enable", path: "/v1/admin/countries/{country_id}/enable",
permission: "country:status", permission: "country:status",
permissions: ["country:status"] permissions: ["country:status"],
}, },
enableGift: { enableGift: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.enableGift, operationId: API_OPERATIONS.enableGift,
path: "/v1/admin/gifts/{gift_id}/enable", path: "/v1/admin/gifts/{gift_id}/enable",
permission: "gift:status", permission: "gift:status",
permissions: ["gift:status"] permissions: ["gift:status"],
}, },
enableRegion: { enableRegion: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.enableRegion, operationId: API_OPERATIONS.enableRegion,
path: "/v1/admin/regions/{region_id}/enable", path: "/v1/admin/regions/{region_id}/enable",
permission: "region:status", permission: "region:status",
permissions: ["region:status"] permissions: ["region:status"],
}, },
enableResource: { enableResource: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.enableResource, operationId: API_OPERATIONS.enableResource,
path: "/v1/admin/resources/{resource_id}/enable", path: "/v1/admin/resources/{resource_id}/enable",
permission: "resource:update", permission: "resource:update",
permissions: ["resource:update"] permissions: ["resource:update"],
}, },
enableResourceGroup: { enableResourceGroup: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.enableResourceGroup, operationId: API_OPERATIONS.enableResourceGroup,
path: "/v1/admin/resource-groups/{group_id}/enable", path: "/v1/admin/resource-groups/{group_id}/enable",
permission: "resource-group:update", permission: "resource-group:update",
permissions: ["resource-group:update"] permissions: ["resource-group:update"],
}, },
exportLoginLogs: { exportLoginLogs: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.exportLoginLogs, operationId: API_OPERATIONS.exportLoginLogs,
path: "/v1/logs/login/export", path: "/v1/logs/login/export",
permission: "log:export", permission: "log:export",
permissions: ["log:export"] permissions: ["log:export"],
}, },
exportOperationLogs: { exportOperationLogs: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.exportOperationLogs, operationId: API_OPERATIONS.exportOperationLogs,
path: "/v1/logs/operations/export", path: "/v1/logs/operations/export",
permission: "log:export", permission: "log:export",
permissions: ["log:export"] permissions: ["log:export"],
}, },
exportUsers: { exportUsers: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.exportUsers, operationId: API_OPERATIONS.exportUsers,
path: "/v1/users/export", path: "/v1/users/export",
permission: "user:export", permission: "user:export",
permissions: ["user:export"] permissions: ["user:export"],
}, },
getCountry: { getCountry: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getCountry, operationId: API_OPERATIONS.getCountry,
path: "/v1/admin/countries/{country_id}", path: "/v1/admin/countries/{country_id}",
permission: "country:view", permission: "country:view",
permissions: ["country:view"] permissions: ["country:view"],
}, },
getRegion: { getRegion: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getRegion, operationId: API_OPERATIONS.getRegion,
path: "/v1/admin/regions/{region_id}", path: "/v1/admin/regions/{region_id}",
permission: "region:view", permission: "region:view",
permissions: ["region:view"] permissions: ["region:view"],
}, },
getResource: { getResource: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getResource, operationId: API_OPERATIONS.getResource,
path: "/v1/admin/resources/{resource_id}", path: "/v1/admin/resources/{resource_id}",
permission: "resource:view", permission: "resource:view",
permissions: ["resource:view"] permissions: ["resource:view"],
}, },
getResourceGroup: { getResourceGroup: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getResourceGroup, operationId: API_OPERATIONS.getResourceGroup,
path: "/v1/admin/resource-groups/{group_id}", path: "/v1/admin/resource-groups/{group_id}",
permission: "resource-group:view", permission: "resource-group:view",
permissions: ["resource-group:view"] permissions: ["resource-group:view"],
}, },
getRoleDataScopes: { getRoleDataScopes: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getRoleDataScopes, operationId: API_OPERATIONS.getRoleDataScopes,
path: "/v1/roles/{id}/data-scopes", path: "/v1/roles/{id}/data-scopes",
permission: "role:data-scope", permission: "role:data-scope",
permissions: ["role:data-scope"] permissions: ["role:data-scope"],
}, },
getUser: { getUser: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getUser, operationId: API_OPERATIONS.getUser,
path: "/v1/users/{id}", path: "/v1/users/{id}",
permission: "user:view", permission: "user:view",
permissions: ["user:view"] permissions: ["user:view"],
}, },
grantResource: { grantResource: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.grantResource, operationId: API_OPERATIONS.grantResource,
path: "/v1/admin/resource-grants/resource", path: "/v1/admin/resource-grants/resource",
permission: "resource-grant:create", permission: "resource-grant:create",
permissions: ["resource-grant:create"] permissions: ["resource-grant:create"],
}, },
grantResourceGroup: { grantResourceGroup: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.grantResourceGroup, operationId: API_OPERATIONS.grantResourceGroup,
path: "/v1/admin/resource-grants/group", path: "/v1/admin/resource-grants/group",
permission: "resource-grant:create", permission: "resource-grant:create",
permissions: ["resource-grant:create"] permissions: ["resource-grant:create"],
}, },
listAgencies: { listAgencies: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listAgencies, operationId: API_OPERATIONS.listAgencies,
path: "/v1/admin/agencies", path: "/v1/admin/agencies",
permission: "agency:view", permission: "agency:view",
permissions: ["agency:view"] permissions: ["agency:view"],
}, },
listApps: { listApps: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listApps, operationId: API_OPERATIONS.listApps,
path: "/v1/admin/apps" path: "/v1/admin/apps",
}, },
listBanners: { listBanners: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listBanners, operationId: API_OPERATIONS.listBanners,
path: "/v1/admin/app-config/banners", path: "/v1/admin/app-config/banners",
permission: "app-config:view", permission: "app-config:view",
permissions: ["app-config:view"] permissions: ["app-config:view"],
}, },
listBDLeaders: { listBDLeaders: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listBDLeaders, operationId: API_OPERATIONS.listBDLeaders,
path: "/v1/admin/bd-leaders", path: "/v1/admin/bd-leaders",
permission: "bd:view", permission: "bd:view",
permissions: ["bd:view"] permissions: ["bd:view"],
}, },
listBDs: { listBDs: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listBDs, operationId: API_OPERATIONS.listBDs,
path: "/v1/admin/bds", path: "/v1/admin/bds",
permission: "bd:view", permission: "bd:view",
permissions: ["bd:view"] permissions: ["bd:view"],
}, },
listCoinSellers: { listCoinSellers: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listCoinSellers, operationId: API_OPERATIONS.listCoinSellers,
path: "/v1/admin/coin-sellers", path: "/v1/admin/coin-sellers",
permission: "coin-seller:view", permission: "coin-seller:view",
permissions: ["coin-seller:view"] permissions: ["coin-seller:view"],
}, },
listCountries: { listCountries: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listCountries, operationId: API_OPERATIONS.listCountries,
path: "/v1/admin/countries", path: "/v1/admin/countries",
permission: "country:view", permission: "country:view",
permissions: ["country:view"] permissions: ["country:view"],
}, },
listGifts: { listGifts: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listGifts, operationId: API_OPERATIONS.listGifts,
path: "/v1/admin/gifts", path: "/v1/admin/gifts",
permission: "gift:view", permission: "gift:view",
permissions: ["gift:view"] permissions: ["gift:view"],
}, },
listH5Links: { listH5Links: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listH5Links, operationId: API_OPERATIONS.listH5Links,
path: "/v1/admin/app-config/h5-links", path: "/v1/admin/app-config/h5-links",
permission: "app-config:view", permission: "app-config:view",
permissions: ["app-config:view"] permissions: ["app-config:view"],
}, },
listHosts: { listHosts: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listHosts, operationId: API_OPERATIONS.listHosts,
path: "/v1/admin/hosts", path: "/v1/admin/hosts",
permission: "host:view", permission: "host:view",
permissions: ["host:view"] permissions: ["host:view"],
}, },
listJobs: { listJobs: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listJobs, operationId: API_OPERATIONS.listJobs,
path: "/v1/jobs", path: "/v1/jobs",
permission: "job:view", permission: "job:view",
permissions: ["job:view"] permissions: ["job:view"],
}, },
listLoginLogs: { listLoginLogs: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listLoginLogs, operationId: API_OPERATIONS.listLoginLogs,
path: "/v1/logs/login", path: "/v1/logs/login",
permission: "log:view", permission: "log:view",
permissions: ["log:view"] permissions: ["log:view"],
}, },
listNotifications: { listNotifications: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listNotifications, operationId: API_OPERATIONS.listNotifications,
path: "/v1/notifications", path: "/v1/notifications",
permission: "notification:view", permission: "notification:view",
permissions: ["notification:view"] permissions: ["notification:view"],
}, },
listOperationLogs: { listOperationLogs: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listOperationLogs, operationId: API_OPERATIONS.listOperationLogs,
path: "/v1/logs/operations", path: "/v1/logs/operations",
permission: "log:view", permission: "log:view",
permissions: ["log:view"] permissions: ["log:view"],
}, },
listPermissions: { listPermissions: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listPermissions, operationId: API_OPERATIONS.listPermissions,
path: "/v1/permissions", path: "/v1/permissions",
permission: "permission:view", permission: "permission:view",
permissions: ["permission:view","role:permission","role:manage"] permissions: ["permission:view", "role:permission", "role:manage"],
}, },
listRechargeBills: { listRechargeBills: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listRechargeBills, operationId: API_OPERATIONS.listRechargeBills,
path: "/v1/admin/payment/recharge-bills", path: "/v1/admin/payment/recharge-bills",
permission: "payment-bill:view", permission: "payment-bill:view",
permissions: ["payment-bill:view"] permissions: ["payment-bill:view"],
}, },
listRegions: { listRegions: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listRegions, operationId: API_OPERATIONS.listRegions,
path: "/v1/admin/regions", path: "/v1/admin/regions",
permission: "region:view", permission: "region:view",
permissions: ["region:view"] permissions: ["region:view"],
}, },
listResourceGrants: { listResourceGrants: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listResourceGrants, operationId: API_OPERATIONS.listResourceGrants,
path: "/v1/admin/resource-grants", path: "/v1/admin/resource-grants",
permission: "resource-grant:view", permission: "resource-grant:view",
permissions: ["resource-grant:view"] permissions: ["resource-grant:view"],
}, },
listResourceGroups: { listResourceGroups: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listResourceGroups, operationId: API_OPERATIONS.listResourceGroups,
path: "/v1/admin/resource-groups", path: "/v1/admin/resource-groups",
permission: "resource-group:view", permission: "resource-group:view",
permissions: ["resource-group:view"] permissions: ["resource-group:view"],
}, },
listResources: { listResources: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listResources, operationId: API_OPERATIONS.listResources,
path: "/v1/admin/resources", path: "/v1/admin/resources",
permission: "resource:view", permission: "resource:view",
permissions: ["resource:view"] permissions: ["resource:view"],
}, },
listRoles: { listRoles: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listRoles, operationId: API_OPERATIONS.listRoles,
path: "/v1/roles", path: "/v1/roles",
permission: "role:view", permission: "role:view",
permissions: ["role:view"] permissions: ["role:view"],
}, },
listRooms: { listRooms: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listRooms, operationId: API_OPERATIONS.listRooms,
path: "/v1/admin/rooms", path: "/v1/admin/rooms",
permission: "room:view", permission: "room:view",
permissions: ["room:view"] permissions: ["room:view"],
}, },
listSystemMenus: { listSystemMenus: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listSystemMenus, operationId: API_OPERATIONS.listSystemMenus,
path: "/v1/system/menus", path: "/v1/system/menus",
permission: "menu:view", permission: "menu:view",
permissions: ["menu:view"] permissions: ["menu:view"],
}, },
listUsers: { listUsers: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listUsers, operationId: API_OPERATIONS.listUsers,
path: "/v1/users", path: "/v1/users",
permission: "user:view", permission: "user:view",
permissions: ["user:view"] permissions: ["user:view"],
}, },
login: { login: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.login, operationId: API_OPERATIONS.login,
path: "/v1/auth/login" path: "/v1/auth/login",
}, },
logout: { logout: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.logout, operationId: API_OPERATIONS.logout,
path: "/v1/auth/logout" path: "/v1/auth/logout",
}, },
markAllNotificationsRead: { markAllNotificationsRead: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.markAllNotificationsRead, operationId: API_OPERATIONS.markAllNotificationsRead,
path: "/v1/notifications/read-all", path: "/v1/notifications/read-all",
permission: "notification:read-all", permission: "notification:read-all",
permissions: ["notification:read-all"] permissions: ["notification:read-all"],
}, },
markNotificationRead: { markNotificationRead: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.markNotificationRead, operationId: API_OPERATIONS.markNotificationRead,
path: "/v1/notifications/{id}/read", path: "/v1/notifications/{id}/read",
permission: "notification:read", permission: "notification:read",
permissions: ["notification:read"] permissions: ["notification:read"],
}, },
me: { me: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.me, operationId: API_OPERATIONS.me,
path: "/v1/auth/me" path: "/v1/auth/me",
}, },
navigationMenus: { navigationMenus: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.navigationMenus, operationId: API_OPERATIONS.navigationMenus,
path: "/v1/navigation/menus" path: "/v1/navigation/menus",
}, },
refresh: { refresh: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.refresh, operationId: API_OPERATIONS.refresh,
path: "/v1/auth/refresh" path: "/v1/auth/refresh",
}, },
replaceRegionCountries: { replaceRegionCountries: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.replaceRegionCountries, operationId: API_OPERATIONS.replaceRegionCountries,
path: "/v1/admin/regions/{region_id}/countries", path: "/v1/admin/regions/{region_id}/countries",
permission: "region:update", permission: "region:update",
permissions: ["region:update"] permissions: ["region:update"],
}, },
replaceRoleDataScopes: { replaceRoleDataScopes: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.replaceRoleDataScopes, operationId: API_OPERATIONS.replaceRoleDataScopes,
path: "/v1/roles/{id}/data-scopes", path: "/v1/roles/{id}/data-scopes",
permission: "role:data-scope", permission: "role:data-scope",
permissions: ["role:data-scope"] permissions: ["role:data-scope"],
}, },
replaceRolePermissions: { replaceRolePermissions: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.replaceRolePermissions, operationId: API_OPERATIONS.replaceRolePermissions,
path: "/v1/roles/{id}/permissions", path: "/v1/roles/{id}/permissions",
permission: "role:permission", permission: "role:permission",
permissions: ["role:permission","role:manage"] permissions: ["role:permission", "role:manage"],
}, },
resetUserPassword: { resetUserPassword: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.resetUserPassword, operationId: API_OPERATIONS.resetUserPassword,
path: "/v1/users/{id}/reset-password", path: "/v1/users/{id}/reset-password",
permission: "user:reset-password", permission: "user:reset-password",
permissions: ["user:reset-password"] permissions: ["user:reset-password"],
}, },
search: { search: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.search, operationId: API_OPERATIONS.search,
path: "/v1/search" path: "/v1/search",
}, },
setAgencyJoinEnabled: { setAgencyJoinEnabled: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.setAgencyJoinEnabled, operationId: API_OPERATIONS.setAgencyJoinEnabled,
path: "/v1/admin/agencies/{agency_id}/join-enabled", path: "/v1/admin/agencies/{agency_id}/join-enabled",
permission: "agency:status", permission: "agency:status",
permissions: ["agency:status"] permissions: ["agency:status"],
}, },
setBDLeaderStatus: { setBDLeaderStatus: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.setBDLeaderStatus, operationId: API_OPERATIONS.setBDLeaderStatus,
path: "/v1/admin/bd-leaders/{user_id}/status", path: "/v1/admin/bd-leaders/{user_id}/status",
permission: "bd:update", permission: "bd:update",
permissions: ["bd:update"] permissions: ["bd:update"],
}, },
setBDStatus: { setBDStatus: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.setBDStatus, operationId: API_OPERATIONS.setBDStatus,
path: "/v1/admin/bds/{user_id}/status", path: "/v1/admin/bds/{user_id}/status",
permission: "bd:update", permission: "bd:update",
permissions: ["bd:update"] permissions: ["bd:update"],
}, },
setCoinSellerStatus: { setCoinSellerStatus: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.setCoinSellerStatus, operationId: API_OPERATIONS.setCoinSellerStatus,
path: "/v1/admin/coin-sellers/{user_id}/status", path: "/v1/admin/coin-sellers/{user_id}/status",
permission: "coin-seller:update", permission: "coin-seller:update",
permissions: ["coin-seller:update"] permissions: ["coin-seller:update"],
}, },
sortMenus: { sortMenus: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.sortMenus, operationId: API_OPERATIONS.sortMenus,
path: "/v1/system/menus/sort", path: "/v1/system/menus/sort",
permission: "menu:sort", permission: "menu:sort",
permissions: ["menu:sort"] permissions: ["menu:sort"],
}, },
syncPermissions: { syncPermissions: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.syncPermissions, operationId: API_OPERATIONS.syncPermissions,
path: "/v1/permissions/sync", path: "/v1/permissions/sync",
permission: "permission:sync", permission: "permission:sync",
permissions: ["permission:sync"] permissions: ["permission:sync"],
}, },
updateBanner: { updateBanner: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updateBanner, operationId: API_OPERATIONS.updateBanner,
path: "/v1/admin/app-config/banners/{banner_id}", path: "/v1/admin/app-config/banners/{banner_id}",
permission: "app-config:update", permission: "app-config:update",
permissions: ["app-config:update"] permissions: ["app-config:update"],
}, },
updateCountry: { updateCountry: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateCountry, operationId: API_OPERATIONS.updateCountry,
path: "/v1/admin/countries/{country_id}", path: "/v1/admin/countries/{country_id}",
permission: "country:update", permission: "country:update",
permissions: ["country:update"] permissions: ["country:update"],
}, },
updateGift: { updateGift: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updateGift, operationId: API_OPERATIONS.updateGift,
path: "/v1/admin/gifts/{gift_id}", path: "/v1/admin/gifts/{gift_id}",
permission: "gift:update", permission: "gift:update",
permissions: ["gift:update"] permissions: ["gift:update"],
}, },
updateH5Links: { updateH5Links: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updateH5Links, operationId: API_OPERATIONS.updateH5Links,
path: "/v1/admin/app-config/h5-links", path: "/v1/admin/app-config/h5-links",
permission: "app-config:update", permission: "app-config:update",
permissions: ["app-config:update"] permissions: ["app-config:update"],
}, },
updateMenu: { updateMenu: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateMenu, operationId: API_OPERATIONS.updateMenu,
path: "/v1/system/menus/{id}", path: "/v1/system/menus/{id}",
permission: "menu:update", permission: "menu:update",
permissions: ["menu:update"] permissions: ["menu:update"],
}, },
updateMenuVisible: { updateMenuVisible: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateMenuVisible, operationId: API_OPERATIONS.updateMenuVisible,
path: "/v1/system/menus/{id}/visible", path: "/v1/system/menus/{id}/visible",
permission: "menu:visible", permission: "menu:visible",
permissions: ["menu:visible"] permissions: ["menu:visible"],
}, },
updatePermission: { updatePermission: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updatePermission, operationId: API_OPERATIONS.updatePermission,
path: "/v1/permissions/{id}", path: "/v1/permissions/{id}",
permission: "permission:update", permission: "permission:update",
permissions: ["permission:update"] permissions: ["permission:update"],
}, },
updateRegion: { updateRegion: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateRegion, operationId: API_OPERATIONS.updateRegion,
path: "/v1/admin/regions/{region_id}", path: "/v1/admin/regions/{region_id}",
permission: "region:update", permission: "region:update",
permissions: ["region:update"] permissions: ["region:update"],
}, },
updateResource: { updateResource: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updateResource, operationId: API_OPERATIONS.updateResource,
path: "/v1/admin/resources/{resource_id}", path: "/v1/admin/resources/{resource_id}",
permission: "resource:update", permission: "resource:update",
permissions: ["resource:update"] permissions: ["resource:update"],
}, },
updateResourceGroup: { updateResourceGroup: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updateResourceGroup, operationId: API_OPERATIONS.updateResourceGroup,
path: "/v1/admin/resource-groups/{group_id}", path: "/v1/admin/resource-groups/{group_id}",
permission: "resource-group:update", permission: "resource-group:update",
permissions: ["resource-group:update"] permissions: ["resource-group:update"],
}, },
updateResourceGroupItems: { updateResourceGroupItems: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updateResourceGroupItems, operationId: API_OPERATIONS.updateResourceGroupItems,
path: "/v1/admin/resource-groups/{group_id}/items", path: "/v1/admin/resource-groups/{group_id}/items",
permission: "resource-group:update", permission: "resource-group:update",
permissions: ["resource-group:update"] permissions: ["resource-group:update"],
}, },
updateRole: { updateRole: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateRole, operationId: API_OPERATIONS.updateRole,
path: "/v1/roles/{id}", path: "/v1/roles/{id}",
permission: "role:update", permission: "role:update",
permissions: ["role:update","role:manage"] permissions: ["role:update", "role:manage"],
}, },
updateRoom: { updateRoom: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateRoom, operationId: API_OPERATIONS.updateRoom,
path: "/v1/admin/rooms/{room_id}", path: "/v1/admin/rooms/{room_id}",
permission: "room:update", permission: "room:update",
permissions: ["room:update"] permissions: ["room:update"],
}, },
updateUser: { updateUser: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateUser, operationId: API_OPERATIONS.updateUser,
path: "/v1/users/{id}", path: "/v1/users/{id}",
permission: "user:update", permission: "user:update",
permissions: ["user:update"] permissions: ["user:update"],
}, },
updateUserStatus: { updateUserStatus: {
method: "PATCH", method: "PATCH",
operationId: API_OPERATIONS.updateUserStatus, operationId: API_OPERATIONS.updateUserStatus,
path: "/v1/users/{id}/status", path: "/v1/users/{id}/status",
permission: "user:status", permission: "user:status",
permissions: ["user:status"] permissions: ["user:status"],
}, },
uploadFile: { uploadFile: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.uploadFile, operationId: API_OPERATIONS.uploadFile,
path: "/v1/admin/files/upload", path: "/v1/admin/files/upload",
permission: "upload:create", permission: "upload:create",
permissions: ["upload:create"] permissions: ["upload:create"],
}, },
uploadImage: { uploadImage: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.uploadImage, operationId: API_OPERATIONS.uploadImage,
path: "/v1/admin/files/image/upload", path: "/v1/admin/files/image/upload",
permission: "upload:create", permission: "upload:create",
permissions: ["upload:create"] permissions: ["upload:create"],
} },
}; };
export const API_OPERATION_IDS = Object.values(API_OPERATIONS); export const API_OPERATION_IDS = Object.values(API_OPERATIONS);
@ -922,8 +930,8 @@ export const API_PERMISSION_CODES = Array.from(
new Set( new Set(
Object.values(API_ENDPOINTS) Object.values(API_ENDPOINTS)
.flatMap((endpoint) => endpoint.permissions || (endpoint.permission ? [endpoint.permission] : [])) .flatMap((endpoint) => endpoint.permissions || (endpoint.permission ? [endpoint.permission] : []))
.filter((permission): permission is PermissionCode => Boolean(permission)) .filter((permission): permission is PermissionCode => Boolean(permission)),
) ),
); );
export type ApiPathParams = Record<string, number | string>; export type ApiPathParams = Record<string, number | string>;

View File

@ -212,6 +212,22 @@ export interface paths {
patch: operations["setCoinSellerStatus"]; patch: operations["setCoinSellerStatus"];
trace?: never; trace?: never;
}; };
"/admin/coin-sellers/{user_id}/stock-credits": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["creditCoinSellerStock"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/countries": { "/admin/countries": {
parameters: { parameters: {
query?: never; query?: never;
@ -2103,6 +2119,20 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
creditCoinSellerStock: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listCountries: { listCountries: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -217,14 +217,25 @@ export interface RechargeBillDto {
} }
export interface BDProfileDto { export interface BDProfileDto {
avatar?: string;
createdAtMs?: number; createdAtMs?: number;
createdByAvatar?: string;
createdByDisplayUserId?: string;
createdByUsername?: string;
createdByUserId?: number; createdByUserId?: number;
displayUserId?: string;
parentLeaderUserId?: number; parentLeaderUserId?: number;
parentLeaderDisplayUserId?: string;
parentLeaderUsername?: string;
parentLeaderAvatar?: string;
regionId?: number; regionId?: number;
regionName?: string;
role?: string; role?: string;
status?: string; status?: string;
subBdCount?: number;
updatedAtMs?: number; updatedAtMs?: number;
userId: number; userId: number;
username?: string;
} }
export interface AgencyDto { export interface AgencyDto {
@ -234,27 +245,40 @@ export interface AgencyDto {
joinEnabled?: boolean; joinEnabled?: boolean;
maxHosts?: number; maxHosts?: number;
name?: string; name?: string;
ownerAvatar?: string;
ownerDisplayUserId?: string;
ownerUsername?: string;
ownerUserId?: number; ownerUserId?: number;
parentBdAvatar?: string;
parentBdDisplayUserId?: string;
parentBdUsername?: string;
parentBdUserId?: number; parentBdUserId?: number;
regionId?: number; regionId?: number;
regionName?: string;
status?: string; status?: string;
updatedAtMs?: number; updatedAtMs?: number;
} }
export interface HostProfileDto { export interface HostProfileDto {
avatar?: string;
createdAtMs?: number; createdAtMs?: number;
currentAgencyId?: number; currentAgencyId?: number;
currentAgencyName?: string;
currentMembershipId?: number; currentMembershipId?: number;
displayUserId?: string;
firstBecameHostAtMs?: number; firstBecameHostAtMs?: number;
regionId?: number; regionId?: number;
regionName?: string;
source?: string; source?: string;
status?: string; status?: string;
updatedAtMs?: number; updatedAtMs?: number;
userId: number; userId: number;
username?: string;
} }
export interface CoinSellerDto { export interface CoinSellerDto {
avatar?: string; avatar?: string;
contact?: string;
createdAtMs?: number; createdAtMs?: number;
createdByUserId?: number; createdByUserId?: number;
displayUserId?: string; displayUserId?: string;
@ -268,6 +292,26 @@ export interface CoinSellerDto {
username?: string; username?: string;
} }
export interface CoinSellerStockCreditPayload {
coinAmount: number;
commandId: string;
reason?: string;
rechargeAmount?: string;
type: "usdt_purchase" | "coin_compensation";
}
export interface CoinSellerStockCreditDto {
balanceAfter: number;
coinAmount: number;
countsAsSellerRecharge: boolean;
createdAtMs: number;
paidAmountMicro: number;
paidCurrencyCode: string;
sellerUserId: string;
stockType: string;
transactionId: string;
}
export interface AppUserDto { export interface AppUserDto {
avatar?: string; avatar?: string;
coin?: number; coin?: number;
@ -440,10 +484,13 @@ export interface BDStatusPayload extends HostCommandPayload {
} }
export interface CreateCoinSellerPayload extends HostCommandPayload { export interface CreateCoinSellerPayload extends HostCommandPayload {
contact?: string;
targetUserId: number; targetUserId: number;
} }
export type CoinSellerStatusPayload = BDStatusPayload; export interface CoinSellerStatusPayload extends BDStatusPayload {
contact?: string;
}
export interface CreateAgencyPayload extends HostCommandPayload { export interface CreateAgencyPayload extends HostCommandPayload {
joinEnabled: boolean; joinEnabled: boolean;

View File

@ -3,6 +3,8 @@ import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import InputAdornment from "@mui/material/InputAdornment";
import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import styles from "@/shared/ui/AdminFormDialog.module.css"; import styles from "@/shared/ui/AdminFormDialog.module.css";
@ -66,6 +68,43 @@ export function AdminFormFieldGrid({ children, columns }) {
); );
} }
export function AdminFormReadOnlyField({ label, value }) {
const displayValue = value === undefined || value === null || value === "" ? "-" : value;
return (
<div className={styles.readOnlyField}>
<span className={styles.readOnlyLabel}>{label}</span>
<span className={styles.readOnlyValue}>{displayValue}</span>
</div>
);
}
export function AdminFormAmountField({ className = "", inputProps, InputProps, slotProps, unit, ...props }) {
const inputSlotProps = { ...(slotProps?.input || {}), ...(InputProps || {}) };
return (
<TextField
{...props}
className={[styles.amountField, className].filter(Boolean).join(" ")}
slotProps={{
...slotProps,
htmlInput: { inputMode: "decimal", ...slotProps?.htmlInput, ...inputProps },
input: {
...inputSlotProps,
endAdornment: unit ? (
<InputAdornment position="end">
<span className={styles.inputUnit}>{unit}</span>
</InputAdornment>
) : (
inputSlotProps.endAdornment
)
}
}}
type={props.type || "text"}
/>
);
}
export function AdminFormInlineActions({ children }) { export function AdminFormInlineActions({ children }) {
return <div className={styles.inlineActions}>{children}</div>; return <div className={styles.inlineActions}>{children}</div>;
} }

View File

@ -9,6 +9,7 @@
width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important; width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important;
max-width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important; max-width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important;
border-radius: var(--radius-card); border-radius: var(--radius-card);
box-shadow: var(--shadow-dialog);
} }
.compact { .compact {
@ -77,9 +78,14 @@
gap: var(--space-3); gap: var(--space-3);
} }
.section + .section {
padding-top: var(--space-4);
border-top: 1px solid var(--border-soft);
}
.sectionHeader { .sectionHeader {
display: flex; display: flex;
min-height: var(--control-height); min-height: 20px;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
@ -87,9 +93,16 @@
} }
.sectionTitle { .sectionTitle {
color: var(--text-primary); color: var(--text-secondary);
font-size: var(--admin-font-size); font-size: 13px;
font-weight: 700; font-weight: 700;
line-height: 18px;
}
.section > :global(.MuiTextField-root),
.section > :global(.MuiFormControl-root),
.section > :global(.MuiAutocomplete-root) {
width: 100%;
} }
.sectionActions, .sectionActions,
@ -109,10 +122,62 @@
.fieldGrid > :global(.MuiTextField-root), .fieldGrid > :global(.MuiTextField-root),
.fieldGrid > :global(.MuiFormControl-root), .fieldGrid > :global(.MuiFormControl-root),
.fieldGrid > :global(.MuiAutocomplete-root) { .fieldGrid > :global(.MuiAutocomplete-root),
.fieldGrid > .readOnlyField {
width: 100%; width: 100%;
} }
.readOnlyField {
display: grid;
min-width: 0;
min-height: 56px;
align-content: center;
gap: var(--space-1);
padding: 8px var(--space-3);
border: 1px solid var(--border-soft);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
}
.readOnlyLabel {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.readOnlyValue {
overflow: hidden;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 760;
line-height: var(--admin-line-height);
text-overflow: ellipsis;
white-space: nowrap;
}
.amountField :global(.MuiOutlinedInput-root) {
background: var(--bg-input-strong);
}
.amountField :global(.MuiOutlinedInput-root.Mui-focused) {
box-shadow: 0 0 0 3px var(--focus-ring);
}
.amountField :global(.MuiInputBase-input) {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.inputUnit {
color: var(--text-tertiary);
font-size: 13px;
font-weight: 700;
}
.list { .list {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
@ -135,8 +200,13 @@
} }
.actions { .actions {
position: sticky;
bottom: 0;
z-index: 1;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-2); gap: var(--space-2);
border-top: 1px solid var(--border-soft);
background: var(--bg-card);
padding: var(--space-3) var(--admin-form-content-x) var(--space-5); padding: var(--space-3) var(--admin-form-content-x) var(--space-5);
} }

View File

@ -1,4 +1,6 @@
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx"; import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx"; import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
@ -36,7 +38,22 @@ export function AdminSearchBox(props) {
} }
export function AdminFilterSelect(props) { export function AdminFilterSelect(props) {
return <StatusSelect {...props} className={[styles.statusSelect, props.className || ""].filter(Boolean).join(" ")} />; return (
<StatusSelect {...props} className={[styles.statusSelect, props.className || ""].filter(Boolean).join(" ")} />
);
}
export function AdminFilterResetButton({ disabled = false, label = "重置", onClick }) {
return (
<Button
className={styles.resetButton}
disabled={disabled}
startIcon={<RestartAltOutlined fontSize="small" />}
onClick={onClick}
>
{label}
</Button>
);
} }
export function AdminActionIconButton({ children, label, primary = false, tooltip = label, ...props }) { export function AdminActionIconButton({ children, label, primary = false, tooltip = label, ...props }) {
@ -65,6 +82,6 @@ const primaryActionSx = {
"&:hover": { "&:hover": {
borderColor: "var(--primary-strong)", borderColor: "var(--primary-strong)",
backgroundColor: "var(--primary-strong)", backgroundColor: "var(--primary-strong)",
color: "var(--active-contrast)" color: "var(--active-contrast)",
} },
}; };

View File

@ -77,6 +77,10 @@
flex: 0 0 150px; flex: 0 0 150px;
} }
.resetButton {
flex: 0 0 auto;
}
.listBlock { .listBlock {
display: flex; display: flex;
flex: 1; flex: 1;

View File

@ -1,8 +1,10 @@
import SearchOutlined from "@mui/icons-material/SearchOutlined"; import SearchOutlined from "@mui/icons-material/SearchOutlined";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import Popover from "@mui/material/Popover"; import Popover from "@mui/material/Popover";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button } from "@/shared/ui/Button.jsx";
const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)"; const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
const DEFAULT_RESIZE_MIN_WIDTH = 72; const DEFAULT_RESIZE_MIN_WIDTH = 72;
@ -43,6 +45,7 @@ export function DataTable({
const gridTemplate = preparedColumns.map((column) => column.width).join(" "); const gridTemplate = preparedColumns.map((column) => column.width).join(" ");
const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null; const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null;
const activeFilter = activeFilterColumn?.filter || null; const activeFilter = activeFilterColumn?.filter || null;
const activeFilterIsText = activeFilter?.type === "text";
const filterOptions = useMemo(() => filterVisibleOptions(activeFilter, filterQuery), [activeFilter, filterQuery]); const filterOptions = useMemo(() => filterVisibleOptions(activeFilter, filterQuery), [activeFilter, filterQuery]);
useEffect(() => { useEffect(() => {
@ -112,7 +115,7 @@ export function DataTable({
} }
event.preventDefault(); event.preventDefault();
setFilterMenu({ anchorEl: event.currentTarget, columnKey: column.key }); setFilterMenu({ anchorEl: event.currentTarget, columnKey: column.key });
setFilterQuery(""); setFilterQuery(column.filter.type === "text" ? String(column.filter.value ?? "") : "");
}; };
const closeColumnFilter = () => { const closeColumnFilter = () => {
@ -125,6 +128,17 @@ export function DataTable({
closeColumnFilter(); closeColumnFilter();
}; };
const submitTextFilter = (event) => {
event.preventDefault();
activeFilter?.onChange?.(filterQuery.trim());
closeColumnFilter();
};
const clearTextFilter = () => {
activeFilter?.onChange?.("");
closeColumnFilter();
};
return ( return (
<section className={["table-frame", className].filter(Boolean).join(" ")}> <section className={["table-frame", className].filter(Boolean).join(" ")}>
{title || total !== undefined ? ( {title || total !== undefined ? (
@ -189,6 +203,27 @@ export function DataTable({
onClose={closeColumnFilter} onClose={closeColumnFilter}
> >
{activeFilterColumn ? ( {activeFilterColumn ? (
activeFilterIsText ? (
<form className="admin-table-filter" onSubmit={submitTextFilter}>
<TextField
autoFocus
className="admin-table-filter__search"
disabled={activeFilter.loading}
placeholder={activeFilter.placeholder || `搜索${activeFilterColumn.label || ""}`}
size="small"
value={filterQuery}
onChange={(event) => setFilterQuery(event.target.value)}
/>
<div className="admin-table-filter__actions">
<Button disabled={activeFilter.loading || !String(activeFilter.value ?? "").trim()} onClick={clearTextFilter}>
清空
</Button>
<Button disabled={activeFilter.loading} type="submit" variant="primary">
搜索
</Button>
</div>
</form>
) : (
<div className="admin-table-filter"> <div className="admin-table-filter">
<TextField <TextField
autoFocus autoFocus
@ -199,7 +234,7 @@ export function DataTable({
value={filterQuery} value={filterQuery}
onChange={(event) => setFilterQuery(event.target.value)} onChange={(event) => setFilterQuery(event.target.value)}
/> />
<div className="admin-table-filter__options"> <MenuList className="admin-table-filter__options">
{filterOptions.length ? ( {filterOptions.length ? (
filterOptions.map((option) => ( filterOptions.map((option) => (
<MenuItem <MenuItem
@ -213,8 +248,9 @@ export function DataTable({
) : ( ) : (
<div className="admin-table-filter__empty">{activeFilter.loading ? "加载中..." : "无匹配选项"}</div> <div className="admin-table-filter__empty">{activeFilter.loading ? "加载中..." : "无匹配选项"}</div>
)} )}
</MenuList>
</div> </div>
</div> )
) : null} ) : null}
</Popover> </Popover>
</section> </section>
@ -232,6 +268,7 @@ function HeaderLabel({ column, onOpenFilter }) {
} }
const active = isFilterActive(column.filter); const active = isFilterActive(column.filter);
const filterValueLabel = active ? filterDisplayValue(column.filter) : "";
return ( return (
<button <button
className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""].filter(Boolean).join(" ")} className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""].filter(Boolean).join(" ")}
@ -239,7 +276,10 @@ function HeaderLabel({ column, onOpenFilter }) {
onClick={(event) => onOpenFilter(event, column)} onClick={(event) => onOpenFilter(event, column)}
> >
<SearchOutlined className="admin-cell__head-icon" fontSize="inherit" /> <SearchOutlined className="admin-cell__head-icon" fontSize="inherit" />
<span className="admin-cell__head-label">{content}</span> <span className="admin-cell__head-label">
{content}
{filterValueLabel ? <span className="admin-cell__head-filter">({filterValueLabel})</span> : null}
</span>
</button> </button>
); );
} }
@ -320,6 +360,19 @@ function isFilterActive(filter) {
return filter && filter.value !== undefined && filter.value !== null && String(filter.value) !== ""; return filter && filter.value !== undefined && filter.value !== null && String(filter.value) !== "";
} }
function filterDisplayValue(filter) {
const value = String(filter?.value ?? "");
if (!value) {
return "";
}
if (filter?.type === "text") {
return value;
}
const matchedOption = normalizeFilterOptions(filter).find((option) => option.value === value);
return matchedOption?.label || value;
}
function filterVisibleOptions(filter, query) { function filterVisibleOptions(filter, query) {
if (!filter) { if (!filter) {
return []; return [];

View File

@ -1,7 +1,5 @@
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import Tooltip from "@mui/material/Tooltip";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js";
export function RegionFilterControl({ export function RegionFilterControl({
className = "", className = "",
@ -10,14 +8,10 @@ export function RegionFilterControl({
loading = false, loading = false,
onChange, onChange,
options = [], options = [],
resetLabel = "重置区域",
selectClassName = "", selectClassName = "",
value, value,
...props ...props
}) { }) {
const selectedValue = value === undefined || value === null ? "" : String(value);
const canReset = selectedValue !== "" && !disabled && !loading;
return ( return (
<div className={["region-filter-control", className].filter(Boolean).join(" ")}> <div className={["region-filter-control", className].filter(Boolean).join(" ")}>
<RegionSelect <RegionSelect
@ -30,18 +24,6 @@ export function RegionFilterControl({
value={value} value={value}
onChange={onChange} onChange={onChange}
/> />
<Tooltip arrow title={resetLabel}>
<span>
<IconButton
className="region-filter-control__reset"
disabled={!canReset}
label={resetLabel}
onClick={() => onChange?.("")}
>
<RestartAltOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
</div> </div>
); );
} }
@ -52,14 +34,14 @@ export function createRegionColumnFilter({
onChange, onChange,
options = [], options = [],
placeholder = "搜索区域", placeholder = "搜索区域",
value value,
}) { }) {
return { return createOptionsColumnFilter({
emptyLabel, emptyLabel,
loading, loading,
onChange, onChange,
options, options,
placeholder, placeholder,
value value,
}; });
} }

View File

@ -0,0 +1,20 @@
export function createTextColumnFilter({ loading = false, onChange, placeholder, value }) {
return {
loading,
onChange,
placeholder,
type: "text",
value
};
}
export function createOptionsColumnFilter({ emptyLabel, loading = false, onChange, options = [], placeholder, value }) {
return {
emptyLabel,
loading,
onChange,
options,
placeholder,
value
};
}

View File

@ -130,11 +130,6 @@
flex: 0 0 180px; flex: 0 0 180px;
} }
.region-filter-control__reset.icon-button {
flex: 0 0 var(--control-height);
color: var(--text-tertiary);
}
.MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) { .MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) {
height: var(--control-height); height: var(--control-height);
min-height: var(--control-height); min-height: var(--control-height);
@ -416,7 +411,6 @@
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
} }
} }
.kpi-card { .kpi-card {
@ -677,9 +671,7 @@
min-height: 100vh; min-height: 100vh;
place-items: center; place-items: center;
padding: var(--space-6); padding: var(--space-6);
background: background: var(--login-gradient), var(--bg-page);
var(--login-gradient),
var(--bg-page);
} }
.login-panel { .login-panel {
@ -918,7 +910,7 @@
} }
.admin-table { .admin-table {
--admin-table-cell-x: var(--space-2); --admin-table-cell-x: var(--space-4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: max(100%, var(--admin-table-min-width, 900px)); width: max(100%, var(--admin-table-min-width, 900px));
@ -986,6 +978,11 @@
white-space: nowrap; white-space: nowrap;
} }
.admin-cell__head-filter {
color: var(--primary);
font-weight: 700;
}
.admin-cell__head-trigger { .admin-cell__head-trigger {
display: inline-flex; display: inline-flex;
max-width: 100%; max-width: 100%;
@ -1105,6 +1102,12 @@
overflow: auto; overflow: auto;
} }
.admin-table-filter__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.admin-table-filter__options .MuiMenuItem-root { .admin-table-filter__options .MuiMenuItem-root {
min-height: 34px; min-height: 34px;
border-radius: var(--radius-control); border-radius: var(--radius-control);

View File

@ -130,6 +130,13 @@ export const theme = createTheme({
}, },
input: { input: {
fontSize: 14, fontSize: 14,
"&[type='number']": {
MozAppearance: "textfield"
},
"&[type='number']::-webkit-outer-spin-button, &[type='number']::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0
},
"&::placeholder": { "&::placeholder": {
color: token("text-tertiary"), color: token("text-tertiary"),
opacity: 1 opacity: 1
@ -237,6 +244,10 @@ export const theme = createTheme({
styleOverrides: { styleOverrides: {
root: { root: {
fontSize: 14 fontSize: 14
},
asterisk: {
color: token("danger"),
marginLeft: 2
} }
} }
}, },