优化布局

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -26,7 +26,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
const 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

View File

@ -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;

View File

@ -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
};

View File

@ -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];

View File

@ -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",
};
}

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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 || "-";
}

View File

@ -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");
});

View File

@ -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,
},
);
}

View File

@ -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}

View File

@ -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>
);
}

View File

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

View File

@ -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} />;
}

View File

@ -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()}`;
}

View File

@ -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
);
}

View File

@ -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()}`;
}

View File

@ -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));
});
}

View File

@ -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,
};
}

View File

@ -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
);
}

View File

@ -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%;
}
}

View File

@ -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"}
/>
);
}

View File

@ -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)",
},
};

View File

@ -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 || "创建人"}
/>
);
}

View File

@ -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",
},
};

View File

@ -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)}
/>
);
}

View File

@ -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}
/>
);
}

View File

@ -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)",
},
};

View File

@ -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),
};
}

View File

@ -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>;

View File

@ -1,9 +1,9 @@
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
export function LogFilters({ onQueryChange, query }) {
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>
);
}

View File

@ -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",

View File

@ -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,
};
}

View File

@ -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>
</>
);
}

View File

@ -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> },

View File

@ -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) },
{

View File

@ -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)]);
}

View File

@ -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}
/>
</>
);
}

View File

@ -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, "");
}

View File

@ -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

View File

@ -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}

View File

@ -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>

View File

@ -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 ? (

View File

@ -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 },
{

View File

@ -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);
}

View File

@ -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}
/>
</>
);
}

View File

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

View File

@ -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,
};
}

View File

@ -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");
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
import Checkbox from "@mui/material/Checkbox";
import { 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" },
{

View File

@ -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();

View File

@ -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,
};
}

View File

@ -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} />
</>
);
}

View File

@ -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",

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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>;
}

View File

@ -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);
}

View File

@ -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)",
},
};

View File

@ -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%;
}
}

View File

@ -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 [];

View File

@ -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,
});
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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
}
}
},