优化布局
This commit is contained in:
parent
3698b05159
commit
0fd67199b6
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,7 @@ export function AdminLayout() {
|
||||
<Header
|
||||
activeLabel={activeNav?.label || "总览"}
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
menuItems={menus}
|
||||
onRefresh={refresh}
|
||||
onToggleSidebar={() => setSidebarCollapsed((current) => !current)}
|
||||
/>
|
||||
|
||||
@ -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 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 [searchMounted, setSearchMounted] = useState(false);
|
||||
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
|
||||
@ -186,7 +186,7 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
||||
</TextField>
|
||||
<button className="search-trigger" type="button" onClick={openSearch}>
|
||||
<Search fontSize="small" />
|
||||
<span>搜索用户、角色...</span>
|
||||
<span>搜索菜单、用户、角色...</span>
|
||||
</button>
|
||||
<IconButton label="刷新" onClick={refresh}>
|
||||
<Refresh fontSize="small" />
|
||||
@ -223,7 +223,7 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
||||
|
||||
{searchMounted ? (
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
||||
<SearchDialog menuItems={menuItems} open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
<AdminFormDialog
|
||||
|
||||
@ -94,6 +94,11 @@ function NavGroup({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChildren && children.length === 1 && children[0].path && !isChildActive) {
|
||||
onNavigate(children[0].path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
onToggleExpanded();
|
||||
return;
|
||||
|
||||
@ -23,6 +23,10 @@ import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
||||
|
||||
const deprecatedMenuCodes = new Set(["services"]);
|
||||
const menuLabelOverrides = {
|
||||
system: "后台设置",
|
||||
"system-users": "后台用户"
|
||||
};
|
||||
|
||||
const iconMap = {
|
||||
apartment: ApartmentOutlined,
|
||||
@ -55,7 +59,7 @@ export const fallbackNavigation = [
|
||||
code: "system",
|
||||
icon: SettingsApplicationsOutlined,
|
||||
id: "system",
|
||||
label: "系统管理",
|
||||
label: "后台设置",
|
||||
children: [
|
||||
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
|
||||
routeNavItem("system-roles", { icon: ShieldOutlined }),
|
||||
@ -162,7 +166,7 @@ export function mapBackendMenus(menus = []) {
|
||||
code: item.code,
|
||||
icon: iconMap[item.icon] || MenuOpenOutlined,
|
||||
id: item.code || String(item.id),
|
||||
label: item.label,
|
||||
label: menuLabelOverrides[item.code] || route?.label || item.label,
|
||||
path: route?.path || item.path,
|
||||
permissionCode: route?.permission || item.permissionCode
|
||||
};
|
||||
|
||||
@ -1,79 +1,80 @@
|
||||
export const PERMISSIONS = {
|
||||
overviewView: "overview:view",
|
||||
userView: "user:view",
|
||||
userCreate: "user:create",
|
||||
userUpdate: "user:update",
|
||||
userStatus: "user:status",
|
||||
userResetPassword: "user:reset-password",
|
||||
userExport: "user:export",
|
||||
countryView: "country:view",
|
||||
countryCreate: "country:create",
|
||||
countryUpdate: "country:update",
|
||||
countryStatus: "country:status",
|
||||
regionView: "region:view",
|
||||
regionCreate: "region:create",
|
||||
regionUpdate: "region:update",
|
||||
regionStatus: "region:status",
|
||||
hostView: "host:view",
|
||||
agencyView: "agency:view",
|
||||
agencyCreate: "agency:create",
|
||||
agencyStatus: "agency:status",
|
||||
bdView: "bd:view",
|
||||
bdCreate: "bd:create",
|
||||
bdUpdate: "bd:update",
|
||||
coinSellerView: "coin-seller:view",
|
||||
coinSellerCreate: "coin-seller:create",
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
paymentBillView: "payment-bill:view",
|
||||
roleView: "role:view",
|
||||
roleCreate: "role:create",
|
||||
roleUpdate: "role:update",
|
||||
roleDelete: "role:delete",
|
||||
roleDataScope: "role:data-scope",
|
||||
rolePermission: "role:permission",
|
||||
roleManage: "role:manage",
|
||||
permissionView: "permission:view",
|
||||
permissionCreate: "permission:create",
|
||||
permissionUpdate: "permission:update",
|
||||
permissionDelete: "permission:delete",
|
||||
permissionSync: "permission:sync",
|
||||
menuView: "menu:view",
|
||||
menuCreate: "menu:create",
|
||||
menuUpdate: "menu:update",
|
||||
menuDelete: "menu:delete",
|
||||
menuSort: "menu:sort",
|
||||
menuVisible: "menu:visible",
|
||||
logView: "log:view",
|
||||
logExport: "log:export",
|
||||
notificationView: "notification:view",
|
||||
notificationRead: "notification:read",
|
||||
notificationReadAll: "notification:read-all",
|
||||
notificationDelete: "notification:delete",
|
||||
appUserView: "app-user:view",
|
||||
appUserUpdate: "app-user:update",
|
||||
appUserStatus: "app-user:status",
|
||||
appUserPassword: "app-user:password",
|
||||
roomView: "room:view",
|
||||
roomUpdate: "room:update",
|
||||
roomDelete: "room:delete",
|
||||
appConfigView: "app-config:view",
|
||||
appConfigUpdate: "app-config:update",
|
||||
resourceView: "resource:view",
|
||||
resourceCreate: "resource:create",
|
||||
resourceUpdate: "resource:update",
|
||||
resourceGroupView: "resource-group:view",
|
||||
resourceGroupCreate: "resource-group:create",
|
||||
resourceGroupUpdate: "resource-group:update",
|
||||
resourceGrantView: "resource-grant:view",
|
||||
resourceGrantCreate: "resource-grant:create",
|
||||
giftView: "gift:view",
|
||||
giftCreate: "gift:create",
|
||||
giftUpdate: "gift:update",
|
||||
giftStatus: "gift:status",
|
||||
uploadCreate: "upload:create",
|
||||
jobView: "job:view",
|
||||
jobCancel: "job:cancel",
|
||||
exportCreate: "export:create"
|
||||
overviewView: "overview:view",
|
||||
userView: "user:view",
|
||||
userCreate: "user:create",
|
||||
userUpdate: "user:update",
|
||||
userStatus: "user:status",
|
||||
userResetPassword: "user:reset-password",
|
||||
userExport: "user:export",
|
||||
countryView: "country:view",
|
||||
countryCreate: "country:create",
|
||||
countryUpdate: "country:update",
|
||||
countryStatus: "country:status",
|
||||
regionView: "region:view",
|
||||
regionCreate: "region:create",
|
||||
regionUpdate: "region:update",
|
||||
regionStatus: "region:status",
|
||||
hostView: "host:view",
|
||||
agencyView: "agency:view",
|
||||
agencyCreate: "agency:create",
|
||||
agencyStatus: "agency:status",
|
||||
bdView: "bd:view",
|
||||
bdCreate: "bd:create",
|
||||
bdUpdate: "bd:update",
|
||||
coinSellerView: "coin-seller:view",
|
||||
coinSellerCreate: "coin-seller:create",
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||
paymentBillView: "payment-bill:view",
|
||||
roleView: "role:view",
|
||||
roleCreate: "role:create",
|
||||
roleUpdate: "role:update",
|
||||
roleDelete: "role:delete",
|
||||
roleDataScope: "role:data-scope",
|
||||
rolePermission: "role:permission",
|
||||
roleManage: "role:manage",
|
||||
permissionView: "permission:view",
|
||||
permissionCreate: "permission:create",
|
||||
permissionUpdate: "permission:update",
|
||||
permissionDelete: "permission:delete",
|
||||
permissionSync: "permission:sync",
|
||||
menuView: "menu:view",
|
||||
menuCreate: "menu:create",
|
||||
menuUpdate: "menu:update",
|
||||
menuDelete: "menu:delete",
|
||||
menuSort: "menu:sort",
|
||||
menuVisible: "menu:visible",
|
||||
logView: "log:view",
|
||||
logExport: "log:export",
|
||||
notificationView: "notification:view",
|
||||
notificationRead: "notification:read",
|
||||
notificationReadAll: "notification:read-all",
|
||||
notificationDelete: "notification:delete",
|
||||
appUserView: "app-user:view",
|
||||
appUserUpdate: "app-user:update",
|
||||
appUserStatus: "app-user:status",
|
||||
appUserPassword: "app-user:password",
|
||||
roomView: "room:view",
|
||||
roomUpdate: "room:update",
|
||||
roomDelete: "room:delete",
|
||||
appConfigView: "app-config:view",
|
||||
appConfigUpdate: "app-config:update",
|
||||
resourceView: "resource:view",
|
||||
resourceCreate: "resource:create",
|
||||
resourceUpdate: "resource:update",
|
||||
resourceGroupView: "resource-group:view",
|
||||
resourceGroupCreate: "resource-group:create",
|
||||
resourceGroupUpdate: "resource-group:update",
|
||||
resourceGrantView: "resource-grant:view",
|
||||
resourceGrantCreate: "resource-grant:create",
|
||||
giftView: "gift:view",
|
||||
giftCreate: "gift:create",
|
||||
giftUpdate: "gift:update",
|
||||
giftStatus: "gift:status",
|
||||
uploadCreate: "upload:create",
|
||||
jobView: "job:view",
|
||||
jobCancel: "job:cancel",
|
||||
exportCreate: "export:create",
|
||||
} as const;
|
||||
|
||||
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
@ -81,39 +82,39 @@ export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
export const PERMISSION_CODES = Object.values(PERMISSIONS) as PermissionCode[];
|
||||
|
||||
export const MENU_CODES = {
|
||||
overview: "overview",
|
||||
system: "system",
|
||||
systemUsers: "system-users",
|
||||
systemRoles: "system-roles",
|
||||
systemMenus: "system-menus",
|
||||
logs: "logs",
|
||||
logsLogin: "logs-login",
|
||||
logsOperations: "logs-operations",
|
||||
notifications: "notifications",
|
||||
appUsers: "app-users",
|
||||
appUserList: "app-user-list",
|
||||
rooms: "rooms",
|
||||
roomList: "room-list",
|
||||
appConfig: "app-config",
|
||||
appConfigH5: "app-config-h5",
|
||||
appConfigBanners: "app-config-banners",
|
||||
resources: "resources",
|
||||
resourceList: "resource-list",
|
||||
resourceGroupList: "resource-group-list",
|
||||
resourceGrantList: "resource-grant-list",
|
||||
giftList: "gift-list",
|
||||
geo: "geo",
|
||||
hostOrg: "host-org",
|
||||
hostOrgCountries: "host-org-countries",
|
||||
hostOrgRegions: "host-org-regions",
|
||||
hostOrgAgencies: "host-org-agencies",
|
||||
hostOrgBdLeaders: "host-org-bd-leaders",
|
||||
hostOrgBds: "host-org-bds",
|
||||
hostOrgHosts: "host-org-hosts",
|
||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||
payment: "payment",
|
||||
paymentBillList: "payment-bill-list",
|
||||
jobs: "jobs"
|
||||
overview: "overview",
|
||||
system: "system",
|
||||
systemUsers: "system-users",
|
||||
systemRoles: "system-roles",
|
||||
systemMenus: "system-menus",
|
||||
logs: "logs",
|
||||
logsLogin: "logs-login",
|
||||
logsOperations: "logs-operations",
|
||||
notifications: "notifications",
|
||||
appUsers: "app-users",
|
||||
appUserList: "app-user-list",
|
||||
rooms: "rooms",
|
||||
roomList: "room-list",
|
||||
appConfig: "app-config",
|
||||
appConfigH5: "app-config-h5",
|
||||
appConfigBanners: "app-config-banners",
|
||||
resources: "resources",
|
||||
resourceList: "resource-list",
|
||||
resourceGroupList: "resource-group-list",
|
||||
resourceGrantList: "resource-grant-list",
|
||||
giftList: "gift-list",
|
||||
geo: "geo",
|
||||
hostOrg: "host-org",
|
||||
hostOrgCountries: "host-org-countries",
|
||||
hostOrgRegions: "host-org-regions",
|
||||
hostOrgAgencies: "host-org-agencies",
|
||||
hostOrgBdLeaders: "host-org-bd-leaders",
|
||||
hostOrgBds: "host-org-bds",
|
||||
hostOrgHosts: "host-org-hosts",
|
||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||
payment: "payment",
|
||||
paymentBillList: "payment-bill-list",
|
||||
jobs: "jobs",
|
||||
} as const;
|
||||
|
||||
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];
|
||||
|
||||
@ -12,168 +12,182 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
const emptyData = { items: [], total: 0 };
|
||||
|
||||
const emptyForm = () => ({
|
||||
bannerType: "h5",
|
||||
countryCode: "",
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
param: "",
|
||||
platform: "android",
|
||||
regionId: "",
|
||||
sortOrder: "0",
|
||||
status: "active"
|
||||
bannerType: "h5",
|
||||
countryCode: "",
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
param: "",
|
||||
platform: "android",
|
||||
regionId: "",
|
||||
sortOrder: "0",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
export function useBannerConfigPage() {
|
||||
const abilities = useAppConfigAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const abilities = useAppConfigAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
countryCode,
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status
|
||||
}),
|
||||
[countryCode, keyword, platform, regionId, status]
|
||||
);
|
||||
const queryFn = useCallback(() => listBanners(filters), [filters]);
|
||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载 BANNER 配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["app-config", "banners", filters]
|
||||
});
|
||||
|
||||
const setForm = (patch) => {
|
||||
setFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditingItem(item);
|
||||
setFormState(formFromBanner(item));
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
};
|
||||
|
||||
const submitBanner = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = parseForm(appBannerSchema, normalizeForm(form));
|
||||
const action = editingItem ? "edit" : "create";
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateBanner(editingItem.id, payload);
|
||||
showToast("BANNER 已更新", "success");
|
||||
} else {
|
||||
await createBanner(payload);
|
||||
showToast("BANNER 已新增", "success");
|
||||
}
|
||||
closeDialog();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBanner = async (item) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: item.description || item.param || `BANNER ${item.id}`,
|
||||
title: "删除BANNER",
|
||||
tone: "danger"
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
countryCode,
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status,
|
||||
}),
|
||||
[countryCode, keyword, platform, regionId, status],
|
||||
);
|
||||
const queryFn = useCallback(() => listBanners(filters), [filters]);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载 BANNER 配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["app-config", "banners", filters],
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`delete:${item.id}`);
|
||||
try {
|
||||
await deleteBanner(item.id);
|
||||
await reload();
|
||||
showToast("BANNER 已删除", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
closeDialog,
|
||||
countryCode,
|
||||
countryOptions,
|
||||
data,
|
||||
editingItem,
|
||||
error,
|
||||
form,
|
||||
keyword,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
loadingRegions,
|
||||
openCreate,
|
||||
openEdit,
|
||||
platform,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
removeBanner,
|
||||
setCountryCode,
|
||||
setForm,
|
||||
setKeyword,
|
||||
setPlatform,
|
||||
setRegionId,
|
||||
setStatus,
|
||||
status,
|
||||
submitBanner
|
||||
};
|
||||
const setForm = (patch) => {
|
||||
setFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditingItem(item);
|
||||
setFormState(formFromBanner(item));
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setPlatform("");
|
||||
setRegionId("");
|
||||
setCountryCode("");
|
||||
};
|
||||
|
||||
const submitBanner = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = parseForm(appBannerSchema, normalizeForm(form));
|
||||
const action = editingItem ? "edit" : "create";
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateBanner(editingItem.id, payload);
|
||||
showToast("BANNER 已更新", "success");
|
||||
} else {
|
||||
await createBanner(payload);
|
||||
showToast("BANNER 已新增", "success");
|
||||
}
|
||||
closeDialog();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBanner = async (item) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: item.description || item.param || `BANNER ${item.id}`,
|
||||
title: "删除BANNER",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`delete:${item.id}`);
|
||||
try {
|
||||
await deleteBanner(item.id);
|
||||
await reload();
|
||||
showToast("BANNER 已删除", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
closeDialog,
|
||||
countryCode,
|
||||
countryOptions,
|
||||
data,
|
||||
editingItem,
|
||||
error,
|
||||
form,
|
||||
keyword,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
loadingRegions,
|
||||
openCreate,
|
||||
openEdit,
|
||||
platform,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
removeBanner,
|
||||
resetFilters,
|
||||
setCountryCode,
|
||||
setForm,
|
||||
setKeyword,
|
||||
setPlatform,
|
||||
setRegionId,
|
||||
setStatus,
|
||||
status,
|
||||
submitBanner,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
countryCode: form.countryCode || "",
|
||||
regionId: form.regionId || 0,
|
||||
sortOrder: form.sortOrder || 0
|
||||
};
|
||||
return {
|
||||
...form,
|
||||
countryCode: form.countryCode || "",
|
||||
regionId: form.regionId || 0,
|
||||
sortOrder: form.sortOrder || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function formFromBanner(item) {
|
||||
return {
|
||||
bannerType: item.bannerType || "h5",
|
||||
countryCode: item.countryCode || "",
|
||||
coverUrl: item.coverUrl || "",
|
||||
description: item.description || "",
|
||||
param: item.param || "",
|
||||
platform: item.platform || "android",
|
||||
regionId: item.regionId ? String(item.regionId) : "",
|
||||
sortOrder: String(item.sortOrder || 0),
|
||||
status: item.status || "active"
|
||||
};
|
||||
return {
|
||||
bannerType: item.bannerType || "h5",
|
||||
countryCode: item.countryCode || "",
|
||||
coverUrl: item.coverUrl || "",
|
||||
description: item.description || "",
|
||||
param: item.param || "",
|
||||
platform: item.platform || "android",
|
||||
regionId: item.regionId ? String(item.regionId) : "",
|
||||
sortOrder: String(item.sortOrder || 0),
|
||||
status: item.status || "active",
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,299 +10,362 @@ import styles from "@/features/app-config/app-config.module.css";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
adminListClasses
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.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 { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "关闭"]
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "关闭"],
|
||||
];
|
||||
|
||||
const platformOptions = [
|
||||
["", "全部平台"],
|
||||
["android", "安卓"],
|
||||
["ios", "iOS"]
|
||||
["", "全部平台"],
|
||||
["android", "安卓"],
|
||||
["ios", "iOS"],
|
||||
];
|
||||
|
||||
export function BannerConfigPage() {
|
||||
const page = useBannerConfigPage();
|
||||
const items = page.data.items || [];
|
||||
const columns = useMemo(() => bannerColumns(page), [page]);
|
||||
const page = useBannerConfigPage();
|
||||
const items = page.data.items || [];
|
||||
const columns = useMemo(() => bannerColumns(page), [page]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canUpdate ? (
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||
新增BANNER
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox value={page.keyword} onChange={page.setKeyword} placeholder="搜索参数、国家、描述..." />
|
||||
<AdminFilterSelect label="平台" options={platformOptions} value={page.platform} onChange={page.setPlatform} />
|
||||
<AdminFilterSelect label="状态" options={statusOptions} value={page.status} onChange={page.setStatus} />
|
||||
<RegionFilterControl
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={page.regionOptions}
|
||||
value={page.regionId}
|
||||
onChange={page.setRegionId}
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canUpdate ? (
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openCreate}
|
||||
>
|
||||
新增BANNER
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.keyword && !page.platform && !page.status && !page.regionId && !page.countryCode}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="1240px" rowKey={(item) => item.id} />
|
||||
<div className="pagination-bar">
|
||||
<span>{items.length} 条</span>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="1240px" rowKey={(item) => item.id} />
|
||||
<div className="pagination-bar">
|
||||
<span>{items.length} 条</span>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="narrow"
|
||||
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
title={page.editingItem ? "编辑BANNER" : "新增BANNER"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitBanner}
|
||||
>
|
||||
<UploadField
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="封面图"
|
||||
value={page.form.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 })}>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
||||
required={page.form.bannerType === "h5"}
|
||||
value={page.form.param}
|
||||
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 })}>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">关闭</MenuItem>
|
||||
</TextField>
|
||||
<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="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ step: 1 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<RegionSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={page.regionOptions}
|
||||
value={page.form.regionId}
|
||||
onChange={(regionId) => page.setForm({ regionId })}
|
||||
/>
|
||||
<CountrySelect
|
||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||
emptyLabel="全部国家"
|
||||
label="国家"
|
||||
options={page.countryOptions}
|
||||
value={page.form.countryCode}
|
||||
onChange={(countryCode) => page.setForm({ countryCode })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="narrow"
|
||||
submitDisabled={
|
||||
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
|
||||
}
|
||||
title={page.editingItem ? "编辑BANNER" : "新增BANNER"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitBanner}
|
||||
>
|
||||
<UploadField
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="封面图"
|
||||
value={page.form.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 })}
|
||||
>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
||||
required={page.form.bannerType === "h5"}
|
||||
value={page.form.param}
|
||||
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 })}
|
||||
>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">关闭</MenuItem>
|
||||
</TextField>
|
||||
<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="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
inputProps={{ step: 1 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<RegionSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={page.regionOptions}
|
||||
value={page.form.regionId}
|
||||
onChange={(regionId) => page.setForm({ regionId })}
|
||||
/>
|
||||
<CountrySelect
|
||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||
emptyLabel="全部国家"
|
||||
label="国家"
|
||||
options={page.countryOptions}
|
||||
value={page.form.countryCode}
|
||||
onChange={(countryCode) => page.setForm({ countryCode })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function bannerColumns(page) {
|
||||
const columns = [
|
||||
{
|
||||
key: "cover",
|
||||
label: "封面图",
|
||||
render: (item) => <BannerCover src={item.coverUrl} />,
|
||||
width: "minmax(112px, 0.55fr)"
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "类型 / 参数",
|
||||
render: (item) => <Stack primary={typeLabel(item.bannerType)} secondary={item.param || "-"} />,
|
||||
width: "minmax(260px, 1.3fr)"
|
||||
},
|
||||
{
|
||||
key: "state",
|
||||
label: "平台 / 状态",
|
||||
render: (item) => <Stack primary={platformLabel(item.platform)} secondary={<StatusBadge status={item.status} />} />,
|
||||
width: "minmax(140px, 0.75fr)"
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
label: "区域 / 国家",
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.setRegionId
|
||||
}),
|
||||
render: (item) => <Stack primary={regionLabel(page.regionOptions, item.regionId)} secondary={countryLabel(page.countryOptions, item.countryCode)} />,
|
||||
width: "minmax(180px, 0.95fr)"
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
label: "排序",
|
||||
width: "minmax(80px, 0.4fr)"
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
render: (item) => item.description || "-",
|
||||
width: "minmax(180px, 0.9fr)"
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(160px, 0.8fr)"
|
||||
}
|
||||
];
|
||||
const columns = [
|
||||
{
|
||||
key: "cover",
|
||||
label: "封面图",
|
||||
render: (item) => <BannerCover src={item.coverUrl} />,
|
||||
width: "minmax(112px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "类型 / 参数",
|
||||
render: (item) => <Stack primary={typeLabel(item.bannerType)} secondary={item.param || "-"} />,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索参数、国家、描述",
|
||||
value: page.keyword,
|
||||
onChange: page.setKeyword,
|
||||
}),
|
||||
width: "minmax(260px, 1.3fr)",
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
label: "平台",
|
||||
render: (item) => platformLabel(item.platform),
|
||||
filter: createOptionsColumnFilter({
|
||||
options: platformOptions,
|
||||
placeholder: "搜索平台",
|
||||
value: page.platform,
|
||||
onChange: page.setPlatform,
|
||||
}),
|
||||
width: "minmax(110px, 0.6fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
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({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.setRegionId,
|
||||
}),
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
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",
|
||||
label: "排序",
|
||||
width: "minmax(80px, 0.4fr)",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
render: (item) => item.description || "-",
|
||||
width: "minmax(180px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(160px, 0.8fr)",
|
||||
},
|
||||
];
|
||||
|
||||
if (!page.abilities.canUpdate) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <BannerActions item={item} page={page} />,
|
||||
width: "minmax(96px, 0.45fr)"
|
||||
if (!page.abilities.canUpdate) {
|
||||
return columns;
|
||||
}
|
||||
];
|
||||
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <BannerActions item={item} page={page} />,
|
||||
width: "minmax(96px, 0.45fr)",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function BannerCover({ src }) {
|
||||
if (!src) {
|
||||
return (
|
||||
<span className={styles.bannerCoverEmpty}>
|
||||
<ImageOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <img alt="" className={styles.bannerCover} src={src} />;
|
||||
if (!src) {
|
||||
return (
|
||||
<span className={styles.bannerCoverEmpty}>
|
||||
<ImageOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <img alt="" className={styles.bannerCover} src={src} />;
|
||||
}
|
||||
|
||||
function BannerActions({ item, page }) {
|
||||
const deleting = page.loadingAction === `delete:${item.id}`;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton disabled={Boolean(page.loadingAction)} label="编辑" onClick={() => page.openEdit(item)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton disabled={deleting || Boolean(page.loadingAction)} label="删除" onClick={() => page.removeBanner(item)}>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
const deleting = page.loadingAction === `delete:${item.id}`;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={Boolean(page.loadingAction)}
|
||||
label="编辑"
|
||||
onClick={() => page.openEdit(item)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={deleting || Boolean(page.loadingAction)}
|
||||
label="删除"
|
||||
onClick={() => page.removeBanner(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) {
|
||||
return (
|
||||
<TextField className={className} disabled={disabled} label={label} select size="small" value={value || ""} onChange={(event) => onChange(event.target.value)}>
|
||||
<MenuItem value="">{emptyLabel}</MenuItem>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
return (
|
||||
<TextField
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
select
|
||||
size="small"
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<MenuItem value="">{emptyLabel}</MenuItem>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "active" ? "succeeded" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{status === "active" ? "启用" : "关闭"}
|
||||
</span>
|
||||
);
|
||||
const tone = status === "active" ? "succeeded" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{status === "active" ? "启用" : "关闭"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function typeLabel(value) {
|
||||
return value === "app" ? "APP" : "H5";
|
||||
return value === "app" ? "APP" : "H5";
|
||||
}
|
||||
|
||||
function platformLabel(value) {
|
||||
if (value === "android") {
|
||||
return "安卓";
|
||||
}
|
||||
if (value === "ios") {
|
||||
return "iOS";
|
||||
}
|
||||
return value || "-";
|
||||
if (value === "android") {
|
||||
return "安卓";
|
||||
}
|
||||
if (value === "ios") {
|
||||
return "iOS";
|
||||
}
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function regionLabel(options, value) {
|
||||
if (!value) {
|
||||
return "全部区域";
|
||||
}
|
||||
return options.find((option) => option.value === String(value))?.label || `区域 ${value}`;
|
||||
if (!value) {
|
||||
return "全部区域";
|
||||
}
|
||||
return options.find((option) => option.value === String(value))?.label || `区域 ${value}`;
|
||||
}
|
||||
|
||||
function countryLabel(options, value) {
|
||||
if (!value) {
|
||||
return "全部国家";
|
||||
}
|
||||
return options.find((option) => option.value === value)?.label || value;
|
||||
if (!value) {
|
||||
return "全部国家";
|
||||
}
|
||||
return options.find((option) => option.value === value)?.label || value;
|
||||
}
|
||||
|
||||
@ -3,13 +3,7 @@ import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
banAppUser,
|
||||
listAppUsers,
|
||||
setAppUserPassword,
|
||||
unbanAppUser,
|
||||
updateAppUser
|
||||
} from "@/features/app-users/api";
|
||||
import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
|
||||
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
||||
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
|
||||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
||||
@ -21,177 +15,189 @@ const emptyCountryForm = () => ({ country: "" });
|
||||
const emptyPasswordForm = () => ({ password: "" });
|
||||
|
||||
export function useAppUsersPage() {
|
||||
const abilities = useAppUserAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeUser, setActiveUser] = useState(null);
|
||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
||||
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
status
|
||||
}),
|
||||
[query, status]
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
errorMessage: "加载用户列表失败",
|
||||
fetcher: listAppUsers,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["app-users", filters, page]
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openEdit = (user) => {
|
||||
setActiveUser(user);
|
||||
setEditForm({
|
||||
avatar: user.avatar || "",
|
||||
gender: user.gender || "",
|
||||
username: user.username || ""
|
||||
const abilities = useAppUserAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeUser, setActiveUser] = useState(null);
|
||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
||||
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
status,
|
||||
}),
|
||||
[query, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载用户列表失败",
|
||||
fetcher: listAppUsers,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["app-users", filters, page],
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const openPassword = (user) => {
|
||||
setActiveUser(user);
|
||||
setPasswordForm(emptyPasswordForm());
|
||||
setActiveAction("password");
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCountry = (user) => {
|
||||
setActiveUser(user);
|
||||
setCountryForm({ country: user.country || "" });
|
||||
setActiveAction("country");
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setActiveUser(null);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitEdit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeUser) {
|
||||
return;
|
||||
}
|
||||
await runAction("edit", "用户已更新", async () => {
|
||||
const payload = parseForm(appUserUpdateSchema, editForm);
|
||||
await updateAppUser(activeUser.userId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const openEdit = (user) => {
|
||||
setActiveUser(user);
|
||||
setEditForm({
|
||||
avatar: user.avatar || "",
|
||||
gender: user.gender || "",
|
||||
username: user.username || "",
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const submitCountry = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeUser) {
|
||||
return;
|
||||
}
|
||||
await runAction("country", "国家已更新", async () => {
|
||||
const payload = parseForm(appUserCountrySchema, countryForm);
|
||||
await updateAppUser(activeUser.userId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const openPassword = (user) => {
|
||||
setActiveUser(user);
|
||||
setPasswordForm(emptyPasswordForm());
|
||||
setActiveAction("password");
|
||||
};
|
||||
|
||||
const submitPassword = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeUser) {
|
||||
return;
|
||||
}
|
||||
await runAction("password", "密码已设置", async () => {
|
||||
const payload = parseForm(appUserPasswordSchema, passwordForm);
|
||||
await setAppUserPassword(activeUser.userId, payload);
|
||||
closeAction();
|
||||
});
|
||||
};
|
||||
const openCountry = (user) => {
|
||||
setActiveUser(user);
|
||||
setCountryForm({ country: user.country || "" });
|
||||
setActiveAction("country");
|
||||
};
|
||||
|
||||
const toggleBan = async (user) => {
|
||||
const banned = isBanned(user);
|
||||
const ok = await confirm({
|
||||
confirmText: banned ? "解封" : "封禁",
|
||||
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
|
||||
title: banned ? "解封用户" : "封禁用户",
|
||||
tone: banned ? "primary" : "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
|
||||
if (banned) {
|
||||
await unbanAppUser(user.userId);
|
||||
} else {
|
||||
await banAppUser(user.userId);
|
||||
}
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setActiveUser(null);
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
const submitEdit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeUser) {
|
||||
return;
|
||||
}
|
||||
await runAction("edit", "用户已更新", async () => {
|
||||
const payload = parseForm(appUserUpdateSchema, editForm);
|
||||
await updateAppUser(activeUser.userId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
activeUser,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
countryForm,
|
||||
countryOptions,
|
||||
data,
|
||||
editForm,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
openCountry,
|
||||
openEdit,
|
||||
openPassword,
|
||||
page,
|
||||
passwordForm,
|
||||
query,
|
||||
reload,
|
||||
setCountryForm,
|
||||
setEditForm,
|
||||
setPage,
|
||||
setPasswordForm,
|
||||
status,
|
||||
submitCountry,
|
||||
submitEdit,
|
||||
submitPassword,
|
||||
toggleBan
|
||||
};
|
||||
const submitCountry = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeUser) {
|
||||
return;
|
||||
}
|
||||
await runAction("country", "国家已更新", async () => {
|
||||
const payload = parseForm(appUserCountrySchema, countryForm);
|
||||
await updateAppUser(activeUser.userId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitPassword = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeUser) {
|
||||
return;
|
||||
}
|
||||
await runAction("password", "密码已设置", async () => {
|
||||
const payload = parseForm(appUserPasswordSchema, passwordForm);
|
||||
await setAppUserPassword(activeUser.userId, payload);
|
||||
closeAction();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBan = async (user) => {
|
||||
const banned = isBanned(user);
|
||||
const ok = await confirm({
|
||||
confirmText: banned ? "解封" : "封禁",
|
||||
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
|
||||
title: banned ? "解封用户" : "封禁用户",
|
||||
tone: banned ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
|
||||
if (banned) {
|
||||
await unbanAppUser(user.userId);
|
||||
} else {
|
||||
await banAppUser(user.userId);
|
||||
}
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
activeUser,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
countryForm,
|
||||
countryOptions,
|
||||
data,
|
||||
editForm,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
openCountry,
|
||||
openEdit,
|
||||
openPassword,
|
||||
page,
|
||||
passwordForm,
|
||||
query,
|
||||
reload,
|
||||
resetFilters,
|
||||
setCountryForm,
|
||||
setEditForm,
|
||||
setPage,
|
||||
setPasswordForm,
|
||||
status,
|
||||
submitCountry,
|
||||
submitEdit,
|
||||
submitPassword,
|
||||
toggleBan,
|
||||
};
|
||||
}
|
||||
|
||||
function isBanned(user) {
|
||||
return user.status === "banned" || user.status === "disabled";
|
||||
return user.status === "banned" || user.status === "disabled";
|
||||
}
|
||||
|
||||
@ -10,257 +10,307 @@ import Tooltip from "@mui/material/Tooltip";
|
||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
appUserStatusFilters,
|
||||
appUserStatusLabels,
|
||||
genderOptions
|
||||
} from "@/features/app-users/constants.js";
|
||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||
import styles from "@/features/app-users/app-users.module.css";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "identity",
|
||||
label: "用户",
|
||||
width: "minmax(240px, 1.5fr)",
|
||||
render: (user) => <UserIdentity user={user} />
|
||||
},
|
||||
{ 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: "status",
|
||||
label: "状态",
|
||||
width: "minmax(92px, 0.6fr)",
|
||||
render: (user) => <UserStatus status={user.status} />
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "创建 / 活跃",
|
||||
width: "minmax(180px, 1fr)",
|
||||
render: (user) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{formatMillis(user.createdAtMs)}</span>
|
||||
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
key: "identity",
|
||||
label: "用户",
|
||||
width: "minmax(240px, 1.5fr)",
|
||||
render: (user) => <UserIdentity user={user} />,
|
||||
},
|
||||
{ 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: "status",
|
||||
label: "状态",
|
||||
width: "minmax(92px, 0.6fr)",
|
||||
render: (user) => <UserStatus status={user.status} />,
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "创建 / 活跃",
|
||||
width: "minmax(180px, 1fr)",
|
||||
render: (user) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{formatMillis(user.createdAtMs)}</span>
|
||||
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function AppUserListPage() {
|
||||
const page = useAppUsersPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const tableColumns = [
|
||||
columns[0],
|
||||
{
|
||||
key: "location",
|
||||
label: "国家 / 区域",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (user) => <UserLocation page={page} user={user} />
|
||||
},
|
||||
...columns.slice(1),
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(148px, 0.8fr)",
|
||||
render: (user) => <UserActions page={page} user={user} />
|
||||
}
|
||||
];
|
||||
const page = useAppUsersPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const tableColumns = [
|
||||
{
|
||||
...columns[0],
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索名称、短 ID、用户 ID",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "location",
|
||||
label: "国家 / 区域",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (user) => <UserLocation page={page} user={user} />,
|
||||
},
|
||||
...columns.slice(1).map((column) =>
|
||||
column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: appUserStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
),
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(148px, 0.8fr)",
|
||||
render: (user) => <UserActions page={page} user={user} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<section className={styles.filters}>
|
||||
<SearchBox className={styles.search} value={page.query} onChange={page.changeQuery} placeholder="搜索名称、短 ID、用户 ID..." />
|
||||
<StatusSelect
|
||||
className={styles.statusSelect}
|
||||
options={appUserStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<section className={styles.filters}>
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable 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>
|
||||
</DataState>
|
||||
</div>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable
|
||||
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>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "edit"}
|
||||
open={page.activeAction === "edit"}
|
||||
title="编辑用户"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEdit}
|
||||
>
|
||||
<TextField disabled={!page.abilities.canUpdate} 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]) => (
|
||||
<MenuItem key={value || "empty"} value={value}>{label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</ActionModal>
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "edit"}
|
||||
open={page.activeAction === "edit"}
|
||||
title="编辑用户"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEdit}
|
||||
>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
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]) => (
|
||||
<MenuItem key={value || "empty"} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</ActionModal>
|
||||
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "country"}
|
||||
open={page.activeAction === "country"}
|
||||
title="修改国家"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCountry}
|
||||
>
|
||||
<Autocomplete
|
||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||
getOptionLabel={(option) => option.label || ""}
|
||||
isOptionEqualToValue={(option, value) => option.value === value.value}
|
||||
loading={page.loadingCountries}
|
||||
options={page.countryOptions}
|
||||
renderInput={(params) => <TextField {...params} label="国家" required />}
|
||||
size="small"
|
||||
value={page.countryOptions.find((option) => option.value === page.countryForm.country) || null}
|
||||
onChange={(_, option) => page.setCountryForm({ country: option?.value || "" })}
|
||||
/>
|
||||
</ActionModal>
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "country"}
|
||||
open={page.activeAction === "country"}
|
||||
title="修改国家"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCountry}
|
||||
>
|
||||
<Autocomplete
|
||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||
getOptionLabel={(option) => option.label || ""}
|
||||
isOptionEqualToValue={(option, value) => option.value === value.value}
|
||||
loading={page.loadingCountries}
|
||||
options={page.countryOptions}
|
||||
renderInput={(params) => <TextField {...params} label="国家" required />}
|
||||
size="small"
|
||||
value={page.countryOptions.find((option) => option.value === page.countryForm.country) || null}
|
||||
onChange={(_, option) => page.setCountryForm({ country: option?.value || "" })}
|
||||
/>
|
||||
</ActionModal>
|
||||
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canPassword}
|
||||
loading={page.loadingAction === "password"}
|
||||
open={page.activeAction === "password"}
|
||||
title="设置密码"
|
||||
onClose={page.closeAction}
|
||||
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 })} />
|
||||
</ActionModal>
|
||||
</section>
|
||||
);
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canPassword}
|
||||
loading={page.loadingAction === "password"}
|
||||
open={page.activeAction === "password"}
|
||||
title="设置密码"
|
||||
onClose={page.closeAction}
|
||||
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 })}
|
||||
/>
|
||||
</ActionModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UserLocation({ page, user }) {
|
||||
const countryLabel = formatCountry(user);
|
||||
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<button className={styles.countryButton} type="button" onClick={() => page.openCountry(user)}>
|
||||
{countryLabel}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.primaryText}>{countryLabel}</span>
|
||||
)}
|
||||
<span className={styles.meta}>{regionLabel}</span>
|
||||
</div>
|
||||
);
|
||||
const countryLabel = formatCountry(user);
|
||||
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<button className={styles.countryButton} type="button" onClick={() => page.openCountry(user)}>
|
||||
{countryLabel}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.primaryText}>{countryLabel}</span>
|
||||
)}
|
||||
<span className={styles.meta}>{regionLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIdentity({ user }) {
|
||||
const name = user.username || "-";
|
||||
const shortId = user.displayUserId || user.userId;
|
||||
const gender = genderView(user.gender);
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.avatar}>
|
||||
{user.avatar ? <img src={user.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.nameLine}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={`${styles.gender} ${styles[gender.className]}`}>{gender.symbol}</span>
|
||||
const name = user.username || "-";
|
||||
const shortId = user.displayUserId || user.userId;
|
||||
const gender = genderView(user.gender);
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.avatar}>
|
||||
{user.avatar ? <img src={user.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.nameLine}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={`${styles.gender} ${styles[gender.className]}`}>{gender.symbol}</span>
|
||||
</div>
|
||||
<div className={styles.meta}>{shortId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.meta}>{shortId}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
function UserActions({ page, user }) {
|
||||
const banned = user.status === "banned" || user.status === "disabled";
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Tooltip arrow title="编辑">
|
||||
<span>
|
||||
<IconButton label="编辑" onClick={() => page.openEdit(user)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{page.abilities.canStatus ? (
|
||||
<Tooltip arrow title={banned ? "解封" : "封禁"}>
|
||||
<span>
|
||||
<IconButton disabled={page.loadingAction === `status-${user.userId}`} label={banned ? "解封" : "封禁"} tone={banned ? "success" : "danger"} onClick={() => page.toggleBan(user)}>
|
||||
{banned ? <LockOpenOutlined fontSize="small" /> : <BlockOutlined fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{page.abilities.canPassword ? (
|
||||
<Tooltip arrow title="设置密码">
|
||||
<span>
|
||||
<IconButton label="设置密码" onClick={() => page.openPassword(user)}>
|
||||
<PasswordOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
const banned = user.status === "banned" || user.status === "disabled";
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Tooltip arrow title="编辑">
|
||||
<span>
|
||||
<IconButton label="编辑" onClick={() => page.openEdit(user)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{page.abilities.canStatus ? (
|
||||
<Tooltip arrow title={banned ? "解封" : "封禁"}>
|
||||
<span>
|
||||
<IconButton
|
||||
disabled={page.loadingAction === `status-${user.userId}`}
|
||||
label={banned ? "解封" : "封禁"}
|
||||
tone={banned ? "success" : "danger"}
|
||||
onClick={() => page.toggleBan(user)}
|
||||
>
|
||||
{banned ? <LockOpenOutlined fontSize="small" /> : <BlockOutlined fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{page.abilities.canPassword ? (
|
||||
<Tooltip arrow title="设置密码">
|
||||
<span>
|
||||
<IconButton label="设置密码" onClick={() => page.openPassword(user)}>
|
||||
<PasswordOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserStatus({ status }) {
|
||||
const tone = status === "active" ? "running" : "danger";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{appUserStatusLabels[status] || status || "-"}
|
||||
</span>
|
||||
);
|
||||
const tone = status === "active" ? "running" : "danger";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{appUserStatusLabels[status] || status || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="compact"
|
||||
submitDisabled={disabled || loading}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{children}
|
||||
</AdminFormDialog>
|
||||
);
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="compact"
|
||||
submitDisabled={disabled || loading}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{children}
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function genderView(gender) {
|
||||
const value = String(gender || "").toLowerCase();
|
||||
if (value === "male" || value === "m" || value === "男") {
|
||||
return { className: "genderMale", symbol: "♂" };
|
||||
}
|
||||
if (value === "female" || value === "f" || value === "女") {
|
||||
return { className: "genderFemale", symbol: "♀" };
|
||||
}
|
||||
return { className: "genderUnknown", symbol: "-" };
|
||||
const value = String(gender || "").toLowerCase();
|
||||
if (value === "male" || value === "m" || value === "男") {
|
||||
return { className: "genderMale", symbol: "♂" };
|
||||
}
|
||||
if (value === "female" || value === "f" || value === "女") {
|
||||
return { className: "genderFemale", symbol: "♀" };
|
||||
}
|
||||
return { className: "genderUnknown", symbol: "-" };
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function formatCountry(user) {
|
||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||
}
|
||||
|
||||
@ -1,101 +1,118 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import {
|
||||
createCountry,
|
||||
createCoinSeller,
|
||||
createRegion,
|
||||
disableRegion,
|
||||
listAgencies,
|
||||
listBDs,
|
||||
listCoinSellers,
|
||||
listCountries,
|
||||
listHosts,
|
||||
listRegions,
|
||||
replaceRegionCountries,
|
||||
setCoinSellerStatus,
|
||||
updateCountry
|
||||
createCountry,
|
||||
createCoinSeller,
|
||||
createRegion,
|
||||
creditCoinSellerStock,
|
||||
disableRegion,
|
||||
listAgencies,
|
||||
listBDs,
|
||||
listCoinSellers,
|
||||
listCountries,
|
||||
listHosts,
|
||||
listRegions,
|
||||
replaceRegionCountries,
|
||||
setCoinSellerStatus,
|
||||
updateCountry,
|
||||
} from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
setAccessToken("");
|
||||
vi.unstubAllGlobals();
|
||||
setAccessToken("");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("host org list APIs use generated admin paths and filters", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })))
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
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 listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
|
||||
await listHosts({ agency_id: 41, page: 1, page_size: 10 });
|
||||
await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
|
||||
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
|
||||
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
|
||||
await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" });
|
||||
await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
|
||||
await listHosts({ agency_id: 41, page: 1, page_size: 10 });
|
||||
await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
|
||||
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
|
||||
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 [agencyUrl] = vi.mocked(fetch).mock.calls[1];
|
||||
const [hostUrl] = vi.mocked(fetch).mock.calls[2];
|
||||
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[4];
|
||||
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[5];
|
||||
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
|
||||
const [hostUrl] = vi.mocked(fetch).mock.calls[2];
|
||||
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[4];
|
||||
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("parent_leader_user_id=21");
|
||||
expect(String(bdUrl)).toContain("region_id=7");
|
||||
expect(String(bdUrl)).toContain("status=active");
|
||||
expect(bdInit?.method).toBe("GET");
|
||||
expect(String(agencyUrl)).toContain("/api/v1/admin/agencies?");
|
||||
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
||||
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
||||
expect(String(hostUrl)).toContain("agency_id=41");
|
||||
expect(String(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?");
|
||||
expect(String(coinSellerUrl)).toContain("region_id=7");
|
||||
expect(String(coinSellerUrl)).toContain("status=active");
|
||||
expect(coinSellerInit?.method).toBe("GET");
|
||||
expect(String(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers");
|
||||
expect(coinSellerCreateInit?.method).toBe("POST");
|
||||
expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status");
|
||||
expect(coinSellerStatusInit?.method).toBe("PATCH");
|
||||
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
||||
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
||||
expect(String(bdUrl)).toContain("region_id=7");
|
||||
expect(String(bdUrl)).toContain("status=active");
|
||||
expect(bdInit?.method).toBe("GET");
|
||||
expect(String(agencyUrl)).toContain("/api/v1/admin/agencies?");
|
||||
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
||||
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
||||
expect(String(hostUrl)).toContain("agency_id=41");
|
||||
expect(String(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?");
|
||||
expect(String(coinSellerUrl)).toContain("region_id=7");
|
||||
expect(String(coinSellerUrl)).toContain("status=active");
|
||||
expect(coinSellerInit?.method).toBe("GET");
|
||||
expect(String(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers");
|
||||
expect(coinSellerCreateInit?.method).toBe("POST");
|
||||
expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status");
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], total: 0 } })))
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], total: 0 } }))),
|
||||
);
|
||||
|
||||
await listCountries({ enabled: true });
|
||||
await createCountry({ countryCode: "US", countryDisplayName: "United States", countryName: "United States", enabled: true });
|
||||
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
|
||||
await listRegions({ status: "active" });
|
||||
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });
|
||||
await replaceRegionCountries(7, { countries: ["US", "CA"] });
|
||||
await disableRegion(7);
|
||||
await listCountries({ enabled: true });
|
||||
await createCountry({
|
||||
countryCode: "US",
|
||||
countryDisplayName: "United States",
|
||||
countryName: "United States",
|
||||
enabled: true,
|
||||
});
|
||||
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
|
||||
await listRegions({ status: "active" });
|
||||
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });
|
||||
await replaceRegionCountries(7, { countries: ["US", "CA"] });
|
||||
await disableRegion(7);
|
||||
|
||||
const [countryListUrl, countryListInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [countryCreateUrl, countryCreateInit] = vi.mocked(fetch).mock.calls[1];
|
||||
const [countryUpdateUrl, countryUpdateInit] = vi.mocked(fetch).mock.calls[2];
|
||||
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[4];
|
||||
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[5];
|
||||
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[6];
|
||||
const [countryListUrl, countryListInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [countryCreateUrl, countryCreateInit] = vi.mocked(fetch).mock.calls[1];
|
||||
const [countryUpdateUrl, countryUpdateInit] = vi.mocked(fetch).mock.calls[2];
|
||||
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[4];
|
||||
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[5];
|
||||
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[6];
|
||||
|
||||
expect(String(countryListUrl)).toContain("/api/v1/admin/countries?");
|
||||
expect(String(countryListUrl)).toContain("enabled=true");
|
||||
expect(countryListInit?.method).toBe("GET");
|
||||
expect(String(countryCreateUrl)).toContain("/api/v1/admin/countries");
|
||||
expect(countryCreateInit?.method).toBe("POST");
|
||||
expect(String(countryUpdateUrl)).toContain("/api/v1/admin/countries/12");
|
||||
expect(countryUpdateInit?.method).toBe("PATCH");
|
||||
expect(String(regionListUrl)).toContain("/api/v1/admin/regions?");
|
||||
expect(String(regionListUrl)).toContain("status=active");
|
||||
expect(regionListInit?.method).toBe("GET");
|
||||
expect(String(regionCreateUrl)).toContain("/api/v1/admin/regions");
|
||||
expect(regionCreateInit?.method).toBe("POST");
|
||||
expect(String(regionCountriesUrl)).toContain("/api/v1/admin/regions/7/countries");
|
||||
expect(regionCountriesInit?.method).toBe("PUT");
|
||||
expect(String(regionDisableUrl)).toContain("/api/v1/admin/regions/7");
|
||||
expect(regionDisableInit?.method).toBe("DELETE");
|
||||
expect(String(countryListUrl)).toContain("/api/v1/admin/countries?");
|
||||
expect(String(countryListUrl)).toContain("enabled=true");
|
||||
expect(countryListInit?.method).toBe("GET");
|
||||
expect(String(countryCreateUrl)).toContain("/api/v1/admin/countries");
|
||||
expect(countryCreateInit?.method).toBe("POST");
|
||||
expect(String(countryUpdateUrl)).toContain("/api/v1/admin/countries/12");
|
||||
expect(countryUpdateInit?.method).toBe("PATCH");
|
||||
expect(String(regionListUrl)).toContain("/api/v1/admin/regions?");
|
||||
expect(String(regionListUrl)).toContain("status=active");
|
||||
expect(regionListInit?.method).toBe("GET");
|
||||
expect(String(regionCreateUrl)).toContain("/api/v1/admin/regions");
|
||||
expect(regionCreateInit?.method).toBe("POST");
|
||||
expect(String(regionCountriesUrl)).toContain("/api/v1/admin/regions/7/countries");
|
||||
expect(regionCountriesInit?.method).toBe("PUT");
|
||||
expect(String(regionDisableUrl)).toContain("/api/v1/admin/regions/7");
|
||||
expect(regionDisableInit?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
BDProfileDto,
|
||||
BDStatusPayload,
|
||||
CoinSellerDto,
|
||||
CoinSellerStockCreditDto,
|
||||
CoinSellerStockCreditPayload,
|
||||
CoinSellerStatusPayload,
|
||||
CountryDto,
|
||||
CountryPayload,
|
||||
@ -25,21 +27,21 @@ import type {
|
||||
RegionCountriesPayload,
|
||||
RegionDto,
|
||||
RegionPayload,
|
||||
RegionUpdatePayload
|
||||
RegionUpdatePayload,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
|
||||
const endpoint = API_ENDPOINTS.listCountries;
|
||||
return apiRequest<ApiList<CountryDto>>(apiEndpointPath(API_OPERATIONS.listCountries), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCountry(countryId: EntityId): Promise<CountryDto> {
|
||||
const endpoint = API_ENDPOINTS.getCountry;
|
||||
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;
|
||||
return apiRequest<CountryDto, CountryPayload>(apiEndpointPath(API_OPERATIONS.createCountry), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload): Promise<CountryDto> {
|
||||
const endpoint = API_ENDPOINTS.updateCountry;
|
||||
return apiRequest<CountryDto, CountryUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateCountry, { country_id: countryId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<CountryDto, CountryUpdatePayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateCountry, { country_id: countryId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function enableCountry(countryId: EntityId): Promise<CountryDto> {
|
||||
const endpoint = API_ENDPOINTS.enableCountry;
|
||||
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function disableCountry(countryId: EntityId): Promise<CountryDto> {
|
||||
const endpoint = API_ENDPOINTS.disableCountry;
|
||||
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.disableCountry, { country_id: countryId }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCountry(countryId: EntityId): Promise<CountryDto> {
|
||||
const endpoint = API_ENDPOINTS.deleteCountry;
|
||||
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.deleteCountry, { country_id: countryId }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRegion(regionId: EntityId): Promise<RegionDto> {
|
||||
const endpoint = API_ENDPOINTS.getRegion;
|
||||
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;
|
||||
return apiRequest<RegionDto, RegionPayload>(apiEndpointPath(API_OPERATIONS.createRegion), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRegion(regionId: EntityId, payload: RegionUpdatePayload): Promise<RegionDto> {
|
||||
const endpoint = API_ENDPOINTS.updateRegion;
|
||||
return apiRequest<RegionDto, RegionUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateRegion, { region_id: regionId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<RegionDto, RegionUpdatePayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateRegion, { region_id: regionId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
}
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function enableRegion(regionId: EntityId): Promise<RegionDto> {
|
||||
const endpoint = API_ENDPOINTS.enableRegion;
|
||||
return apiRequest<RegionDto>(apiEndpointPath(API_OPERATIONS.enableRegion, { region_id: regionId }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function disableRegion(regionId: EntityId): Promise<RegionDto> {
|
||||
const endpoint = API_ENDPOINTS.disableRegion;
|
||||
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;
|
||||
return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDLeaders), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -140,7 +148,7 @@ export function listBDs(query: PageQuery = {}): Promise<ApiPage<BDProfileDto>> {
|
||||
const endpoint = API_ENDPOINTS.listBDs;
|
||||
return apiRequest<ApiPage<BDProfileDto>>(apiEndpointPath(API_OPERATIONS.listBDs), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -148,7 +156,7 @@ export function listAgencies(query: PageQuery = {}): Promise<ApiPage<AgencyDto>>
|
||||
const endpoint = API_ENDPOINTS.listAgencies;
|
||||
return apiRequest<ApiPage<AgencyDto>>(apiEndpointPath(API_OPERATIONS.listAgencies), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -156,7 +164,7 @@ export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto
|
||||
const endpoint = API_ENDPOINTS.listHosts;
|
||||
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -164,7 +172,7 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
|
||||
const endpoint = API_ENDPOINTS.listCoinSellers;
|
||||
return apiRequest<ApiPage<CoinSellerDto>>(apiEndpointPath(API_OPERATIONS.listCoinSellers), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -172,7 +180,7 @@ export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfil
|
||||
const endpoint = API_ENDPOINTS.createBDLeader;
|
||||
return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -180,23 +188,32 @@ export function createBD(payload: CreateBDPayload): Promise<BDProfileDto> {
|
||||
const endpoint = API_ENDPOINTS.createBD;
|
||||
return apiRequest<BDProfileDto, CreateBDPayload>(apiEndpointPath(API_OPERATIONS.createBD), {
|
||||
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> {
|
||||
const endpoint = API_ENDPOINTS.setBDLeaderStatus;
|
||||
return apiRequest<BDProfileDto, BDStatusPayload>(apiEndpointPath(API_OPERATIONS.setBDLeaderStatus, { user_id: userId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<BDProfileDto, BDStatusPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.setBDLeaderStatus, { user_id: userId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function setBDStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> {
|
||||
const endpoint = API_ENDPOINTS.setBDStatus;
|
||||
return apiRequest<BDProfileDto, BDStatusPayload>(apiEndpointPath(API_OPERATIONS.setBDStatus, { user_id: userId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -204,7 +221,7 @@ export function createCoinSeller(payload: CreateCoinSellerPayload): Promise<Coin
|
||||
const endpoint = API_ENDPOINTS.createCoinSeller;
|
||||
return apiRequest<CoinSellerDto, CreateCoinSellerPayload>(apiEndpointPath(API_OPERATIONS.createCoinSeller), {
|
||||
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 }),
|
||||
{
|
||||
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;
|
||||
return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> {
|
||||
const endpoint = API_ENDPOINTS.closeAgency;
|
||||
return apiRequest<AgencyDto, HostCommandPayload>(apiEndpointPath(API_OPERATIONS.closeAgency, { agency_id: agencyId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<AgencyDto, HostCommandPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.closeAgency, { agency_id: agencyId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
}
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ export function HostOrgActionModal({
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
size = "compact",
|
||||
submitLabel = "提交",
|
||||
submitVariant = "primary",
|
||||
title
|
||||
@ -15,7 +16,7 @@ export function HostOrgActionModal({
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="compact"
|
||||
size={size}
|
||||
submitDisabled={disabled || loading}
|
||||
submitLabel={submitLabel}
|
||||
submitVariant={submitVariant}
|
||||
|
||||
@ -1,50 +1,20 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
export function HostOrgFilters({
|
||||
extraFilters = [],
|
||||
loadingRegions = false,
|
||||
onQueryChange,
|
||||
onRegionIdChange,
|
||||
onStatusChange,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions = [],
|
||||
searchPlaceholder,
|
||||
status,
|
||||
statusOptions
|
||||
extraFilters = [],
|
||||
onReset,
|
||||
query,
|
||||
regionId,
|
||||
status,
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
<SearchBox className={styles.search} value={query} onChange={onQueryChange} placeholder={searchPlaceholder} />
|
||||
<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>
|
||||
);
|
||||
const hasActiveFilters = [query, regionId, status, ...extraFilters.map((filter) => filter.value)].some(
|
||||
(value) => String(value || "").trim() !== "",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
{onReset ? <AdminFilterResetButton disabled={!hasActiveFilters} onClick={onReset} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/features/host-org/components/HostOrgIdentity.jsx
Normal file
47
src/features/host-org/components/HostOrgIdentity.jsx
Normal 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 || "-";
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
|
||||
export function HostOrgTable({ columns, regionFilter, ...props }) {
|
||||
const tableColumns = regionFilter
|
||||
? columns.map((column) => (column.key === "regionId" ? { ...column, filter: regionFilter } : column))
|
||||
: columns;
|
||||
const tableColumns = regionFilter
|
||||
? columns.map((column) => (column.key === "regionId" ? { ...column, filter: regionFilter } : column))
|
||||
: columns;
|
||||
|
||||
return <DataTable {...props} columns={tableColumns} />;
|
||||
return <DataTable {...props} columns={tableColumns} />;
|
||||
}
|
||||
|
||||
@ -2,177 +2,182 @@ import { useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
closeAgency,
|
||||
createAgency,
|
||||
listAgencies,
|
||||
setAgencyJoinEnabled
|
||||
} from "@/features/host-org/api";
|
||||
import { closeAgency, createAgency, listAgencies, setAgencyJoinEnabled } from "@/features/host-org/api";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
|
||||
import {
|
||||
agencyCloseSchema,
|
||||
agencyJoinEnabledSchema,
|
||||
createAgencySchema
|
||||
} from "@/features/host-org/schema";
|
||||
import { agencyCloseSchema, agencyJoinEnabledSchema, createAgencySchema } from "@/features/host-org/schema";
|
||||
|
||||
const emptyAgencyForm = () => ({
|
||||
commandId: makeCommandId("agency"),
|
||||
joinEnabled: true,
|
||||
maxHosts: 0,
|
||||
name: "",
|
||||
ownerUserId: "",
|
||||
parentBdUserId: "",
|
||||
reason: ""
|
||||
commandId: makeCommandId("agency"),
|
||||
joinEnabled: true,
|
||||
maxHosts: 0,
|
||||
name: "",
|
||||
ownerUserId: "",
|
||||
parentBdUserId: "",
|
||||
reason: "",
|
||||
});
|
||||
const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" });
|
||||
const emptyJoinEnabledForm = () => ({
|
||||
agencyId: "",
|
||||
commandId: makeCommandId("agency-join"),
|
||||
joinEnabled: true,
|
||||
reason: ""
|
||||
agencyId: "",
|
||||
commandId: makeCommandId("agency-join"),
|
||||
joinEnabled: true,
|
||||
reason: "",
|
||||
});
|
||||
const pageSize = 10;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
|
||||
export function useHostAgenciesPage() {
|
||||
const abilities = useAgencyAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [parentBdUserId, setParentBdUserId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
|
||||
const [closeForm, setCloseForm] = useState(emptyCloseForm);
|
||||
const [joinEnabledForm, setJoinEnabledForm] = useState(emptyJoinEnabledForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
parent_bd_user_id: parentBdUserId,
|
||||
region_id: regionId,
|
||||
status
|
||||
}),
|
||||
[parentBdUserId, query, regionId, status]
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
errorMessage: "加载 Agency 列表失败",
|
||||
fetcher: listAgencies,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "agencies", filters, page]
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeParentBdUserId = (value) => {
|
||||
setParentBdUserId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitAgency = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("agency", "Agency 已创建", async () => {
|
||||
const payload = parseForm(createAgencySchema, agencyForm);
|
||||
const data = await createAgency(payload);
|
||||
setAgencyForm(emptyAgencyForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
const abilities = useAgencyAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [parentBdUserId, setParentBdUserId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
|
||||
const [closeForm, setCloseForm] = useState(emptyCloseForm);
|
||||
const [joinEnabledForm, setJoinEnabledForm] = useState(emptyJoinEnabledForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
parent_bd_user_id: parentBdUserId,
|
||||
region_id: regionId,
|
||||
status,
|
||||
}),
|
||||
[parentBdUserId, query, regionId, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载 Agency 列表失败",
|
||||
fetcher: listAgencies,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "agencies", filters, page],
|
||||
});
|
||||
};
|
||||
|
||||
const submitClose = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("agency-close", "Agency 已关闭", async () => {
|
||||
const payload = parseForm(agencyCloseSchema, closeForm);
|
||||
const { agencyId, ...body } = payload;
|
||||
const data = await closeAgency(agencyId, body);
|
||||
setCloseForm(emptyCloseForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitJoinEnabled = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("agency-join", "入会开关已更新", async () => {
|
||||
const payload = parseForm(agencyJoinEnabledSchema, joinEnabledForm);
|
||||
const { agencyId, ...body } = payload;
|
||||
const data = await setAgencyJoinEnabled(agencyId, body);
|
||||
setJoinEnabledForm(emptyJoinEnabledForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
agencyForm,
|
||||
changeParentBdUserId,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeForm,
|
||||
data,
|
||||
error,
|
||||
joinEnabledForm,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
page,
|
||||
parentBdUserId,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
closeAction: () => setActiveAction(""),
|
||||
openAgencyForm: () => setActiveAction("agency"),
|
||||
openCloseForm: () => setActiveAction("agency-close"),
|
||||
openJoinEnabledForm: () => setActiveAction("agency-join"),
|
||||
setAgencyForm,
|
||||
setCloseForm,
|
||||
setJoinEnabledForm,
|
||||
setPage,
|
||||
status,
|
||||
submitAgency,
|
||||
submitClose,
|
||||
submitJoinEnabled
|
||||
};
|
||||
const changeParentBdUserId = (value) => {
|
||||
setParentBdUserId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setParentBdUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitAgency = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("agency", "Agency 已创建", async () => {
|
||||
const payload = parseForm(createAgencySchema, agencyForm);
|
||||
const data = await createAgency(payload);
|
||||
setAgencyForm(emptyAgencyForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const submitClose = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("agency-close", "Agency 已关闭", async () => {
|
||||
const payload = parseForm(agencyCloseSchema, closeForm);
|
||||
const { agencyId, ...body } = payload;
|
||||
const data = await closeAgency(agencyId, body);
|
||||
setCloseForm(emptyCloseForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const submitJoinEnabled = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("agency-join", "入会开关已更新", async () => {
|
||||
const payload = parseForm(agencyJoinEnabledSchema, joinEnabledForm);
|
||||
const { agencyId, ...body } = payload;
|
||||
const data = await setAgencyJoinEnabled(agencyId, body);
|
||||
setJoinEnabledForm(emptyJoinEnabledForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
agencyForm,
|
||||
changeParentBdUserId,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeForm,
|
||||
data,
|
||||
error,
|
||||
joinEnabledForm,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
page,
|
||||
parentBdUserId,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
closeAction: () => setActiveAction(""),
|
||||
openAgencyForm: () => setActiveAction("agency"),
|
||||
openCloseForm: () => setActiveAction("agency-close"),
|
||||
openJoinEnabledForm: () => setActiveAction("agency-join"),
|
||||
setAgencyForm,
|
||||
setCloseForm,
|
||||
setJoinEnabledForm,
|
||||
setPage,
|
||||
status,
|
||||
submitAgency,
|
||||
submitClose,
|
||||
submitJoinEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCommandId(prefix) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
@ -1,166 +1,344 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createBD,
|
||||
createBDLeader,
|
||||
listBDLeaders,
|
||||
listBDs,
|
||||
setBDLeaderStatus,
|
||||
setBDStatus
|
||||
createBD,
|
||||
createBDLeader,
|
||||
deleteBDLeader,
|
||||
listBDLeaders,
|
||||
listBDs,
|
||||
setBDLeaderStatus,
|
||||
setBDStatus,
|
||||
} from "@/features/host-org/api";
|
||||
import { listAppUsers } from "@/features/app-users/api";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { useBDAbilities } from "@/features/host-org/permissions.js";
|
||||
import { bdStatusSchema, createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
|
||||
|
||||
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", 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 emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyLookup = () => ({ error: "", loading: false, user: null });
|
||||
|
||||
export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
const abilities = useBDAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [parentLeaderUserId, setParentLeaderUserId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
|
||||
const [bdForm, setBDForm] = useState(emptyBDForm);
|
||||
const [bdStatusForm, setBDStatusForm] = useState(() => emptyBDStatusForm(profileType));
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
|
||||
region_id: regionId,
|
||||
status
|
||||
}),
|
||||
[parentLeaderUserId, profileType, query, regionId, status]
|
||||
);
|
||||
const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
errorMessage: profileType === "leader" ? "加载 BD Leader 列表失败" : "加载 BD 列表失败",
|
||||
fetcher,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "bds", profileType, filters, page]
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeParentLeaderUserId = (value) => {
|
||||
setParentLeaderUserId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitBDLeader = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("bd-leader", "BD Leader 已创建", async () => {
|
||||
const payload = parseForm(createBDLeaderSchema, bdLeaderForm);
|
||||
const data = await createBDLeader(payload);
|
||||
setBDLeaderForm(emptyBDLeaderForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
const abilities = useBDAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [parentLeaderUserId, setParentLeaderUserId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
|
||||
const [bdForm, setBDForm] = useState(emptyBDForm);
|
||||
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 [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
|
||||
region_id: regionId,
|
||||
status,
|
||||
}),
|
||||
[parentLeaderUserId, profileType, query, regionId, status],
|
||||
);
|
||||
const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: profileType === "leader" ? "加载 BD Leader 列表失败" : "加载 BD 列表失败",
|
||||
fetcher,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "bds", profileType, filters, page],
|
||||
});
|
||||
};
|
||||
|
||||
const submitBD = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("bd", "BD 已创建", async () => {
|
||||
const payload = parseForm(createBDSchema, bdForm);
|
||||
const data = await createBD(payload);
|
||||
setBDForm(emptyBDForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitBDStatus = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("bd-status", profileType === "leader" ? "BD Leader 状态已更新" : "BD 状态已更新", async () => {
|
||||
const payload = parseForm(bdStatusSchema, { ...bdStatusForm, targetType: profileType });
|
||||
const { targetType, targetUserId, ...body } = payload;
|
||||
const data = targetType === "leader"
|
||||
? await setBDLeaderStatus(targetUserId, body)
|
||||
: await setBDStatus(targetUserId, body);
|
||||
setBDStatusForm(emptyBDStatusForm(profileType));
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (activeAction !== "leader-add-bd") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
const keyword = String(bdForm.targetUserId || "").trim();
|
||||
if (!keyword) {
|
||||
setBDUserLookup(emptyLookup());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
bdForm,
|
||||
bdLeaderForm,
|
||||
bdStatusForm,
|
||||
changeParentLeaderUserId,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
page,
|
||||
parentLeaderUserId,
|
||||
profileType,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
closeAction: () => setActiveAction(""),
|
||||
openBDForm: () => setActiveAction("bd"),
|
||||
openBDLeaderForm: () => setActiveAction("bd-leader"),
|
||||
openBDStatusForm: () => setActiveAction("bd-status"),
|
||||
setBDForm,
|
||||
setBDLeaderForm,
|
||||
setBDStatusForm,
|
||||
setPage,
|
||||
status,
|
||||
submitBD,
|
||||
submitBDLeader,
|
||||
submitBDStatus
|
||||
};
|
||||
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) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeParentLeaderUserId = (value) => {
|
||||
setParentLeaderUserId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setParentLeaderUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitBDLeader = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("bd-leader", "BD Leader 已创建", async () => {
|
||||
const payload = parseForm(createBDLeaderSchema, bdLeaderForm);
|
||||
const data = await createBDLeader(payload);
|
||||
setBDLeaderForm(emptyBDLeaderForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const submitBD = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("bd", "BD 已创建", async () => {
|
||||
const payload = parseForm(createBDSchema, bdForm);
|
||||
const data = await createBD(payload);
|
||||
setBDForm(emptyBDForm());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
await runAction("bd-status", profileType === "leader" ? "BD Leader 状态已更新" : "BD 状态已更新", async () => {
|
||||
const payload = parseForm(bdStatusSchema, { ...bdStatusForm, targetType: profileType });
|
||||
const { targetType, targetUserId, ...body } = payload;
|
||||
const data =
|
||||
targetType === "leader"
|
||||
? await setBDLeaderStatus(targetUserId, body)
|
||||
: await setBDStatus(targetUserId, body);
|
||||
setBDStatusForm(emptyBDStatusForm(profileType));
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setBDUserLookup(emptyLookup());
|
||||
setLeaderBDsError("");
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
bdForm,
|
||||
bdLeaderForm,
|
||||
bdStatusForm,
|
||||
bdUserLookup,
|
||||
changeParentLeaderUserId,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
leaderBDs,
|
||||
leaderBDsError,
|
||||
leaderBDsLoading,
|
||||
page,
|
||||
parentLeaderUserId,
|
||||
profileType,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
removeBDLeader,
|
||||
resetFilters,
|
||||
closeAction,
|
||||
loadLeaderBDs: () => loadLeaderBDs(selectedLeader),
|
||||
openAddLeaderBD,
|
||||
openBDForm: () => setActiveAction("bd"),
|
||||
openBDLeaderForm: () => setActiveAction("bd-leader"),
|
||||
openBDStatusForm: () => setActiveAction("bd-status"),
|
||||
openLeaderBDs,
|
||||
selectedLeader,
|
||||
setBDForm,
|
||||
setBDLeaderForm,
|
||||
setBDStatusForm,
|
||||
setPage,
|
||||
status,
|
||||
submitBD,
|
||||
submitBDLeader,
|
||||
submitLeaderBD,
|
||||
submitBDStatus,
|
||||
toggleBDLeader,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,197 +2,234 @@ import { useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.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 { coinSellerStatusSchema, createCoinSellerSchema } from "@/features/host-org/schema";
|
||||
import {
|
||||
coinSellerStatusSchema,
|
||||
coinSellerStockCreditSchema,
|
||||
createCoinSellerSchema,
|
||||
} from "@/features/host-org/schema";
|
||||
|
||||
const pageSize = 10;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), reason: "", targetUserId: "" });
|
||||
const emptyEditForm = () => ({ commandId: makeCommandId("coin-seller-status"), reason: "", status: "active", targetUserId: "" });
|
||||
const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), contact: "", reason: "", 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() {
|
||||
const abilities = useCoinSellerAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||
const [selectedSeller, setSelectedSeller] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status
|
||||
}),
|
||||
[query, regionId, status]
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
errorMessage: "加载币商列表失败",
|
||||
fetcher: listCoinSellers,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "coin-sellers", filters, page]
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreateSeller = () => {
|
||||
setCreateForm(emptyCreateForm());
|
||||
setSelectedSeller(null);
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEditSeller = (seller) => {
|
||||
if (!seller?.userId) {
|
||||
return;
|
||||
}
|
||||
setSelectedSeller(seller);
|
||||
setEditForm({
|
||||
commandId: makeCommandId("coin-seller-status"),
|
||||
reason: "",
|
||||
status: seller.status || "active",
|
||||
targetUserId: seller.userId
|
||||
const abilities = useCoinSellerAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||
const [stockForm, setStockForm] = useState(emptyStockForm);
|
||||
const [selectedSeller, setSelectedSeller] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status,
|
||||
}),
|
||||
[query, regionId, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载币商列表失败",
|
||||
fetcher: listCoinSellers,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "coin-sellers", filters, page],
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setSelectedSeller(null);
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitCreateSeller = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("coin-seller-create", "币商已添加", async () => {
|
||||
const payload = parseForm(createCoinSellerSchema, createForm);
|
||||
await createCoinSeller(payload);
|
||||
setCreateForm(emptyCreateForm());
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitEditSeller = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`, "币商已更新", async () => {
|
||||
const payload = parseForm(coinSellerStatusSchema, editForm);
|
||||
const { targetUserId, ...body } = payload;
|
||||
await setCoinSellerStatus(targetUserId, body);
|
||||
setEditForm(emptyEditForm());
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleSeller = async (seller, nextEnabled = seller.status !== "active") => {
|
||||
if (!abilities.canUpdate || !seller?.userId) {
|
||||
return;
|
||||
}
|
||||
const nextStatus = nextEnabled ? "active" : "disabled";
|
||||
if (seller.status === nextStatus) {
|
||||
return;
|
||||
}
|
||||
await runAction(`coin-seller-status-${seller.userId}`, nextEnabled ? "币商已启用" : "币商已停用", async () => {
|
||||
await setCoinSellerStatus(seller.userId, {
|
||||
commandId: makeCommandId("coin-seller-status"),
|
||||
reason: "",
|
||||
status: nextStatus
|
||||
});
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
};
|
||||
const openCreateSeller = () => {
|
||||
setCreateForm(emptyCreateForm());
|
||||
setSelectedSeller(null);
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
const openEditSeller = (seller) => {
|
||||
if (!seller?.userId) {
|
||||
return;
|
||||
}
|
||||
setSelectedSeller(seller);
|
||||
setEditForm({
|
||||
commandId: makeCommandId("coin-seller-status"),
|
||||
contact: seller.contact || "",
|
||||
reason: "update coin seller contact",
|
||||
status: seller.status || "active",
|
||||
targetUserId: seller.userId,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
createForm,
|
||||
data,
|
||||
deleteSeller,
|
||||
editForm,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreateSeller,
|
||||
openEditSeller,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
selectedSeller,
|
||||
setCreateForm,
|
||||
setEditForm,
|
||||
setPage,
|
||||
status,
|
||||
submitCreateSeller,
|
||||
submitEditSeller,
|
||||
toggleSeller
|
||||
};
|
||||
const openStockCredit = (seller) => {
|
||||
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
|
||||
return;
|
||||
}
|
||||
setSelectedSeller(seller);
|
||||
setStockForm(emptyStockForm());
|
||||
setActiveAction("stock");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setSelectedSeller(null);
|
||||
};
|
||||
|
||||
const submitCreateSeller = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("coin-seller-create", "币商已添加", async () => {
|
||||
const payload = parseForm(createCoinSellerSchema, createForm);
|
||||
await createCoinSeller(payload);
|
||||
setCreateForm(emptyCreateForm());
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitEditSeller = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(
|
||||
`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`,
|
||||
"币商已更新",
|
||||
async () => {
|
||||
const payload = parseForm(coinSellerStatusSchema, editForm);
|
||||
const { targetUserId, ...body } = payload;
|
||||
await setCoinSellerStatus(targetUserId, body);
|
||||
setEditForm(emptyEditForm());
|
||||
closeAction();
|
||||
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();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSeller = async (seller, nextEnabled = seller.status !== "active") => {
|
||||
if (!abilities.canUpdate || !seller?.userId) {
|
||||
return;
|
||||
}
|
||||
const nextStatus = nextEnabled ? "active" : "disabled";
|
||||
if (seller.status === nextStatus) {
|
||||
return;
|
||||
}
|
||||
await runAction(`coin-seller-status-${seller.userId}`, nextEnabled ? "币商已启用" : "币商已停用", async () => {
|
||||
await setCoinSellerStatus(seller.userId, {
|
||||
commandId: makeCommandId("coin-seller-status"),
|
||||
reason: "",
|
||||
status: nextStatus,
|
||||
});
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
createForm,
|
||||
data,
|
||||
editForm,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openCreateSeller,
|
||||
openEditSeller,
|
||||
openStockCredit,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
selectedSeller,
|
||||
setCreateForm,
|
||||
setEditForm,
|
||||
setPage,
|
||||
setStockForm,
|
||||
status,
|
||||
stockForm,
|
||||
submitCreateSeller,
|
||||
submitEditSeller,
|
||||
submitStockCredit,
|
||||
toggleSeller,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCommandId(prefix) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
@ -2,160 +2,163 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createCountry,
|
||||
disableCountry,
|
||||
enableCountry,
|
||||
listCountries,
|
||||
updateCountry
|
||||
} from "@/features/host-org/api";
|
||||
import { createCountry, disableCountry, enableCountry, listCountries, updateCountry } from "@/features/host-org/api";
|
||||
import { useCountryAbilities } from "@/features/host-org/permissions.js";
|
||||
import { countryCreateSchema, countryUpdateSchema } from "@/features/host-org/schema";
|
||||
|
||||
const emptyCountryForm = () => ({
|
||||
countryCode: "",
|
||||
countryDisplayName: "",
|
||||
countryName: "",
|
||||
enabled: true,
|
||||
flag: "",
|
||||
isoAlpha3: "",
|
||||
isoNumeric: "",
|
||||
phoneCountryCode: "",
|
||||
sortOrder: 0
|
||||
countryCode: "",
|
||||
countryDisplayName: "",
|
||||
countryName: "",
|
||||
enabled: true,
|
||||
flag: "",
|
||||
isoAlpha3: "",
|
||||
isoNumeric: "",
|
||||
phoneCountryCode: "",
|
||||
sortOrder: 0,
|
||||
});
|
||||
const emptyData = { items: [], total: 0 };
|
||||
|
||||
export function useHostCountriesPage() {
|
||||
const abilities = useCountryAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [enabledStatus, setEnabledStatus] = useState("");
|
||||
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
||||
const [editingCountry, setEditingCountry] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
enabled: enabledStatus === "enabled" ? true : enabledStatus === "disabled" ? false : undefined
|
||||
}),
|
||||
[enabledStatus]
|
||||
);
|
||||
const queryFn = useCallback(() => listCountries(filters), [filters]);
|
||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载国家列表失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["host-org", "countries", filters]
|
||||
});
|
||||
const items = useMemo(() => filterCountries(data?.items || [], query), [data?.items, query]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const changeEnabledStatus = (value) => {
|
||||
setEnabledStatus(value);
|
||||
};
|
||||
|
||||
const openCreateCountry = () => {
|
||||
setEditingCountry(null);
|
||||
setCountryForm(emptyCountryForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEditCountry = (country) => {
|
||||
setEditingCountry(country);
|
||||
setCountryForm({
|
||||
countryCode: country.countryCode || "",
|
||||
countryDisplayName: country.countryDisplayName || "",
|
||||
countryName: country.countryName || "",
|
||||
enabled: Boolean(country.enabled),
|
||||
flag: country.flag || "",
|
||||
isoAlpha3: country.isoAlpha3 || "",
|
||||
isoNumeric: country.isoNumeric || "",
|
||||
phoneCountryCode: country.phoneCountryCode || "",
|
||||
sortOrder: country.sortOrder || 0
|
||||
const abilities = useCountryAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [enabledStatus, setEnabledStatus] = useState("");
|
||||
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
||||
const [editingCountry, setEditingCountry] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
enabled: enabledStatus === "enabled" ? true : enabledStatus === "disabled" ? false : undefined,
|
||||
}),
|
||||
[enabledStatus],
|
||||
);
|
||||
const queryFn = useCallback(() => listCountries(filters), [filters]);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载国家列表失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["host-org", "countries", filters],
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
const items = useMemo(() => filterCountries(data?.items || [], query), [data?.items, query]);
|
||||
|
||||
const submitCountry = async (event) => {
|
||||
event.preventDefault();
|
||||
const isEdit = activeAction === "edit";
|
||||
await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => {
|
||||
const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm);
|
||||
const data = isEdit
|
||||
? await updateCountry(editingCountry.countryId, payload)
|
||||
: await createCountry(payload);
|
||||
setCountryForm(emptyCountryForm());
|
||||
setEditingCountry(null);
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const toggleCountry = async (country, nextEnabled = !country.enabled) => {
|
||||
if (!abilities.canStatus || Boolean(country.enabled) === nextEnabled) {
|
||||
return;
|
||||
}
|
||||
await runAction(`country-status-${country.countryId}`, nextEnabled ? "国家已启用" : "国家已禁用", async () => {
|
||||
const data = nextEnabled ? await enableCountry(country.countryId) : await disableCountry(country.countryId);
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
const changeEnabledStatus = (value) => {
|
||||
setEnabledStatus(value);
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setEnabledStatus("");
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
changeEnabledStatus,
|
||||
changeQuery,
|
||||
closeAction: () => setActiveAction(""),
|
||||
countryForm,
|
||||
data: { ...data, items, total: items.length },
|
||||
editingCountry,
|
||||
enabledStatus,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
openCreateCountry,
|
||||
openEditCountry,
|
||||
query,
|
||||
reload,
|
||||
setCountryForm,
|
||||
submitCountry,
|
||||
toggleCountry
|
||||
};
|
||||
const openCreateCountry = () => {
|
||||
setEditingCountry(null);
|
||||
setCountryForm(emptyCountryForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEditCountry = (country) => {
|
||||
setEditingCountry(country);
|
||||
setCountryForm({
|
||||
countryCode: country.countryCode || "",
|
||||
countryDisplayName: country.countryDisplayName || "",
|
||||
countryName: country.countryName || "",
|
||||
enabled: Boolean(country.enabled),
|
||||
flag: country.flag || "",
|
||||
isoAlpha3: country.isoAlpha3 || "",
|
||||
isoNumeric: country.isoNumeric || "",
|
||||
phoneCountryCode: country.phoneCountryCode || "",
|
||||
sortOrder: country.sortOrder || 0,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const submitCountry = async (event) => {
|
||||
event.preventDefault();
|
||||
const isEdit = activeAction === "edit";
|
||||
await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => {
|
||||
const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm);
|
||||
const data = isEdit ? await updateCountry(editingCountry.countryId, payload) : await createCountry(payload);
|
||||
setCountryForm(emptyCountryForm());
|
||||
setEditingCountry(null);
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCountry = async (country, nextEnabled = !country.enabled) => {
|
||||
if (!abilities.canStatus || Boolean(country.enabled) === nextEnabled) {
|
||||
return;
|
||||
}
|
||||
await runAction(`country-status-${country.countryId}`, nextEnabled ? "国家已启用" : "国家已禁用", async () => {
|
||||
const data = nextEnabled ? await enableCountry(country.countryId) : await disableCountry(country.countryId);
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
changeEnabledStatus,
|
||||
changeQuery,
|
||||
closeAction: () => setActiveAction(""),
|
||||
countryForm,
|
||||
data: { ...data, items, total: items.length },
|
||||
editingCountry,
|
||||
enabledStatus,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
openCreateCountry,
|
||||
openEditCountry,
|
||||
query,
|
||||
reload,
|
||||
resetFilters,
|
||||
setCountryForm,
|
||||
submitCountry,
|
||||
toggleCountry,
|
||||
};
|
||||
}
|
||||
|
||||
function filterCountries(items, query) {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => {
|
||||
return [
|
||||
item.countryCode,
|
||||
item.countryDisplayName,
|
||||
item.countryName,
|
||||
item.isoAlpha3,
|
||||
item.isoNumeric,
|
||||
item.phoneCountryCode
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
||||
});
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => {
|
||||
return [
|
||||
item.countryCode,
|
||||
item.countryDisplayName,
|
||||
item.countryName,
|
||||
item.isoAlpha3,
|
||||
item.isoNumeric,
|
||||
item.phoneCountryCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
||||
});
|
||||
}
|
||||
|
||||
@ -8,68 +8,82 @@ const pageSize = 10;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
|
||||
export function useHostHostsPage() {
|
||||
const abilities = useHostAbilities();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [agencyId, setAgencyId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
agency_id: agencyId,
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status
|
||||
}),
|
||||
[agencyId, query, regionId, status]
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
errorMessage: "加载主播列表失败",
|
||||
fetcher: listHosts,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "hosts", filters, page]
|
||||
});
|
||||
const abilities = useHostAbilities();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [agencyId, setAgencyId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
agency_id: agencyId,
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status,
|
||||
}),
|
||||
[agencyId, query, regionId, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载主播列表失败",
|
||||
fetcher: listHosts,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "hosts", filters, page],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeAgencyId = (value) => {
|
||||
setAgencyId(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeAgencyId = (value) => {
|
||||
setAgencyId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
agencyId,
|
||||
changeAgencyId,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
loadingRegions,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
setPage,
|
||||
status
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setAgencyId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
agencyId,
|
||||
changeAgencyId,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
loadingRegions,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
setPage,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,200 +3,225 @@ import { parseForm } from "@/shared/forms/validation";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createRegion,
|
||||
disableRegion,
|
||||
enableRegion,
|
||||
listCountries,
|
||||
listRegions,
|
||||
replaceRegionCountries,
|
||||
updateRegion
|
||||
createRegion,
|
||||
disableRegion,
|
||||
enableRegion,
|
||||
listCountries,
|
||||
listRegions,
|
||||
replaceRegionCountries,
|
||||
updateRegion,
|
||||
} from "@/features/host-org/api";
|
||||
import { useRegionAbilities } from "@/features/host-org/permissions.js";
|
||||
import { regionCreateSchema, regionUpdateSchema } from "@/features/host-org/schema";
|
||||
|
||||
const emptyRegionForm = () => ({
|
||||
countries: [],
|
||||
name: "",
|
||||
regionCode: "",
|
||||
sortOrder: 0
|
||||
countries: [],
|
||||
name: "",
|
||||
regionCode: "",
|
||||
sortOrder: 0,
|
||||
});
|
||||
const emptyData = { items: [], total: 0 };
|
||||
const globalRegionCode = "GLOBAL";
|
||||
|
||||
export function useHostRegionsPage() {
|
||||
const abilities = useRegionAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionForm, setRegionFormState] = useState(emptyRegionForm);
|
||||
const [editingRegion, setEditingRegion] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const queryFn = useCallback(() => listRegions(), []);
|
||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载区域列表失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["host-org", "regions"]
|
||||
});
|
||||
const countryQueryFn = useCallback(() => listCountries(), []);
|
||||
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
|
||||
errorMessage: "加载国家列表失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["host-org", "countries", "region-options"],
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
const items = useMemo(() => filterRegions(data?.items || [], query, status), [data?.items, query, status]);
|
||||
const assignedCountries = useMemo(() => {
|
||||
const currentRegionId = editingRegion?.regionId;
|
||||
const assigned = new Set();
|
||||
(data?.items || []).forEach((region) => {
|
||||
if (region.status !== "active" || region.regionId === currentRegionId || isGlobalRegionCode(region.regionCode)) {
|
||||
return;
|
||||
}
|
||||
(region.countries || []).forEach((country) => assigned.add(country));
|
||||
const abilities = useRegionAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionForm, setRegionFormState] = useState(emptyRegionForm);
|
||||
const [editingRegion, setEditingRegion] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const queryFn = useCallback(() => listRegions(), []);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载区域列表失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["host-org", "regions"],
|
||||
});
|
||||
return assigned;
|
||||
}, [data?.items, editingRegion?.regionId]);
|
||||
const countryOptions = useMemo(() => {
|
||||
return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))]
|
||||
.filter((countryCode) => !assignedCountries.has(countryCode))
|
||||
.sort();
|
||||
}, [assignedCountries, countriesData?.items]);
|
||||
const countryLabels = useMemo(() => {
|
||||
return (countriesData?.items || []).reduce((labels, country) => {
|
||||
if (country.countryCode) {
|
||||
labels[country.countryCode] = [country.countryCode, country.countryDisplayName || country.countryName].filter(Boolean).join(" · ");
|
||||
}
|
||||
return labels;
|
||||
}, {});
|
||||
}, [countriesData?.items]);
|
||||
const isGlobalRegionForm = isGlobalRegionCode(regionForm.regionCode);
|
||||
|
||||
const setRegionForm = (nextForm) => {
|
||||
const normalized = isGlobalRegionCode(nextForm.regionCode) ? { ...nextForm, countries: [] } : nextForm;
|
||||
setRegionFormState(normalized);
|
||||
};
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
};
|
||||
|
||||
const openCreateRegion = () => {
|
||||
setEditingRegion(null);
|
||||
setRegionFormState(emptyRegionForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEditRegion = (region) => {
|
||||
setEditingRegion(region);
|
||||
setRegionFormState({
|
||||
countries: region.countries || [],
|
||||
name: region.name || "",
|
||||
regionCode: region.regionCode || "",
|
||||
sortOrder: region.sortOrder || 0
|
||||
const countryQueryFn = useCallback(() => listCountries(), []);
|
||||
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
|
||||
errorMessage: "加载国家列表失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["host-org", "countries", "region-options"],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
const items = useMemo(() => filterRegions(data?.items || [], query, status), [data?.items, query, status]);
|
||||
const assignedCountries = useMemo(() => {
|
||||
const currentRegionId = editingRegion?.regionId;
|
||||
const assigned = new Set();
|
||||
(data?.items || []).forEach((region) => {
|
||||
if (
|
||||
region.status !== "active" ||
|
||||
region.regionId === currentRegionId ||
|
||||
isGlobalRegionCode(region.regionCode)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
(region.countries || []).forEach((country) => assigned.add(country));
|
||||
});
|
||||
return assigned;
|
||||
}, [data?.items, editingRegion?.regionId]);
|
||||
const countryOptions = useMemo(() => {
|
||||
return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))]
|
||||
.filter((countryCode) => !assignedCountries.has(countryCode))
|
||||
.sort();
|
||||
}, [assignedCountries, countriesData?.items]);
|
||||
const countryLabels = useMemo(() => {
|
||||
return (countriesData?.items || []).reduce((labels, country) => {
|
||||
if (country.countryCode) {
|
||||
labels[country.countryCode] = [country.countryCode, country.countryDisplayName || country.countryName]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
return labels;
|
||||
}, {});
|
||||
}, [countriesData?.items]);
|
||||
const isGlobalRegionForm = isGlobalRegionCode(regionForm.regionCode);
|
||||
|
||||
const submitRegion = async (event) => {
|
||||
event.preventDefault();
|
||||
const isEdit = activeAction === "edit";
|
||||
await runAction(isEdit ? "region-edit" : "region-create", isEdit ? "区域已更新" : "区域已创建", async () => {
|
||||
const payload = parseForm(isEdit ? regionUpdateSchema : regionCreateSchema, regionForm);
|
||||
const data = isEdit ? await updateExistingRegion(editingRegion, payload) : await createRegion(payload);
|
||||
setRegionFormState(emptyRegionForm());
|
||||
setEditingRegion(null);
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
const setRegionForm = (nextForm) => {
|
||||
const normalized = isGlobalRegionCode(nextForm.regionCode) ? { ...nextForm, countries: [] } : nextForm;
|
||||
setRegionFormState(normalized);
|
||||
};
|
||||
|
||||
const toggleRegion = async (region, nextActive = region.status !== "active") => {
|
||||
if (isGlobalRegionCode(region.regionCode) || !abilities.canStatus || (region.status === "active") === nextActive) {
|
||||
return;
|
||||
}
|
||||
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
|
||||
const data = nextActive ? await enableRegion(region.regionId) : await disableRegion(region.regionId);
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
closeAction: () => setActiveAction(""),
|
||||
countryLabels,
|
||||
countryOptions,
|
||||
data: { ...data, items, total: items.length },
|
||||
editingRegion,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
isGlobalRegionForm,
|
||||
openCreateRegion,
|
||||
openEditRegion,
|
||||
query,
|
||||
regionForm,
|
||||
reload,
|
||||
setRegionForm,
|
||||
status,
|
||||
submitRegion,
|
||||
toggleRegion
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const openCreateRegion = () => {
|
||||
setEditingRegion(null);
|
||||
setRegionFormState(emptyRegionForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEditRegion = (region) => {
|
||||
setEditingRegion(region);
|
||||
setRegionFormState({
|
||||
countries: region.countries || [],
|
||||
name: region.name || "",
|
||||
regionCode: region.regionCode || "",
|
||||
sortOrder: region.sortOrder || 0,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const submitRegion = async (event) => {
|
||||
event.preventDefault();
|
||||
const isEdit = activeAction === "edit";
|
||||
await runAction(isEdit ? "region-edit" : "region-create", isEdit ? "区域已更新" : "区域已创建", async () => {
|
||||
const payload = parseForm(isEdit ? regionUpdateSchema : regionCreateSchema, regionForm);
|
||||
const data = isEdit ? await updateExistingRegion(editingRegion, payload) : await createRegion(payload);
|
||||
setRegionFormState(emptyRegionForm());
|
||||
setEditingRegion(null);
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRegion = async (region, nextActive = region.status !== "active") => {
|
||||
if (
|
||||
isGlobalRegionCode(region.regionCode) ||
|
||||
!abilities.canStatus ||
|
||||
(region.status === "active") === nextActive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
|
||||
const data = nextActive ? await enableRegion(region.regionId) : await disableRegion(region.regionId);
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
closeAction: () => setActiveAction(""),
|
||||
countryLabels,
|
||||
countryOptions,
|
||||
data: { ...data, items, total: items.length },
|
||||
editingRegion,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
isGlobalRegionForm,
|
||||
openCreateRegion,
|
||||
openEditRegion,
|
||||
query,
|
||||
regionForm,
|
||||
reload,
|
||||
resetFilters,
|
||||
setRegionForm,
|
||||
status,
|
||||
submitRegion,
|
||||
toggleRegion,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateExistingRegion(region, payload) {
|
||||
const { countries, ...regionPayload } = payload;
|
||||
const updatedRegion = await updateRegion(region.regionId, regionPayload);
|
||||
if (hasCountryChanges(region.countries || [], countries || [])) {
|
||||
return replaceRegionCountries(region.regionId, { countries });
|
||||
}
|
||||
return updatedRegion;
|
||||
const { countries, ...regionPayload } = payload;
|
||||
const updatedRegion = await updateRegion(region.regionId, regionPayload);
|
||||
if (hasCountryChanges(region.countries || [], countries || [])) {
|
||||
return replaceRegionCountries(region.regionId, { countries });
|
||||
}
|
||||
return updatedRegion;
|
||||
}
|
||||
|
||||
function hasCountryChanges(previousCountries, nextCountries) {
|
||||
const previous = [...previousCountries].sort();
|
||||
const next = [...nextCountries].sort();
|
||||
return previous.length !== next.length || previous.some((country, index) => country !== next[index]);
|
||||
const previous = [...previousCountries].sort();
|
||||
const next = [...nextCountries].sort();
|
||||
return previous.length !== next.length || previous.some((country, index) => country !== next[index]);
|
||||
}
|
||||
|
||||
function filterRegions(items, query, status) {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (status && item.status !== status) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
return [item.regionCode, item.name, ...(item.countries || [])]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
||||
});
|
||||
const keyword = query.trim().toLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (status && item.status !== status) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
return [item.regionCode, item.name, ...(item.countries || [])]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
||||
});
|
||||
}
|
||||
|
||||
function isGlobalRegionCode(regionCode) {
|
||||
return String(regionCode || "").trim().toUpperCase() === globalRegionCode;
|
||||
return (
|
||||
String(regionCode || "")
|
||||
.trim()
|
||||
.toUpperCase() === globalRegionCode
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,202 +1,258 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contentPanel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contentPanel :global(.data-state) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.contentPanel :global(.table-frame) {
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.contentPanel :global(.table-scroll) {
|
||||
overflow: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.contentPanel :global(.admin-row--head) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toolbarLeft {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 0 1 300px;
|
||||
flex: 0 1 300px;
|
||||
}
|
||||
|
||||
.statusSelect {
|
||||
flex: 0 0 150px;
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
|
||||
.filterFields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.filterFields :global(.MuiTextField-root) {
|
||||
width: 170px;
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.flagCell {
|
||||
font-size: var(--admin-font-size);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.listBlock {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.listBlock :global(.pagination-bar) {
|
||||
margin: var(--space-3) var(--space-5) var(--space-4);
|
||||
margin: var(--space-3) var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.inlineValue {
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.sellerIdentity {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sellerAvatar {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
object-fit: cover;
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sellerText {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sellerName {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sellerMeta {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar,
|
||||
.toolbarLeft,
|
||||
.filters,
|
||||
.filterFields {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toolbar,
|
||||
.toolbarLeft,
|
||||
.filters,
|
||||
.filterFields {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.toolbarActions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.statusSelect {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.statusSelect {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterFields :global(.MuiTextField-root) {
|
||||
width: 100%;
|
||||
}
|
||||
.filterFields :global(.MuiTextField-root) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,143 +7,324 @@ import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
||||
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 { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
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";
|
||||
|
||||
const agencyColumns = [
|
||||
{ key: "agencyId", label: "Agency ID", width: "minmax(110px, 0.8fr)" },
|
||||
{ key: "name", label: "名称", width: "minmax(160px, 1.2fr)" },
|
||||
{ key: "ownerUserId", label: "Owner 用户 ID" },
|
||||
{ key: "parentBdUserId", label: "上级 BD 用户 ID" },
|
||||
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" },
|
||||
{
|
||||
key: "joinEnabled",
|
||||
label: "入会",
|
||||
render: (item) => <span className={item.joinEnabled ? styles.positive : styles.negative}>{item.joinEnabled ? "允许" : "禁止"}</span>,
|
||||
width: "minmax(80px, 0.6fr)"
|
||||
},
|
||||
{ key: "maxHosts", label: "最大主播数", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
|
||||
{
|
||||
key: "agency",
|
||||
label: "Agency",
|
||||
render: (item) => <HostOrgEntity id={item.agencyId} name={item.name} prefix="Agency " />,
|
||||
width: "minmax(190px, 1fr)",
|
||||
},
|
||||
{
|
||||
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",
|
||||
label: "入会",
|
||||
render: (item) => (
|
||||
<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)",
|
||||
},
|
||||
];
|
||||
|
||||
export function HostAgenciesPage() {
|
||||
const page = useHostAgenciesPage();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const statusDisabled = !page.abilities.canStatus;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const toolbarActions = [
|
||||
page.abilities.canStatus
|
||||
? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm }
|
||||
: null,
|
||||
page.abilities.canStatus
|
||||
? { icon: <BlockOutlined fontSize="small" />, label: "关闭 Agency", onClick: page.openCloseForm, tone: "danger" }
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
|
||||
: null
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "上级 BD 用户 ID",
|
||||
name: "parentBdUserId",
|
||||
onChange: page.changeParentBdUserId,
|
||||
value: page.parentBdUserId
|
||||
const page = useHostAgenciesPage();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const statusDisabled = !page.abilities.canStatus;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const toolbarActions = [
|
||||
page.abilities.canStatus
|
||||
? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm }
|
||||
: null,
|
||||
page.abilities.canStatus
|
||||
? {
|
||||
icon: <BlockOutlined fontSize="small" />,
|
||||
label: "关闭 Agency",
|
||||
onClick: page.openCloseForm,
|
||||
tone: "danger",
|
||||
}
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索 Agency、Owner、用户...",
|
||||
status: page.status,
|
||||
statusOptions: agencyStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
}}
|
||||
/>
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
|
||||
: null,
|
||||
].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;
|
||||
});
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={agencyColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => item.agencyId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "上级 BD 用户 ID",
|
||||
name: "parentBdUserId",
|
||||
onChange: page.changeParentBdUserId,
|
||||
value: page.parentBdUserId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索 Agency、Owner、用户...",
|
||||
status: page.status,
|
||||
statusOptions: agencyStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "agency"}
|
||||
open={page.activeAction === "agency"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitAgency}
|
||||
title="创建 Agency"
|
||||
>
|
||||
<TextField disabled={createDisabled} label="Agency 名称" 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
|
||||
control={<Switch checked={page.agencyForm.joinEnabled} disabled={createDisabled} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })} />}
|
||||
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 })} />
|
||||
</HostOrgActionModal>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1250px"
|
||||
rowKey={(item) => item.agencyId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={statusDisabled}
|
||||
loading={page.loadingAction === "agency-join"}
|
||||
open={page.activeAction === "agency-join"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitJoinEnabled}
|
||||
title="入会开关"
|
||||
>
|
||||
<TextField disabled={statusDisabled} label="Agency ID" required type="number" value={page.joinEnabledForm.agencyId} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })} />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={page.joinEnabledForm.joinEnabled} disabled={statusDisabled} onChange={(event) => page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })} />}
|
||||
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 })} />
|
||||
</HostOrgActionModal>
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "agency"}
|
||||
open={page.activeAction === "agency"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitAgency}
|
||||
title="创建 Agency"
|
||||
>
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
label="Agency 名称"
|
||||
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
|
||||
control={
|
||||
<Switch
|
||||
checked={page.agencyForm.joinEnabled}
|
||||
disabled={createDisabled}
|
||||
onChange={(event) =>
|
||||
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
}
|
||||
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 })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={statusDisabled}
|
||||
loading={page.loadingAction === "agency-close"}
|
||||
open={page.activeAction === "agency-close"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitClose}
|
||||
submitLabel="确认关闭"
|
||||
submitVariant="danger"
|
||||
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 disabled={statusDisabled} label="原因" multiline minRows={2} required value={page.closeForm.reason} onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })} />
|
||||
</HostOrgActionModal>
|
||||
<HostOrgActionModal
|
||||
disabled={statusDisabled}
|
||||
loading={page.loadingAction === "agency-join"}
|
||||
open={page.activeAction === "agency-join"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitJoinEnabled}
|
||||
title="入会开关"
|
||||
>
|
||||
<TextField
|
||||
disabled={statusDisabled}
|
||||
label="Agency ID"
|
||||
required
|
||||
type="number"
|
||||
value={page.joinEnabledForm.agencyId}
|
||||
onChange={(event) =>
|
||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={page.joinEnabledForm.joinEnabled}
|
||||
disabled={statusDisabled}
|
||||
onChange={(event) =>
|
||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
}
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
</section>
|
||||
);
|
||||
<HostOrgActionModal
|
||||
disabled={statusDisabled}
|
||||
loading={page.loadingAction === "agency-close"}
|
||||
open={page.activeAction === "agency-close"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitClose}
|
||||
submitLabel="确认关闭"
|
||||
submitVariant="danger"
|
||||
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
|
||||
disabled={statusDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
required
|
||||
value={page.closeForm.reason}
|
||||
onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
</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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import SyncAltOutlined from "@mui/icons-material/SyncAltOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.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 { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
@ -15,96 +17,323 @@ import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
const bdLeaderColumns = [
|
||||
{ key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" },
|
||||
{ key: "role", label: "角色", width: "minmax(110px, 0.8fr)" },
|
||||
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "createdByUserId", label: "创建人用户 ID" },
|
||||
{ key: "createdAtMs", label: "创建时间", render: (item) => formatMillis(item.createdAtMs), width: "minmax(170px, 1fr)" },
|
||||
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
|
||||
const bdLeaderBaseColumns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户",
|
||||
render: (item) => <PersonIdentity item={item} />,
|
||||
width: "minmax(220px, 1.25fr)",
|
||||
},
|
||||
{
|
||||
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() {
|
||||
const page = useHostBdsPage({ profileType: "leader" });
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const toolbarActions = [
|
||||
page.abilities.canUpdate
|
||||
? { icon: <SyncAltOutlined fontSize="small" />, label: "BD Leader 状态", onClick: page.openBDStatusForm }
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 BD Leader", onClick: page.openBDLeaderForm, variant: "primary" }
|
||||
: null
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
}}
|
||||
/>
|
||||
const page = useHostBdsPage({ profileType: "leader" });
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const bdLeaderColumns = [
|
||||
...bdLeaderBaseColumns,
|
||||
page.abilities.canCreate || page.abilities.canUpdate
|
||||
? {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <BDLeaderActions item={item} page={page} />,
|
||||
width: "minmax(118px, 0.7fr)",
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const toolbarActions = page.abilities.canCreate
|
||||
? [
|
||||
{
|
||||
icon: <Add fontSize="small" />,
|
||||
label: "创建 BD Leader",
|
||||
onClick: page.openBDLeaderForm,
|
||||
variant: "primary",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={bdLeaderColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => `bd-leader-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "bd-leader"}
|
||||
open={page.activeAction === "bd-leader"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBDLeader}
|
||||
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 })} />
|
||||
<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>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={bdLeaderColumns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1120px"
|
||||
rowKey={(item) => `bd-leader-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={updateDisabled}
|
||||
loading={page.loadingAction === "bd-status"}
|
||||
open={page.activeAction === "bd-status"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBDStatus}
|
||||
title="BD Leader 状态"
|
||||
>
|
||||
<TextField 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="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
<TextField disabled={updateDisabled} label="原因" multiline minRows={2} value={page.bdStatusForm.reason} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} />
|
||||
</HostOrgActionModal>
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "bd-leader"}
|
||||
open={page.activeAction === "bd-leader"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBDLeader}
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
</section>
|
||||
);
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "leader-add-bd"}
|
||||
open={page.activeAction === "leader-add-bd"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitLeaderBD}
|
||||
title="增加下级 BD"
|
||||
>
|
||||
<TextField
|
||||
disabled
|
||||
label="上级 Leader"
|
||||
value={page.selectedLeader?.displayUserId || page.selectedLeader?.userId || ""}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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)",
|
||||
},
|
||||
};
|
||||
|
||||
@ -5,114 +5,271 @@ import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||
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 { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
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";
|
||||
|
||||
const bdColumns = [
|
||||
{ key: "userId", label: "用户 ID", width: "minmax(110px, 0.8fr)" },
|
||||
{ key: "role", label: "角色", width: "minmax(110px, 0.8fr)" },
|
||||
{ key: "parentLeaderUserId", label: "上级 Leader 用户 ID", render: (item) => item.parentLeaderUserId || "-" },
|
||||
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "status", label: "状态", render: (item) => <HostOrgStatus value={item.status} />, width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "createdByUserId", label: "创建人用户 ID" },
|
||||
{ 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: "user",
|
||||
label: "用户",
|
||||
render: (item) => <BDUser item={item} />,
|
||||
width: "minmax(220px, 1.25fr)",
|
||||
},
|
||||
{ key: "role", label: "角色", width: "minmax(100px, 0.65fr)" },
|
||||
{
|
||||
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() {
|
||||
const page = useHostBdsPage({ profileType: "bd" });
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const toolbarActions = [
|
||||
page.abilities.canUpdate
|
||||
? { icon: <SyncAltOutlined fontSize="small" />, label: "BD 状态", onClick: page.openBDStatusForm }
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
||||
: null
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "上级 Leader 用户 ID",
|
||||
name: "parentLeaderUserId",
|
||||
onChange: page.changeParentLeaderUserId,
|
||||
value: page.parentLeaderUserId
|
||||
}
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
}}
|
||||
/>
|
||||
const page = useHostBdsPage({ profileType: "bd" });
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const toolbarActions = [
|
||||
page.abilities.canUpdate
|
||||
? { icon: <SyncAltOutlined fontSize="small" />, label: "BD 状态", onClick: page.openBDStatusForm }
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
||||
: null,
|
||||
].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;
|
||||
});
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={bdColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => `bd-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "上级 Leader 用户 ID",
|
||||
name: "parentLeaderUserId",
|
||||
onChange: page.changeParentLeaderUserId,
|
||||
value: page.parentLeaderUserId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "bd"}
|
||||
open={page.activeAction === "bd"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBD}
|
||||
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 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>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1260px"
|
||||
rowKey={(item) => `bd-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={updateDisabled}
|
||||
loading={page.loadingAction === "bd-status"}
|
||||
open={page.activeAction === "bd-status"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBDStatus}
|
||||
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 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="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
<TextField disabled={updateDisabled} label="原因" multiline minRows={2} value={page.bdStatusForm.reason} onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })} />
|
||||
</HostOrgActionModal>
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "bd"}
|
||||
open={page.activeAction === "bd"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBD}
|
||||
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
|
||||
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>
|
||||
|
||||
</section>
|
||||
);
|
||||
<HostOrgActionModal
|
||||
disabled={updateDisabled}
|
||||
loading={page.loadingAction === "bd-status"}
|
||||
open={page.activeAction === "bd-status"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBDStatus}
|
||||
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
|
||||
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="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={updateDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.bdStatusForm.reason}
|
||||
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
</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 || "创建人"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,182 +1,374 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
AdminFormAmountField,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormReadOnlyField,
|
||||
AdminFormSection,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.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 { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
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";
|
||||
|
||||
const baseColumns = [
|
||||
{
|
||||
key: "seller",
|
||||
label: "币商",
|
||||
render: (item) => <SellerIdentity item={item} />,
|
||||
width: "minmax(220px, 1.3fr)"
|
||||
},
|
||||
{ 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)" }
|
||||
{
|
||||
key: "seller",
|
||||
label: "币商",
|
||||
render: (item) => <SellerIdentity item={item} />,
|
||||
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)",
|
||||
},
|
||||
];
|
||||
|
||||
export function HostCoinSellersPage() {
|
||||
const page = useHostCoinSellersPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const columns = [
|
||||
...baseColumns.slice(0, 2),
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <SellerStatusSwitch item={item} page={page} />,
|
||||
width: "minmax(90px, 0.6fr)"
|
||||
},
|
||||
...baseColumns.slice(2),
|
||||
page.abilities.canUpdate
|
||||
? {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <SellerActions item={item} page={page} />,
|
||||
width: "minmax(120px, 0.8fr)"
|
||||
}
|
||||
: null
|
||||
].filter(Boolean);
|
||||
const toolbarActions = page.abilities.canCreate
|
||||
? [{ icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }]
|
||||
: [];
|
||||
const page = useHostCoinSellersPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const columns = [
|
||||
...baseColumns.slice(0, 2),
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <SellerStatusSwitch item={item} page={page} />,
|
||||
width: "minmax(90px, 0.6fr)",
|
||||
},
|
||||
...baseColumns.slice(2),
|
||||
page.abilities.canStockCredit
|
||||
? {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <SellerActions item={item} page={page} />,
|
||||
width: "minmax(90px, 0.5fr)",
|
||||
}
|
||||
: null,
|
||||
]
|
||||
.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
|
||||
? [{ icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: coinSellerStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
filters={{
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名...",
|
||||
status: page.status,
|
||||
statusOptions: coinSellerStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1040px"
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1040px"
|
||||
regionFilter={regionFilter}
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "coin-seller-create"}
|
||||
open={page.activeAction === "create"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCreateSeller}
|
||||
title="添加币商"
|
||||
>
|
||||
<TextField 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.reason} onChange={(event) => page.setCreateForm({ ...page.createForm, reason: event.target.value })} />
|
||||
</HostOrgActionModal>
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "coin-seller-create"}
|
||||
open={page.activeAction === "create"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCreateSeller}
|
||||
title="添加币商"
|
||||
>
|
||||
<TextField
|
||||
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
|
||||
disabled={updateDisabled}
|
||||
loading={String(page.loadingAction).startsWith("coin-seller-edit")}
|
||||
open={page.activeAction === "edit"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEditSeller}
|
||||
title="编辑币商"
|
||||
>
|
||||
<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 })}>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
<TextField disabled={updateDisabled} label="联系方式" value={page.editForm.reason} onChange={(event) => page.setEditForm({ ...page.editForm, reason: event.target.value })} />
|
||||
</HostOrgActionModal>
|
||||
</section>
|
||||
);
|
||||
<HostOrgActionModal
|
||||
disabled={updateDisabled}
|
||||
loading={String(page.loadingAction).startsWith("coin-seller-edit")}
|
||||
open={page.activeAction === "edit"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEditSeller}
|
||||
title="编辑联系方式"
|
||||
>
|
||||
<TextField disabled label="用户 ID" required type="number" value={page.editForm.targetUserId} />
|
||||
<TextField
|
||||
disabled={updateDisabled}
|
||||
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>
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SellerIdentity({ item }) {
|
||||
const name = item.username || "-";
|
||||
const meta = item.displayUserId || item.userId;
|
||||
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}>{name.slice(0, 1).toUpperCase()}</span>}
|
||||
<span className={styles.sellerText}>
|
||||
<span className={styles.sellerName}>{name}</span>
|
||||
<span className={styles.sellerMeta}>{meta}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.sellerIdentity}>
|
||||
{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.sellerName}>{name}</span>
|
||||
<span className={styles.sellerMeta}>{meta}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SellerStatusSwitch({ item, page }) {
|
||||
return (
|
||||
<Switch
|
||||
checked={item.status === "active"}
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-status-${item.userId}`}
|
||||
inputProps={{ "aria-label": item.status === "active" ? "停用币商" : "启用币商" }}
|
||||
onChange={(event) => page.toggleSeller(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Switch
|
||||
checked={item.status === "active"}
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-status-${item.userId}`}
|
||||
inputProps={{ "aria-label": item.status === "active" ? "停用币商" : "启用币商" }}
|
||||
onChange={(event) => page.toggleSeller(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SellerContact({ item, page }) {
|
||||
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 }) {
|
||||
const deleting = page.loadingAction === `coin-seller-delete-${item.userId}`;
|
||||
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton label="编辑币商" onClick={() => page.openEditSeller(item)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton disabled={deleting || item.status === "disabled"} label="删除币商" tone="danger" onClick={() => page.deleteSeller(item)}>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page.abilities.canStockCredit ? (
|
||||
<AdminActionIconButton
|
||||
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-${item.userId}`}
|
||||
label="加金币"
|
||||
sx={coinCreditActionSx}
|
||||
onClick={() => page.openStockCredit(item)}
|
||||
>
|
||||
<MonetizationOnOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
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 || "-";
|
||||
if (item.regionName) {
|
||||
return item.regionName;
|
||||
}
|
||||
const option = regionOptions.find((region) => String(region.value) === String(item.regionId));
|
||||
return option?.name || item.regionId || "-";
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@ -6,138 +6,211 @@ import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { countryEnabledFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
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";
|
||||
|
||||
const baseCountryColumns = [
|
||||
{ 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: "countryCode", label: "国家码", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "countryDisplayName", label: "展示名称", width: "minmax(150px, 1fr)" },
|
||||
{ key: "countryName", label: "国家名称", width: "minmax(150px, 1fr)" },
|
||||
{ key: "phoneCountryCode", label: "电话区号", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" },
|
||||
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
|
||||
{ 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: "countryCode", label: "国家码", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "countryDisplayName", label: "展示名称", width: "minmax(150px, 1fr)" },
|
||||
{ key: "countryName", label: "国家名称", width: "minmax(150px, 1fr)" },
|
||||
{ key: "phoneCountryCode", label: "电话区号", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" },
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function HostCountriesPage() {
|
||||
const page = useHostCountriesPage();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const countryColumns = [
|
||||
...baseCountryColumns.slice(0, 6),
|
||||
{
|
||||
key: "enabled",
|
||||
label: "状态",
|
||||
render: (item) => <CountryStatusSwitch item={item} page={page} />,
|
||||
width: "minmax(90px, 0.6fr)"
|
||||
},
|
||||
...baseCountryColumns.slice(6)
|
||||
];
|
||||
const columns = page.abilities.canUpdate
|
||||
? [
|
||||
...countryColumns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <CountryActions item={item} page={page} />,
|
||||
width: "minmax(104px, 0.7fr)"
|
||||
}
|
||||
]
|
||||
: countryColumns;
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={(
|
||||
<>
|
||||
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索国家码、国家名称..." />
|
||||
<AdminFilterSelect
|
||||
options={countryEnabledFilters}
|
||||
value={page.enabledStatus}
|
||||
onChange={page.changeEnabledStatus}
|
||||
const page = useHostCountriesPage();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const countryColumns = [
|
||||
...baseCountryColumns.slice(0, 6),
|
||||
{
|
||||
key: "enabled",
|
||||
label: "状态",
|
||||
render: (item) => <CountryStatusSwitch item={item} page={page} />,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: countryEnabledFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.enabledStatus,
|
||||
onChange: page.changeEnabledStatus,
|
||||
}),
|
||||
width: "minmax(90px, 0.6fr)",
|
||||
},
|
||||
...baseCountryColumns.slice(6),
|
||||
].map((column) =>
|
||||
column.key === "countryCode"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索国家码、国家名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
const columns = page.abilities.canUpdate
|
||||
? [
|
||||
...countryColumns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <CountryActions item={item} page={page} />,
|
||||
width: "minmax(104px, 0.7fr)",
|
||||
},
|
||||
]
|
||||
: countryColumns;
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query && !page.enabledStatus} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
actions={page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="创建国家" primary onClick={page.openCreateCountry}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
rowKey={(item) => item.countryId}
|
||||
/>
|
||||
<div className="pagination-bar">
|
||||
<span>{total} 条</span>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<HostOrgTable columns={columns} items={items} rowKey={(item) => item.countryId} />
|
||||
<div className="pagination-bar">
|
||||
<span>{total} 条</span>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
|
||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCountry}
|
||||
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 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" ? (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={page.countryForm.enabled} disabled={createDisabled} onChange={(event) => page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })} />}
|
||||
label={page.countryForm.enabled ? "启用" : "停用"}
|
||||
/>
|
||||
) : null}
|
||||
</HostOrgActionModal>
|
||||
|
||||
</AdminListPage>
|
||||
);
|
||||
<HostOrgActionModal
|
||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
|
||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitCountry}
|
||||
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
|
||||
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" ? (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={page.countryForm.enabled}
|
||||
disabled={createDisabled}
|
||||
onChange={(event) =>
|
||||
page.setCountryForm({ ...page.countryForm, enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={page.countryForm.enabled ? "启用" : "停用"}
|
||||
/>
|
||||
) : null}
|
||||
</HostOrgActionModal>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryActions({ item, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page.abilities.canUpdate ? (
|
||||
<AdminActionIconButton label="编辑国家" onClick={() => page.openEditCountry(item)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page.abilities.canUpdate ? (
|
||||
<AdminActionIconButton label="编辑国家" onClick={() => page.openEditCountry(item)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryStatusSwitch({ item, page }) {
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(item.enabled)}
|
||||
disabled={!page.abilities.canStatus || page.loadingAction === `country-status-${item.countryId}`}
|
||||
inputProps={{ "aria-label": item.enabled ? "禁用国家" : "启用国家" }}
|
||||
onChange={(event) => page.toggleCountry(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(item.enabled)}
|
||||
disabled={!page.abilities.canStatus || page.loadingAction === `country-status-${item.countryId}`}
|
||||
inputProps={{ "aria-label": item.enabled ? "禁用国家" : "启用国家" }}
|
||||
onChange={(event) => page.toggleCountry(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,73 +1,170 @@
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.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 { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
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";
|
||||
|
||||
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: "regionId", label: "区域 ID", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "currentAgencyId", label: "当前 Agency ID", render: (item) => item.currentAgencyId || "-" },
|
||||
{ key: "currentMembershipId", label: "当前 Membership ID", render: (item) => item.currentMembershipId || "-" },
|
||||
{ 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: "user",
|
||||
label: "用户",
|
||||
render: (item) => <HostUser item={item} />,
|
||||
width: "minmax(220px, 1.25fr)",
|
||||
},
|
||||
{
|
||||
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() {
|
||||
const page = useHostHostsPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const page = useHostHostsPage();
|
||||
const items = page.data.items || [];
|
||||
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 (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "Agency ID",
|
||||
name: "agencyId",
|
||||
onChange: page.changeAgencyId,
|
||||
value: page.agencyId
|
||||
}
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名、Agency...",
|
||||
status: page.status,
|
||||
statusOptions: hostStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
filters={{
|
||||
extraFilters: [
|
||||
{
|
||||
label: "Agency ID",
|
||||
name: "agencyId",
|
||||
onChange: page.changeAgencyId,
|
||||
value: page.agencyId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
regionId: page.regionId,
|
||||
regionOptions: page.regionOptions,
|
||||
searchPlaceholder: "搜索用户 ID、展示 ID、用户名、Agency...",
|
||||
status: page.status,
|
||||
statusOptions: hostStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={hostColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1240px"
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HostUser({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.avatar}
|
||||
displayUserId={item.displayUserId}
|
||||
fallbackId={item.userId}
|
||||
username={item.username}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,192 +5,231 @@ import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.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 { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
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";
|
||||
|
||||
const baseRegionColumns = [
|
||||
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.6fr)" },
|
||||
{ key: "regionCode", label: "区域编码", width: "minmax(110px, 0.8fr)" },
|
||||
{ key: "name", label: "区域名称", width: "minmax(150px, 1fr)" },
|
||||
{ key: "countries", label: "国家码", render: (item) => <CountryCodes countries={item.countries} />, width: "minmax(220px, 1.5fr)" },
|
||||
{ key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" },
|
||||
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
|
||||
{ key: "regionId", label: "区域 ID", width: "minmax(90px, 0.6fr)" },
|
||||
{ key: "regionCode", label: "区域编码", width: "minmax(110px, 0.8fr)" },
|
||||
{ key: "name", label: "区域名称", width: "minmax(150px, 1fr)" },
|
||||
{
|
||||
key: "countries",
|
||||
label: "国家码",
|
||||
render: (item) => <CountryCodes countries={item.countries} />,
|
||||
width: "minmax(220px, 1.5fr)",
|
||||
},
|
||||
{ key: "sortOrder", label: "排序", width: "minmax(80px, 0.5fr)" },
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function HostRegionsPage() {
|
||||
const page = useHostRegionsPage();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const regionColumns = [
|
||||
...baseRegionColumns.slice(0, 3),
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <RegionStatusSwitch item={item} page={page} />,
|
||||
width: "minmax(90px, 0.6fr)"
|
||||
},
|
||||
...baseRegionColumns.slice(3)
|
||||
];
|
||||
const columns = page.abilities.canUpdate
|
||||
? [
|
||||
...regionColumns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <RegionActions item={item} page={page} />,
|
||||
width: "minmax(80px, 0.5fr)"
|
||||
}
|
||||
]
|
||||
: regionColumns;
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<section className={styles.filters}>
|
||||
<SearchBox className={styles.search} value={page.query} onChange={page.changeQuery} placeholder="搜索区域、国家码..." />
|
||||
<StatusSelect
|
||||
className={styles.statusSelect}
|
||||
options={regionStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
const page = useHostRegionsPage();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const regionColumns = [
|
||||
...baseRegionColumns.slice(0, 3),
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <RegionStatusSwitch item={item} page={page} />,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: regionStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
width: "minmax(90px, 0.6fr)",
|
||||
},
|
||||
...baseRegionColumns.slice(3),
|
||||
].map((column) =>
|
||||
column.key === "regionCode"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索区域、国家码",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
const columns = page.abilities.canUpdate
|
||||
? [
|
||||
...regionColumns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <RegionActions item={item} page={page} />,
|
||||
width: "minmax(80px, 0.5fr)",
|
||||
},
|
||||
]
|
||||
: regionColumns;
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<section className={styles.filters}>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{page.abilities.canCreate ? (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Tooltip arrow title="创建区域">
|
||||
<span>
|
||||
<IconButton label="创建区域" onClick={page.openCreateRegion} sx={primaryActionSx}>
|
||||
<Add fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{page.abilities.canCreate ? (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Tooltip arrow title="创建区域">
|
||||
<span>
|
||||
<IconButton label="创建区域" onClick={page.openCreateRegion} sx={primaryActionSx}>
|
||||
<Add fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable columns={columns} items={items} rowKey={(item) => item.regionId} />
|
||||
<div className="pagination-bar">
|
||||
<span>{total} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
rowKey={(item) => item.regionId}
|
||||
/>
|
||||
<div className="pagination-bar">
|
||||
<span>{total} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||
loading={page.loadingAction === "region-create" || page.loadingAction === "region-edit"}
|
||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitRegion}
|
||||
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 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
|
||||
disabled={page.isGlobalRegionForm || (page.activeAction === "edit" ? updateDisabled : createDisabled)}
|
||||
label={page.isGlobalRegionForm ? "国家码" : "国家码 *"}
|
||||
labels={page.countryLabels}
|
||||
loading={page.loadingCountries}
|
||||
options={page.countryOptions}
|
||||
value={page.regionForm.countries}
|
||||
onChange={(countries) => page.setRegionForm({ ...page.regionForm, countries })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
</section>
|
||||
);
|
||||
<HostOrgActionModal
|
||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||
loading={page.loadingAction === "region-create" || page.loadingAction === "region-edit"}
|
||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitRegion}
|
||||
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
|
||||
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
|
||||
disabled={
|
||||
page.isGlobalRegionForm || (page.activeAction === "edit" ? updateDisabled : createDisabled)
|
||||
}
|
||||
label={page.isGlobalRegionForm ? "国家码" : "国家码 *"}
|
||||
labels={page.countryLabels}
|
||||
loading={page.loadingCountries}
|
||||
options={page.countryOptions}
|
||||
value={page.regionForm.countries}
|
||||
onChange={(countries) => page.setRegionForm({ ...page.regionForm, countries })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionActions({ item, page }) {
|
||||
if (item.regionCode === "GLOBAL") {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Tooltip arrow title="编辑区域">
|
||||
<span>
|
||||
<IconButton label="编辑区域" onClick={() => page.openEditRegion(item)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
if (item.regionCode === "GLOBAL") {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Tooltip arrow title="编辑区域">
|
||||
<span>
|
||||
<IconButton label="编辑区域" onClick={() => page.openEditRegion(item)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionStatusSwitch({ item, page }) {
|
||||
const checked = item.status === "active";
|
||||
const isGlobal = item.regionCode === "GLOBAL";
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={isGlobal || !page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
|
||||
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}
|
||||
onChange={(event) => page.toggleRegion(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
const checked = item.status === "active";
|
||||
const isGlobal = item.regionCode === "GLOBAL";
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={isGlobal || !page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
|
||||
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}
|
||||
onChange={(event) => page.toggleRegion(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryCodeSelect({ disabled, label, labels, loading, onChange, options, value }) {
|
||||
const selected = value || [];
|
||||
const mergedOptions = [...new Set([...options, ...selected])].sort();
|
||||
return (
|
||||
<MultiValueAutocomplete
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
labels={labels}
|
||||
loading={loading}
|
||||
options={mergedOptions}
|
||||
value={selected}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
const selected = value || [];
|
||||
const mergedOptions = [...new Set([...options, ...selected])].sort();
|
||||
return (
|
||||
<MultiValueAutocomplete
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
labels={labels}
|
||||
loading={loading}
|
||||
options={mergedOptions}
|
||||
value={selected}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryCodes({ countries = [] }) {
|
||||
const items = Array.isArray(countries) ? countries : [];
|
||||
if (!items.length) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className="admin-tag-list">
|
||||
{items.map((country) => (
|
||||
<span className="admin-tag" key={country}>
|
||||
{country}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const items = Array.isArray(countries) ? countries : [];
|
||||
if (!items.length) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className="admin-tag-list">
|
||||
{items.map((country) => (
|
||||
<span className="admin-tag" key={country}>
|
||||
{country}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryActionSx = {
|
||||
borderColor: "var(--primary)",
|
||||
backgroundColor: "var(--primary)",
|
||||
color: "var(--active-contrast)",
|
||||
"&:hover": {
|
||||
borderColor: "var(--primary-strong)",
|
||||
backgroundColor: "var(--primary-strong)",
|
||||
color: "var(--active-contrast)"
|
||||
}
|
||||
borderColor: "var(--primary)",
|
||||
backgroundColor: "var(--primary)",
|
||||
color: "var(--active-contrast)",
|
||||
"&:hover": {
|
||||
borderColor: "var(--primary-strong)",
|
||||
backgroundColor: "var(--primary-strong)",
|
||||
color: "var(--active-contrast)",
|
||||
},
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ export function useCountryAbilities() {
|
||||
canCreate: can(PERMISSIONS.countryCreate),
|
||||
canStatus: can(PERMISSIONS.countryStatus),
|
||||
canUpdate: can(PERMISSIONS.countryUpdate),
|
||||
canView: can(PERMISSIONS.countryView)
|
||||
canView: can(PERMISSIONS.countryView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export function useRegionAbilities() {
|
||||
canCreate: can(PERMISSIONS.regionCreate),
|
||||
canStatus: can(PERMISSIONS.regionStatus),
|
||||
canUpdate: can(PERMISSIONS.regionUpdate),
|
||||
canView: can(PERMISSIONS.regionView)
|
||||
canView: can(PERMISSIONS.regionView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ export function useAgencyAbilities() {
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.agencyCreate),
|
||||
canStatus: can(PERMISSIONS.agencyStatus),
|
||||
canView: can(PERMISSIONS.agencyView)
|
||||
canView: can(PERMISSIONS.agencyView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export function useHostAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canView: can(PERMISSIONS.hostView)
|
||||
canView: can(PERMISSIONS.hostView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export function useBDAbilities() {
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.bdCreate),
|
||||
canUpdate: can(PERMISSIONS.bdUpdate),
|
||||
canView: can(PERMISSIONS.bdView)
|
||||
canView: can(PERMISSIONS.bdView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -56,7 +56,8 @@ export function useCoinSellerAbilities() {
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.coinSellerCreate),
|
||||
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
|
||||
canUpdate: can(PERMISSIONS.coinSellerUpdate),
|
||||
canView: can(PERMISSIONS.coinSellerView)
|
||||
canView: can(PERMISSIONS.coinSellerView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,12 +2,13 @@ import { z } from "zod";
|
||||
|
||||
const commandBaseSchema = z.object({
|
||||
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({
|
||||
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");
|
||||
@ -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 codeSchema = (label: string) => z.string().trim().min(1, `请输入${label}`).max(40, `${label}不能超过 40 个字符`);
|
||||
const countryCodesSchema = z.array(z.string()).transform((values, ctx) => {
|
||||
const countries = values
|
||||
.map((item) => item.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
const countries = values.map((item) => item.trim().toUpperCase()).filter(Boolean);
|
||||
if (new Set(countries).size !== countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "国家码不能重复" });
|
||||
}
|
||||
@ -31,74 +30,100 @@ const retiredRegionCodes = new Set([
|
||||
"MICRONESIA",
|
||||
"POLYNESIA",
|
||||
"SOUTHERN AFRICA",
|
||||
"UNSPECIFIED"
|
||||
"UNSPECIFIED",
|
||||
]);
|
||||
const requiredCountryCodesSchema = countryCodesSchema.superRefine((countries, ctx) => {
|
||||
if (!countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "请选择国家码" });
|
||||
}
|
||||
});
|
||||
const regionBaseSchema = z.object({
|
||||
countries: countryCodesSchema,
|
||||
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
|
||||
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
|
||||
sortOrder: sortOrderSchema
|
||||
}).superRefine((value, ctx) => {
|
||||
if (retiredRegionCodes.has(value.regionCode)) {
|
||||
ctx.addIssue({ code: "custom", message: "该区域已移除", path: ["regionCode"] });
|
||||
}
|
||||
if (value.regionCode !== "GLOBAL" && !value.countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "请选择国家码", path: ["countries"] });
|
||||
}
|
||||
if (value.regionCode === "GLOBAL" && value.countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "GLOBAL 区域不需要选择国家", path: ["countries"] });
|
||||
}
|
||||
});
|
||||
const regionBaseSchema = z
|
||||
.object({
|
||||
countries: countryCodesSchema,
|
||||
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
|
||||
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
|
||||
sortOrder: sortOrderSchema,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (retiredRegionCodes.has(value.regionCode)) {
|
||||
ctx.addIssue({ code: "custom", message: "该区域已移除", path: ["regionCode"] });
|
||||
}
|
||||
if (value.regionCode !== "GLOBAL" && !value.countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "请选择国家码", path: ["countries"] });
|
||||
}
|
||||
if (value.regionCode === "GLOBAL" && value.countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "GLOBAL 区域不需要选择国家", path: ["countries"] });
|
||||
}
|
||||
});
|
||||
|
||||
export const createBDLeaderSchema = commandContactSchema.extend({
|
||||
regionId: regionIdSchema,
|
||||
targetUserId: userIdSchema
|
||||
targetUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const createBDSchema = commandBaseSchema.extend({
|
||||
parentLeaderUserId: userIdSchema,
|
||||
targetUserId: userIdSchema
|
||||
targetUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const bdStatusSchema = commandBaseSchema.extend({
|
||||
status: z.string().trim().min(1, "请输入状态").max(40, "状态不能超过 40 个字符"),
|
||||
targetType: z.enum(["bd", "leader"]),
|
||||
targetUserId: userIdSchema
|
||||
targetUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const createCoinSellerSchema = commandContactSchema.extend({
|
||||
targetUserId: userIdSchema
|
||||
targetUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const coinSellerStatusSchema = commandContactSchema.extend({
|
||||
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({
|
||||
joinEnabled: z.boolean().default(true),
|
||||
maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0),
|
||||
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
|
||||
ownerUserId: userIdSchema,
|
||||
parentBdUserId: userIdSchema
|
||||
parentBdUserId: userIdSchema,
|
||||
});
|
||||
|
||||
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({
|
||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
|
||||
joinEnabled: z.boolean().default(true)
|
||||
joinEnabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
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 个字符"),
|
||||
countryName: z.string().trim().min(1, "请输入国家名称").max(80, "国家名称不能超过 80 个字符"),
|
||||
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()),
|
||||
isoNumeric: optionalTextSchema(8, "ISO Numeric 不能超过 8 个字符"),
|
||||
phoneCountryCode: optionalTextSchema(16, "电话区号不能超过 16 个字符"),
|
||||
sortOrder: sortOrderSchema
|
||||
sortOrder: sortOrderSchema,
|
||||
});
|
||||
|
||||
export const countryUpdateSchema = countryCreateSchema.omit({
|
||||
countryCode: true,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
export const regionCreateSchema = regionBaseSchema;
|
||||
@ -119,7 +144,7 @@ export const regionCreateSchema = regionBaseSchema;
|
||||
export const regionUpdateSchema = regionBaseSchema;
|
||||
|
||||
export const regionCountriesSchema = z.object({
|
||||
countries: requiredCountryCodesSchema
|
||||
countries: requiredCountryCodesSchema,
|
||||
});
|
||||
|
||||
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 CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
||||
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
||||
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
||||
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
||||
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
||||
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
|
||||
export function LogFilters({ onQueryChange, query }) {
|
||||
return (
|
||||
<section className="filters-panel">
|
||||
<SearchBox value={query} onChange={onQueryChange} placeholder="搜索用户、IP、动作、资源..." />
|
||||
</section>
|
||||
);
|
||||
export function LogFilters({ onReset, query }) {
|
||||
return (
|
||||
<section className="filters-panel">
|
||||
<AdminFilterResetButton disabled={!query} onClick={onReset} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
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 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: "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: "resource",
|
||||
|
||||
@ -9,46 +9,52 @@ const pageSize = 10;
|
||||
const emptyData = { items: [], total: 0, page: 1, pageSize };
|
||||
|
||||
export function useLogsPage(type) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const abilities = useLogAbilities();
|
||||
const isLogin = type === "login";
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const abilities = useLogAbilities();
|
||||
const isLogin = type === "login";
|
||||
|
||||
const requestQuery = useMemo(() => toPageQuery({ keyword: query, page, pageSize }), [page, query]);
|
||||
const queryFn = useCallback(async () => {
|
||||
const loader = isLogin ? listLoginLogs : listOperationLogs;
|
||||
return loader(requestQuery);
|
||||
}, [isLogin, requestQuery]);
|
||||
const requestQuery = useMemo(() => toPageQuery({ keyword: query, page, pageSize }), [page, query]);
|
||||
const queryFn = useCallback(async () => {
|
||||
const loader = isLogin ? listLoginLogs : listOperationLogs;
|
||||
return loader(requestQuery);
|
||||
}, [isLogin, requestQuery]);
|
||||
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载日志失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["logs", type, requestQuery]
|
||||
});
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载日志失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["logs", type, requestQuery],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const exporter = isLogin ? exportLoginLogs : exportOperationLogs;
|
||||
const filename = isLogin ? "hyapp-login-logs.csv" : "hyapp-operation-logs.csv";
|
||||
return downloadCsv(() => exporter({ keyword: query }), filename);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeQuery,
|
||||
data,
|
||||
downloadLogs,
|
||||
error,
|
||||
isLogin,
|
||||
loading,
|
||||
page,
|
||||
query,
|
||||
reload,
|
||||
setPage
|
||||
};
|
||||
const downloadLogs = () => {
|
||||
const exporter = isLogin ? exportLoginLogs : exportOperationLogs;
|
||||
const filename = isLogin ? "hyapp-login-logs.csv" : "hyapp-operation-logs.csv";
|
||||
return downloadCsv(() => exporter({ keyword: query }), filename);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeQuery,
|
||||
data,
|
||||
downloadLogs,
|
||||
error,
|
||||
isLogin,
|
||||
loading,
|
||||
page,
|
||||
query,
|
||||
reload,
|
||||
resetFilters,
|
||||
setPage,
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,17 +6,22 @@ import { LogToolbar } from "@/features/logs/components/LogToolbar.jsx";
|
||||
import { useLogsPage } from "@/features/logs/hooks/useLogsPage.js";
|
||||
|
||||
export function LogsPage({ type }) {
|
||||
const page = useLogsPage(type);
|
||||
const page = useLogsPage(type);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} />
|
||||
<LogFilters query={page.query} onQueryChange={page.changeQuery} />
|
||||
return (
|
||||
<>
|
||||
<LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} />
|
||||
<LogFilters query={page.query} onReset={page.resetFilters} />
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<LogTable data={page.data} isLogin={page.isLogin} />
|
||||
<PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={page.data.total || 0} onPageChange={page.setPage} />
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<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}
|
||||
/>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,8 +10,9 @@ import {
|
||||
adminListClasses
|
||||
} from "@/shared/ui/AdminListLayout.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 [dragOverId, setDragOverId] = useState(null);
|
||||
const columns = [
|
||||
@ -25,6 +26,11 @@ export function MenuTable({ abilities, flatMenus, onEdit, onReorder, onRemove, o
|
||||
key: "label",
|
||||
label: "菜单",
|
||||
width: "minmax(180px, 1.2fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索菜单、权限码",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
}),
|
||||
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> },
|
||||
|
||||
@ -3,14 +3,24 @@ import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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: "kind", label: "类型", width: "110px", render: (permission) => kindLabel(permission.kind) },
|
||||
{
|
||||
|
||||
@ -4,273 +4,293 @@ import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createMenu,
|
||||
createPermission,
|
||||
deleteMenu,
|
||||
deletePermission,
|
||||
listPermissions,
|
||||
listSystemMenus,
|
||||
sortMenus,
|
||||
syncPermissions,
|
||||
updateMenu,
|
||||
updateMenuVisible,
|
||||
updatePermission
|
||||
createMenu,
|
||||
createPermission,
|
||||
deleteMenu,
|
||||
deletePermission,
|
||||
listPermissions,
|
||||
listSystemMenus,
|
||||
sortMenus,
|
||||
syncPermissions,
|
||||
updateMenu,
|
||||
updateMenuVisible,
|
||||
updatePermission,
|
||||
} from "@/features/menus/api";
|
||||
import { useMenuAbilities } from "@/features/menus/permissions.js";
|
||||
import { menuFormSchema, permissionFormSchema } from "@/features/menus/schema";
|
||||
|
||||
const emptyMenu = {
|
||||
code: "",
|
||||
icon: "menu",
|
||||
label: "",
|
||||
parentId: "",
|
||||
path: "",
|
||||
permissionCode: "",
|
||||
sort: 0,
|
||||
visible: true
|
||||
code: "",
|
||||
icon: "menu",
|
||||
label: "",
|
||||
parentId: "",
|
||||
path: "",
|
||||
permissionCode: "",
|
||||
sort: 0,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const emptyPermission = { code: "", description: "", kind: "button", name: "" };
|
||||
const emptyData = { menus: [], permissions: [] };
|
||||
|
||||
export function useMenusPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuDrawerOpen, setMenuDrawerOpen] = useState(false);
|
||||
const [permissionDrawerOpen, setPermissionDrawerOpen] = useState(false);
|
||||
const [editingMenu, setEditingMenu] = useState(null);
|
||||
const [editingPermission, setEditingPermission] = useState(null);
|
||||
const [menuForm, setMenuForm] = useState(emptyMenu);
|
||||
const [permissionForm, setPermissionForm] = useState(emptyPermission);
|
||||
const abilities = useMenuAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuDrawerOpen, setMenuDrawerOpen] = useState(false);
|
||||
const [permissionDrawerOpen, setPermissionDrawerOpen] = useState(false);
|
||||
const [editingMenu, setEditingMenu] = useState(null);
|
||||
const [editingPermission, setEditingPermission] = useState(null);
|
||||
const [menuForm, setMenuForm] = useState(emptyMenu);
|
||||
const [permissionForm, setPermissionForm] = useState(emptyPermission);
|
||||
const abilities = useMenuAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const queryFn = useCallback(async () => {
|
||||
const menuData = await listSystemMenus();
|
||||
const permissionData = abilities.canLoadPermissions ? await listPermissions() : [];
|
||||
return { menus: menuData || [], permissions: permissionData || [] };
|
||||
}, [abilities.canLoadPermissions]);
|
||||
const queryFn = useCallback(async () => {
|
||||
const menuData = await listSystemMenus();
|
||||
const permissionData = abilities.canLoadPermissions ? await listPermissions() : [];
|
||||
return { menus: menuData || [], permissions: permissionData || [] };
|
||||
}, [abilities.canLoadPermissions]);
|
||||
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载菜单权限失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["menus", abilities.canLoadPermissions]
|
||||
});
|
||||
|
||||
const flatMenus = useMemo(() => flattenMenus(data.menus || []), [data.menus]);
|
||||
const filteredFlatMenus = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return flatMenus;
|
||||
}
|
||||
return flatMenus.filter((menu) => {
|
||||
return [menu.label, menu.code, menu.path, menu.permissionCode].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载菜单权限失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["menus", abilities.canLoadPermissions],
|
||||
});
|
||||
}, [flatMenus, query]);
|
||||
const filteredPermissions = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
const items = data.permissions || [];
|
||||
if (!keyword) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((permission) => {
|
||||
return [permission.name, permission.code, permission.kind, permission.description].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
});
|
||||
}, [data.permissions, query]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
const flatMenus = useMemo(() => flattenMenus(data.menus || []), [data.menus]);
|
||||
const filteredFlatMenus = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return flatMenus;
|
||||
}
|
||||
return flatMenus.filter((menu) => {
|
||||
return [menu.label, menu.code, menu.path, menu.permissionCode].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [flatMenus, query]);
|
||||
const filteredPermissions = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
const items = data.permissions || [];
|
||||
if (!keyword) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((permission) => {
|
||||
return [permission.name, permission.code, permission.kind, permission.description].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [data.permissions, query]);
|
||||
|
||||
const openCreateMenu = () => {
|
||||
setEditingMenu(null);
|
||||
setMenuForm(emptyMenu);
|
||||
setMenuDrawerOpen(true);
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const openEditMenu = (menu) => {
|
||||
setEditingMenu(menu);
|
||||
setMenuForm({
|
||||
code: menu.code,
|
||||
icon: menu.icon || "menu",
|
||||
label: menu.label,
|
||||
parentId: menu.parentId ? String(menu.parentId) : "",
|
||||
path: menu.path || "",
|
||||
permissionCode: menu.permissionCode || "",
|
||||
sort: menu.sort || 0,
|
||||
visible: Boolean(menu.visible)
|
||||
});
|
||||
setMenuDrawerOpen(true);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const openCreatePermission = () => {
|
||||
setEditingPermission(null);
|
||||
setPermissionForm(emptyPermission);
|
||||
setPermissionDrawerOpen(true);
|
||||
};
|
||||
const openCreateMenu = () => {
|
||||
setEditingMenu(null);
|
||||
setMenuForm(emptyMenu);
|
||||
setMenuDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEditPermission = (permission) => {
|
||||
setEditingPermission(permission);
|
||||
setPermissionForm({
|
||||
code: permission.code,
|
||||
description: permission.description || "",
|
||||
kind: permission.kind,
|
||||
name: permission.name
|
||||
});
|
||||
setPermissionDrawerOpen(true);
|
||||
};
|
||||
const openEditMenu = (menu) => {
|
||||
setEditingMenu(menu);
|
||||
setMenuForm({
|
||||
code: menu.code,
|
||||
icon: menu.icon || "menu",
|
||||
label: menu.label,
|
||||
parentId: menu.parentId ? String(menu.parentId) : "",
|
||||
path: menu.path || "",
|
||||
permissionCode: menu.permissionCode || "",
|
||||
sort: menu.sort || 0,
|
||||
visible: Boolean(menu.visible),
|
||||
});
|
||||
setMenuDrawerOpen(true);
|
||||
};
|
||||
|
||||
const submitMenu = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const payload = parseForm(menuFormSchema, menuForm);
|
||||
if (editingMenu) {
|
||||
await updateMenu(editingMenu.id, payload);
|
||||
showToast("菜单已更新", "success");
|
||||
} else {
|
||||
await createMenu(payload);
|
||||
showToast("菜单已创建", "success");
|
||||
}
|
||||
setMenuDrawerOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存菜单失败", "error");
|
||||
}
|
||||
};
|
||||
const openCreatePermission = () => {
|
||||
setEditingPermission(null);
|
||||
setPermissionForm(emptyPermission);
|
||||
setPermissionDrawerOpen(true);
|
||||
};
|
||||
|
||||
const submitPermission = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const payload = parseForm(permissionFormSchema, permissionForm);
|
||||
if (editingPermission) {
|
||||
await updatePermission(editingPermission.id, payload);
|
||||
showToast("权限已更新", "success");
|
||||
} else {
|
||||
await createPermission(payload);
|
||||
showToast("权限已创建", "success");
|
||||
}
|
||||
setPermissionDrawerOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存权限失败", "error");
|
||||
}
|
||||
};
|
||||
const openEditPermission = (permission) => {
|
||||
setEditingPermission(permission);
|
||||
setPermissionForm({
|
||||
code: permission.code,
|
||||
description: permission.description || "",
|
||||
kind: permission.kind,
|
||||
name: permission.name,
|
||||
});
|
||||
setPermissionDrawerOpen(true);
|
||||
};
|
||||
|
||||
const removeMenu = async (menu) => {
|
||||
const ok = await confirm({ confirmText: "删除", message: `${menu.label} 删除前需要先删除子菜单。`, title: "删除菜单", tone: "danger" });
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteMenu(menu.id);
|
||||
showToast("菜单已删除", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除菜单失败", "error");
|
||||
}
|
||||
};
|
||||
const submitMenu = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const payload = parseForm(menuFormSchema, menuForm);
|
||||
if (editingMenu) {
|
||||
await updateMenu(editingMenu.id, payload);
|
||||
showToast("菜单已更新", "success");
|
||||
} else {
|
||||
await createMenu(payload);
|
||||
showToast("菜单已创建", "success");
|
||||
}
|
||||
setMenuDrawerOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存菜单失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const removePermission = async (permission) => {
|
||||
const ok = await confirm({ confirmText: "删除", message: `${permission.name} 删除前不能被角色或菜单引用。`, title: "删除权限", tone: "danger" });
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deletePermission(permission.id);
|
||||
showToast("权限已删除", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除权限失败", "error");
|
||||
}
|
||||
};
|
||||
const submitPermission = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const payload = parseForm(permissionFormSchema, permissionForm);
|
||||
if (editingPermission) {
|
||||
await updatePermission(editingPermission.id, payload);
|
||||
showToast("权限已更新", "success");
|
||||
} else {
|
||||
await createPermission(payload);
|
||||
showToast("权限已创建", "success");
|
||||
}
|
||||
setPermissionDrawerOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存权限失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVisible = async (menu) => {
|
||||
try {
|
||||
await updateMenuVisible(menu.id, !menu.visible);
|
||||
showToast("菜单状态已更新", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "更新菜单失败", "error");
|
||||
}
|
||||
};
|
||||
const removeMenu = async (menu) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${menu.label} 删除前需要先删除子菜单。`,
|
||||
title: "删除菜单",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteMenu(menu.id);
|
||||
showToast("菜单已删除", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除菜单失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const reorderMenu = async (source, target) => {
|
||||
if (!source || !target || source.id === target.id || (source.parentId || null) !== (target.parentId || null)) {
|
||||
return;
|
||||
}
|
||||
const siblings = flatMenus
|
||||
.filter((item) => (item.parentId || null) === (source.parentId || null))
|
||||
.sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.id - b.id);
|
||||
const fromIndex = siblings.findIndex((item) => item.id === source.id);
|
||||
const toIndex = siblings.findIndex((item) => item.id === target.id);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
const reordered = [...siblings];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
const removePermission = async (permission) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${permission.name} 删除前不能被角色或菜单引用。`,
|
||||
title: "删除权限",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deletePermission(permission.id);
|
||||
showToast("权限已删除", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除权限失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await sortMenus(reordered.map((item, index) => ({ id: item.id, sort: (index + 1) * 10 })));
|
||||
showToast("菜单排序已更新", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "排序失败", "error");
|
||||
}
|
||||
};
|
||||
const toggleVisible = async (menu) => {
|
||||
try {
|
||||
await updateMenuVisible(menu.id, !menu.visible);
|
||||
showToast("菜单状态已更新", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "更新菜单失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const syncDefaultPermissions = async () => {
|
||||
const ok = await confirm({ confirmText: "同步", message: "将同步系统默认权限定义。", title: "同步权限" });
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await syncPermissions();
|
||||
showToast("权限已同步", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "同步权限失败", "error");
|
||||
}
|
||||
};
|
||||
const reorderMenu = async (source, target) => {
|
||||
if (!source || !target || source.id === target.id || (source.parentId || null) !== (target.parentId || null)) {
|
||||
return;
|
||||
}
|
||||
const siblings = flatMenus
|
||||
.filter((item) => (item.parentId || null) === (source.parentId || null))
|
||||
.sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.id - b.id);
|
||||
const fromIndex = siblings.findIndex((item) => item.id === source.id);
|
||||
const toIndex = siblings.findIndex((item) => item.id === target.id);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
const reordered = [...siblings];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeQuery,
|
||||
closeMenuDrawer: () => setMenuDrawerOpen(false),
|
||||
closePermissionDrawer: () => setPermissionDrawerOpen(false),
|
||||
editingMenu,
|
||||
editingPermission,
|
||||
error,
|
||||
flatMenus: filteredFlatMenus,
|
||||
loading,
|
||||
menuDrawerOpen,
|
||||
menuForm,
|
||||
menus: data.menus || [],
|
||||
openCreateMenu,
|
||||
openCreatePermission,
|
||||
openEditMenu,
|
||||
openEditPermission,
|
||||
permissionDrawerOpen,
|
||||
permissionForm,
|
||||
permissions: filteredPermissions,
|
||||
query,
|
||||
reload,
|
||||
removeMenu,
|
||||
removePermission,
|
||||
reorderMenu,
|
||||
setMenuForm,
|
||||
setPermissionForm,
|
||||
submitMenu,
|
||||
submitPermission,
|
||||
syncDefaultPermissions,
|
||||
toggleVisible
|
||||
};
|
||||
try {
|
||||
await sortMenus(reordered.map((item, index) => ({ id: item.id, sort: (index + 1) * 10 })));
|
||||
showToast("菜单排序已更新", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "排序失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const syncDefaultPermissions = async () => {
|
||||
const ok = await confirm({ confirmText: "同步", message: "将同步系统默认权限定义。", title: "同步权限" });
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await syncPermissions();
|
||||
showToast("权限已同步", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "同步权限失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeQuery,
|
||||
closeMenuDrawer: () => setMenuDrawerOpen(false),
|
||||
closePermissionDrawer: () => setPermissionDrawerOpen(false),
|
||||
editingMenu,
|
||||
editingPermission,
|
||||
error,
|
||||
flatMenus: filteredFlatMenus,
|
||||
loading,
|
||||
menuDrawerOpen,
|
||||
menuForm,
|
||||
menus: data.menus || [],
|
||||
openCreateMenu,
|
||||
openCreatePermission,
|
||||
openEditMenu,
|
||||
openEditPermission,
|
||||
permissionDrawerOpen,
|
||||
permissionForm,
|
||||
permissions: filteredPermissions,
|
||||
query,
|
||||
reload,
|
||||
resetFilters,
|
||||
removeMenu,
|
||||
removePermission,
|
||||
reorderMenu,
|
||||
setMenuForm,
|
||||
setPermissionForm,
|
||||
submitMenu,
|
||||
submitPermission,
|
||||
syncDefaultPermissions,
|
||||
toggleVisible,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenMenus(menus, depth = 0) {
|
||||
return menus.flatMap((menu) => [
|
||||
{ ...menu, depth },
|
||||
...flattenMenus(menu.children || [], depth + 1)
|
||||
]);
|
||||
return menus.flatMap((menu) => [{ ...menu, depth }, ...flattenMenus(menu.children || [], depth + 1)]);
|
||||
}
|
||||
|
||||
@ -2,11 +2,11 @@ import Add from "@mui/icons-material/Add";
|
||||
import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { MenuFormDrawer } from "@/features/menus/components/MenuFormDrawer.jsx";
|
||||
import { MenuTable } from "@/features/menus/components/MenuTable.jsx";
|
||||
@ -15,72 +15,78 @@ import { PermissionTable } from "@/features/menus/components/PermissionTable.jsx
|
||||
import { useMenusPage } from "@/features/menus/hooks/useMenusPage.js";
|
||||
|
||||
export function MenuManagementPage() {
|
||||
const page = useMenusPage();
|
||||
const page = useMenusPage();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索菜单、权限码..." />}
|
||||
actions={(
|
||||
<>
|
||||
{page.abilities.canSyncPermission ? (
|
||||
<AdminActionIconButton label="同步权限" onClick={page.syncDefaultPermissions}>
|
||||
<SyncOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreatePermission ? (
|
||||
<AdminActionIconButton label="新增权限" onClick={page.openCreatePermission}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreateMenu ? (
|
||||
<AdminActionIconButton label="新增菜单" primary onClick={page.openCreateMenu}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{page.abilities.canSyncPermission ? (
|
||||
<AdminActionIconButton label="同步权限" onClick={page.syncDefaultPermissions}>
|
||||
<SyncOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreatePermission ? (
|
||||
<AdminActionIconButton label="新增权限" onClick={page.openCreatePermission}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreateMenu ? (
|
||||
<AdminActionIconButton label="新增菜单" primary onClick={page.openCreateMenu}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<section className="permission-page-grid">
|
||||
<MenuTable
|
||||
abilities={page.abilities}
|
||||
flatMenus={page.flatMenus}
|
||||
onEdit={page.openEditMenu}
|
||||
onReorder={page.reorderMenu}
|
||||
onRemove={page.removeMenu}
|
||||
onToggleVisible={page.toggleVisible}
|
||||
/>
|
||||
<PermissionTable
|
||||
abilities={page.abilities}
|
||||
onEdit={page.openEditPermission}
|
||||
onRemove={page.removePermission}
|
||||
permissions={page.permissions}
|
||||
/>
|
||||
</section>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<section className="permission-page-grid">
|
||||
<MenuTable
|
||||
abilities={page.abilities}
|
||||
flatMenus={page.flatMenus}
|
||||
query={page.query}
|
||||
onEdit={page.openEditMenu}
|
||||
onQueryChange={page.changeQuery}
|
||||
onReorder={page.reorderMenu}
|
||||
onRemove={page.removeMenu}
|
||||
onToggleVisible={page.toggleVisible}
|
||||
/>
|
||||
<PermissionTable
|
||||
abilities={page.abilities}
|
||||
query={page.query}
|
||||
onEdit={page.openEditPermission}
|
||||
onQueryChange={page.changeQuery}
|
||||
onRemove={page.removePermission}
|
||||
permissions={page.permissions}
|
||||
/>
|
||||
</section>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
|
||||
<MenuFormDrawer
|
||||
editingMenu={page.editingMenu}
|
||||
form={page.menuForm}
|
||||
onClose={page.closeMenuDrawer}
|
||||
onSubmit={page.submitMenu}
|
||||
open={page.menuDrawerOpen}
|
||||
setForm={page.setMenuForm}
|
||||
/>
|
||||
<PermissionFormDrawer
|
||||
editingPermission={page.editingPermission}
|
||||
form={page.permissionForm}
|
||||
onClose={page.closePermissionDrawer}
|
||||
onSubmit={page.submitPermission}
|
||||
open={page.permissionDrawerOpen}
|
||||
setForm={page.setPermissionForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<MenuFormDrawer
|
||||
editingMenu={page.editingMenu}
|
||||
form={page.menuForm}
|
||||
onClose={page.closeMenuDrawer}
|
||||
onSubmit={page.submitMenu}
|
||||
open={page.menuDrawerOpen}
|
||||
setForm={page.setMenuForm}
|
||||
/>
|
||||
<PermissionFormDrawer
|
||||
editingPermission={page.editingPermission}
|
||||
form={page.permissionForm}
|
||||
onClose={page.closePermissionDrawer}
|
||||
onSubmit={page.submitPermission}
|
||||
open={page.permissionDrawerOpen}
|
||||
setForm={page.setPermissionForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,208 +1,281 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo, useState } from "react";
|
||||
import { listRechargeBills } from "@/features/payment/api";
|
||||
import {
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox,
|
||||
adminListClasses
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const pageSize = 20;
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["succeeded", "成功"]
|
||||
["", "全部状态"],
|
||||
["succeeded", "成功"],
|
||||
];
|
||||
const rechargeTypeOptions = [
|
||||
["", "全部途径"],
|
||||
["coin_seller_transfer", "币商充值"]
|
||||
["", "全部途径"],
|
||||
["coin_seller_transfer", "币商充值"],
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "bill",
|
||||
label: "账单",
|
||||
width: "minmax(240px, 1.4fr)",
|
||||
render: (bill) => (
|
||||
<Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
label: "用户 / 来源",
|
||||
width: "minmax(180px, 0.9fr)",
|
||||
render: (bill) => (
|
||||
<Stack primary={`用户 ${bill.userId || "-"}`} secondary={bill.sellerUserId ? `币商 ${bill.sellerUserId}` : "-"} />
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "金币 / 金额",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (bill) => (
|
||||
<Stack primary={formatNumber(bill.coinAmount)} secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "policy",
|
||||
label: "兑换口径",
|
||||
width: "minmax(170px, 0.9fr)",
|
||||
render: (bill) => (
|
||||
<Stack
|
||||
primary={bill.policyVersion || "-"}
|
||||
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "minmax(110px, 0.55fr)",
|
||||
render: (bill) => <Stack primary={bill.targetRegionId ? `区域 ${bill.targetRegionId}` : "-"} secondary={bill.sellerRegionId ? `来源 ${bill.sellerRegionId}` : "-"} />
|
||||
},
|
||||
{
|
||||
key: "state",
|
||||
label: "途径 / 状态",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (bill) => (
|
||||
<Stack primary={rechargeTypeLabel(bill.rechargeType)} secondary={<StatusBadge status={bill.status} />} />
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "创建时间",
|
||||
width: "minmax(160px, 0.8fr)",
|
||||
render: (bill) => formatMillis(bill.createdAtMs)
|
||||
}
|
||||
{
|
||||
key: "bill",
|
||||
label: "账单",
|
||||
width: "minmax(240px, 1.4fr)",
|
||||
render: (bill) => <Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "用户 ID",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (bill) => bill.userId || "-",
|
||||
},
|
||||
{
|
||||
key: "sellerUserId",
|
||||
label: "币商 ID",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (bill) => bill.sellerUserId || "-",
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "金币 / 金额",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (bill) => (
|
||||
<Stack
|
||||
primary={formatNumber(bill.coinAmount)}
|
||||
secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "policy",
|
||||
label: "兑换口径",
|
||||
width: "minmax(170px, 0.9fr)",
|
||||
render: (bill) => (
|
||||
<Stack
|
||||
primary={bill.policyVersion || "-"}
|
||||
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "minmax(110px, 0.55fr)",
|
||||
render: (bill) => (
|
||||
<Stack
|
||||
primary={bill.targetRegionId ? `区域 ${bill.targetRegionId}` : "-"}
|
||||
secondary={bill.sellerRegionId ? `来源 ${bill.sellerRegionId}` : "-"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rechargeType",
|
||||
label: "充值途径",
|
||||
width: "minmax(130px, 0.7fr)",
|
||||
render: (bill) => rechargeTypeLabel(bill.rechargeType),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(100px, 0.55fr)",
|
||||
render: (bill) => <StatusBadge status={bill.status} />,
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "创建时间",
|
||||
width: "minmax(160px, 0.8fr)",
|
||||
render: (bill) => formatMillis(bill.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
export function PaymentBillListPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [rechargeType, setRechargeType] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [sellerUserId, setSellerUserId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [rechargeType, setRechargeType] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [sellerUserId, setSellerUserId] = useState("");
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword,
|
||||
recharge_type: rechargeType,
|
||||
seller_user_id: sellerUserId,
|
||||
status,
|
||||
user_id: userId
|
||||
}),
|
||||
[keyword, rechargeType, sellerUserId, status, userId]
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取账单列表失败",
|
||||
fetcher: listRechargeBills,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["payment-bills", filters, page]
|
||||
});
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword,
|
||||
recharge_type: rechargeType,
|
||||
seller_user_id: sellerUserId,
|
||||
status,
|
||||
user_id: userId,
|
||||
}),
|
||||
[keyword, rechargeType, sellerUserId, status, userId],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取账单列表失败",
|
||||
fetcher: listRechargeBills,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["payment-bills", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
const resetPage = (setter) => (value) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const resetPage = (setter) => (value) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox value={keyword} onChange={resetPage(setKeyword)} placeholder="搜索账单号、命令号、外部单号..." />
|
||||
<TextField
|
||||
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)} />
|
||||
</>
|
||||
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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={columns} items={items} minWidth="1180px" rowKey={(bill) => bill.transactionId} />
|
||||
{total > 0 ? <PaginationBar page={page} pageSize={data.pageSize || pageSize} total={total} onPageChange={setPage} /> : null}
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
);
|
||||
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 (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!keyword && !status && !rechargeType && !userId && !sellerUserId}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
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>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function rechargeTypeLabel(value) {
|
||||
return rechargeTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
|
||||
return rechargeTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
|
||||
}
|
||||
|
||||
function statusLabel(value) {
|
||||
if (value === "succeeded") {
|
||||
return "成功";
|
||||
}
|
||||
if (value === "failed") {
|
||||
return "失败";
|
||||
}
|
||||
return value || "-";
|
||||
if (value === "succeeded") {
|
||||
return "成功";
|
||||
}
|
||||
if (value === "failed") {
|
||||
return "失败";
|
||||
}
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === 0 || value) {
|
||||
return Number(value).toLocaleString("zh-CN");
|
||||
}
|
||||
return "-";
|
||||
if (value === 0 || value) {
|
||||
return Number(value).toLocaleString("zh-CN");
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function formatMoney(value, currency = "USD") {
|
||||
if (!(value === 0 || value)) {
|
||||
return "-";
|
||||
}
|
||||
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2
|
||||
})}`;
|
||||
if (!(value === 0 || value)) {
|
||||
return "-";
|
||||
}
|
||||
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function digitsOnly(value) {
|
||||
return value.replace(/\D/g, "");
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
@ -221,6 +221,13 @@ export function useResourceListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setResourceType("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||
abilities,
|
||||
@ -234,6 +241,7 @@ export function useResourceListPage() {
|
||||
openEditResource,
|
||||
resourceType,
|
||||
selectedResource,
|
||||
resetFilters,
|
||||
setForm,
|
||||
status,
|
||||
submitResource,
|
||||
@ -385,6 +393,12 @@ export function useResourceGroupListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||
abilities,
|
||||
@ -401,6 +415,7 @@ export function useResourceGroupListPage() {
|
||||
resourceOptions,
|
||||
resourceOptionsLoading,
|
||||
selectedGroup,
|
||||
resetFilters,
|
||||
setForm,
|
||||
status,
|
||||
submitGroup,
|
||||
@ -534,6 +549,13 @@ export function useGiftListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||
abilities,
|
||||
@ -551,6 +573,7 @@ export function useGiftListPage() {
|
||||
resourceOptions,
|
||||
resourceOptionsLoading,
|
||||
selectedGift,
|
||||
resetFilters,
|
||||
setForm,
|
||||
status,
|
||||
submitGift,
|
||||
@ -650,6 +673,12 @@ export function useResourceGrantListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||
abilities,
|
||||
@ -662,6 +691,7 @@ export function useResourceGrantListPage() {
|
||||
openCreateGrant,
|
||||
optionsLoading,
|
||||
resourceOptions,
|
||||
resetFilters,
|
||||
setForm,
|
||||
status,
|
||||
submitGrant,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,13 +6,13 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
resourceGrantStatusFilters,
|
||||
@ -86,19 +86,36 @@ export function ResourceGrantListPage() {
|
||||
const page = useResourceGrantListPage();
|
||||
const items = page.data.items || [];
|
||||
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 (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索用户 ID..." />
|
||||
<AdminFilterSelect
|
||||
options={resourceGrantStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
</>
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGrant ? (
|
||||
@ -110,7 +127,12 @@ export function ResourceGrantListPage() {
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<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 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
|
||||
@ -22,20 +22,16 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
resourceGroupAssetLabels,
|
||||
resourceStatusFilters,
|
||||
resourceTypeLabels,
|
||||
} from "@/features/resources/constants.js";
|
||||
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
||||
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
import styles from "@/features/resources/resources.module.css";
|
||||
|
||||
@ -83,9 +79,24 @@ export function ResourceGroupListPage() {
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const columns = baseColumns.map((column) =>
|
||||
column.key === "status"
|
||||
column.key === "group"
|
||||
? {
|
||||
...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} />,
|
||||
}
|
||||
: column.key === "actions"
|
||||
@ -100,18 +111,7 @@ export function ResourceGroupListPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox
|
||||
value={page.query}
|
||||
onChange={page.changeQuery}
|
||||
placeholder="搜索资源组名称、资源组编码..."
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
options={resourceStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
</>
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGroup ? (
|
||||
@ -135,7 +135,9 @@ export function ResourceGroupListPage() {
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<ResourceGroupFormDialog
|
||||
disabled={page.activeAction === "edit" ? !page.abilities.canUpdateGroup : !page.abilities.canCreateGroup}
|
||||
disabled={
|
||||
page.activeAction === "edit" ? !page.abilities.canUpdateGroup : !page.abilities.canCreateGroup
|
||||
}
|
||||
form={page.form}
|
||||
loading={
|
||||
page.activeAction === "edit"
|
||||
@ -230,13 +232,25 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
|
||||
title="组内资源"
|
||||
actions={
|
||||
<AdminFormInlineActions>
|
||||
<Button disabled={loading} onClick={page.addResourceItem} startIcon={<Inventory2Outlined fontSize="small" />}>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={page.addResourceItem}
|
||||
startIcon={<Inventory2Outlined fontSize="small" />}
|
||||
>
|
||||
资源
|
||||
</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 disabled={loading} onClick={() => page.addWalletAssetItem("DIAMOND")} startIcon={<DiamondOutlined fontSize="small" />}>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => page.addWalletAssetItem("DIAMOND")}
|
||||
startIcon={<DiamondOutlined fontSize="small" />}
|
||||
>
|
||||
钻石
|
||||
</Button>
|
||||
</AdminFormInlineActions>
|
||||
|
||||
@ -11,14 +11,14 @@ import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminRowActions,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
grantStrategyLabels,
|
||||
@ -80,9 +80,34 @@ export function ResourceListPage() {
|
||||
const total = page.data.total || 0;
|
||||
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
|
||||
const columns = baseColumns.map((column) =>
|
||||
column.key === "status"
|
||||
column.key === "resource"
|
||||
? {
|
||||
...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} />,
|
||||
}
|
||||
: column.key === "actions"
|
||||
@ -97,24 +122,10 @@ export function ResourceListPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox
|
||||
value={page.query}
|
||||
onChange={page.changeQuery}
|
||||
placeholder="搜索资源名称、资源编码..."
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
options={resourceStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
label="类型"
|
||||
options={resourceTypeFilters}
|
||||
value={page.resourceType}
|
||||
onChange={page.changeResourceType}
|
||||
/>
|
||||
</>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status && !page.resourceType}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
|
||||
@ -2,10 +2,20 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.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 = [
|
||||
{ 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: "permissionCount", label: "权限数量", width: "110px", render: (role) => role.permissions?.length || 0 },
|
||||
{
|
||||
|
||||
@ -4,12 +4,12 @@ import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createRole,
|
||||
deleteRole,
|
||||
listPermissions,
|
||||
listRoles,
|
||||
replaceRolePermissions,
|
||||
updateRole
|
||||
createRole,
|
||||
deleteRole,
|
||||
listPermissions,
|
||||
listRoles,
|
||||
replaceRolePermissions,
|
||||
updateRole,
|
||||
} from "@/features/roles/api";
|
||||
import { useRoleAbilities } from "@/features/roles/permissions.js";
|
||||
import { roleFormSchema } from "@/features/roles/schema";
|
||||
@ -17,220 +17,233 @@ import { roleFormSchema } from "@/features/roles/schema";
|
||||
const emptyRole = { code: "", description: "", name: "", permissionIds: [] };
|
||||
const emptyData = { permissions: [], roles: [] };
|
||||
const permissionGroupDefinitions = [
|
||||
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
||||
{ key: "users", prefixes: ["user"], title: "用户管理" },
|
||||
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
||||
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
||||
{ key: "app-users", prefixes: ["app-user"], title: "App 用户" },
|
||||
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
|
||||
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
||||
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 管理" },
|
||||
{ key: "bds", prefixes: ["bd"], title: "BD 管理" },
|
||||
{ key: "hosts", prefixes: ["host"], title: "主播管理" },
|
||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||
{ key: "notifications", prefixes: ["notification"], title: "通知中心" },
|
||||
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
|
||||
{ key: "common", prefixes: ["upload"], title: "通用能力" }
|
||||
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
||||
{ key: "users", prefixes: ["user"], title: "用户管理" },
|
||||
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
||||
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
||||
{ key: "app-users", prefixes: ["app-user"], title: "App 用户" },
|
||||
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
|
||||
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
||||
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 管理" },
|
||||
{ key: "bds", prefixes: ["bd"], title: "BD 管理" },
|
||||
{ key: "hosts", prefixes: ["host"], title: "主播管理" },
|
||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||
{ key: "notifications", prefixes: ["notification"], title: "通知中心" },
|
||||
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
|
||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||
];
|
||||
const fallbackPermissionGroup = { key: "other", title: "其他权限" };
|
||||
const permissionKindOrder = { menu: 1, button: 2, api: 3 };
|
||||
|
||||
export function useRolesPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState(null);
|
||||
const [form, setForm] = useState(emptyRole);
|
||||
const abilities = useRoleAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState(null);
|
||||
const [form, setForm] = useState(emptyRole);
|
||||
const abilities = useRoleAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const queryFn = useCallback(async () => {
|
||||
const roleData = await listRoles();
|
||||
const permissionData = abilities.canLoadPermissions ? await listPermissions() : [];
|
||||
return { permissions: permissionData || [], roles: roleData || [] };
|
||||
}, [abilities.canLoadPermissions]);
|
||||
const queryFn = useCallback(async () => {
|
||||
const roleData = await listRoles();
|
||||
const permissionData = abilities.canLoadPermissions ? await listPermissions() : [];
|
||||
return { permissions: permissionData || [], roles: roleData || [] };
|
||||
}, [abilities.canLoadPermissions]);
|
||||
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载角色失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["roles", abilities.canLoadPermissions]
|
||||
});
|
||||
|
||||
const permissionGroups = useMemo(() => {
|
||||
return groupPermissionsByMenu(data.permissions || []);
|
||||
}, [data.permissions]);
|
||||
|
||||
const roles = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
const items = data.roles || [];
|
||||
if (!keyword) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((role) => {
|
||||
return [role.name, role.code, role.description].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载角色失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["roles", abilities.canLoadPermissions],
|
||||
});
|
||||
}, [data.roles, query]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
const permissionGroups = useMemo(() => {
|
||||
return groupPermissionsByMenu(data.permissions || []);
|
||||
}, [data.permissions]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRole(null);
|
||||
setForm(emptyRole);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (role) => {
|
||||
setEditingRole(role);
|
||||
setForm({
|
||||
code: role.code,
|
||||
description: role.description || "",
|
||||
name: role.name,
|
||||
permissionIds: role.permissions?.map((permission) => permission.id) || []
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const payload = parseForm(roleFormSchema, form);
|
||||
if (editingRole) {
|
||||
if (abilities.canUpdate) {
|
||||
await updateRole(editingRole.id, {
|
||||
code: payload.code,
|
||||
description: payload.description,
|
||||
name: payload.name
|
||||
});
|
||||
const roles = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
const items = data.roles || [];
|
||||
if (!keyword) {
|
||||
return items;
|
||||
}
|
||||
if (abilities.canGrant) {
|
||||
await replaceRolePermissions(editingRole.id, payload.permissionIds);
|
||||
}
|
||||
showToast("角色已更新", "success");
|
||||
} else {
|
||||
await createRole({
|
||||
code: payload.code,
|
||||
description: payload.description,
|
||||
name: payload.name,
|
||||
permissionIds: abilities.canGrant ? payload.permissionIds : []
|
||||
return items.filter((role) => {
|
||||
return [role.name, role.code, role.description].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
});
|
||||
showToast("角色已创建", "success");
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存角色失败", "error");
|
||||
}
|
||||
};
|
||||
}, [data.roles, query]);
|
||||
|
||||
const remove = async (role) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${role.name} 删除前需要先解绑用户。`,
|
||||
title: "删除角色",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteRole(role.id);
|
||||
showToast("角色已删除", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除角色失败", "error");
|
||||
}
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const togglePermission = (permissionId, checked) => {
|
||||
if (!abilities.canGrant) {
|
||||
return;
|
||||
}
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
permissionIds: checked
|
||||
? Array.from(new Set([...current.permissionIds, permissionId]))
|
||||
: current.permissionIds.filter((id) => id !== permissionId)
|
||||
}));
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const togglePermissionGroup = (permissionIds, checked) => {
|
||||
if (!abilities.canGrant) {
|
||||
return;
|
||||
}
|
||||
setForm((current) => {
|
||||
const targetIds = new Set(permissionIds);
|
||||
const openCreate = () => {
|
||||
setEditingRole(null);
|
||||
setForm(emptyRole);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
return {
|
||||
...current,
|
||||
permissionIds: checked
|
||||
? Array.from(new Set([...current.permissionIds, ...permissionIds]))
|
||||
: current.permissionIds.filter((id) => !targetIds.has(id))
|
||||
};
|
||||
});
|
||||
};
|
||||
const openEdit = (role) => {
|
||||
setEditingRole(role);
|
||||
setForm({
|
||||
code: role.code,
|
||||
description: role.description || "",
|
||||
name: role.name,
|
||||
permissionIds: role.permissions?.map((permission) => permission.id) || [],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeQuery,
|
||||
closeDrawer,
|
||||
drawerOpen,
|
||||
editingRole,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
openCreate,
|
||||
openEdit,
|
||||
permissionGroups,
|
||||
permissions: data.permissions || [],
|
||||
query,
|
||||
reload,
|
||||
remove,
|
||||
roles,
|
||||
setForm,
|
||||
submit,
|
||||
togglePermission,
|
||||
togglePermissionGroup
|
||||
};
|
||||
const closeDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const payload = parseForm(roleFormSchema, form);
|
||||
if (editingRole) {
|
||||
if (abilities.canUpdate) {
|
||||
await updateRole(editingRole.id, {
|
||||
code: payload.code,
|
||||
description: payload.description,
|
||||
name: payload.name,
|
||||
});
|
||||
}
|
||||
if (abilities.canGrant) {
|
||||
await replaceRolePermissions(editingRole.id, payload.permissionIds);
|
||||
}
|
||||
showToast("角色已更新", "success");
|
||||
} else {
|
||||
await createRole({
|
||||
code: payload.code,
|
||||
description: payload.description,
|
||||
name: payload.name,
|
||||
permissionIds: abilities.canGrant ? payload.permissionIds : [],
|
||||
});
|
||||
showToast("角色已创建", "success");
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存角色失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (role) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${role.name} 删除前需要先解绑用户。`,
|
||||
title: "删除角色",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteRole(role.id);
|
||||
showToast("角色已删除", "success");
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除角色失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (permissionId, checked) => {
|
||||
if (!abilities.canGrant) {
|
||||
return;
|
||||
}
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
permissionIds: checked
|
||||
? Array.from(new Set([...current.permissionIds, permissionId]))
|
||||
: current.permissionIds.filter((id) => id !== permissionId),
|
||||
}));
|
||||
};
|
||||
|
||||
const togglePermissionGroup = (permissionIds, checked) => {
|
||||
if (!abilities.canGrant) {
|
||||
return;
|
||||
}
|
||||
setForm((current) => {
|
||||
const targetIds = new Set(permissionIds);
|
||||
|
||||
return {
|
||||
...current,
|
||||
permissionIds: checked
|
||||
? Array.from(new Set([...current.permissionIds, ...permissionIds]))
|
||||
: current.permissionIds.filter((id) => !targetIds.has(id)),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeQuery,
|
||||
closeDrawer,
|
||||
drawerOpen,
|
||||
editingRole,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
openCreate,
|
||||
openEdit,
|
||||
permissionGroups,
|
||||
permissions: data.permissions || [],
|
||||
query,
|
||||
reload,
|
||||
remove,
|
||||
resetFilters,
|
||||
roles,
|
||||
setForm,
|
||||
submit,
|
||||
togglePermission,
|
||||
togglePermissionGroup,
|
||||
};
|
||||
}
|
||||
|
||||
function groupPermissionsByMenu(permissions) {
|
||||
const groups = new Map(permissionGroupDefinitions.map((definition) => [definition.key, { ...definition, items: [] }]));
|
||||
const prefixToGroup = new Map(
|
||||
permissionGroupDefinitions.flatMap((definition) => definition.prefixes.map((prefix) => [prefix, definition.key]))
|
||||
);
|
||||
const fallbackGroup = { ...fallbackPermissionGroup, items: [] };
|
||||
const groups = new Map(
|
||||
permissionGroupDefinitions.map((definition) => [definition.key, { ...definition, items: [] }]),
|
||||
);
|
||||
const prefixToGroup = new Map(
|
||||
permissionGroupDefinitions.flatMap((definition) =>
|
||||
definition.prefixes.map((prefix) => [prefix, definition.key]),
|
||||
),
|
||||
);
|
||||
const fallbackGroup = { ...fallbackPermissionGroup, items: [] };
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
const prefix = getPermissionPrefix(permission.code);
|
||||
const groupKey = prefixToGroup.get(prefix);
|
||||
const group = groupKey ? groups.get(groupKey) : fallbackGroup;
|
||||
group.items.push(permission);
|
||||
});
|
||||
permissions.forEach((permission) => {
|
||||
const prefix = getPermissionPrefix(permission.code);
|
||||
const groupKey = prefixToGroup.get(prefix);
|
||||
const group = groupKey ? groups.get(groupKey) : fallbackGroup;
|
||||
group.items.push(permission);
|
||||
});
|
||||
|
||||
return [...groups.values(), fallbackGroup]
|
||||
.filter((group) => group.items.length > 0)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: [...group.items].sort(comparePermissions)
|
||||
}));
|
||||
return [...groups.values(), fallbackGroup]
|
||||
.filter((group) => group.items.length > 0)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: [...group.items].sort(comparePermissions),
|
||||
}));
|
||||
}
|
||||
|
||||
function getPermissionPrefix(code = "") {
|
||||
return code.split(":")[0] || "other";
|
||||
return code.split(":")[0] || "other";
|
||||
}
|
||||
|
||||
function comparePermissions(left, right) {
|
||||
const kindDiff = (permissionKindOrder[left.kind] || 9) - (permissionKindOrder[right.kind] || 9);
|
||||
if (kindDiff !== 0) {
|
||||
return kindDiff;
|
||||
}
|
||||
return left.code.localeCompare(right.code);
|
||||
const kindDiff = (permissionKindOrder[left.kind] || 9) - (permissionKindOrder[right.kind] || 9);
|
||||
if (kindDiff !== 0) {
|
||||
return kindDiff;
|
||||
}
|
||||
return left.code.localeCompare(right.code);
|
||||
}
|
||||
|
||||
@ -1,53 +1,64 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { RoleFormDrawer } from "@/features/roles/components/RoleFormDrawer.jsx";
|
||||
import { RoleTable } from "@/features/roles/components/RoleTable.jsx";
|
||||
import { useRolesPage } from "@/features/roles/hooks/useRolesPage.js";
|
||||
|
||||
export function RoleManagementPage() {
|
||||
const page = useRolesPage();
|
||||
const page = useRolesPage();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索角色、编码..." />}
|
||||
actions={page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<RoleTable abilities={page.abilities} onEdit={page.openEdit} onRemove={page.remove} roles={page.roles} />
|
||||
<div className="pagination-bar">
|
||||
<span>{page.roles.length} 条</span>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<RoleTable
|
||||
abilities={page.abilities}
|
||||
query={page.query}
|
||||
onEdit={page.openEdit}
|
||||
onQueryChange={page.changeQuery}
|
||||
onRemove={page.remove}
|
||||
roles={page.roles}
|
||||
/>
|
||||
<div className="pagination-bar">
|
||||
<span>{page.roles.length} 条</span>
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
|
||||
<RoleFormDrawer
|
||||
abilities={page.abilities}
|
||||
editingRole={page.editingRole}
|
||||
form={page.form}
|
||||
onClose={page.closeDrawer}
|
||||
onSubmit={page.submit}
|
||||
onTogglePermissionGroup={page.togglePermissionGroup}
|
||||
onTogglePermission={page.togglePermission}
|
||||
open={page.drawerOpen}
|
||||
permissionGroups={page.permissionGroups}
|
||||
setForm={page.setForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<RoleFormDrawer
|
||||
abilities={page.abilities}
|
||||
editingRole={page.editingRole}
|
||||
form={page.form}
|
||||
onClose={page.closeDrawer}
|
||||
onSubmit={page.submit}
|
||||
onTogglePermissionGroup={page.togglePermissionGroup}
|
||||
onTogglePermission={page.togglePermission}
|
||||
open={page.drawerOpen}
|
||||
permissionGroups={page.permissionGroups}
|
||||
setForm={page.setForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
120
src/features/rooms/components/RoomDetailDrawer.jsx
Normal file
120
src/features/rooms/components/RoomDetailDrawer.jsx
Normal 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}` : "-";
|
||||
}
|
||||
@ -11,166 +11,185 @@ import { roomStatusSchema, roomUpdateSchema } from "@/features/rooms/schema";
|
||||
const pageSize = 10;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyForm = () => ({
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
title: "",
|
||||
visibleRegionId: 0
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
title: "",
|
||||
visibleRegionId: 0,
|
||||
});
|
||||
const emptyStatusForm = () => ({ status: "active" });
|
||||
|
||||
export function useRoomsPage() {
|
||||
const abilities = useRoomAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeRoom, setActiveRoom] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [statusForm, setStatusForm] = useState(emptyStatusForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status
|
||||
}),
|
||||
[query, regionId, status]
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
errorMessage: "加载房间列表失败",
|
||||
fetcher: listRooms,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["rooms", filters, page]
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openEdit = (room) => {
|
||||
setActiveRoom(room);
|
||||
setForm({
|
||||
coverUrl: room.coverUrl || "",
|
||||
description: "",
|
||||
title: room.title || "",
|
||||
visibleRegionId: room.visibleRegionId || 0
|
||||
const abilities = useRoomAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeRoom, setActiveRoom] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [statusForm, setStatusForm] = useState(emptyStatusForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status,
|
||||
}),
|
||||
[query, regionId, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载房间列表失败",
|
||||
fetcher: listRooms,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["rooms", filters, page],
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const openStatus = (room) => {
|
||||
setActiveRoom(room);
|
||||
setStatusForm({ status: room.status === "closed" ? "closed" : "active" });
|
||||
setActiveAction("status");
|
||||
};
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setActiveRoom(null);
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitEdit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeRoom) {
|
||||
return;
|
||||
}
|
||||
await runAction("edit", "房间已更新", async () => {
|
||||
const payload = parseForm(roomUpdateSchema, form);
|
||||
if (!String(form.description || "").trim()) {
|
||||
delete payload.description;
|
||||
}
|
||||
await updateRoom(activeRoom.roomId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const changeRegionId = (value) => {
|
||||
setRegionId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitStatus = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeRoom) {
|
||||
return;
|
||||
}
|
||||
await runAction("status", "房间状态已更新", async () => {
|
||||
const payload = parseForm(roomStatusSchema, statusForm);
|
||||
await updateRoom(activeRoom.roomId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const removeRoom = async (room) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${room.title || room.roomId} 会从房间列表和房间状态中删除。`,
|
||||
title: "删除房间",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await runAction(`delete-${room.roomId}`, "房间已删除", async () => {
|
||||
await deleteRoom(room.roomId);
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
const openEdit = (room) => {
|
||||
setActiveRoom(room);
|
||||
setForm({
|
||||
coverUrl: room.coverUrl || "",
|
||||
description: "",
|
||||
title: room.title || "",
|
||||
visibleRegionId: room.visibleRegionId || 0,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
const openDetail = (room) => {
|
||||
setActiveRoom(room);
|
||||
setActiveAction("detail");
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
activeRoom,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
data,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openEdit,
|
||||
openStatus,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
removeRoom,
|
||||
setForm,
|
||||
setPage,
|
||||
setStatusForm,
|
||||
status,
|
||||
statusForm,
|
||||
submitStatus,
|
||||
submitEdit
|
||||
};
|
||||
const openStatus = (room) => {
|
||||
setActiveRoom(room);
|
||||
setStatusForm({ status: room.status === "closed" ? "closed" : "active" });
|
||||
setActiveAction("status");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setActiveRoom(null);
|
||||
};
|
||||
|
||||
const submitEdit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeRoom) {
|
||||
return;
|
||||
}
|
||||
await runAction("edit", "房间已更新", async () => {
|
||||
const payload = parseForm(roomUpdateSchema, form);
|
||||
if (!String(form.description || "").trim()) {
|
||||
delete payload.description;
|
||||
}
|
||||
await updateRoom(activeRoom.roomId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitStatus = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeRoom) {
|
||||
return;
|
||||
}
|
||||
await runAction("status", "房间状态已更新", async () => {
|
||||
const payload = parseForm(roomStatusSchema, statusForm);
|
||||
await updateRoom(activeRoom.roomId, payload);
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const removeRoom = async (room) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${room.title || room.roomId} 会从房间列表和房间状态中删除。`,
|
||||
title: "删除房间",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await runAction(`delete-${room.roomId}`, "房间已删除", async () => {
|
||||
await deleteRoom(room.roomId);
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
activeRoom,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
data,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openDetail,
|
||||
openEdit,
|
||||
openStatus,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
removeRoom,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setPage,
|
||||
setStatusForm,
|
||||
status,
|
||||
statusForm,
|
||||
submitStatus,
|
||||
submitEdit,
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,242 +10,319 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.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 { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { RoomDetailDrawer } from "@/features/rooms/components/RoomDetailDrawer.jsx";
|
||||
import { roomStatusFilters, roomStatusLabels } from "@/features/rooms/constants.js";
|
||||
import { useRoomsPage } from "@/features/rooms/hooks/useRoomsPage.js";
|
||||
import styles from "@/features/rooms/rooms.module.css";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "identity",
|
||||
label: "房间",
|
||||
width: "minmax(240px, 1.5fr)",
|
||||
render: (room) => <RoomIdentity room={room} />
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
label: "房主",
|
||||
width: "minmax(210px, 1.2fr)",
|
||||
render: (room) => <OwnerIdentity room={room} />
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "minmax(140px, 0.8fr)",
|
||||
render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-")
|
||||
},
|
||||
{
|
||||
key: "onlineCount",
|
||||
label: "房间人数",
|
||||
width: "minmax(110px, 0.7fr)",
|
||||
render: (room) => `${formatNumber(room.onlineCount)} 人`
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(100px, 0.7fr)",
|
||||
render: (room) => <RoomStatus status={room.status} />
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "创建时间",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (room) => formatMillis(room.createdAtMs)
|
||||
}
|
||||
{
|
||||
key: "identity",
|
||||
label: "房间",
|
||||
width: "minmax(240px, 1.5fr)",
|
||||
render: (room) => <RoomIdentity room={room} />,
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
label: "房主",
|
||||
width: "minmax(210px, 1.2fr)",
|
||||
render: (room) => <OwnerIdentity room={room} />,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "minmax(140px, 0.8fr)",
|
||||
render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-"),
|
||||
},
|
||||
{
|
||||
key: "onlineCount",
|
||||
label: "房间人数",
|
||||
width: "minmax(110px, 0.7fr)",
|
||||
render: (room) => `${formatNumber(room.onlineCount)} 人`,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(100px, 0.7fr)",
|
||||
render: (room) => <RoomStatus status={room.status} />,
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "创建时间",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (room) => formatMillis(room.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
export function RoomListPage() {
|
||||
const page = useRoomsPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const tableColumns = [
|
||||
...columns.map((column) =>
|
||||
column.key === "region"
|
||||
? {
|
||||
...column,
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})
|
||||
}
|
||||
: column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
render: (room) => (
|
||||
<RoomStatus disabled={!page.abilities.canUpdate} status={room.status} onClick={() => page.openStatus(room)} />
|
||||
)
|
||||
}
|
||||
: column
|
||||
),
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(104px, 0.6fr)",
|
||||
render: (room) => <RoomActions page={page} room={room} />
|
||||
}
|
||||
];
|
||||
const page = useRoomsPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const tableColumns = [
|
||||
...columns.map((column) =>
|
||||
column.key === "region"
|
||||
? {
|
||||
...column,
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
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,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: roomStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (room) => (
|
||||
<RoomStatus
|
||||
disabled={!page.abilities.canUpdate}
|
||||
status={room.status}
|
||||
onClick={() => page.openStatus(room)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: column,
|
||||
),
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(104px, 0.6fr)",
|
||||
render: (room) => <RoomActions page={page} room={room} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<section className={styles.filters}>
|
||||
<SearchBox className={styles.search} value={page.query} onChange={page.changeQuery} placeholder="搜索房间、房主..." />
|
||||
<StatusSelect
|
||||
className={styles.statusSelect}
|
||||
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>
|
||||
</div>
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<div className={styles.toolbar}>
|
||||
<section className={styles.filters}>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status && !page.regionId}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable 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>
|
||||
</DataState>
|
||||
</div>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<DataTable
|
||||
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>
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "edit"}
|
||||
open={page.activeAction === "edit"}
|
||||
title="编辑房间"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEdit}
|
||||
>
|
||||
<TextField disabled={!page.abilities.canUpdate} label="房间名称" 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>
|
||||
<RoomDetailDrawer open={page.activeAction === "detail"} room={page.activeRoom} onClose={page.closeAction} />
|
||||
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "status"}
|
||||
open={page.activeAction === "status"}
|
||||
title="房间状态"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitStatus}
|
||||
>
|
||||
<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="closed">关闭</MenuItem>
|
||||
</TextField>
|
||||
</ActionModal>
|
||||
</section>
|
||||
);
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "edit"}
|
||||
open={page.activeAction === "edit"}
|
||||
title="编辑房间"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEdit}
|
||||
>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="房间名称"
|
||||
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
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "status"}
|
||||
open={page.activeAction === "status"}
|
||||
title="房间状态"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitStatus}
|
||||
>
|
||||
<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="closed">关闭</MenuItem>
|
||||
</TextField>
|
||||
</ActionModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomIdentity({ room }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.cover}>
|
||||
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.name}>{room.title || "-"}</div>
|
||||
<div className={styles.meta}>{room.roomId}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function RoomIdentity({ onClick, room }) {
|
||||
const content = (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.cover}>
|
||||
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.name}>{room.title || "-"}</div>
|
||||
<div className={styles.meta}>{room.roomId}</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 }) {
|
||||
const owner = room.owner || {};
|
||||
const ownerName = owner.username || "-";
|
||||
const ownerDisplayId = owner.displayUserId || room.ownerUserId || "-";
|
||||
const owner = room.owner || {};
|
||||
const ownerName = owner.username || "-";
|
||||
const ownerDisplayId = owner.displayUserId || room.ownerUserId || "-";
|
||||
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.ownerAvatar}>
|
||||
{owner.avatar ? <img src={owner.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.name}>{ownerName}</div>
|
||||
<div className={styles.meta}>{ownerDisplayId}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.ownerAvatar}>
|
||||
{owner.avatar ? <img src={owner.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.identityText}>
|
||||
<div className={styles.name}>{ownerName}</div>
|
||||
<div className={styles.meta}>{ownerDisplayId}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomActions({ page, room }) {
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Tooltip arrow title="编辑">
|
||||
<span>
|
||||
<IconButton label="编辑" onClick={() => page.openEdit(room)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{page.abilities.canDelete ? (
|
||||
<Tooltip arrow title="删除">
|
||||
<span>
|
||||
<IconButton disabled={page.loadingAction === `delete-${room.roomId}`} label="删除" tone="danger" onClick={() => page.removeRoom(room)}>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Tooltip arrow title="编辑">
|
||||
<span>
|
||||
<IconButton label="编辑" onClick={() => page.openEdit(room)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{page.abilities.canDelete ? (
|
||||
<Tooltip arrow title="删除">
|
||||
<span>
|
||||
<IconButton
|
||||
disabled={page.loadingAction === `delete-${room.roomId}`}
|
||||
label="删除"
|
||||
tone="danger"
|
||||
onClick={() => page.removeRoom(room)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomStatus({ disabled, onClick, status }) {
|
||||
const normalizedStatus = status === "active" ? "active" : "closed";
|
||||
const tone = normalizedStatus === "active" ? "running" : "stopped";
|
||||
const badge = (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{roomStatusLabels[normalizedStatus]}
|
||||
</span>
|
||||
);
|
||||
if (disabled) {
|
||||
return badge;
|
||||
}
|
||||
return (
|
||||
<button className={styles.statusButton} type="button" onClick={onClick}>
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
const normalizedStatus = status === "active" ? "active" : "closed";
|
||||
const tone = normalizedStatus === "active" ? "running" : "stopped";
|
||||
const badge = (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{roomStatusLabels[normalizedStatus]}
|
||||
</span>
|
||||
);
|
||||
if (disabled) {
|
||||
return badge;
|
||||
}
|
||||
return (
|
||||
<button className={styles.statusButton} type="button" onClick={onClick}>
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, title }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="compact"
|
||||
submitDisabled={disabled || loading}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{children}
|
||||
</AdminFormDialog>
|
||||
);
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="compact"
|
||||
submitDisabled={disabled || loading}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{children}
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
@ -78,6 +78,34 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
@ -170,6 +198,156 @@
|
||||
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) {
|
||||
.toolbar,
|
||||
.filters {
|
||||
@ -183,4 +361,8 @@
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,14 +2,18 @@ import Dialog from "@mui/material/Dialog";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { searchGlobal } from "@/features/search/api";
|
||||
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 [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const menuResults = useMemo(() => searchMenus(menuItems, keyword), [keyword, menuItems]);
|
||||
const displayResults = useMemo(() => [...menuResults, ...results], [menuResults, results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -53,11 +57,11 @@ export function SearchDialog({ onClose, onSelect, open }) {
|
||||
slotProps={{ paper: { className: "search-dialog" } }}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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)}>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
@ -69,8 +73,61 @@ export function SearchDialog({ onClose, onSelect, open }) {
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
{!results.length ? <div className="search-dialog__empty">{loading ? "搜索中..." : "暂无匹配结果"}</div> : null}
|
||||
{!displayResults.length ? <div className="search-dialog__empty">{loading ? "搜索中..." : "暂无匹配结果"}</div> : null}
|
||||
</List>
|
||||
</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();
|
||||
}
|
||||
|
||||
42
src/features/search/components/SearchDialog.test.jsx
Normal file
42
src/features/search/components/SearchDialog.test.jsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { UserStatusBadge } from "./UserStatusBadge.jsx";
|
||||
|
||||
export function UserTable({
|
||||
@ -16,11 +17,19 @@ export function UserTable({
|
||||
canStatus,
|
||||
onEdit,
|
||||
onOpenProfile = () => {},
|
||||
onQueryChange,
|
||||
onResetPassword,
|
||||
onRoleFilterChange,
|
||||
onStatusFilterChange,
|
||||
onToggleDisabled,
|
||||
onToggleLocked,
|
||||
query,
|
||||
roleFilter,
|
||||
roleFilters = [],
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
statusFilter,
|
||||
statusFilters = [],
|
||||
users
|
||||
}) {
|
||||
const allChecked = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
|
||||
@ -51,28 +60,55 @@ export function UserTable({
|
||||
key: "user",
|
||||
label: "用户",
|
||||
width: "minmax(180px, 1.4fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户、账号、角色、团队",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
}),
|
||||
render: (user) => (
|
||||
<div className="user-main">
|
||||
<button
|
||||
aria-label={`查看 ${user.name || user.account || user.id} 信息`}
|
||||
className="user-avatar-button"
|
||||
type="button"
|
||||
onClick={() => onOpenProfile(user)}
|
||||
>
|
||||
<button
|
||||
aria-label={`查看 ${user.name || user.account || user.id} 信息`}
|
||||
className="user-main-button"
|
||||
type="button"
|
||||
onClick={() => onOpenProfile(user)}
|
||||
>
|
||||
<span className="user-main">
|
||||
<span className="user-avatar">
|
||||
<Person fontSize="small" />
|
||||
</span>
|
||||
</button>
|
||||
<div>
|
||||
<div className="service-name">{user.name}</div>
|
||||
<div className="service-desc">{user.account}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="user-main__text">
|
||||
<span className="service-name">{user.name}</span>
|
||||
<span className="service-desc">{user.account}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
{ 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: "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: "lastLogin", label: "最后登录", width: "minmax(120px, 0.9fr)", className: "muted" },
|
||||
{
|
||||
|
||||
@ -14,7 +14,7 @@ const baseUser = {
|
||||
team: "平台"
|
||||
};
|
||||
|
||||
test("opens user profile from avatar", async () => {
|
||||
test("opens user profile from user identity", async () => {
|
||||
const onOpenProfile = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@ -8,13 +8,13 @@ import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listRoles } from "@/features/roles/api";
|
||||
import {
|
||||
batchUpdateUserStatus,
|
||||
createUser,
|
||||
exportUsers,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus
|
||||
batchUpdateUserStatus,
|
||||
createUser,
|
||||
exportUsers,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus,
|
||||
} from "@/features/users/api";
|
||||
import { useUserAbilities } from "@/features/users/permissions.js";
|
||||
import { userCreateFormSchema, userUpdateFormSchema } from "@/features/users/schema";
|
||||
@ -24,245 +24,253 @@ const emptyUsers = { items: [], total: 0, page: 1, pageSize };
|
||||
const emptyData = { roles: [], users: emptyUsers };
|
||||
|
||||
const emptyForm = {
|
||||
mfaEnabled: false,
|
||||
name: "",
|
||||
password: "",
|
||||
roleIds: [],
|
||||
status: "active",
|
||||
team: "",
|
||||
username: ""
|
||||
mfaEnabled: false,
|
||||
name: "",
|
||||
password: "",
|
||||
roleIds: [],
|
||||
status: "active",
|
||||
team: "",
|
||||
username: "",
|
||||
};
|
||||
|
||||
export function useUsersPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [profileUser, setProfileUser] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const abilities = useUserAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [profileUser, setProfileUser] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const abilities = useUserAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => toPageQuery({ keyword: query, page, pageSize, role: roleFilter, status: statusFilter }),
|
||||
[page, query, roleFilter, statusFilter]
|
||||
);
|
||||
|
||||
const queryFn = useCallback(async () => {
|
||||
const [userData, roleData] = await Promise.all([
|
||||
listUsers(requestQuery),
|
||||
abilities.canViewRoles ? listRoles() : []
|
||||
]);
|
||||
setSelectedIds([]);
|
||||
return { roles: roleData || [], users: userData || emptyUsers };
|
||||
}, [abilities.canViewRoles, requestQuery]);
|
||||
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载用户失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["users", requestQuery, abilities.canViewRoles]
|
||||
});
|
||||
|
||||
const users = data?.users || emptyUsers;
|
||||
const roles = useMemo(() => data?.roles || [], [data?.roles]);
|
||||
|
||||
const roleFilters = useMemo(() => [["", "全部角色"], ...roles.map((role) => [role.name, role.name])], [roles]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const items = users.items || [];
|
||||
const active = items.filter((user) => user.status === "active").length;
|
||||
const locked = items.filter((user) => user.status === "locked").length;
|
||||
const admin = items.filter((user) => String(user.role).includes("管理员")).length;
|
||||
return { active, admin, locked };
|
||||
}, [users.items]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRoleFilter = (value) => {
|
||||
setRoleFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatusFilter = (value) => {
|
||||
setStatusFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm(emptyForm);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (user) => {
|
||||
setEditingUser(user);
|
||||
setForm({
|
||||
mfaEnabled: user.mfaEnabled,
|
||||
name: user.name,
|
||||
password: "",
|
||||
roleIds: user.roleIds || [],
|
||||
status: user.status,
|
||||
team: user.team || "",
|
||||
username: user.account
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
};
|
||||
|
||||
const openProfile = (user) => {
|
||||
setProfileUser(user);
|
||||
};
|
||||
|
||||
const closeProfile = () => {
|
||||
setProfileUser(null);
|
||||
};
|
||||
|
||||
const submitUser = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
const payload = parseForm(userUpdateFormSchema, form);
|
||||
await updateUser(editingUser.id, payload);
|
||||
showToast("用户已更新", "success");
|
||||
} else {
|
||||
const payload = parseForm(userCreateFormSchema, form);
|
||||
const result = await createUser(payload);
|
||||
showToast(`用户已创建,初始密码:${result.initialPassword}`, "success");
|
||||
}
|
||||
setFormOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存用户失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLocked = async (user) => {
|
||||
const nextStatus = user.status === "locked" ? "active" : "locked";
|
||||
const ok = await confirm({
|
||||
confirmText: user.status === "locked" ? "解锁" : "锁定",
|
||||
message: `${user.name} 的登录状态会立即变更。`,
|
||||
title: user.status === "locked" ? "解锁用户" : "锁定用户",
|
||||
tone: user.status === "locked" ? "primary" : "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await updateUserStatus(user.id, nextStatus);
|
||||
showToast("用户状态已更新", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const toggleDisabled = async (user) => {
|
||||
const nextStatus = user.status === "disabled" ? "active" : "disabled";
|
||||
const ok = await confirm({
|
||||
confirmText: user.status === "disabled" ? "启用" : "停用",
|
||||
message: `${user.name} 的账号状态会立即变更。`,
|
||||
title: user.status === "disabled" ? "启用用户" : "停用用户",
|
||||
tone: user.status === "disabled" ? "primary" : "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await updateUserStatus(user.id, nextStatus);
|
||||
showToast("用户状态已更新", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const resetPassword = async (user) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "重置",
|
||||
message: `${user.name} 的密码会重置为系统生成的初始密码。`,
|
||||
title: "重置密码",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
const result = await resetUserPassword(user.id);
|
||||
showToast(`已重置,初始密码:${result.initialPassword}`, "success");
|
||||
};
|
||||
|
||||
const batchStatus = async (status) => {
|
||||
if (!selectedIds.length) {
|
||||
showToast("请先选择用户", "warning");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
confirmText: "确认",
|
||||
message: `将更新 ${selectedIds.length} 个用户状态。`,
|
||||
title: "批量更新状态",
|
||||
tone: status === "active" ? "primary" : "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await batchUpdateUserStatus(selectedIds, status);
|
||||
showToast("批量状态已更新", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const downloadUsers = () => {
|
||||
return downloadCsv(
|
||||
() => exportUsers({ keyword: query, role: roleFilter, status: statusFilter }),
|
||||
"hyapp-users.csv"
|
||||
const requestQuery = useMemo(
|
||||
() => toPageQuery({ keyword: query, page, pageSize, role: roleFilter, status: statusFilter }),
|
||||
[page, query, roleFilter, statusFilter],
|
||||
);
|
||||
};
|
||||
|
||||
const setRoleChecked = (roleId, checked) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId)
|
||||
}));
|
||||
};
|
||||
const queryFn = useCallback(async () => {
|
||||
const [userData, roleData] = await Promise.all([
|
||||
listUsers(requestQuery),
|
||||
abilities.canViewRoles ? listRoles() : [],
|
||||
]);
|
||||
setSelectedIds([]);
|
||||
return { roles: roleData || [], users: userData || emptyUsers };
|
||||
}, [abilities.canViewRoles, requestQuery]);
|
||||
|
||||
return {
|
||||
abilities,
|
||||
batchStatus,
|
||||
changeQuery,
|
||||
changeRoleFilter,
|
||||
changeStatusFilter,
|
||||
closeForm,
|
||||
closeProfile,
|
||||
downloadUsers,
|
||||
editingUser,
|
||||
error,
|
||||
form,
|
||||
formOpen,
|
||||
loading,
|
||||
navigate,
|
||||
openCreate,
|
||||
openEdit,
|
||||
openProfile,
|
||||
page,
|
||||
profileUser,
|
||||
query,
|
||||
reload,
|
||||
resetPassword,
|
||||
roleFilter,
|
||||
roleFilters,
|
||||
roles,
|
||||
selectedIds,
|
||||
setForm,
|
||||
setPage,
|
||||
setRoleChecked,
|
||||
setSelectedIds,
|
||||
stats,
|
||||
statusFilter,
|
||||
submitUser,
|
||||
toggleDisabled,
|
||||
toggleLocked,
|
||||
users
|
||||
};
|
||||
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载用户失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["users", requestQuery, abilities.canViewRoles],
|
||||
});
|
||||
|
||||
const users = data?.users || emptyUsers;
|
||||
const roles = useMemo(() => data?.roles || [], [data?.roles]);
|
||||
|
||||
const roleFilters = useMemo(() => [["", "全部角色"], ...roles.map((role) => [role.name, role.name])], [roles]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const items = users.items || [];
|
||||
const active = items.filter((user) => user.status === "active").length;
|
||||
const locked = items.filter((user) => user.status === "locked").length;
|
||||
const admin = items.filter((user) => String(user.role).includes("管理员")).length;
|
||||
return { active, admin, locked };
|
||||
}, [users.items]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeRoleFilter = (value) => {
|
||||
setRoleFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatusFilter = (value) => {
|
||||
setStatusFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setRoleFilter("");
|
||||
setStatusFilter("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm(emptyForm);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (user) => {
|
||||
setEditingUser(user);
|
||||
setForm({
|
||||
mfaEnabled: user.mfaEnabled,
|
||||
name: user.name,
|
||||
password: "",
|
||||
roleIds: user.roleIds || [],
|
||||
status: user.status,
|
||||
team: user.team || "",
|
||||
username: user.account,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
};
|
||||
|
||||
const openProfile = (user) => {
|
||||
setProfileUser(user);
|
||||
};
|
||||
|
||||
const closeProfile = () => {
|
||||
setProfileUser(null);
|
||||
};
|
||||
|
||||
const submitUser = async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
const payload = parseForm(userUpdateFormSchema, form);
|
||||
await updateUser(editingUser.id, payload);
|
||||
showToast("用户已更新", "success");
|
||||
} else {
|
||||
const payload = parseForm(userCreateFormSchema, form);
|
||||
const result = await createUser(payload);
|
||||
showToast(`用户已创建,初始密码:${result.initialPassword}`, "success");
|
||||
}
|
||||
setFormOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存用户失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLocked = async (user) => {
|
||||
const nextStatus = user.status === "locked" ? "active" : "locked";
|
||||
const ok = await confirm({
|
||||
confirmText: user.status === "locked" ? "解锁" : "锁定",
|
||||
message: `${user.name} 的登录状态会立即变更。`,
|
||||
title: user.status === "locked" ? "解锁用户" : "锁定用户",
|
||||
tone: user.status === "locked" ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await updateUserStatus(user.id, nextStatus);
|
||||
showToast("用户状态已更新", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const toggleDisabled = async (user) => {
|
||||
const nextStatus = user.status === "disabled" ? "active" : "disabled";
|
||||
const ok = await confirm({
|
||||
confirmText: user.status === "disabled" ? "启用" : "停用",
|
||||
message: `${user.name} 的账号状态会立即变更。`,
|
||||
title: user.status === "disabled" ? "启用用户" : "停用用户",
|
||||
tone: user.status === "disabled" ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await updateUserStatus(user.id, nextStatus);
|
||||
showToast("用户状态已更新", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const resetPassword = async (user) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "重置",
|
||||
message: `${user.name} 的密码会重置为系统生成的初始密码。`,
|
||||
title: "重置密码",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
const result = await resetUserPassword(user.id);
|
||||
showToast(`已重置,初始密码:${result.initialPassword}`, "success");
|
||||
};
|
||||
|
||||
const batchStatus = async (status) => {
|
||||
if (!selectedIds.length) {
|
||||
showToast("请先选择用户", "warning");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
confirmText: "确认",
|
||||
message: `将更新 ${selectedIds.length} 个用户状态。`,
|
||||
title: "批量更新状态",
|
||||
tone: status === "active" ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await batchUpdateUserStatus(selectedIds, status);
|
||||
showToast("批量状态已更新", "success");
|
||||
reload();
|
||||
};
|
||||
|
||||
const downloadUsers = () => {
|
||||
return downloadCsv(
|
||||
() => exportUsers({ keyword: query, role: roleFilter, status: statusFilter }),
|
||||
"hyapp-users.csv",
|
||||
);
|
||||
};
|
||||
|
||||
const setRoleChecked = (roleId, checked) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
batchStatus,
|
||||
changeQuery,
|
||||
changeRoleFilter,
|
||||
changeStatusFilter,
|
||||
closeForm,
|
||||
closeProfile,
|
||||
downloadUsers,
|
||||
editingUser,
|
||||
error,
|
||||
form,
|
||||
formOpen,
|
||||
loading,
|
||||
navigate,
|
||||
openCreate,
|
||||
openEdit,
|
||||
openProfile,
|
||||
page,
|
||||
profileUser,
|
||||
query,
|
||||
reload,
|
||||
resetPassword,
|
||||
resetFilters,
|
||||
roleFilter,
|
||||
roleFilters,
|
||||
roles,
|
||||
selectedIds,
|
||||
setForm,
|
||||
setPage,
|
||||
setRoleChecked,
|
||||
setSelectedIds,
|
||||
stats,
|
||||
statusFilter,
|
||||
submitUser,
|
||||
toggleDisabled,
|
||||
toggleLocked,
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,12 +6,11 @@ import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { UserFormDrawer } from "@/features/users/components/UserFormDrawer.jsx";
|
||||
import { UserProfileDrawer } from "@/features/users/components/UserProfileDrawer.jsx";
|
||||
@ -20,85 +19,97 @@ import { useUsersPage } from "@/features/users/hooks/useUsersPage.js";
|
||||
import { userStatusFilters } from "@/features/users/constants.js";
|
||||
|
||||
export function UserManagementPage() {
|
||||
const page = useUsersPage();
|
||||
const page = useUsersPage();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={(
|
||||
<>
|
||||
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索用户、账号、角色、团队..." />
|
||||
<AdminFilterSelect label="角色" options={page.roleFilters} value={page.roleFilter} onChange={page.changeRoleFilter} />
|
||||
<AdminFilterSelect options={userStatusFilters} value={page.statusFilter} onChange={page.changeStatusFilter} />
|
||||
</>
|
||||
)}
|
||||
actions={(
|
||||
<>
|
||||
{page.abilities.canViewRoles ? (
|
||||
<AdminActionIconButton label="角色配置" onClick={() => page.navigate("/system/roles")}>
|
||||
<ShieldOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canStatus ? (
|
||||
<>
|
||||
<AdminActionIconButton label="批量启用" onClick={() => page.batchStatus("active")}>
|
||||
<VerifiedUserOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton label="批量停用" tone="danger" onClick={() => page.batchStatus("disabled")}>
|
||||
<PersonRemoveOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{page.abilities.canExport ? (
|
||||
<AdminActionIconButton label="导出" onClick={page.downloadUsers}>
|
||||
<DownloadOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="新增用户" primary onClick={page.openCreate}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.roleFilter && !page.statusFilter}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{page.abilities.canViewRoles ? (
|
||||
<AdminActionIconButton label="角色配置" onClick={() => page.navigate("/system/roles")}>
|
||||
<ShieldOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canStatus ? (
|
||||
<>
|
||||
<AdminActionIconButton label="批量启用" onClick={() => page.batchStatus("active")}>
|
||||
<VerifiedUserOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
label="批量停用"
|
||||
tone="danger"
|
||||
onClick={() => page.batchStatus("disabled")}
|
||||
>
|
||||
<PersonRemoveOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{page.abilities.canExport ? (
|
||||
<AdminActionIconButton label="导出" onClick={page.downloadUsers}>
|
||||
<DownloadOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="新增用户" primary onClick={page.openCreate}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<UserTable
|
||||
canEdit={page.abilities.canEdit}
|
||||
canResetPassword={page.abilities.canResetPassword}
|
||||
canStatus={page.abilities.canStatus}
|
||||
onEdit={page.openEdit}
|
||||
onOpenProfile={page.openProfile}
|
||||
onResetPassword={page.resetPassword}
|
||||
onToggleDisabled={page.toggleDisabled}
|
||||
onToggleLocked={page.toggleLocked}
|
||||
selectedIds={page.selectedIds}
|
||||
setSelectedIds={page.setSelectedIds}
|
||||
users={page.users.items || []}
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<UserTable
|
||||
canEdit={page.abilities.canEdit}
|
||||
canResetPassword={page.abilities.canResetPassword}
|
||||
canStatus={page.abilities.canStatus}
|
||||
onEdit={page.openEdit}
|
||||
onOpenProfile={page.openProfile}
|
||||
onResetPassword={page.resetPassword}
|
||||
onToggleDisabled={page.toggleDisabled}
|
||||
onToggleLocked={page.toggleLocked}
|
||||
query={page.query}
|
||||
roleFilter={page.roleFilter}
|
||||
roleFilters={page.roleFilters}
|
||||
selectedIds={page.selectedIds}
|
||||
setSelectedIds={page.setSelectedIds}
|
||||
statusFilter={page.statusFilter}
|
||||
statusFilters={userStatusFilters}
|
||||
onQueryChange={page.changeQuery}
|
||||
onRoleFilterChange={page.changeRoleFilter}
|
||||
onStatusFilterChange={page.changeStatusFilter}
|
||||
users={page.users.items || []}
|
||||
/>
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.users.pageSize || 10}
|
||||
total={page.users.total || 0}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
|
||||
<UserFormDrawer
|
||||
editingUser={page.editingUser}
|
||||
form={page.form}
|
||||
onClose={page.closeForm}
|
||||
onRoleChecked={page.setRoleChecked}
|
||||
onSubmit={page.submitUser}
|
||||
open={page.formOpen}
|
||||
roles={page.roles}
|
||||
setForm={page.setForm}
|
||||
/>
|
||||
<PaginationBar page={page.page} pageSize={page.users.pageSize || 10} total={page.users.total || 0} onPageChange={page.setPage} />
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
|
||||
<UserFormDrawer
|
||||
editingUser={page.editingUser}
|
||||
form={page.form}
|
||||
onClose={page.closeForm}
|
||||
onRoleChecked={page.setRoleChecked}
|
||||
onSubmit={page.submitUser}
|
||||
open={page.formOpen}
|
||||
roles={page.roles}
|
||||
setForm={page.setForm}
|
||||
/>
|
||||
<UserProfileDrawer
|
||||
onClose={page.closeProfile}
|
||||
open={Boolean(page.profileUser)}
|
||||
user={page.profileUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<UserProfileDrawer onClose={page.closeProfile} open={Boolean(page.profileUser)} user={page.profileUser} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const usersRoutes = [
|
||||
{
|
||||
label: "用户管理",
|
||||
label: "后台用户",
|
||||
loader: () => import("./pages/UserManagementPage.jsx").then((module) => module.UserManagementPage),
|
||||
menuCode: MENU_CODES.systemUsers,
|
||||
pageKey: "users",
|
||||
|
||||
@ -19,6 +19,59 @@
|
||||
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 {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
@ -33,24 +86,6 @@
|
||||
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 {
|
||||
background: var(--primary-surface-strong);
|
||||
transform: scale(1.08);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
30
src/shared/api/generated/schema.d.ts
vendored
30
src/shared/api/generated/schema.d.ts
vendored
@ -212,6 +212,22 @@ export interface paths {
|
||||
patch: operations["setCoinSellerStatus"];
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2103,6 +2119,20 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
creditCoinSellerStock: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
user_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listCountries: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -217,14 +217,25 @@ export interface RechargeBillDto {
|
||||
}
|
||||
|
||||
export interface BDProfileDto {
|
||||
avatar?: string;
|
||||
createdAtMs?: number;
|
||||
createdByAvatar?: string;
|
||||
createdByDisplayUserId?: string;
|
||||
createdByUsername?: string;
|
||||
createdByUserId?: number;
|
||||
displayUserId?: string;
|
||||
parentLeaderUserId?: number;
|
||||
parentLeaderDisplayUserId?: string;
|
||||
parentLeaderUsername?: string;
|
||||
parentLeaderAvatar?: string;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
subBdCount?: number;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface AgencyDto {
|
||||
@ -234,27 +245,40 @@ export interface AgencyDto {
|
||||
joinEnabled?: boolean;
|
||||
maxHosts?: number;
|
||||
name?: string;
|
||||
ownerAvatar?: string;
|
||||
ownerDisplayUserId?: string;
|
||||
ownerUsername?: string;
|
||||
ownerUserId?: number;
|
||||
parentBdAvatar?: string;
|
||||
parentBdDisplayUserId?: string;
|
||||
parentBdUsername?: string;
|
||||
parentBdUserId?: number;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface HostProfileDto {
|
||||
avatar?: string;
|
||||
createdAtMs?: number;
|
||||
currentAgencyId?: number;
|
||||
currentAgencyName?: string;
|
||||
currentMembershipId?: number;
|
||||
displayUserId?: string;
|
||||
firstBecameHostAtMs?: number;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface CoinSellerDto {
|
||||
avatar?: string;
|
||||
contact?: string;
|
||||
createdAtMs?: number;
|
||||
createdByUserId?: number;
|
||||
displayUserId?: string;
|
||||
@ -268,6 +292,26 @@ export interface CoinSellerDto {
|
||||
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 {
|
||||
avatar?: string;
|
||||
coin?: number;
|
||||
@ -440,10 +484,13 @@ export interface BDStatusPayload extends HostCommandPayload {
|
||||
}
|
||||
|
||||
export interface CreateCoinSellerPayload extends HostCommandPayload {
|
||||
contact?: string;
|
||||
targetUserId: number;
|
||||
}
|
||||
|
||||
export type CoinSellerStatusPayload = BDStatusPayload;
|
||||
export interface CoinSellerStatusPayload extends BDStatusPayload {
|
||||
contact?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgencyPayload extends HostCommandPayload {
|
||||
joinEnabled: boolean;
|
||||
|
||||
@ -3,6 +3,8 @@ import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
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 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 }) {
|
||||
return <div className={styles.inlineActions}>{children}</div>;
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
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);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.compact {
|
||||
@ -77,9 +78,14 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
min-height: 20px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
@ -87,9 +93,16 @@
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.section > :global(.MuiTextField-root),
|
||||
.section > :global(.MuiFormControl-root),
|
||||
.section > :global(.MuiAutocomplete-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sectionActions,
|
||||
@ -109,10 +122,62 @@
|
||||
|
||||
.fieldGrid > :global(.MuiTextField-root),
|
||||
.fieldGrid > :global(.MuiFormControl-root),
|
||||
.fieldGrid > :global(.MuiAutocomplete-root) {
|
||||
.fieldGrid > :global(.MuiAutocomplete-root),
|
||||
.fieldGrid > .readOnlyField {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@ -135,8 +200,13 @@
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
justify-content: flex-end;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -1,70 +1,87 @@
|
||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
import styles from "@/shared/ui/AdminListLayout.module.css";
|
||||
|
||||
export function AdminListPage({ children }) {
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminListToolbar({ actions, filters }) {
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<section className={styles.filters}>{filters}</section>
|
||||
</div>
|
||||
{actions ? <div className={styles.toolbarActions}>{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<section className={styles.filters}>{filters}</section>
|
||||
</div>
|
||||
{actions ? <div className={styles.toolbarActions}>{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminListBody({ children }) {
|
||||
return <div className={styles.listBlock}>{children}</div>;
|
||||
return <div className={styles.listBlock}>{children}</div>;
|
||||
}
|
||||
|
||||
export function AdminRowActions({ children }) {
|
||||
return <div className={styles.rowActions}>{children}</div>;
|
||||
return <div className={styles.rowActions}>{children}</div>;
|
||||
}
|
||||
|
||||
export function AdminSearchBox(props) {
|
||||
return <SearchBox {...props} className={[styles.search, props.className || ""].filter(Boolean).join(" ")} />;
|
||||
return <SearchBox {...props} className={[styles.search, props.className || ""].filter(Boolean).join(" ")} />;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<Tooltip arrow title={tooltip}>
|
||||
<span>
|
||||
<IconButton
|
||||
{...props}
|
||||
className={props.className || ""}
|
||||
label={label}
|
||||
sx={primary ? primaryActionSx : props.sx}
|
||||
>
|
||||
{children}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip arrow title={tooltip}>
|
||||
<span>
|
||||
<IconButton
|
||||
{...props}
|
||||
className={props.className || ""}
|
||||
label={label}
|
||||
sx={primary ? primaryActionSx : props.sx}
|
||||
>
|
||||
{children}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const adminListClasses = styles;
|
||||
|
||||
const primaryActionSx = {
|
||||
borderColor: "var(--primary)",
|
||||
backgroundColor: "var(--primary)",
|
||||
color: "var(--active-contrast)",
|
||||
"&:hover": {
|
||||
borderColor: "var(--primary-strong)",
|
||||
backgroundColor: "var(--primary-strong)",
|
||||
color: "var(--active-contrast)"
|
||||
}
|
||||
borderColor: "var(--primary)",
|
||||
backgroundColor: "var(--primary)",
|
||||
color: "var(--active-contrast)",
|
||||
"&:hover": {
|
||||
borderColor: "var(--primary-strong)",
|
||||
backgroundColor: "var(--primary-strong)",
|
||||
color: "var(--active-contrast)",
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,135 +1,139 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4));
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contentPanel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.contentPanel :global(.data-state),
|
||||
.contentPanel :global(.table-frame) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.contentPanel :global(.table-scroll) {
|
||||
overflow: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.contentPanel :global(.admin-row--head) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toolbarLeft {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 0 1 300px;
|
||||
flex: 0 1 300px;
|
||||
}
|
||||
|
||||
.statusSelect {
|
||||
flex: 0 0 150px;
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.listBlock {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.listBlock :global(.pagination-bar) {
|
||||
margin: var(--space-3) var(--space-5) var(--space-4);
|
||||
margin: var(--space-3) var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.dragRow {
|
||||
cursor: grab;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.dragRow:active {
|
||||
cursor: grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dragRowActive {
|
||||
background: var(--primary-hover);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar,
|
||||
.toolbarLeft,
|
||||
.filters {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toolbar,
|
||||
.toolbarLeft,
|
||||
.filters {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.toolbarActions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.search,
|
||||
.statusSelect {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.search,
|
||||
.statusSelect {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import MenuList from "@mui/material/MenuList";
|
||||
import Popover from "@mui/material/Popover";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
|
||||
const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
|
||||
const DEFAULT_RESIZE_MIN_WIDTH = 72;
|
||||
@ -43,6 +45,7 @@ export function DataTable({
|
||||
const gridTemplate = preparedColumns.map((column) => column.width).join(" ");
|
||||
const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null;
|
||||
const activeFilter = activeFilterColumn?.filter || null;
|
||||
const activeFilterIsText = activeFilter?.type === "text";
|
||||
const filterOptions = useMemo(() => filterVisibleOptions(activeFilter, filterQuery), [activeFilter, filterQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -112,7 +115,7 @@ export function DataTable({
|
||||
}
|
||||
event.preventDefault();
|
||||
setFilterMenu({ anchorEl: event.currentTarget, columnKey: column.key });
|
||||
setFilterQuery("");
|
||||
setFilterQuery(column.filter.type === "text" ? String(column.filter.value ?? "") : "");
|
||||
};
|
||||
|
||||
const closeColumnFilter = () => {
|
||||
@ -125,6 +128,17 @@ export function DataTable({
|
||||
closeColumnFilter();
|
||||
};
|
||||
|
||||
const submitTextFilter = (event) => {
|
||||
event.preventDefault();
|
||||
activeFilter?.onChange?.(filterQuery.trim());
|
||||
closeColumnFilter();
|
||||
};
|
||||
|
||||
const clearTextFilter = () => {
|
||||
activeFilter?.onChange?.("");
|
||||
closeColumnFilter();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={["table-frame", className].filter(Boolean).join(" ")}>
|
||||
{title || total !== undefined ? (
|
||||
@ -189,32 +203,54 @@ export function DataTable({
|
||||
onClose={closeColumnFilter}
|
||||
>
|
||||
{activeFilterColumn ? (
|
||||
<div className="admin-table-filter">
|
||||
<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__options">
|
||||
{filterOptions.length ? (
|
||||
filterOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
selected={String(activeFilter.value ?? "") === option.value}
|
||||
onClick={() => changeFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))
|
||||
) : (
|
||||
<div className="admin-table-filter__empty">{activeFilter.loading ? "加载中..." : "无匹配选项"}</div>
|
||||
)}
|
||||
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">
|
||||
<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)}
|
||||
/>
|
||||
<MenuList className="admin-table-filter__options">
|
||||
{filterOptions.length ? (
|
||||
filterOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
selected={String(activeFilter.value ?? "") === option.value}
|
||||
onClick={() => changeFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))
|
||||
) : (
|
||||
<div className="admin-table-filter__empty">{activeFilter.loading ? "加载中..." : "无匹配选项"}</div>
|
||||
)}
|
||||
</MenuList>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Popover>
|
||||
</section>
|
||||
@ -232,6 +268,7 @@ function HeaderLabel({ column, onOpenFilter }) {
|
||||
}
|
||||
|
||||
const active = isFilterActive(column.filter);
|
||||
const filterValueLabel = active ? filterDisplayValue(column.filter) : "";
|
||||
return (
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -320,6 +360,19 @@ function isFilterActive(filter) {
|
||||
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) {
|
||||
if (!filter) {
|
||||
return [];
|
||||
|
||||
@ -1,65 +1,47 @@
|
||||
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 { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
|
||||
export function RegionFilterControl({
|
||||
className = "",
|
||||
disabled = false,
|
||||
emptyLabel = "全部区域",
|
||||
loading = false,
|
||||
onChange,
|
||||
options = [],
|
||||
resetLabel = "重置区域",
|
||||
selectClassName = "",
|
||||
value,
|
||||
...props
|
||||
className = "",
|
||||
disabled = false,
|
||||
emptyLabel = "全部区域",
|
||||
loading = false,
|
||||
onChange,
|
||||
options = [],
|
||||
selectClassName = "",
|
||||
value,
|
||||
...props
|
||||
}) {
|
||||
const selectedValue = value === undefined || value === null ? "" : String(value);
|
||||
const canReset = selectedValue !== "" && !disabled && !loading;
|
||||
|
||||
return (
|
||||
<div className={["region-filter-control", className].filter(Boolean).join(" ")}>
|
||||
<RegionSelect
|
||||
{...props}
|
||||
className={["region-filter-control__select", selectClassName].filter(Boolean).join(" ")}
|
||||
disabled={disabled}
|
||||
emptyLabel={emptyLabel}
|
||||
loading={loading}
|
||||
options={options}
|
||||
value={value}
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<div className={["region-filter-control", className].filter(Boolean).join(" ")}>
|
||||
<RegionSelect
|
||||
{...props}
|
||||
className={["region-filter-control__select", selectClassName].filter(Boolean).join(" ")}
|
||||
disabled={disabled}
|
||||
emptyLabel={emptyLabel}
|
||||
loading={loading}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createRegionColumnFilter({
|
||||
emptyLabel = "全部区域",
|
||||
loading = false,
|
||||
onChange,
|
||||
options = [],
|
||||
placeholder = "搜索区域",
|
||||
value
|
||||
}) {
|
||||
return {
|
||||
emptyLabel,
|
||||
loading,
|
||||
emptyLabel = "全部区域",
|
||||
loading = false,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
value
|
||||
};
|
||||
options = [],
|
||||
placeholder = "搜索区域",
|
||||
value,
|
||||
}) {
|
||||
return createOptionsColumnFilter({
|
||||
emptyLabel,
|
||||
loading,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
20
src/shared/ui/tableFilters.js
Normal file
20
src/shared/ui/tableFilters.js
Normal 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
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
11
src/theme.js
11
src/theme.js
@ -130,6 +130,13 @@ export const theme = createTheme({
|
||||
},
|
||||
input: {
|
||||
fontSize: 14,
|
||||
"&[type='number']": {
|
||||
MozAppearance: "textfield"
|
||||
},
|
||||
"&[type='number']::-webkit-outer-spin-button, &[type='number']::-webkit-inner-spin-button": {
|
||||
WebkitAppearance: "none",
|
||||
margin: 0
|
||||
},
|
||||
"&::placeholder": {
|
||||
color: token("text-tertiary"),
|
||||
opacity: 1
|
||||
@ -237,6 +244,10 @@ export const theme = createTheme({
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: 14
|
||||
},
|
||||
asterisk: {
|
||||
color: token("danger"),
|
||||
marginLeft: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user