优化布局
This commit is contained in:
parent
3698b05159
commit
0fd67199b6
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,7 @@ export function AdminLayout() {
|
||||
<Header
|
||||
activeLabel={activeNav?.label || "总览"}
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
menuItems={menus}
|
||||
onRefresh={refresh}
|
||||
onToggleSidebar={() => setSidebarCollapsed((current) => !current)}
|
||||
/>
|
||||
|
||||
@ -26,7 +26,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
|
||||
const emptyPasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
|
||||
|
||||
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) {
|
||||
export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefresh, onToggleSidebar }) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchMounted, setSearchMounted] = useState(false);
|
||||
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
|
||||
@ -186,7 +186,7 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
||||
</TextField>
|
||||
<button className="search-trigger" type="button" onClick={openSearch}>
|
||||
<Search fontSize="small" />
|
||||
<span>搜索用户、角色...</span>
|
||||
<span>搜索菜单、用户、角色...</span>
|
||||
</button>
|
||||
<IconButton label="刷新" onClick={refresh}>
|
||||
<Refresh fontSize="small" />
|
||||
@ -223,7 +223,7 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
||||
|
||||
{searchMounted ? (
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
||||
<SearchDialog menuItems={menuItems} open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
<AdminFormDialog
|
||||
|
||||
@ -94,6 +94,11 @@ function NavGroup({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChildren && children.length === 1 && children[0].path && !isChildActive) {
|
||||
onNavigate(children[0].path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
onToggleExpanded();
|
||||
return;
|
||||
|
||||
@ -23,6 +23,10 @@ import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||
import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
||||
|
||||
const deprecatedMenuCodes = new Set(["services"]);
|
||||
const menuLabelOverrides = {
|
||||
system: "后台设置",
|
||||
"system-users": "后台用户"
|
||||
};
|
||||
|
||||
const iconMap = {
|
||||
apartment: ApartmentOutlined,
|
||||
@ -55,7 +59,7 @@ export const fallbackNavigation = [
|
||||
code: "system",
|
||||
icon: SettingsApplicationsOutlined,
|
||||
id: "system",
|
||||
label: "系统管理",
|
||||
label: "后台设置",
|
||||
children: [
|
||||
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
|
||||
routeNavItem("system-roles", { icon: ShieldOutlined }),
|
||||
@ -162,7 +166,7 @@ export function mapBackendMenus(menus = []) {
|
||||
code: item.code,
|
||||
icon: iconMap[item.icon] || MenuOpenOutlined,
|
||||
id: item.code || String(item.id),
|
||||
label: item.label,
|
||||
label: menuLabelOverrides[item.code] || route?.label || item.label,
|
||||
path: route?.path || item.path,
|
||||
permissionCode: route?.permission || item.permissionCode
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ export const PERMISSIONS = {
|
||||
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",
|
||||
@ -73,7 +74,7 @@ export const PERMISSIONS = {
|
||||
uploadCreate: "upload:create",
|
||||
jobView: "job:view",
|
||||
jobCancel: "job:cancel",
|
||||
exportCreate: "export:create"
|
||||
exportCreate: "export:create",
|
||||
} as const;
|
||||
|
||||
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
@ -113,7 +114,7 @@ export const MENU_CODES = {
|
||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||
payment: "payment",
|
||||
paymentBillList: "payment-bill-list",
|
||||
jobs: "jobs"
|
||||
jobs: "jobs",
|
||||
} as const;
|
||||
|
||||
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];
|
||||
|
||||
@ -20,7 +20,7 @@ const emptyForm = () => ({
|
||||
platform: "android",
|
||||
regionId: "",
|
||||
sortOrder: "0",
|
||||
status: "active"
|
||||
status: "active",
|
||||
});
|
||||
|
||||
export function useBannerConfigPage() {
|
||||
@ -45,15 +45,20 @@ export function useBannerConfigPage() {
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status
|
||||
status,
|
||||
}),
|
||||
[countryCode, keyword, platform, regionId, status]
|
||||
[countryCode, keyword, platform, regionId, status],
|
||||
);
|
||||
const queryFn = useCallback(() => listBanners(filters), [filters]);
|
||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载 BANNER 配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["app-config", "banners", filters]
|
||||
queryKey: ["app-config", "banners", filters],
|
||||
});
|
||||
|
||||
const setForm = (patch) => {
|
||||
@ -78,6 +83,14 @@ export function useBannerConfigPage() {
|
||||
setFormState(emptyForm());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setPlatform("");
|
||||
setRegionId("");
|
||||
setCountryCode("");
|
||||
};
|
||||
|
||||
const submitBanner = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = parseForm(appBannerSchema, normalizeForm(form));
|
||||
@ -105,7 +118,7 @@ export function useBannerConfigPage() {
|
||||
confirmText: "删除",
|
||||
message: item.description || item.param || `BANNER ${item.id}`,
|
||||
title: "删除BANNER",
|
||||
tone: "danger"
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -144,6 +157,7 @@ export function useBannerConfigPage() {
|
||||
regionOptions,
|
||||
reload,
|
||||
removeBanner,
|
||||
resetFilters,
|
||||
setCountryCode,
|
||||
setForm,
|
||||
setKeyword,
|
||||
@ -151,7 +165,7 @@ export function useBannerConfigPage() {
|
||||
setRegionId,
|
||||
setStatus,
|
||||
status,
|
||||
submitBanner
|
||||
submitBanner,
|
||||
};
|
||||
}
|
||||
|
||||
@ -160,7 +174,7 @@ function normalizeForm(form) {
|
||||
...form,
|
||||
countryCode: form.countryCode || "",
|
||||
regionId: form.regionId || 0,
|
||||
sortOrder: form.sortOrder || 0
|
||||
sortOrder: form.sortOrder || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -174,6 +188,6 @@ function formFromBanner(item) {
|
||||
platform: item.platform || "android",
|
||||
regionId: item.regionId ? String(item.regionId) : "",
|
||||
sortOrder: String(item.sortOrder || 0),
|
||||
status: item.status || "active"
|
||||
status: item.status || "active",
|
||||
};
|
||||
}
|
||||
|
||||
@ -11,31 +11,30 @@ import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
adminListClasses
|
||||
} 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", "关闭"]
|
||||
["disabled", "关闭"],
|
||||
];
|
||||
|
||||
const platformOptions = [
|
||||
["", "全部平台"],
|
||||
["android", "安卓"],
|
||||
["ios", "iOS"]
|
||||
["ios", "iOS"],
|
||||
];
|
||||
|
||||
export function BannerConfigPage() {
|
||||
@ -48,32 +47,20 @@ export function BannerConfigPage() {
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canUpdate ? (
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||
<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}
|
||||
<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}>
|
||||
@ -89,7 +76,9 @@ export function BannerConfigPage() {
|
||||
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="narrow"
|
||||
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
submitDisabled={
|
||||
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
|
||||
}
|
||||
title={page.editingItem ? "编辑BANNER" : "新增BANNER"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitBanner}
|
||||
@ -100,7 +89,14 @@ export function BannerConfigPage() {
|
||||
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 })}>
|
||||
<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>
|
||||
@ -111,11 +107,25 @@ export function BannerConfigPage() {
|
||||
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 })}>
|
||||
<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 })}>
|
||||
<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>
|
||||
@ -162,49 +172,86 @@ function bannerColumns(page) {
|
||||
key: "cover",
|
||||
label: "封面图",
|
||||
render: (item) => <BannerCover src={item.coverUrl} />,
|
||||
width: "minmax(112px, 0.55fr)"
|
||||
width: "minmax(112px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "类型 / 参数",
|
||||
render: (item) => <Stack primary={typeLabel(item.bannerType)} secondary={item.param || "-"} />,
|
||||
width: "minmax(260px, 1.3fr)"
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索参数、国家、描述",
|
||||
value: page.keyword,
|
||||
onChange: page.setKeyword,
|
||||
}),
|
||||
width: "minmax(260px, 1.3fr)",
|
||||
},
|
||||
{
|
||||
key: "state",
|
||||
label: "平台 / 状态",
|
||||
render: (item) => <Stack primary={platformLabel(item.platform)} secondary={<StatusBadge status={item.status} />} />,
|
||||
width: "minmax(140px, 0.75fr)"
|
||||
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: "scope",
|
||||
label: "区域 / 国家",
|
||||
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
|
||||
onChange: page.setRegionId,
|
||||
}),
|
||||
render: (item) => <Stack primary={regionLabel(page.regionOptions, item.regionId)} secondary={countryLabel(page.countryOptions, item.countryCode)} />,
|
||||
width: "minmax(180px, 0.95fr)"
|
||||
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)"
|
||||
width: "minmax(80px, 0.4fr)",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
render: (item) => item.description || "-",
|
||||
width: "minmax(180px, 0.9fr)"
|
||||
width: "minmax(180px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(160px, 0.8fr)"
|
||||
}
|
||||
width: "minmax(160px, 0.8fr)",
|
||||
},
|
||||
];
|
||||
|
||||
if (!page.abilities.canUpdate) {
|
||||
@ -217,8 +264,8 @@ function bannerColumns(page) {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <BannerActions item={item} page={page} />,
|
||||
width: "minmax(96px, 0.45fr)"
|
||||
}
|
||||
width: "minmax(96px, 0.45fr)",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -237,10 +284,18 @@ function BannerActions({ item, page }) {
|
||||
const deleting = page.loadingAction === `delete:${item.id}`;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton disabled={Boolean(page.loadingAction)} label="编辑" onClick={() => page.openEdit(item)}>
|
||||
<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)}>
|
||||
<AdminActionIconButton
|
||||
disabled={deleting || Boolean(page.loadingAction)}
|
||||
label="删除"
|
||||
onClick={() => page.removeBanner(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
@ -249,7 +304,15 @@ function BannerActions({ item, page }) {
|
||||
|
||||
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)}>
|
||||
<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}>
|
||||
|
||||
@ -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";
|
||||
@ -37,17 +31,22 @@ export function useAppUsersPage() {
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
status
|
||||
status,
|
||||
}),
|
||||
[query, status]
|
||||
[query, status],
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载用户列表失败",
|
||||
fetcher: listAppUsers,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["app-users", filters, page]
|
||||
queryKey: ["app-users", filters, page],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
@ -60,12 +59,18 @@ export function useAppUsersPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openEdit = (user) => {
|
||||
setActiveUser(user);
|
||||
setEditForm({
|
||||
avatar: user.avatar || "",
|
||||
gender: user.gender || "",
|
||||
username: user.username || ""
|
||||
username: user.username || "",
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
@ -131,7 +136,7 @@ export function useAppUsersPage() {
|
||||
confirmText: banned ? "解封" : "封禁",
|
||||
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
|
||||
title: banned ? "解封用户" : "封禁用户",
|
||||
tone: banned ? "primary" : "danger"
|
||||
tone: banned ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -180,6 +185,7 @@ export function useAppUsersPage() {
|
||||
passwordForm,
|
||||
query,
|
||||
reload,
|
||||
resetFilters,
|
||||
setCountryForm,
|
||||
setEditForm,
|
||||
setPage,
|
||||
@ -188,7 +194,7 @@ export function useAppUsersPage() {
|
||||
submitCountry,
|
||||
submitEdit,
|
||||
submitPassword,
|
||||
toggleBan
|
||||
toggleBan,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -10,17 +10,13 @@ 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";
|
||||
|
||||
@ -29,7 +25,7 @@ const columns = [
|
||||
key: "identity",
|
||||
label: "用户",
|
||||
width: "minmax(240px, 1.5fr)",
|
||||
render: (user) => <UserIdentity user={user} />
|
||||
render: (user) => <UserIdentity user={user} />,
|
||||
},
|
||||
{ key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) },
|
||||
{ key: "diamond", label: "钻石", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.diamond) },
|
||||
@ -37,7 +33,7 @@ const columns = [
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(92px, 0.6fr)",
|
||||
render: (user) => <UserStatus status={user.status} />
|
||||
render: (user) => <UserStatus status={user.status} />,
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
@ -48,8 +44,8 @@ const columns = [
|
||||
<span>{formatMillis(user.createdAtMs)}</span>
|
||||
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function AppUserListPage() {
|
||||
@ -57,20 +53,39 @@ export function AppUserListPage() {
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const tableColumns = [
|
||||
columns[0],
|
||||
{
|
||||
...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} />
|
||||
render: (user) => <UserLocation page={page} user={user} />,
|
||||
},
|
||||
...columns.slice(1),
|
||||
...columns.slice(1).map((column) =>
|
||||
column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: appUserStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
),
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(148px, 0.8fr)",
|
||||
render: (user) => <UserActions page={page} user={user} />
|
||||
}
|
||||
render: (user) => <UserActions page={page} user={user} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@ -78,20 +93,26 @@ export function AppUserListPage() {
|
||||
<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}
|
||||
/>
|
||||
<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}
|
||||
<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>
|
||||
@ -104,11 +125,29 @@ export function AppUserListPage() {
|
||||
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 })}>
|
||||
<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>
|
||||
<MenuItem key={value || "empty"} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</ActionModal>
|
||||
@ -142,7 +181,13 @@ export function AppUserListPage() {
|
||||
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 })} />
|
||||
<TextField
|
||||
disabled={!page.abilities.canPassword}
|
||||
label="新密码"
|
||||
type="password"
|
||||
value={page.passwordForm.password}
|
||||
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
|
||||
/>
|
||||
</ActionModal>
|
||||
</section>
|
||||
);
|
||||
@ -201,7 +246,12 @@ function UserActions({ page, user }) {
|
||||
{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)}>
|
||||
<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>
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
createCountry,
|
||||
createCoinSeller,
|
||||
createRegion,
|
||||
creditCoinSellerStock,
|
||||
disableRegion,
|
||||
listAgencies,
|
||||
listBDs,
|
||||
@ -13,7 +14,7 @@ import {
|
||||
listRegions,
|
||||
replaceRegionCountries,
|
||||
setCoinSellerStatus,
|
||||
updateCountry
|
||||
updateCountry,
|
||||
} from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
@ -24,7 +25,9 @@ afterEach(() => {
|
||||
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.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" });
|
||||
@ -33,6 +36,12 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
|
||||
await 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];
|
||||
@ -40,6 +49,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [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");
|
||||
@ -58,16 +68,23 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
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.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 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" });
|
||||
|
||||
@ -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 }), {
|
||||
return apiRequest<CountryDto, CountryUpdatePayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateCountry, { country_id: countryId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
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 }), {
|
||||
return apiRequest<RegionDto, RegionUpdatePayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateRegion, { region_id: regionId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
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 }), {
|
||||
return apiRequest<BDProfileDto, BDStatusPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.setBDLeaderStatus, { user_id: userId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
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 }), {
|
||||
return apiRequest<AgencyDto, HostCommandPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.closeAgency, { agency_id: agencyId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> {
|
||||
@ -241,7 +275,7 @@ export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnab
|
||||
apiEndpointPath(API_OPERATIONS.setAgencyJoinEnabled, { agency_id: agencyId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
}
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ export function HostOrgActionModal({
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
size = "compact",
|
||||
submitLabel = "提交",
|
||||
submitVariant = "primary",
|
||||
title
|
||||
@ -15,7 +16,7 @@ export function HostOrgActionModal({
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="compact"
|
||||
size={size}
|
||||
submitDisabled={disabled || loading}
|
||||
submitLabel={submitLabel}
|
||||
submitVariant={submitVariant}
|
||||
|
||||
@ -1,50 +1,20 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
export function HostOrgFilters({
|
||||
extraFilters = [],
|
||||
loadingRegions = false,
|
||||
onQueryChange,
|
||||
onRegionIdChange,
|
||||
onStatusChange,
|
||||
onReset,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions = [],
|
||||
searchPlaceholder,
|
||||
status,
|
||||
statusOptions
|
||||
}) {
|
||||
const hasActiveFilters = [query, regionId, status, ...extraFilters.map((filter) => filter.value)].some(
|
||||
(value) => String(value || "").trim() !== "",
|
||||
);
|
||||
|
||||
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>
|
||||
{onReset ? <AdminFilterResetButton disabled={!hasActiveFilters} onClick={onReset} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/features/host-org/components/HostOrgIdentity.jsx
Normal file
47
src/features/host-org/components/HostOrgIdentity.jsx
Normal file
@ -0,0 +1,47 @@
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
export function HostOrgPerson({ avatar, displayUserId, fallbackId, username }) {
|
||||
const name = username || "-";
|
||||
const meta = displayUserId || fallbackId || "-";
|
||||
|
||||
return (
|
||||
<div className={styles.sellerIdentity}>
|
||||
{avatar ? (
|
||||
<img alt="" className={styles.sellerAvatar} src={avatar} />
|
||||
) : (
|
||||
<span className={styles.sellerAvatar}>
|
||||
{String(name || meta)
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.sellerText}>
|
||||
<span className={styles.sellerName}>{name}</span>
|
||||
<span className={styles.sellerMeta}>{meta}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostOrgEntity({ id, name, prefix = "" }) {
|
||||
if (!id && !name) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<span className={styles.sellerText}>
|
||||
<span className={styles.sellerName}>{name || "-"}</span>
|
||||
<span className={styles.sellerMeta}>
|
||||
{prefix}
|
||||
{id || "-"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function hostOrgRegionName(item, regionOptions = []) {
|
||||
if (item.regionName) {
|
||||
return item.regionName;
|
||||
}
|
||||
const option = regionOptions.find((region) => String(region.value) === String(item.regionId));
|
||||
return option?.name || item.regionId || "-";
|
||||
}
|
||||
@ -2,19 +2,10 @@ 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"),
|
||||
@ -23,14 +14,14 @@ const emptyAgencyForm = () => ({
|
||||
name: "",
|
||||
ownerUserId: "",
|
||||
parentBdUserId: "",
|
||||
reason: ""
|
||||
reason: "",
|
||||
});
|
||||
const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" });
|
||||
const emptyJoinEnabledForm = () => ({
|
||||
agencyId: "",
|
||||
commandId: makeCommandId("agency-join"),
|
||||
joinEnabled: true,
|
||||
reason: ""
|
||||
reason: "",
|
||||
});
|
||||
const pageSize = 10;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
@ -54,17 +45,22 @@ export function useHostAgenciesPage() {
|
||||
keyword: query,
|
||||
parent_bd_user_id: parentBdUserId,
|
||||
region_id: regionId,
|
||||
status
|
||||
status,
|
||||
}),
|
||||
[parentBdUserId, query, regionId, status]
|
||||
[parentBdUserId, query, regionId, status],
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载 Agency 列表失败",
|
||||
fetcher: listAgencies,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "agencies", filters, page]
|
||||
queryKey: ["host-org", "agencies", filters, page],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
@ -87,6 +83,14 @@ export function useHostAgenciesPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setParentBdUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitAgency = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("agency", "Agency 已创建", async () => {
|
||||
@ -158,6 +162,7 @@ export function useHostAgenciesPage() {
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
closeAction: () => setActiveAction(""),
|
||||
openAgencyForm: () => setActiveAction("agency"),
|
||||
openCloseForm: () => setActiveAction("agency-close"),
|
||||
@ -169,7 +174,7 @@ export function useHostAgenciesPage() {
|
||||
status,
|
||||
submitAgency,
|
||||
submitClose,
|
||||
submitJoinEnabled
|
||||
submitJoinEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,27 +1,38 @@
|
||||
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,
|
||||
deleteBDLeader,
|
||||
listBDLeaders,
|
||||
listBDs,
|
||||
setBDLeaderStatus,
|
||||
setBDStatus
|
||||
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 confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [query, setQuery] = useState("");
|
||||
@ -32,6 +43,11 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
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(
|
||||
@ -39,20 +55,80 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
keyword: query,
|
||||
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
|
||||
region_id: regionId,
|
||||
status
|
||||
status,
|
||||
}),
|
||||
[parentLeaderUserId, profileType, query, regionId, status]
|
||||
[parentLeaderUserId, profileType, query, regionId, status],
|
||||
);
|
||||
const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: profileType === "leader" ? "加载 BD Leader 列表失败" : "加载 BD 列表失败",
|
||||
fetcher,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "bds", profileType, filters, page]
|
||||
queryKey: ["host-org", "bds", profileType, filters, page],
|
||||
});
|
||||
|
||||
const loadLeaderBDs = useCallback(async (leader) => {
|
||||
if (!leader?.userId) {
|
||||
setLeaderBDs(emptyData);
|
||||
return;
|
||||
}
|
||||
setLeaderBDsError("");
|
||||
setLeaderBDsLoading(true);
|
||||
try {
|
||||
const data = await listBDs({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
parent_leader_user_id: leader.userId,
|
||||
});
|
||||
setLeaderBDs(data || emptyData);
|
||||
} catch (err) {
|
||||
setLeaderBDsError(err.message || "加载下级 BD 失败");
|
||||
} finally {
|
||||
setLeaderBDsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAction !== "leader-add-bd") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyword = String(bdForm.targetUserId || "").trim();
|
||||
if (!keyword) {
|
||||
setBDUserLookup(emptyLookup());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
setBDUserLookup({ error: "", loading: true, user: null });
|
||||
try {
|
||||
const data = await listAppUsers({ keyword, page: 1, page_size: 5 });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setBDUserLookup({ error: "", loading: false, user: pickUserMatch(data?.items || [], keyword) });
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setBDUserLookup({ error: err.message || "搜索用户失败", loading: false, user: null });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [activeAction, bdForm.targetUserId]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
@ -73,6 +149,14 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setParentLeaderUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitBDLeader = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("bd-leader", "BD Leader 已创建", async () => {
|
||||
@ -97,12 +181,26 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const submitLeaderBD = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction("leader-add-bd", "下级 BD 已添加", async () => {
|
||||
const payload = parseForm(createBDSchema, bdForm);
|
||||
const data = await createBD(payload);
|
||||
setBDForm(emptyBDForm());
|
||||
setBDUserLookup(emptyLookup());
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const submitBDStatus = async (event) => {
|
||||
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"
|
||||
const data =
|
||||
targetType === "leader"
|
||||
? await setBDLeaderStatus(targetUserId, body)
|
||||
: await setBDStatus(targetUserId, body);
|
||||
setBDStatusForm(emptyBDStatusForm(profileType));
|
||||
@ -112,6 +210,57 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBDLeader = async (leader, enabled) => {
|
||||
const status = enabled ? "active" : "disabled";
|
||||
await runAction(
|
||||
`bd-leader-status-${leader.userId}`,
|
||||
enabled ? "BD Leader 已启用" : "BD Leader 已停用",
|
||||
async () => {
|
||||
const data = await setBDLeaderStatus(leader.userId, {
|
||||
commandId: makeCommandId("bd-leader-status"),
|
||||
reason: "",
|
||||
status,
|
||||
});
|
||||
await reload();
|
||||
return data;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeBDLeader = async (leader) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `确认删除 BD Leader ${leader.displayUserId || leader.userId}?删除后该身份会从列表移除。`,
|
||||
title: "删除 BD Leader",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runAction(`bd-leader-delete-${leader.userId}`, "BD Leader 已删除", async () => {
|
||||
const data = await deleteBDLeader(leader.userId);
|
||||
await reload();
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const openLeaderBDs = (leader) => {
|
||||
setSelectedLeader(leader);
|
||||
setActiveAction("leader-bds");
|
||||
void loadLeaderBDs(leader);
|
||||
};
|
||||
|
||||
const openAddLeaderBD = (leader) => {
|
||||
setSelectedLeader(leader);
|
||||
setBDUserLookup(emptyLookup());
|
||||
setBDForm({
|
||||
...emptyBDForm(),
|
||||
parentLeaderUserId: leader.displayUserId || String(leader.userId || ""),
|
||||
});
|
||||
setActiveAction("leader-add-bd");
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
@ -124,12 +273,19 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setBDUserLookup(emptyLookup());
|
||||
setLeaderBDsError("");
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
bdForm,
|
||||
bdLeaderForm,
|
||||
bdStatusForm,
|
||||
bdUserLookup,
|
||||
changeParentLeaderUserId,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
@ -139,6 +295,9 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
leaderBDs,
|
||||
leaderBDsError,
|
||||
leaderBDsLoading,
|
||||
page,
|
||||
parentLeaderUserId,
|
||||
profileType,
|
||||
@ -146,10 +305,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
closeAction: () => setActiveAction(""),
|
||||
removeBDLeader,
|
||||
resetFilters,
|
||||
closeAction,
|
||||
loadLeaderBDs: () => loadLeaderBDs(selectedLeader),
|
||||
openAddLeaderBD,
|
||||
openBDForm: () => setActiveAction("bd"),
|
||||
openBDLeaderForm: () => setActiveAction("bd-leader"),
|
||||
openBDStatusForm: () => setActiveAction("bd-status"),
|
||||
openLeaderBDs,
|
||||
selectedLeader,
|
||||
setBDForm,
|
||||
setBDLeaderForm,
|
||||
setBDStatusForm,
|
||||
@ -157,10 +322,23 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
status,
|
||||
submitBD,
|
||||
submitBDLeader,
|
||||
submitBDStatus
|
||||
submitLeaderBD,
|
||||
submitBDStatus,
|
||||
toggleBDLeader,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCommandId(prefix) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
function pickUserMatch(items, keyword) {
|
||||
const normalized = String(keyword || "").trim();
|
||||
return (
|
||||
items.find(
|
||||
(item) => String(item.displayUserId || "") === normalized || String(item.userId || "") === normalized,
|
||||
) ||
|
||||
items[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,20 +2,35 @@ 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("");
|
||||
@ -25,23 +40,29 @@ export function useHostCoinSellersPage() {
|
||||
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
|
||||
status,
|
||||
}),
|
||||
[query, regionId, status]
|
||||
[query, regionId, status],
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载币商列表失败",
|
||||
fetcher: listCoinSellers,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "coin-sellers", filters, page]
|
||||
queryKey: ["host-org", "coin-sellers", filters, page],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
@ -59,6 +80,13 @@ export function useHostCoinSellersPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreateSeller = () => {
|
||||
setCreateForm(emptyCreateForm());
|
||||
setSelectedSeller(null);
|
||||
@ -72,13 +100,23 @@ export function useHostCoinSellersPage() {
|
||||
setSelectedSeller(seller);
|
||||
setEditForm({
|
||||
commandId: makeCommandId("coin-seller-status"),
|
||||
reason: "",
|
||||
contact: seller.contact || "",
|
||||
reason: "update coin seller contact",
|
||||
status: seller.status || "active",
|
||||
targetUserId: seller.userId
|
||||
targetUserId: seller.userId,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const openStockCredit = (seller) => {
|
||||
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
|
||||
return;
|
||||
}
|
||||
setSelectedSeller(seller);
|
||||
setStockForm(emptyStockForm());
|
||||
setActiveAction("stock");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setSelectedSeller(null);
|
||||
@ -97,13 +135,31 @@ export function useHostCoinSellersPage() {
|
||||
|
||||
const submitEditSeller = async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`, "币商已更新", async () => {
|
||||
await runAction(
|
||||
`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`,
|
||||
"币商已更新",
|
||||
async () => {
|
||||
const payload = parseForm(coinSellerStatusSchema, editForm);
|
||||
const { 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();
|
||||
});
|
||||
};
|
||||
|
||||
@ -119,30 +175,7 @@ export function useHostCoinSellersPage() {
|
||||
await setCoinSellerStatus(seller.userId, {
|
||||
commandId: makeCommandId("coin-seller-status"),
|
||||
reason: "",
|
||||
status: nextStatus
|
||||
});
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSeller = async (seller) => {
|
||||
if (!abilities.canUpdate || !seller?.userId) {
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: seller.username || seller.displayUserId || String(seller.userId),
|
||||
title: "删除币商",
|
||||
tone: "danger"
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await runAction(`coin-seller-delete-${seller.userId}`, "币商已删除", async () => {
|
||||
await setCoinSellerStatus(seller.userId, {
|
||||
commandId: makeCommandId("coin-seller-delete"),
|
||||
reason: "delete coin seller",
|
||||
status: "disabled"
|
||||
status: nextStatus,
|
||||
});
|
||||
await reload();
|
||||
});
|
||||
@ -169,7 +202,6 @@ export function useHostCoinSellersPage() {
|
||||
closeAction,
|
||||
createForm,
|
||||
data,
|
||||
deleteSeller,
|
||||
editForm,
|
||||
error,
|
||||
loading,
|
||||
@ -177,19 +209,24 @@ export function useHostCoinSellersPage() {
|
||||
loadingRegions,
|
||||
openCreateSeller,
|
||||
openEditSeller,
|
||||
openStockCredit,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
selectedSeller,
|
||||
setCreateForm,
|
||||
setEditForm,
|
||||
setPage,
|
||||
setStockForm,
|
||||
status,
|
||||
stockForm,
|
||||
submitCreateSeller,
|
||||
submitEditSeller,
|
||||
toggleSeller
|
||||
submitStockCredit,
|
||||
toggleSeller,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -2,13 +2,7 @@ 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";
|
||||
|
||||
@ -21,7 +15,7 @@ const emptyCountryForm = () => ({
|
||||
isoAlpha3: "",
|
||||
isoNumeric: "",
|
||||
phoneCountryCode: "",
|
||||
sortOrder: 0
|
||||
sortOrder: 0,
|
||||
});
|
||||
const emptyData = { items: [], total: 0 };
|
||||
|
||||
@ -36,16 +30,21 @@ export function useHostCountriesPage() {
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
enabled: enabledStatus === "enabled" ? true : enabledStatus === "disabled" ? false : undefined
|
||||
enabled: enabledStatus === "enabled" ? true : enabledStatus === "disabled" ? false : undefined,
|
||||
}),
|
||||
[enabledStatus]
|
||||
[enabledStatus],
|
||||
);
|
||||
const queryFn = useCallback(() => listCountries(filters), [filters]);
|
||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载国家列表失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["host-org", "countries", filters]
|
||||
queryKey: ["host-org", "countries", filters],
|
||||
});
|
||||
const items = useMemo(() => filterCountries(data?.items || [], query), [data?.items, query]);
|
||||
|
||||
@ -57,6 +56,11 @@ export function useHostCountriesPage() {
|
||||
setEnabledStatus(value);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setEnabledStatus("");
|
||||
};
|
||||
|
||||
const openCreateCountry = () => {
|
||||
setEditingCountry(null);
|
||||
setCountryForm(emptyCountryForm());
|
||||
@ -74,7 +78,7 @@ export function useHostCountriesPage() {
|
||||
isoAlpha3: country.isoAlpha3 || "",
|
||||
isoNumeric: country.isoNumeric || "",
|
||||
phoneCountryCode: country.phoneCountryCode || "",
|
||||
sortOrder: country.sortOrder || 0
|
||||
sortOrder: country.sortOrder || 0,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
@ -84,9 +88,7 @@ export function useHostCountriesPage() {
|
||||
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);
|
||||
const data = isEdit ? await updateCountry(editingCountry.countryId, payload) : await createCountry(payload);
|
||||
setCountryForm(emptyCountryForm());
|
||||
setEditingCountry(null);
|
||||
setActiveAction("");
|
||||
@ -135,9 +137,10 @@ export function useHostCountriesPage() {
|
||||
openEditCountry,
|
||||
query,
|
||||
reload,
|
||||
resetFilters,
|
||||
setCountryForm,
|
||||
submitCountry,
|
||||
toggleCountry
|
||||
toggleCountry,
|
||||
};
|
||||
}
|
||||
|
||||
@ -153,7 +156,7 @@ function filterCountries(items, query) {
|
||||
item.countryName,
|
||||
item.isoAlpha3,
|
||||
item.isoNumeric,
|
||||
item.phoneCountryCode
|
||||
item.phoneCountryCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
||||
|
||||
@ -20,17 +20,22 @@ export function useHostHostsPage() {
|
||||
agency_id: agencyId,
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status
|
||||
status,
|
||||
}),
|
||||
[agencyId, query, regionId, status]
|
||||
[agencyId, query, regionId, status],
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载主播列表失败",
|
||||
fetcher: listHosts,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-org", "hosts", filters, page]
|
||||
queryKey: ["host-org", "hosts", filters, page],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
@ -53,6 +58,14 @@ export function useHostHostsPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setAgencyId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
agencyId,
|
||||
@ -69,7 +82,8 @@ export function useHostHostsPage() {
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
setPage,
|
||||
status
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
listCountries,
|
||||
listRegions,
|
||||
replaceRegionCountries,
|
||||
updateRegion
|
||||
updateRegion,
|
||||
} from "@/features/host-org/api";
|
||||
import { useRegionAbilities } from "@/features/host-org/permissions.js";
|
||||
import { regionCreateSchema, regionUpdateSchema } from "@/features/host-org/schema";
|
||||
@ -18,7 +18,7 @@ const emptyRegionForm = () => ({
|
||||
countries: [],
|
||||
name: "",
|
||||
regionCode: "",
|
||||
sortOrder: 0
|
||||
sortOrder: 0,
|
||||
});
|
||||
const emptyData = { items: [], total: 0 };
|
||||
const globalRegionCode = "GLOBAL";
|
||||
@ -33,25 +33,34 @@ export function useHostRegionsPage() {
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const queryFn = useCallback(() => listRegions(), []);
|
||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载区域列表失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["host-org", "regions"]
|
||||
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
|
||||
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)) {
|
||||
if (
|
||||
region.status !== "active" ||
|
||||
region.regionId === currentRegionId ||
|
||||
isGlobalRegionCode(region.regionCode)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
(region.countries || []).forEach((country) => assigned.add(country));
|
||||
@ -66,7 +75,9 @@ export function useHostRegionsPage() {
|
||||
const countryLabels = useMemo(() => {
|
||||
return (countriesData?.items || []).reduce((labels, country) => {
|
||||
if (country.countryCode) {
|
||||
labels[country.countryCode] = [country.countryCode, country.countryDisplayName || country.countryName].filter(Boolean).join(" · ");
|
||||
labels[country.countryCode] = [country.countryCode, country.countryDisplayName || country.countryName]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
return labels;
|
||||
}, {});
|
||||
@ -86,6 +97,11 @@ export function useHostRegionsPage() {
|
||||
setStatus(value);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const openCreateRegion = () => {
|
||||
setEditingRegion(null);
|
||||
setRegionFormState(emptyRegionForm());
|
||||
@ -98,7 +114,7 @@ export function useHostRegionsPage() {
|
||||
countries: region.countries || [],
|
||||
name: region.name || "",
|
||||
regionCode: region.regionCode || "",
|
||||
sortOrder: region.sortOrder || 0
|
||||
sortOrder: region.sortOrder || 0,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
@ -118,7 +134,11 @@ export function useHostRegionsPage() {
|
||||
};
|
||||
|
||||
const toggleRegion = async (region, nextActive = region.status !== "active") => {
|
||||
if (isGlobalRegionCode(region.regionCode) || !abilities.canStatus || (region.status === "active") === nextActive) {
|
||||
if (
|
||||
isGlobalRegionCode(region.regionCode) ||
|
||||
!abilities.canStatus ||
|
||||
(region.status === "active") === nextActive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
|
||||
@ -160,10 +180,11 @@ export function useHostRegionsPage() {
|
||||
query,
|
||||
regionForm,
|
||||
reload,
|
||||
resetFilters,
|
||||
setRegionForm,
|
||||
status,
|
||||
submitRegion,
|
||||
toggleRegion
|
||||
toggleRegion,
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,5 +219,9 @@ function filterRegions(items, query, status) {
|
||||
}
|
||||
|
||||
function isGlobalRegionCode(regionCode) {
|
||||
return String(regionCode || "").trim().toUpperCase() === globalRegionCode;
|
||||
return (
|
||||
String(regionCode || "")
|
||||
.trim()
|
||||
.toUpperCase() === globalRegionCode
|
||||
);
|
||||
}
|
||||
|
||||
@ -165,6 +165,62 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@ -7,30 +7,64 @@ 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: "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)"
|
||||
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)",
|
||||
},
|
||||
{ key: "maxHosts", label: "最大主播数", width: "minmax(90px, 0.7fr)" },
|
||||
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
|
||||
];
|
||||
|
||||
export function HostAgenciesPage() {
|
||||
@ -44,12 +78,52 @@ export function HostAgenciesPage() {
|
||||
? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm }
|
||||
: null,
|
||||
page.abilities.canStatus
|
||||
? { icon: <BlockOutlined fontSize="small" />, label: "关闭 Agency", onClick: page.openCloseForm, tone: "danger" }
|
||||
? {
|
||||
icon: <BlockOutlined fontSize="small" />,
|
||||
label: "关闭 Agency",
|
||||
onClick: page.openCloseForm,
|
||||
tone: "danger",
|
||||
}
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
|
||||
: null
|
||||
: 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;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
@ -61,8 +135,8 @@ export function HostAgenciesPage() {
|
||||
label: "上级 BD 用户 ID",
|
||||
name: "parentBdUserId",
|
||||
onChange: page.changeParentBdUserId,
|
||||
value: page.parentBdUserId
|
||||
}
|
||||
value: page.parentBdUserId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
@ -73,24 +147,28 @@ export function HostAgenciesPage() {
|
||||
statusOptions: agencyStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={agencyColumns}
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
minWidth="1250px"
|
||||
rowKey={(item) => item.agencyId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -103,15 +181,56 @@ export function HostAgenciesPage() {
|
||||
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 })} />
|
||||
<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 })} />}
|
||||
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 })} />
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.agencyForm.reason}
|
||||
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
<HostOrgActionModal
|
||||
@ -122,12 +241,38 @@ export function HostAgenciesPage() {
|
||||
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 })} />
|
||||
<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 })} />}
|
||||
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 })} />
|
||||
<TextField
|
||||
disabled={statusDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.joinEnabledForm.reason}
|
||||
onChange={(event) =>
|
||||
page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value })
|
||||
}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
<HostOrgActionModal
|
||||
@ -140,10 +285,46 @@ export function HostAgenciesPage() {
|
||||
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 })} />
|
||||
<TextField
|
||||
disabled={statusDisabled}
|
||||
label="Agency ID"
|
||||
required
|
||||
type="number"
|
||||
value={page.closeForm.agencyId}
|
||||
onChange={(event) => page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={statusDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
required
|
||||
value={page.closeForm.reason}
|
||||
onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgencyOwner({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.ownerAvatar}
|
||||
displayUserId={item.ownerDisplayUserId}
|
||||
fallbackId={item.ownerUserId}
|
||||
username={item.ownerUsername || "Owner"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ParentBD({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.parentBdAvatar}
|
||||
displayUserId={item.parentBdDisplayUserId}
|
||||
fallbackId={item.parentBdUserId}
|
||||
username={item.parentBdUsername || "BD"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import SyncAltOutlined from "@mui/icons-material/SyncAltOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
@ -15,30 +17,72 @@ 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 }
|
||||
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,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 BD Leader", onClick: page.openBDLeaderForm, variant: "primary" }
|
||||
: null
|
||||
].filter(Boolean);
|
||||
const toolbarActions = page.abilities.canCreate
|
||||
? [
|
||||
{
|
||||
icon: <Add fontSize="small" />,
|
||||
label: "创建 BD Leader",
|
||||
onClick: page.openBDLeaderForm,
|
||||
variant: "primary",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
@ -54,7 +98,8 @@ export function HostBdLeadersPage() {
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -62,16 +107,19 @@ export function HostBdLeadersPage() {
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={bdLeaderColumns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
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}
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -84,27 +132,208 @@ export function HostBdLeadersPage() {
|
||||
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 })} />
|
||||
<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>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={updateDisabled}
|
||||
loading={page.loadingAction === "bd-status"}
|
||||
open={page.activeAction === "bd-status"}
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "leader-add-bd"}
|
||||
open={page.activeAction === "leader-add-bd"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitBDStatus}
|
||||
title="BD Leader 状态"
|
||||
onSubmit={page.submitLeaderBD}
|
||||
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 })} />
|
||||
<TextField
|
||||
disabled
|
||||
label="上级 Leader"
|
||||
value={page.selectedLeader?.displayUserId || page.selectedLeader?.userId || ""}
|
||||
/>
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
label="下级用户短 ID"
|
||||
required
|
||||
type="number"
|
||||
value={page.bdForm.targetUserId}
|
||||
onChange={(event) => page.setBDForm({ ...page.bdForm, targetUserId: event.target.value })}
|
||||
/>
|
||||
<UserLookupPreview keyword={page.bdForm.targetUserId} lookup={page.bdUserLookup} />
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.bdForm.reason}
|
||||
onChange={(event) => page.setBDForm({ ...page.bdForm, reason: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
<SideDrawer
|
||||
open={page.activeAction === "leader-bds"}
|
||||
title={`下级 BD ${page.selectedLeader?.displayUserId || page.selectedLeader?.userId || ""}`}
|
||||
width="wide"
|
||||
onClose={page.closeAction}
|
||||
>
|
||||
<DataState error={page.leaderBDsError} loading={page.leaderBDsLoading} onRetry={page.loadLeaderBDs}>
|
||||
<div className={styles.childBdList}>
|
||||
{(page.leaderBDs.items || []).length ? (
|
||||
page.leaderBDs.items.map((item) => (
|
||||
<div className={styles.childBdItem} key={item.userId}>
|
||||
<PersonIdentity item={item} />
|
||||
<span className={styles.childBdStatus}>
|
||||
<HostOrgStatus value={item.status} />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">暂无下级 BD</div>
|
||||
)}
|
||||
</div>
|
||||
</DataState>
|
||||
</SideDrawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonIdentity({ item }) {
|
||||
const name = item.username || "-";
|
||||
const meta = item.displayUserId || item.userId || "-";
|
||||
return (
|
||||
<div className={styles.sellerIdentity}>
|
||||
{item.avatar ? (
|
||||
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
|
||||
) : (
|
||||
<span className={styles.sellerAvatar}>
|
||||
{String(name || meta)
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.sellerText}>
|
||||
<span className={styles.sellerName}>{name}</span>
|
||||
<span className={styles.sellerMeta}>{meta}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatorIdentity({ item }) {
|
||||
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<PersonIdentity
|
||||
item={{
|
||||
avatar: item.createdByAvatar,
|
||||
displayUserId: item.createdByDisplayUserId,
|
||||
userId: item.createdByUserId,
|
||||
username: item.createdByUsername || (item.createdByUserId ? `用户 ${item.createdByUserId}` : "-"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BDLeaderStatusSwitch({ item, page }) {
|
||||
return (
|
||||
<Switch
|
||||
checked={item.status === "active"}
|
||||
disabled={!page?.abilities.canUpdate || page.loadingAction === `bd-leader-status-${item.userId}`}
|
||||
inputProps={{ "aria-label": item.status === "active" ? "停用 BD Leader" : "启用 BD Leader" }}
|
||||
onChange={(event) => page.toggleBDLeader(item, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SubBDCount({ item, page }) {
|
||||
return (
|
||||
<button className={styles.contactButton} type="button" onClick={() => page.openLeaderBDs(item)}>
|
||||
{Number(item.subBdCount || 0).toLocaleString("zh-CN")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BDLeaderActions({ item, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="增加下级 BD" onClick={() => page.openAddLeaderBD(item)}>
|
||||
<PersonAddAlt1Outlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canUpdate ? (
|
||||
<AdminActionIconButton
|
||||
disabled={page.loadingAction === `bd-leader-delete-${item.userId}`}
|
||||
label="删除 BD Leader"
|
||||
sx={dangerActionSx}
|
||||
onClick={() => page.removeBDLeader(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function UserLookupPreview({ keyword, lookup }) {
|
||||
const hasKeyword = String(keyword || "").trim();
|
||||
if (!hasKeyword) {
|
||||
return <div className={styles.lookupPanel}>输入用户短 ID 后自动搜索用户</div>;
|
||||
}
|
||||
if (lookup.loading) {
|
||||
return <div className={styles.lookupPanel}>搜索中...</div>;
|
||||
}
|
||||
if (lookup.error) {
|
||||
return <div className={`${styles.lookupPanel} ${styles.negative}`}>{lookup.error}</div>;
|
||||
}
|
||||
if (!lookup.user) {
|
||||
return <div className={styles.lookupPanel}>未找到匹配用户,提交时后端会再次校验</div>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.lookupPanel}>
|
||||
<PersonIdentity item={lookup.user} />
|
||||
<span className={styles.sellerMeta}>{lookup.user.regionName || "未分配区域"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function regionName(item, regionOptions = []) {
|
||||
if (item.regionName) {
|
||||
return item.regionName;
|
||||
}
|
||||
const option = regionOptions.find((region) => String(region.value) === String(item.regionId));
|
||||
return option?.name || item.regionId || "-";
|
||||
}
|
||||
|
||||
const dangerActionSx = {
|
||||
borderColor: "rgba(239, 68, 68, 0.3)",
|
||||
backgroundColor: "rgba(239, 68, 68, 0.08)",
|
||||
color: "var(--danger)",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(239, 68, 68, 0.5)",
|
||||
backgroundColor: "rgba(239, 68, 68, 0.14)",
|
||||
color: "var(--danger)",
|
||||
},
|
||||
};
|
||||
|
||||
@ -5,24 +5,60 @@ 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() {
|
||||
@ -37,8 +73,43 @@ export function HostBdsPage() {
|
||||
: null,
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
||||
: null
|
||||
: 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;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
@ -50,8 +121,8 @@ export function HostBdsPage() {
|
||||
label: "上级 Leader 用户 ID",
|
||||
name: "parentLeaderUserId",
|
||||
onChange: page.changeParentLeaderUserId,
|
||||
value: page.parentLeaderUserId
|
||||
}
|
||||
value: page.parentLeaderUserId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
@ -62,24 +133,28 @@ export function HostBdsPage() {
|
||||
statusOptions: bdStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={bdColumns}
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
minWidth="1260px"
|
||||
rowKey={(item) => `bd-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -92,9 +167,30 @@ export function HostBdsPage() {
|
||||
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 })} />
|
||||
<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>
|
||||
|
||||
<HostOrgActionModal
|
||||
@ -105,14 +201,75 @@ export function HostBdsPage() {
|
||||
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 })}>
|
||||
<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 })} />
|
||||
<TextField
|
||||
disabled={updateDisabled}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.bdStatusForm.reason}
|
||||
onChange={(event) => page.setBDStatusForm({ ...page.bdStatusForm, reason: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BDUser({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.avatar}
|
||||
displayUserId={item.displayUserId}
|
||||
fallbackId={item.userId}
|
||||
username={item.username}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ParentLeader({ item }) {
|
||||
if (!item.parentLeaderUserId) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.parentLeaderAvatar}
|
||||
displayUserId={item.parentLeaderDisplayUserId}
|
||||
fallbackId={item.parentLeaderUserId}
|
||||
username={item.parentLeaderUsername || "Leader"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Creator({ item }) {
|
||||
if (!item.createdByUserId && !item.createdByDisplayUserId && !item.createdByUsername) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.createdByAvatar}
|
||||
displayUserId={item.createdByDisplayUserId}
|
||||
fallbackId={item.createdByUserId}
|
||||
username={item.createdByUsername || "创建人"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
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 = [
|
||||
@ -21,13 +27,44 @@ const baseColumns = [
|
||||
key: "seller",
|
||||
label: "币商",
|
||||
render: (item) => <SellerIdentity item={item} />,
|
||||
width: "minmax(220px, 1.3fr)"
|
||||
width: "minmax(220px, 1.3fr)",
|
||||
},
|
||||
{
|
||||
key: "regionId",
|
||||
label: "区域",
|
||||
render: (item, _index, context) => regionName(item, context?.regionOptions),
|
||||
width: "minmax(140px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "contact",
|
||||
label: "联系方式",
|
||||
render: (item, _index, context) => <SellerContact item={item} page={context?.page} />,
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "merchantAssetType",
|
||||
label: "资产类型",
|
||||
render: (item) => item.merchantAssetType || "-",
|
||||
width: "minmax(150px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "merchantBalance",
|
||||
label: "币商余额",
|
||||
render: (item) => formatNumber(item.merchantBalance),
|
||||
width: "minmax(120px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "createdByUserId",
|
||||
label: "创建人 ID",
|
||||
render: (item) => item.createdByUserId || "-",
|
||||
width: "minmax(110px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
{ key: "regionId", label: "区域", render: (item, _index, context) => regionName(item, context?.regionOptions), width: "minmax(140px, 0.8fr)" },
|
||||
{ key: "merchantAssetType", label: "资产类型", render: (item) => item.merchantAssetType || "-", width: "minmax(150px, 0.9fr)" },
|
||||
{ key: "merchantBalance", label: "币商余额", render: (item) => formatNumber(item.merchantBalance), width: "minmax(120px, 0.8fr)" },
|
||||
{ key: "createdByUserId", label: "创建人 ID", render: (item) => item.createdByUserId || "-", width: "minmax(110px, 0.7fr)" },
|
||||
{ key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)" }
|
||||
];
|
||||
|
||||
export function HostCoinSellersPage() {
|
||||
@ -42,18 +79,49 @@ export function HostCoinSellersPage() {
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <SellerStatusSwitch item={item} page={page} />,
|
||||
width: "minmax(90px, 0.6fr)"
|
||||
width: "minmax(90px, 0.6fr)",
|
||||
},
|
||||
...baseColumns.slice(2),
|
||||
page.abilities.canUpdate
|
||||
page.abilities.canStockCredit
|
||||
? {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <SellerActions item={item} page={page} />,
|
||||
width: "minmax(120px, 0.8fr)"
|
||||
width: "minmax(90px, 0.5fr)",
|
||||
}
|
||||
: null
|
||||
].filter(Boolean);
|
||||
: 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" }]
|
||||
: [];
|
||||
@ -73,7 +141,8 @@ export function HostCoinSellersPage() {
|
||||
statusOptions: coinSellerStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -81,18 +150,20 @@ export function HostCoinSellersPage() {
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1040px"
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
regionFilter={regionFilter}
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
@ -105,8 +176,20 @@ export function HostCoinSellersPage() {
|
||||
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 })} />
|
||||
<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
|
||||
@ -115,14 +198,90 @@ export function HostCoinSellersPage() {
|
||||
open={page.activeAction === "edit"}
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitEditSeller}
|
||||
title="编辑币商"
|
||||
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
|
||||
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>
|
||||
<TextField disabled={updateDisabled} label="联系方式" value={page.editForm.reason} onChange={(event) => page.setEditForm({ ...page.editForm, reason: event.target.value })} />
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<AdminFormAmountField
|
||||
disabled={!page.abilities.canStockCredit}
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
label="充值金币"
|
||||
required
|
||||
unit="金币"
|
||||
value={page.stockForm.coinAmount}
|
||||
onChange={(event) =>
|
||||
page.setStockForm({ ...page.stockForm, coinAmount: event.target.value })
|
||||
}
|
||||
/>
|
||||
{page.stockForm.type === "usdt_purchase" ? (
|
||||
<AdminFormAmountField
|
||||
disabled={!page.abilities.canStockCredit}
|
||||
label="充值金额"
|
||||
required
|
||||
unit="USDT"
|
||||
value={page.stockForm.rechargeAmount}
|
||||
onChange={(event) =>
|
||||
page.setStockForm({ ...page.stockForm, rechargeAmount: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="备注信息">
|
||||
<TextField
|
||||
disabled={!page.abilities.canStockCredit}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.stockForm.reason}
|
||||
onChange={(event) => page.setStockForm({ ...page.stockForm, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</HostOrgActionModal>
|
||||
</section>
|
||||
);
|
||||
@ -134,7 +293,11 @@ function SellerIdentity({ item }) {
|
||||
|
||||
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>}
|
||||
{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>
|
||||
@ -154,17 +317,32 @@ function SellerStatusSwitch({ item, page }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SellerActions({ item, page }) {
|
||||
const deleting = page.loadingAction === `coin-seller-delete-${item.userId}`;
|
||||
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 }) {
|
||||
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" />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@ -180,3 +358,17 @@ function regionName(item, regionOptions = []) {
|
||||
function formatNumber(value) {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@ -7,28 +7,38 @@ import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox
|
||||
} 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: "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: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function HostCountriesPage() {
|
||||
@ -43,10 +53,27 @@ export function HostCountriesPage() {
|
||||
key: "enabled",
|
||||
label: "状态",
|
||||
render: (item) => <CountryStatusSwitch item={item} page={page} />,
|
||||
width: "minmax(90px, 0.6fr)"
|
||||
filter: createOptionsColumnFilter({
|
||||
options: countryEnabledFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.enabledStatus,
|
||||
onChange: page.changeEnabledStatus,
|
||||
}),
|
||||
width: "minmax(90px, 0.6fr)",
|
||||
},
|
||||
...baseCountryColumns.slice(6)
|
||||
];
|
||||
...baseCountryColumns.slice(6),
|
||||
].map((column) =>
|
||||
column.key === "countryCode"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索国家码、国家名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
const columns = page.abilities.canUpdate
|
||||
? [
|
||||
...countryColumns,
|
||||
@ -54,37 +81,28 @@ export function HostCountriesPage() {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <CountryActions item={item} page={page} />,
|
||||
width: "minmax(104px, 0.7fr)"
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
actions={page.abilities.canCreate ? (
|
||||
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}
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
rowKey={(item) => item.countryId}
|
||||
/>
|
||||
<HostOrgTable columns={columns} items={items} rowKey={(item) => item.countryId} />
|
||||
<div className="pagination-bar">
|
||||
<span>{total} 条</span>
|
||||
</div>
|
||||
@ -99,22 +117,77 @@ export function HostCountriesPage() {
|
||||
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 })} />
|
||||
<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 })} />}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,29 +1,111 @@
|
||||
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 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}>
|
||||
@ -35,8 +117,8 @@ export function HostHostsPage() {
|
||||
label: "Agency ID",
|
||||
name: "agencyId",
|
||||
onChange: page.changeAgencyId,
|
||||
value: page.agencyId
|
||||
}
|
||||
value: page.agencyId,
|
||||
},
|
||||
],
|
||||
loadingRegions: page.loadingRegions,
|
||||
query: page.query,
|
||||
@ -47,27 +129,42 @@ export function HostHostsPage() {
|
||||
statusOptions: hostStatusFilters,
|
||||
onQueryChange: page.changeQuery,
|
||||
onRegionIdChange: page.changeRegionId,
|
||||
onStatusChange: page.changeStatus
|
||||
onReset: page.resetFilters,
|
||||
onStatusChange: page.changeStatus,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={hostColumns}
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
minWidth="1240px"
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={total}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HostUser({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.avatar}
|
||||
displayUserId={item.displayUserId}
|
||||
fallbackId={item.userId}
|
||||
username={item.username}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,23 +5,33 @@ 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: "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: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function HostRegionsPage() {
|
||||
@ -36,10 +46,27 @@ export function HostRegionsPage() {
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <RegionStatusSwitch item={item} page={page} />,
|
||||
width: "minmax(90px, 0.6fr)"
|
||||
filter: createOptionsColumnFilter({
|
||||
options: regionStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
width: "minmax(90px, 0.6fr)",
|
||||
},
|
||||
...baseRegionColumns.slice(3)
|
||||
];
|
||||
...baseRegionColumns.slice(3),
|
||||
].map((column) =>
|
||||
column.key === "regionCode"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索区域、国家码",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
const columns = page.abilities.canUpdate
|
||||
? [
|
||||
...regionColumns,
|
||||
@ -47,8 +74,8 @@ export function HostRegionsPage() {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <RegionActions item={item} page={page} />,
|
||||
width: "minmax(80px, 0.5fr)"
|
||||
}
|
||||
width: "minmax(80px, 0.5fr)",
|
||||
},
|
||||
]
|
||||
: regionColumns;
|
||||
return (
|
||||
@ -57,12 +84,9 @@ export function HostRegionsPage() {
|
||||
<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}
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@ -82,11 +106,7 @@ export function HostRegionsPage() {
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
rowKey={(item) => item.regionId}
|
||||
/>
|
||||
<HostOrgTable columns={columns} items={items} rowKey={(item) => item.regionId} />
|
||||
<div className="pagination-bar">
|
||||
<span>{total} 条</span>
|
||||
</div>
|
||||
@ -102,11 +122,31 @@ export function HostRegionsPage() {
|
||||
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 })} />
|
||||
<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)}
|
||||
disabled={
|
||||
page.isGlobalRegionForm || (page.activeAction === "edit" ? updateDisabled : createDisabled)
|
||||
}
|
||||
label={page.isGlobalRegionForm ? "国家码" : "国家码 *"}
|
||||
labels={page.countryLabels}
|
||||
loading={page.loadingCountries}
|
||||
@ -115,7 +155,6 @@ export function HostRegionsPage() {
|
||||
onChange={(countries) => page.setRegionForm({ ...page.regionForm, countries })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -191,6 +230,6 @@ const primaryActionSx = {
|
||||
"&:hover": {
|
||||
borderColor: "var(--primary-strong)",
|
||||
backgroundColor: "var(--primary-strong)",
|
||||
color: "var(--active-contrast)"
|
||||
}
|
||||
color: "var(--active-contrast)",
|
||||
},
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ export function useCountryAbilities() {
|
||||
canCreate: can(PERMISSIONS.countryCreate),
|
||||
canStatus: can(PERMISSIONS.countryStatus),
|
||||
canUpdate: can(PERMISSIONS.countryUpdate),
|
||||
canView: can(PERMISSIONS.countryView)
|
||||
canView: can(PERMISSIONS.countryView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export function useRegionAbilities() {
|
||||
canCreate: can(PERMISSIONS.regionCreate),
|
||||
canStatus: can(PERMISSIONS.regionStatus),
|
||||
canUpdate: can(PERMISSIONS.regionUpdate),
|
||||
canView: can(PERMISSIONS.regionView)
|
||||
canView: can(PERMISSIONS.regionView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ export function useAgencyAbilities() {
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.agencyCreate),
|
||||
canStatus: can(PERMISSIONS.agencyStatus),
|
||||
canView: can(PERMISSIONS.agencyView)
|
||||
canView: can(PERMISSIONS.agencyView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export function useHostAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canView: can(PERMISSIONS.hostView)
|
||||
canView: can(PERMISSIONS.hostView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export function useBDAbilities() {
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.bdCreate),
|
||||
canUpdate: can(PERMISSIONS.bdUpdate),
|
||||
canView: can(PERMISSIONS.bdView)
|
||||
canView: can(PERMISSIONS.bdView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -56,7 +56,8 @@ export function useCoinSellerAbilities() {
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.coinSellerCreate),
|
||||
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
|
||||
canUpdate: can(PERMISSIONS.coinSellerUpdate),
|
||||
canView: can(PERMISSIONS.coinSellerView)
|
||||
canView: can(PERMISSIONS.coinSellerView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,12 +2,13 @@ import { z } from "zod";
|
||||
|
||||
const commandBaseSchema = z.object({
|
||||
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
|
||||
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default("")
|
||||
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default(""),
|
||||
});
|
||||
|
||||
const commandContactSchema = z.object({
|
||||
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
|
||||
reason: z.string().trim().max(200, "联系方式不能超过 200 个字符").optional().default("")
|
||||
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
|
||||
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default(""),
|
||||
});
|
||||
|
||||
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
|
||||
@ -16,9 +17,7 @@ const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").d
|
||||
const optionalTextSchema = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
|
||||
const codeSchema = (label: string) => z.string().trim().min(1, `请输入${label}`).max(40, `${label}不能超过 40 个字符`);
|
||||
const countryCodesSchema = z.array(z.string()).transform((values, ctx) => {
|
||||
const countries = values
|
||||
.map((item) => item.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
const countries = values.map((item) => item.trim().toUpperCase()).filter(Boolean);
|
||||
if (new Set(countries).size !== countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "国家码不能重复" });
|
||||
}
|
||||
@ -31,19 +30,21 @@ 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({
|
||||
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) => {
|
||||
sortOrder: sortOrderSchema,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (retiredRegionCodes.has(value.regionCode)) {
|
||||
ctx.addIssue({ code: "custom", message: "该区域已移除", path: ["regionCode"] });
|
||||
}
|
||||
@ -57,27 +58,47 @@ const regionBaseSchema = z.object({
|
||||
|
||||
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({
|
||||
@ -85,20 +106,24 @@ export const createAgencySchema = commandBaseSchema.extend({
|
||||
maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0),
|
||||
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
|
||||
ownerUserId: userIdSchema,
|
||||
parentBdUserId: userIdSchema
|
||||
parentBdUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const agencyCloseSchema = commandBaseSchema.extend({
|
||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID")
|
||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
|
||||
});
|
||||
|
||||
export const agencyJoinEnabledSchema = commandBaseSchema.extend({
|
||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
|
||||
joinEnabled: z.boolean().default(true)
|
||||
joinEnabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const countryCreateSchema = z.object({
|
||||
countryCode: z.string().trim().regex(/^[A-Za-z]{2,3}$/, "国家码必须是 2-3 位字母").transform((value) => value.toUpperCase()),
|
||||
countryCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z]{2,3}$/, "国家码必须是 2-3 位字母")
|
||||
.transform((value) => value.toUpperCase()),
|
||||
countryDisplayName: z.string().trim().min(1, "请输入展示名称").max(80, "展示名称不能超过 80 个字符"),
|
||||
countryName: z.string().trim().min(1, "请输入国家名称").max(80, "国家名称不能超过 80 个字符"),
|
||||
enabled: z.boolean().default(true),
|
||||
@ -106,12 +131,12 @@ export const countryCreateSchema = z.object({
|
||||
isoAlpha3: optionalTextSchema(8, "ISO Alpha-3 不能超过 8 个字符").transform((value) => value.toUpperCase()),
|
||||
isoNumeric: optionalTextSchema(8, "ISO Numeric 不能超过 8 个字符"),
|
||||
phoneCountryCode: optionalTextSchema(16, "电话区号不能超过 16 个字符"),
|
||||
sortOrder: sortOrderSchema
|
||||
sortOrder: sortOrderSchema,
|
||||
});
|
||||
|
||||
export const countryUpdateSchema = countryCreateSchema.omit({
|
||||
countryCode: true,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
export const regionCreateSchema = regionBaseSchema;
|
||||
@ -119,7 +144,7 @@ export const regionCreateSchema = regionBaseSchema;
|
||||
export const regionUpdateSchema = regionBaseSchema;
|
||||
|
||||
export const regionCountriesSchema = z.object({
|
||||
countries: requiredCountryCodesSchema
|
||||
countries: requiredCountryCodesSchema,
|
||||
});
|
||||
|
||||
export type CreateBDLeaderForm = z.infer<typeof createBDLeaderSchema>;
|
||||
@ -127,6 +152,7 @@ export type CreateBDForm = z.infer<typeof createBDSchema>;
|
||||
export type BDStatusForm = z.infer<typeof bdStatusSchema>;
|
||||
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
||||
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
||||
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
||||
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
||||
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
||||
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
|
||||
export function LogFilters({ onQueryChange, query }) {
|
||||
export function LogFilters({ onReset, query }) {
|
||||
return (
|
||||
<section className="filters-panel">
|
||||
<SearchBox value={query} onChange={onQueryChange} placeholder="搜索用户、IP、动作、资源..." />
|
||||
<AdminFilterResetButton disabled={!query} onClick={onReset} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
|
||||
export function LogTable({ data, isLogin }) {
|
||||
export function LogTable({ data, isLogin, onQueryChange, query }) {
|
||||
const items = data.items || [];
|
||||
const columns = isLogin
|
||||
? [
|
||||
{ key: "username", label: "用户", width: "minmax(140px, 1fr)" },
|
||||
{
|
||||
key: "username",
|
||||
label: "用户",
|
||||
width: "minmax(140px, 1fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户、IP、动作、资源",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
})
|
||||
},
|
||||
{ key: "ip", label: "IP", width: "minmax(140px, 1fr)", render: (item) => <span className="muted">{item.ip || "-"}</span> },
|
||||
{ key: "status", label: "状态", width: "110px" },
|
||||
{
|
||||
@ -21,7 +31,16 @@ export function LogTable({ data, isLogin }) {
|
||||
}
|
||||
]
|
||||
: [
|
||||
{ key: "username", label: "用户", width: "minmax(140px, 1fr)" },
|
||||
{
|
||||
key: "username",
|
||||
label: "用户",
|
||||
width: "minmax(140px, 1fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户、IP、动作、资源",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
})
|
||||
},
|
||||
{ key: "action", label: "动作", width: "minmax(120px, 1fr)" },
|
||||
{
|
||||
key: "resource",
|
||||
|
||||
@ -24,7 +24,7 @@ export function useLogsPage(type) {
|
||||
errorMessage: "加载日志失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["logs", type, requestQuery]
|
||||
queryKey: ["logs", type, requestQuery],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
@ -32,6 +32,11 @@ export function useLogsPage(type) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const exporter = isLogin ? exportLoginLogs : exportOperationLogs;
|
||||
const filename = isLogin ? "hyapp-login-logs.csv" : "hyapp-operation-logs.csv";
|
||||
@ -49,6 +54,7 @@ export function useLogsPage(type) {
|
||||
page,
|
||||
query,
|
||||
reload,
|
||||
setPage
|
||||
resetFilters,
|
||||
setPage,
|
||||
};
|
||||
}
|
||||
|
||||
@ -11,11 +11,16 @@ export function LogsPage({ type }) {
|
||||
return (
|
||||
<>
|
||||
<LogToolbar canExport={page.abilities.canExport} isLogin={page.isLogin} onExport={page.downloadLogs} />
|
||||
<LogFilters query={page.query} onQueryChange={page.changeQuery} />
|
||||
<LogFilters query={page.query} onReset={page.resetFilters} />
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<LogTable data={page.data} isLogin={page.isLogin} />
|
||||
<PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={page.data.total || 0} onPageChange={page.setPage} />
|
||||
<LogTable data={page.data} isLogin={page.isLogin} query={page.query} onQueryChange={page.changeQuery} />
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.data.pageSize || 10}
|
||||
total={page.data.total || 0}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -10,8 +10,9 @@ import {
|
||||
adminListClasses
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
|
||||
export function MenuTable({ abilities, flatMenus, onEdit, onReorder, onRemove, onToggleVisible }) {
|
||||
export function MenuTable({ abilities, flatMenus, onEdit, onQueryChange, onReorder, onRemove, onToggleVisible, query }) {
|
||||
const [draggingMenu, setDraggingMenu] = useState(null);
|
||||
const [dragOverId, setDragOverId] = useState(null);
|
||||
const columns = [
|
||||
@ -25,6 +26,11 @@ export function MenuTable({ abilities, flatMenus, onEdit, onReorder, onRemove, o
|
||||
key: "label",
|
||||
label: "菜单",
|
||||
width: "minmax(180px, 1.2fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索菜单、权限码",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
}),
|
||||
render: (menu) => <span style={{ paddingLeft: menu.depth * 18 }}>{menu.label}</span>
|
||||
},
|
||||
{ key: "path", label: "路由", width: "minmax(180px, 1.2fr)", render: (menu) => <span className="muted">{menu.path || "-"}</span> },
|
||||
|
||||
@ -3,14 +3,24 @@ import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { kindLabel } from "@/features/menus/constants.js";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
|
||||
export function PermissionTable({ abilities, onEdit, onRemove, permissions }) {
|
||||
export function PermissionTable({ abilities, onEdit, onQueryChange, onRemove, permissions, query }) {
|
||||
if (!abilities.canLoadPermissions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: "name", label: "权限", width: "minmax(160px, 1fr)" },
|
||||
{
|
||||
key: "name",
|
||||
label: "权限",
|
||||
width: "minmax(160px, 1fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索权限、权限码",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
})
|
||||
},
|
||||
{ key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (permission) => <span className="muted">{permission.code}</span> },
|
||||
{ key: "kind", label: "类型", width: "110px", render: (permission) => kindLabel(permission.kind) },
|
||||
{
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
syncPermissions,
|
||||
updateMenu,
|
||||
updateMenuVisible,
|
||||
updatePermission
|
||||
updatePermission,
|
||||
} from "@/features/menus/api";
|
||||
import { useMenuAbilities } from "@/features/menus/permissions.js";
|
||||
import { menuFormSchema, permissionFormSchema } from "@/features/menus/schema";
|
||||
@ -27,7 +27,7 @@ const emptyMenu = {
|
||||
path: "",
|
||||
permissionCode: "",
|
||||
sort: 0,
|
||||
visible: true
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const emptyPermission = { code: "", description: "", kind: "button", name: "" };
|
||||
@ -55,7 +55,7 @@ export function useMenusPage() {
|
||||
errorMessage: "加载菜单权限失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["menus", abilities.canLoadPermissions]
|
||||
queryKey: ["menus", abilities.canLoadPermissions],
|
||||
});
|
||||
|
||||
const flatMenus = useMemo(() => flattenMenus(data.menus || []), [data.menus]);
|
||||
@ -65,7 +65,11 @@ export function useMenusPage() {
|
||||
return flatMenus;
|
||||
}
|
||||
return flatMenus.filter((menu) => {
|
||||
return [menu.label, menu.code, menu.path, menu.permissionCode].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
return [menu.label, menu.code, menu.path, menu.permissionCode].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [flatMenus, query]);
|
||||
const filteredPermissions = useMemo(() => {
|
||||
@ -75,7 +79,11 @@ export function useMenusPage() {
|
||||
return items;
|
||||
}
|
||||
return items.filter((permission) => {
|
||||
return [permission.name, permission.code, permission.kind, permission.description].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
return [permission.name, permission.code, permission.kind, permission.description].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [data.permissions, query]);
|
||||
|
||||
@ -83,6 +91,10 @@ export function useMenusPage() {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const openCreateMenu = () => {
|
||||
setEditingMenu(null);
|
||||
setMenuForm(emptyMenu);
|
||||
@ -99,7 +111,7 @@ export function useMenusPage() {
|
||||
path: menu.path || "",
|
||||
permissionCode: menu.permissionCode || "",
|
||||
sort: menu.sort || 0,
|
||||
visible: Boolean(menu.visible)
|
||||
visible: Boolean(menu.visible),
|
||||
});
|
||||
setMenuDrawerOpen(true);
|
||||
};
|
||||
@ -116,7 +128,7 @@ export function useMenusPage() {
|
||||
code: permission.code,
|
||||
description: permission.description || "",
|
||||
kind: permission.kind,
|
||||
name: permission.name
|
||||
name: permission.name,
|
||||
});
|
||||
setPermissionDrawerOpen(true);
|
||||
};
|
||||
@ -158,7 +170,12 @@ export function useMenusPage() {
|
||||
};
|
||||
|
||||
const removeMenu = async (menu) => {
|
||||
const ok = await confirm({ confirmText: "删除", message: `${menu.label} 删除前需要先删除子菜单。`, title: "删除菜单", tone: "danger" });
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${menu.label} 删除前需要先删除子菜单。`,
|
||||
title: "删除菜单",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@ -172,7 +189,12 @@ export function useMenusPage() {
|
||||
};
|
||||
|
||||
const removePermission = async (permission) => {
|
||||
const ok = await confirm({ confirmText: "删除", message: `${permission.name} 删除前不能被角色或菜单引用。`, title: "删除权限", tone: "danger" });
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${permission.name} 删除前不能被角色或菜单引用。`,
|
||||
title: "删除权限",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@ -256,6 +278,7 @@ export function useMenusPage() {
|
||||
permissions: filteredPermissions,
|
||||
query,
|
||||
reload,
|
||||
resetFilters,
|
||||
removeMenu,
|
||||
removePermission,
|
||||
reorderMenu,
|
||||
@ -264,13 +287,10 @@ export function useMenusPage() {
|
||||
submitMenu,
|
||||
submitPermission,
|
||||
syncDefaultPermissions,
|
||||
toggleVisible
|
||||
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)]);
|
||||
}
|
||||
|
||||
@ -3,10 +3,10 @@ import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { MenuFormDrawer } from "@/features/menus/components/MenuFormDrawer.jsx";
|
||||
import { MenuTable } from "@/features/menus/components/MenuTable.jsx";
|
||||
@ -21,8 +21,10 @@ export function MenuManagementPage() {
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索菜单、权限码..." />}
|
||||
actions={(
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{page.abilities.canSyncPermission ? (
|
||||
<AdminActionIconButton label="同步权限" onClick={page.syncDefaultPermissions}>
|
||||
@ -40,7 +42,7 @@ export function MenuManagementPage() {
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
@ -49,14 +51,18 @@ export function MenuManagementPage() {
|
||||
<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}
|
||||
/>
|
||||
|
||||
@ -1,28 +1,26 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo, useState } from "react";
|
||||
import { listRechargeBills } from "@/features/payment/api";
|
||||
import {
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox,
|
||||
adminListClasses
|
||||
} 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 = [
|
||||
@ -30,25 +28,30 @@ const columns = [
|
||||
key: "bill",
|
||||
label: "账单",
|
||||
width: "minmax(240px, 1.4fr)",
|
||||
render: (bill) => (
|
||||
<Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />
|
||||
)
|
||||
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: "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)} />
|
||||
)
|
||||
<Stack
|
||||
primary={formatNumber(bill.coinAmount)}
|
||||
secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "policy",
|
||||
@ -59,28 +62,37 @@ const columns = [
|
||||
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}` : "-"} />
|
||||
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: "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)
|
||||
}
|
||||
render: (bill) => formatMillis(bill.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
export function PaymentBillListPage() {
|
||||
@ -97,9 +109,9 @@ export function PaymentBillListPage() {
|
||||
recharge_type: rechargeType,
|
||||
seller_user_id: sellerUserId,
|
||||
status,
|
||||
user_id: userId
|
||||
user_id: userId,
|
||||
}),
|
||||
[keyword, rechargeType, sellerUserId, status, userId]
|
||||
[keyword, rechargeType, sellerUserId, status, userId],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取账单列表失败",
|
||||
@ -107,7 +119,7 @@ export function PaymentBillListPage() {
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["payment-bills", filters, page]
|
||||
queryKey: ["payment-bills", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
@ -118,35 +130,96 @@ export function PaymentBillListPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setRechargeType("");
|
||||
setUserId("");
|
||||
setSellerUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
const tableColumns = columns.map((column) => {
|
||||
if (column.key === "bill") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索账单号、命令号、外部单号",
|
||||
value: keyword,
|
||||
onChange: resetPage(setKeyword),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "userId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID",
|
||||
value: userId,
|
||||
onChange: (value) => resetPage(setUserId)(digitsOnly(value)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "sellerUserId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索币商 ID",
|
||||
value: sellerUserId,
|
||||
onChange: (value) => resetPage(setSellerUserId)(digitsOnly(value)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "rechargeType") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: rechargeTypeOptions,
|
||||
placeholder: "搜索充值途径",
|
||||
value: rechargeType,
|
||||
onChange: resetPage(setRechargeType),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
placeholder: "搜索状态",
|
||||
value: status,
|
||||
onChange: resetPage(setStatus),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<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))}
|
||||
<AdminFilterResetButton
|
||||
disabled={!keyword && !status && !rechargeType && !userId && !sellerUserId}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
<TextField
|
||||
className={adminListClasses.statusSelect}
|
||||
label="币商 ID"
|
||||
size="small"
|
||||
value={sellerUserId}
|
||||
onChange={(event) => resetPage(setSellerUserId)(digitsOnly(event.target.value))}
|
||||
/>
|
||||
<AdminFilterSelect label="充值途径" options={rechargeTypeOptions} value={rechargeType} onChange={resetPage(setRechargeType)} />
|
||||
<AdminFilterSelect label="状态" options={statusOptions} value={status} onChange={resetPage(setStatus)} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<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}
|
||||
<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>
|
||||
@ -199,7 +272,7 @@ function formatMoney(value, currency = "USD") {
|
||||
}
|
||||
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -17,17 +17,17 @@ import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||
import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminRowActions,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { resourceStatusFilters } from "@/features/resources/constants.js";
|
||||
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
@ -43,16 +43,16 @@ const giftTypeOptions = [
|
||||
["flag", "国旗礼物"],
|
||||
["activity", "活动礼物"],
|
||||
["magic", "魔法礼物"],
|
||||
["custom", "定制礼物"]
|
||||
["custom", "定制礼物"],
|
||||
];
|
||||
const chargeAssetOptions = [
|
||||
["COIN", "金币"],
|
||||
["DIAMOND", "钻石"]
|
||||
["DIAMOND", "钻石"],
|
||||
];
|
||||
const effectOptions = [
|
||||
["animation", "动画"],
|
||||
["music", "音乐"],
|
||||
["global_broadcast", "全局广播"]
|
||||
["global_broadcast", "全局广播"],
|
||||
];
|
||||
const effectLabels = Object.fromEntries(effectOptions);
|
||||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
@ -62,13 +62,13 @@ const baseColumns = [
|
||||
key: "gift",
|
||||
label: "礼物",
|
||||
width: "minmax(260px, 1.4fr)",
|
||||
render: (gift) => <GiftIdentity gift={gift} />
|
||||
render: (gift) => <GiftIdentity gift={gift} />,
|
||||
},
|
||||
{
|
||||
key: "resource",
|
||||
label: "资源",
|
||||
width: "minmax(220px, 1.1fr)",
|
||||
render: (gift) => <ResourceRef gift={gift} />
|
||||
render: (gift) => <ResourceRef gift={gift} />,
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
@ -77,9 +77,11 @@ const baseColumns = [
|
||||
render: (gift) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{giftTypeLabel(gift.giftTypeCode)}</span>
|
||||
<span className={styles.meta}>{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}</span>
|
||||
<span className={styles.meta}>
|
||||
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "income",
|
||||
@ -90,12 +92,12 @@ const baseColumns = [
|
||||
<span>{formatNumber(gift.giftPointAmount)}</span>
|
||||
<span className={styles.meta}>{formatNumber(gift.heatValue)}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "regions",
|
||||
label: "区域",
|
||||
width: "minmax(160px, 0.8fr)"
|
||||
width: "minmax(160px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "effects",
|
||||
@ -106,24 +108,24 @@ const baseColumns = [
|
||||
<span>{effectiveRangeLabel(gift)}</span>
|
||||
<span className={styles.meta}>{effectTypesLabel(gift.effectTypes)}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(92px, 0.55fr)"
|
||||
width: "minmax(92px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.9fr)",
|
||||
render: (gift) => formatMillis(gift.updatedAtMs || gift.createdAtMs)
|
||||
render: (gift) => formatMillis(gift.updatedAtMs || gift.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(76px, 0.4fr)"
|
||||
}
|
||||
width: "minmax(76px, 0.4fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function GiftListPage() {
|
||||
@ -141,21 +143,37 @@ export function GiftListPage() {
|
||||
loading: page.loadingRegions,
|
||||
options: giftRegionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
onChange: page.changeRegionId,
|
||||
}),
|
||||
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />
|
||||
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "gift") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索礼物名称、礼物 ID",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
render: (gift) => <GiftRowActions gift={gift} page={page} />
|
||||
render: (gift) => <GiftRowActions gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
return column;
|
||||
@ -165,17 +183,10 @@ export function GiftListPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索礼物名称、礼物 ID..." />
|
||||
<AdminFilterSelect options={resourceStatusFilters} value={page.status} onChange={page.changeStatus} />
|
||||
<RegionFilterControl
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={giftRegionOptions}
|
||||
value={page.regionId}
|
||||
onChange={page.changeRegionId}
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status && !page.regionId}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGift ? (
|
||||
@ -275,7 +286,9 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||
>
|
||||
{giftTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
@ -301,7 +314,9 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
||||
>
|
||||
{chargeAssetOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
@ -396,13 +411,10 @@ function DateTimeField({ disabled, label, onChange, value }) {
|
||||
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
|
||||
};
|
||||
const changeDay = (day) => {
|
||||
setDraftDate((current) => new Date(
|
||||
day.getFullYear(),
|
||||
day.getMonth(),
|
||||
day.getDate(),
|
||||
current.getHours(),
|
||||
current.getMinutes()
|
||||
));
|
||||
setDraftDate(
|
||||
(current) =>
|
||||
new Date(day.getFullYear(), day.getMonth(), day.getDate(), current.getHours(), current.getMinutes()),
|
||||
);
|
||||
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||
};
|
||||
const changeTime = (unit, nextValue) => {
|
||||
@ -428,7 +440,7 @@ function DateTimeField({ disabled, label, onChange, value }) {
|
||||
<InputAdornment position="end">
|
||||
<CalendarMonthOutlined fontSize="small" />
|
||||
</InputAdornment>
|
||||
)
|
||||
),
|
||||
}}
|
||||
label={label}
|
||||
placeholder="长期有效"
|
||||
@ -454,7 +466,9 @@ function DateTimeField({ disabled, label, onChange, value }) {
|
||||
</div>
|
||||
<div className={styles.calendarGrid}>
|
||||
{weekLabels.map((weekday) => (
|
||||
<span className={styles.weekday} key={weekday}>{weekday}</span>
|
||||
<span className={styles.weekday} key={weekday}>
|
||||
{weekday}
|
||||
</span>
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const isOutside = day.getMonth() !== viewDate.getMonth();
|
||||
@ -464,8 +478,10 @@ function DateTimeField({ disabled, label, onChange, value }) {
|
||||
className={[
|
||||
styles.dayButton,
|
||||
isOutside ? styles.dayOutside : "",
|
||||
isSelected ? styles.daySelected : ""
|
||||
].filter(Boolean).join(" ")}
|
||||
isSelected ? styles.daySelected : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={day.toISOString()}
|
||||
type="button"
|
||||
onClick={() => changeDay(day)}
|
||||
@ -484,7 +500,9 @@ function DateTimeField({ disabled, label, onChange, value }) {
|
||||
onChange={(event) => changeTime("hour", event.target.value)}
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, hour) => (
|
||||
<MenuItem key={hour} value={String(hour)}>{twoDigits(hour)}</MenuItem>
|
||||
<MenuItem key={hour} value={String(hour)}>
|
||||
{twoDigits(hour)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
@ -495,7 +513,9 @@ function DateTimeField({ disabled, label, onChange, value }) {
|
||||
onChange={(event) => changeTime("minute", event.target.value)}
|
||||
>
|
||||
{Array.from({ length: 60 }, (_, minute) => (
|
||||
<MenuItem key={minute} value={String(minute)}>{twoDigits(minute)}</MenuItem>
|
||||
<MenuItem key={minute} value={String(minute)}>
|
||||
{twoDigits(minute)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</div>
|
||||
@ -527,7 +547,7 @@ function DateTimeField({ disabled, label, onChange, value }) {
|
||||
function GiftRegionSelect({ disabled, labels, onChange, options, value }) {
|
||||
const selected = value || [];
|
||||
const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort(
|
||||
(left, right) => Number(left) - Number(right)
|
||||
(left, right) => Number(left) - Number(right),
|
||||
);
|
||||
return (
|
||||
<MultiValueAutocomplete
|
||||
@ -564,7 +584,9 @@ function RegionTags({ regionIds = [], regionLabelById }) {
|
||||
return (
|
||||
<div className="admin-tag-list">
|
||||
{items.map((regionId) => (
|
||||
<span className="admin-tag" key={regionId}>{regionLabelById[String(regionId)] || regionId}</span>
|
||||
<span className="admin-tag" key={regionId}>
|
||||
{regionLabelById[String(regionId)] || regionId}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@ -619,8 +641,9 @@ function effectTypeLabel(value) {
|
||||
return effectOptions.find(([optionValue]) => optionValue === value)?.[1] || value;
|
||||
}
|
||||
|
||||
function effectTypesLabel(values = []) {
|
||||
return values.length ? values.map(effectTypeLabel).join("、") : "无特效";
|
||||
function effectTypesLabel(values) {
|
||||
const list = values || [];
|
||||
return list.length ? list.map(effectTypeLabel).join("、") : "无特效";
|
||||
}
|
||||
|
||||
function parseLocalDateTime(value) {
|
||||
@ -663,9 +686,11 @@ function calendarDays(viewDate) {
|
||||
}
|
||||
|
||||
function sameDay(left, right) {
|
||||
return left.getFullYear() === right.getFullYear()
|
||||
&& left.getMonth() === right.getMonth()
|
||||
&& left.getDate() === right.getDate();
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function monthTitle(date) {
|
||||
@ -691,7 +716,7 @@ function shortDateTime(value) {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
minute: "2-digit",
|
||||
}).format(new Date(Number(value)));
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,13 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
resourceGrantStatusFilters,
|
||||
@ -86,19 +86,36 @@ export function ResourceGrantListPage() {
|
||||
const page = useResourceGrantListPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const columns = grantColumns.map((column) => {
|
||||
if (column.key === "target") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceGrantStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索用户 ID..." />
|
||||
<AdminFilterSelect
|
||||
options={resourceGrantStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
</>
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGrant ? (
|
||||
@ -110,7 +127,12 @@ export function ResourceGrantListPage() {
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable columns={grantColumns} items={items} minWidth="1190px" rowKey={(grant) => grant.grantId} />
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1190px"
|
||||
rowKey={(grant) => grant.grantId}
|
||||
/>
|
||||
{total > 0 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
|
||||
@ -22,20 +22,16 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
resourceGroupAssetLabels,
|
||||
resourceStatusFilters,
|
||||
resourceTypeLabels,
|
||||
} from "@/features/resources/constants.js";
|
||||
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
||||
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
import styles from "@/features/resources/resources.module.css";
|
||||
|
||||
@ -83,9 +79,24 @@ export function ResourceGroupListPage() {
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const columns = baseColumns.map((column) =>
|
||||
column.key === "status"
|
||||
column.key === "group"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索资源组名称、资源组编码",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (group) => <ResourceGroupStatusSwitch group={group} page={page} />,
|
||||
}
|
||||
: column.key === "actions"
|
||||
@ -100,18 +111,7 @@ export function ResourceGroupListPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox
|
||||
value={page.query}
|
||||
onChange={page.changeQuery}
|
||||
placeholder="搜索资源组名称、资源组编码..."
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
options={resourceStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
</>
|
||||
<AdminFilterResetButton disabled={!page.query && !page.status} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreateGroup ? (
|
||||
@ -135,7 +135,9 @@ export function ResourceGroupListPage() {
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<ResourceGroupFormDialog
|
||||
disabled={page.activeAction === "edit" ? !page.abilities.canUpdateGroup : !page.abilities.canCreateGroup}
|
||||
disabled={
|
||||
page.activeAction === "edit" ? !page.abilities.canUpdateGroup : !page.abilities.canCreateGroup
|
||||
}
|
||||
form={page.form}
|
||||
loading={
|
||||
page.activeAction === "edit"
|
||||
@ -230,13 +232,25 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
|
||||
title="组内资源"
|
||||
actions={
|
||||
<AdminFormInlineActions>
|
||||
<Button disabled={loading} onClick={page.addResourceItem} startIcon={<Inventory2Outlined fontSize="small" />}>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={page.addResourceItem}
|
||||
startIcon={<Inventory2Outlined fontSize="small" />}
|
||||
>
|
||||
资源
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={() => page.addWalletAssetItem("COIN")} startIcon={<MonetizationOnOutlined fontSize="small" />}>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => page.addWalletAssetItem("COIN")}
|
||||
startIcon={<MonetizationOnOutlined fontSize="small" />}
|
||||
>
|
||||
金币
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={() => page.addWalletAssetItem("DIAMOND")} startIcon={<DiamondOutlined fontSize="small" />}>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => page.addWalletAssetItem("DIAMOND")}
|
||||
startIcon={<DiamondOutlined fontSize="small" />}
|
||||
>
|
||||
钻石
|
||||
</Button>
|
||||
</AdminFormInlineActions>
|
||||
|
||||
@ -11,14 +11,14 @@ import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminRowActions,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
grantStrategyLabels,
|
||||
@ -80,9 +80,34 @@ export function ResourceListPage() {
|
||||
const total = page.data.total || 0;
|
||||
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
|
||||
const columns = baseColumns.map((column) =>
|
||||
column.key === "status"
|
||||
column.key === "resource"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索资源名称、资源编码",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
}
|
||||
: column.key === "type"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceTypeFilters,
|
||||
placeholder: "搜索类型",
|
||||
value: page.resourceType,
|
||||
onChange: page.changeResourceType,
|
||||
}),
|
||||
}
|
||||
: column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (resource) => <ResourceStatusSwitch page={page} resource={resource} />,
|
||||
}
|
||||
: column.key === "actions"
|
||||
@ -97,24 +122,10 @@ export function ResourceListPage() {
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<AdminSearchBox
|
||||
value={page.query}
|
||||
onChange={page.changeQuery}
|
||||
placeholder="搜索资源名称、资源编码..."
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status && !page.resourceType}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
options={resourceStatusFilters}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
label="类型"
|
||||
options={resourceTypeFilters}
|
||||
value={page.resourceType}
|
||||
onChange={page.changeResourceType}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
|
||||
@ -2,10 +2,20 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
|
||||
export function RoleTable({ abilities, onEdit, onRemove, roles }) {
|
||||
export function RoleTable({ abilities, onEdit, onQueryChange, onRemove, query, roles }) {
|
||||
const columns = [
|
||||
{ key: "name", label: "角色", width: "minmax(160px, 1fr)" },
|
||||
{
|
||||
key: "name",
|
||||
label: "角色",
|
||||
width: "minmax(160px, 1fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索角色、编码",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
})
|
||||
},
|
||||
{ key: "code", label: "编码", width: "minmax(180px, 1.2fr)", render: (role) => <span className="muted">{role.code}</span> },
|
||||
{ key: "permissionCount", label: "权限数量", width: "110px", render: (role) => role.permissions?.length || 0 },
|
||||
{
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
listPermissions,
|
||||
listRoles,
|
||||
replaceRolePermissions,
|
||||
updateRole
|
||||
updateRole,
|
||||
} from "@/features/roles/api";
|
||||
import { useRoleAbilities } from "@/features/roles/permissions.js";
|
||||
import { roleFormSchema } from "@/features/roles/schema";
|
||||
@ -31,7 +31,7 @@ const permissionGroupDefinitions = [
|
||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||
{ key: "notifications", prefixes: ["notification"], title: "通知中心" },
|
||||
{ key: "jobs", prefixes: ["job", "export"], title: "任务中心" },
|
||||
{ key: "common", prefixes: ["upload"], title: "通用能力" }
|
||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||
];
|
||||
const fallbackPermissionGroup = { key: "other", title: "其他权限" };
|
||||
const permissionKindOrder = { menu: 1, button: 2, api: 3 };
|
||||
@ -55,7 +55,7 @@ export function useRolesPage() {
|
||||
errorMessage: "加载角色失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["roles", abilities.canLoadPermissions]
|
||||
queryKey: ["roles", abilities.canLoadPermissions],
|
||||
});
|
||||
|
||||
const permissionGroups = useMemo(() => {
|
||||
@ -69,7 +69,11 @@ export function useRolesPage() {
|
||||
return items;
|
||||
}
|
||||
return items.filter((role) => {
|
||||
return [role.name, role.code, role.description].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
return [role.name, role.code, role.description].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [data.roles, query]);
|
||||
|
||||
@ -77,6 +81,10 @@ export function useRolesPage() {
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRole(null);
|
||||
setForm(emptyRole);
|
||||
@ -89,7 +97,7 @@ export function useRolesPage() {
|
||||
code: role.code,
|
||||
description: role.description || "",
|
||||
name: role.name,
|
||||
permissionIds: role.permissions?.map((permission) => permission.id) || []
|
||||
permissionIds: role.permissions?.map((permission) => permission.id) || [],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
@ -107,7 +115,7 @@ export function useRolesPage() {
|
||||
await updateRole(editingRole.id, {
|
||||
code: payload.code,
|
||||
description: payload.description,
|
||||
name: payload.name
|
||||
name: payload.name,
|
||||
});
|
||||
}
|
||||
if (abilities.canGrant) {
|
||||
@ -119,7 +127,7 @@ export function useRolesPage() {
|
||||
code: payload.code,
|
||||
description: payload.description,
|
||||
name: payload.name,
|
||||
permissionIds: abilities.canGrant ? payload.permissionIds : []
|
||||
permissionIds: abilities.canGrant ? payload.permissionIds : [],
|
||||
});
|
||||
showToast("角色已创建", "success");
|
||||
}
|
||||
@ -135,7 +143,7 @@ export function useRolesPage() {
|
||||
confirmText: "删除",
|
||||
message: `${role.name} 删除前需要先解绑用户。`,
|
||||
title: "删除角色",
|
||||
tone: "danger"
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -157,7 +165,7 @@ export function useRolesPage() {
|
||||
...current,
|
||||
permissionIds: checked
|
||||
? Array.from(new Set([...current.permissionIds, permissionId]))
|
||||
: current.permissionIds.filter((id) => id !== permissionId)
|
||||
: current.permissionIds.filter((id) => id !== permissionId),
|
||||
}));
|
||||
};
|
||||
|
||||
@ -172,7 +180,7 @@ export function useRolesPage() {
|
||||
...current,
|
||||
permissionIds: checked
|
||||
? Array.from(new Set([...current.permissionIds, ...permissionIds]))
|
||||
: current.permissionIds.filter((id) => !targetIds.has(id))
|
||||
: current.permissionIds.filter((id) => !targetIds.has(id)),
|
||||
};
|
||||
});
|
||||
};
|
||||
@ -193,18 +201,23 @@ export function useRolesPage() {
|
||||
query,
|
||||
reload,
|
||||
remove,
|
||||
resetFilters,
|
||||
roles,
|
||||
setForm,
|
||||
submit,
|
||||
togglePermission,
|
||||
togglePermissionGroup
|
||||
togglePermissionGroup,
|
||||
};
|
||||
}
|
||||
|
||||
function groupPermissionsByMenu(permissions) {
|
||||
const groups = new Map(permissionGroupDefinitions.map((definition) => [definition.key, { ...definition, items: [] }]));
|
||||
const groups = new Map(
|
||||
permissionGroupDefinitions.map((definition) => [definition.key, { ...definition, items: [] }]),
|
||||
);
|
||||
const prefixToGroup = new Map(
|
||||
permissionGroupDefinitions.flatMap((definition) => definition.prefixes.map((prefix) => [prefix, definition.key]))
|
||||
permissionGroupDefinitions.flatMap((definition) =>
|
||||
definition.prefixes.map((prefix) => [prefix, definition.key]),
|
||||
),
|
||||
);
|
||||
const fallbackGroup = { ...fallbackPermissionGroup, items: [] };
|
||||
|
||||
@ -219,7 +232,7 @@ function groupPermissionsByMenu(permissions) {
|
||||
.filter((group) => group.items.length > 0)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: [...group.items].sort(comparePermissions)
|
||||
items: [...group.items].sort(comparePermissions),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -2,10 +2,10 @@ import Add from "@mui/icons-material/Add";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { RoleFormDrawer } from "@/features/roles/components/RoleFormDrawer.jsx";
|
||||
import { RoleTable } from "@/features/roles/components/RoleTable.jsx";
|
||||
@ -18,17 +18,28 @@ export function RoleManagementPage() {
|
||||
<>
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索角色、编码..." />}
|
||||
actions={page.abilities.canCreate ? (
|
||||
filters={
|
||||
<AdminFilterResetButton disabled={!page.query} onClick={page.resetFilters} />
|
||||
}
|
||||
actions={
|
||||
page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="新增角色" primary onClick={page.openCreate}>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
) : 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} />
|
||||
<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>
|
||||
|
||||
120
src/features/rooms/components/RoomDetailDrawer.jsx
Normal file
120
src/features/rooms/components/RoomDetailDrawer.jsx
Normal file
@ -0,0 +1,120 @@
|
||||
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { roomStatusLabels } from "@/features/rooms/constants.js";
|
||||
import styles from "@/features/rooms/rooms.module.css";
|
||||
|
||||
export function RoomDetailDrawer({ onClose, open, room }) {
|
||||
const owner = room?.owner || {};
|
||||
const normalizedStatus = room?.status === "active" ? "active" : "closed";
|
||||
const ownerDisplayId = owner.displayUserId || room?.ownerUserId || "-";
|
||||
|
||||
return (
|
||||
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
|
||||
{room ? (
|
||||
<>
|
||||
<header className={styles.detailHero}>
|
||||
<span className={styles.detailCover}>
|
||||
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="medium" />}
|
||||
</span>
|
||||
<div className={styles.detailHeroText}>
|
||||
<div className={styles.detailTitleRow}>
|
||||
<h3>{room.title || "-"}</h3>
|
||||
<StatusBadge status={normalizedStatus} />
|
||||
</div>
|
||||
<span className={styles.detailMeta}>房间 ID {room.roomId}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
["房间名称", room.title],
|
||||
["房间 ID", room.roomId],
|
||||
["房间状态", roomStatusLabels[normalizedStatus]],
|
||||
["房间模式", room.mode],
|
||||
["区域", room.regionName || formatRegion(room.visibleRegionId)],
|
||||
["可见区域 ID", room.visibleRegionId],
|
||||
["创建时间", formatMillis(room.createdAtMs)],
|
||||
["更新时间", formatMillis(room.updatedAtMs)]
|
||||
]}
|
||||
title="基础信息"
|
||||
/>
|
||||
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>房主信息</h3>
|
||||
<div className={styles.detailOwner}>
|
||||
<span className={styles.detailOwnerAvatar}>
|
||||
{owner.avatar ? <img src={owner.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<div className={styles.detailOwnerText}>
|
||||
<strong>{owner.username || "-"}</strong>
|
||||
<span>{ownerDisplayId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="房主用户 ID" value={room.ownerUserId || owner.userId} />
|
||||
<DetailItem label="主持用户 ID" value={room.hostUserId} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
["在线人数", `${formatNumber(room.onlineCount)} 人`],
|
||||
["座位数", `${formatNumber(room.seatCount)} 个`],
|
||||
["已占座位", `${formatNumber(room.occupiedSeatCount)} 个`],
|
||||
["热度", formatNumber(room.heat)],
|
||||
["排序分", formatNumber(room.sortScore)]
|
||||
]}
|
||||
title="房间数据"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ items, title }) {
|
||||
return (
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
||||
<div className={styles.detailGrid}>
|
||||
{items.map(([label, value]) => (
|
||||
<DetailItem key={label} label={label} value={value} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }) {
|
||||
return (
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{label}</span>
|
||||
<strong className={styles.detailValue}>{displayValue(value)}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "active" ? "running" : "stopped";
|
||||
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{roomStatusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function displayValue(value) {
|
||||
return value === 0 || value ? value : "-";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function formatRegion(value) {
|
||||
return value ? `区域 ${value}` : "-";
|
||||
}
|
||||
@ -14,7 +14,7 @@ const emptyForm = () => ({
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
title: "",
|
||||
visibleRegionId: 0
|
||||
visibleRegionId: 0,
|
||||
});
|
||||
const emptyStatusForm = () => ({ status: "active" });
|
||||
|
||||
@ -36,17 +36,22 @@ export function useRoomsPage() {
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
status
|
||||
status,
|
||||
}),
|
||||
[query, regionId, status]
|
||||
[query, regionId, status],
|
||||
);
|
||||
const { data = emptyData, error, loading, reload } = usePaginatedQuery({
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载房间列表失败",
|
||||
fetcher: listRooms,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["rooms", filters, page]
|
||||
queryKey: ["rooms", filters, page],
|
||||
});
|
||||
|
||||
const changeQuery = (value) => {
|
||||
@ -64,17 +69,29 @@ export function useRoomsPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openEdit = (room) => {
|
||||
setActiveRoom(room);
|
||||
setForm({
|
||||
coverUrl: room.coverUrl || "",
|
||||
description: "",
|
||||
title: room.title || "",
|
||||
visibleRegionId: room.visibleRegionId || 0
|
||||
visibleRegionId: room.visibleRegionId || 0,
|
||||
});
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const openDetail = (room) => {
|
||||
setActiveRoom(room);
|
||||
setActiveAction("detail");
|
||||
};
|
||||
|
||||
const openStatus = (room) => {
|
||||
setActiveRoom(room);
|
||||
setStatusForm({ status: room.status === "closed" ? "closed" : "active" });
|
||||
@ -120,7 +137,7 @@ export function useRoomsPage() {
|
||||
confirmText: "删除",
|
||||
message: `${room.title || room.roomId} 会从房间列表和房间状态中删除。`,
|
||||
title: "删除房间",
|
||||
tone: "danger"
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -157,6 +174,7 @@ export function useRoomsPage() {
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingRegions,
|
||||
openDetail,
|
||||
openEdit,
|
||||
openStatus,
|
||||
page,
|
||||
@ -165,12 +183,13 @@ export function useRoomsPage() {
|
||||
regionOptions,
|
||||
reload,
|
||||
removeRoom,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setPage,
|
||||
setStatusForm,
|
||||
status,
|
||||
statusForm,
|
||||
submitStatus,
|
||||
submitEdit
|
||||
submitEdit,
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,12 +10,13 @@ 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";
|
||||
@ -25,38 +26,38 @@ const columns = [
|
||||
key: "identity",
|
||||
label: "房间",
|
||||
width: "minmax(240px, 1.5fr)",
|
||||
render: (room) => <RoomIdentity room={room} />
|
||||
render: (room) => <RoomIdentity room={room} />,
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
label: "房主",
|
||||
width: "minmax(210px, 1.2fr)",
|
||||
render: (room) => <OwnerIdentity room={room} />
|
||||
render: (room) => <OwnerIdentity room={room} />,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "minmax(140px, 0.8fr)",
|
||||
render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-")
|
||||
render: (room) => room.regionName || (room.visibleRegionId ? `区域 ${room.visibleRegionId}` : "-"),
|
||||
},
|
||||
{
|
||||
key: "onlineCount",
|
||||
label: "房间人数",
|
||||
width: "minmax(110px, 0.7fr)",
|
||||
render: (room) => `${formatNumber(room.onlineCount)} 人`
|
||||
render: (room) => `${formatNumber(room.onlineCount)} 人`,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(100px, 0.7fr)",
|
||||
render: (room) => <RoomStatus status={room.status} />
|
||||
render: (room) => <RoomStatus status={room.status} />,
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "创建时间",
|
||||
width: "minmax(170px, 1fr)",
|
||||
render: (room) => formatMillis(room.createdAtMs)
|
||||
}
|
||||
render: (room) => formatMillis(room.createdAtMs),
|
||||
},
|
||||
];
|
||||
|
||||
export function RoomListPage() {
|
||||
@ -72,24 +73,44 @@ export function RoomListPage() {
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})
|
||||
onChange: page.changeRegionId,
|
||||
}),
|
||||
}
|
||||
: column.key === "identity"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索房间、房主",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
render: (room) => <RoomIdentity room={room} onClick={() => page.openDetail(room)} />,
|
||||
}
|
||||
: column.key === "status"
|
||||
? {
|
||||
...column,
|
||||
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)} />
|
||||
)
|
||||
<RoomStatus
|
||||
disabled={!page.abilities.canUpdate}
|
||||
status={room.status}
|
||||
onClick={() => page.openStatus(room)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: column
|
||||
: column,
|
||||
),
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(104px, 0.6fr)",
|
||||
render: (room) => <RoomActions page={page} room={room} />
|
||||
}
|
||||
render: (room) => <RoomActions page={page} room={room} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@ -97,32 +118,35 @@ export function RoomListPage() {
|
||||
<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}
|
||||
<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}
|
||||
<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>
|
||||
|
||||
<RoomDetailDrawer open={page.activeAction === "detail"} room={page.activeRoom} onClose={page.closeAction} />
|
||||
|
||||
<ActionModal
|
||||
disabled={!page.abilities.canUpdate}
|
||||
loading={page.loadingAction === "edit"}
|
||||
@ -131,10 +155,36 @@ export function RoomListPage() {
|
||||
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 })} />
|
||||
<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
|
||||
@ -145,7 +195,14 @@ export function RoomListPage() {
|
||||
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 })}>
|
||||
<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>
|
||||
@ -154,8 +211,8 @@ export function RoomListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function RoomIdentity({ room }) {
|
||||
return (
|
||||
function RoomIdentity({ onClick, room }) {
|
||||
const content = (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.cover}>
|
||||
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="small" />}
|
||||
@ -166,6 +223,21 @@ function RoomIdentity({ room }) {
|
||||
</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 }) {
|
||||
@ -201,7 +273,12 @@ function RoomActions({ page, room }) {
|
||||
{page.abilities.canDelete ? (
|
||||
<Tooltip arrow title="删除">
|
||||
<span>
|
||||
<IconButton disabled={page.loadingAction === `delete-${room.roomId}`} label="删除" tone="danger" onClick={() => page.removeRoom(room)}>
|
||||
<IconButton
|
||||
disabled={page.loadingAction === `delete-${room.roomId}`}
|
||||
label="删除"
|
||||
tone="danger"
|
||||
onClick={() => page.removeRoom(room)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
|
||||
@ -78,6 +78,34 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.identityButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-base) var(--ease-standard),
|
||||
box-shadow var(--motion-base) var(--ease-standard),
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.identityButton:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.identityButton:hover .name {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.identityButton:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
@ -170,6 +198,156 @@
|
||||
border-color: var(--primary-border);
|
||||
}
|
||||
|
||||
.detailHero {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detailCover {
|
||||
display: inline-flex;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detailCover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detailHeroText {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.detailTitleRow {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.detailTitleRow h3 {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailMeta {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.detailSection {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.detailSectionTitle {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.detailItem {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailOwner {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.detailOwnerAvatar {
|
||||
display: inline-flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detailOwnerAvatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detailOwnerText {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.detailOwnerText strong,
|
||||
.detailOwnerText span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailOwnerText strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.detailOwnerText span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar,
|
||||
.filters {
|
||||
@ -183,4 +361,8 @@
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,14 +2,18 @@ import Dialog from "@mui/material/Dialog";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { searchGlobal } from "@/features/search/api";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
|
||||
export function SearchDialog({ onClose, onSelect, open }) {
|
||||
const maxMenuResults = 8;
|
||||
|
||||
export function SearchDialog({ menuItems = [], onClose, onSelect, open }) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const menuResults = useMemo(() => searchMenus(menuItems, keyword), [keyword, menuItems]);
|
||||
const displayResults = useMemo(() => [...menuResults, ...results], [menuResults, results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -53,11 +57,11 @@ export function SearchDialog({ onClose, onSelect, open }) {
|
||||
slotProps={{ paper: { className: "search-dialog" } }}
|
||||
>
|
||||
<div className="search-dialog__head">
|
||||
<SearchBox autoFocus className="search-dialog__input" value={keyword} onChange={setKeyword} placeholder="搜索用户、角色..." />
|
||||
<SearchBox autoFocus className="search-dialog__input" value={keyword} onChange={setKeyword} placeholder="搜索菜单、用户、角色..." />
|
||||
</div>
|
||||
|
||||
<List className="search-dialog__list" disablePadding>
|
||||
{results.map((item) => (
|
||||
{displayResults.map((item) => (
|
||||
<ListItemButton className="search-dialog__item" key={`${item.type}-${item.id}`} onClick={() => selectResult(item)}>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
@ -69,8 +73,61 @@ export function SearchDialog({ onClose, onSelect, open }) {
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
{!results.length ? <div className="search-dialog__empty">{loading ? "搜索中..." : "暂无匹配结果"}</div> : null}
|
||||
{!displayResults.length ? <div className="search-dialog__empty">{loading ? "搜索中..." : "暂无匹配结果"}</div> : null}
|
||||
</List>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function searchMenus(items = [], keyword = "") {
|
||||
const normalizedKeyword = normalizeSearchText(keyword);
|
||||
if (!normalizedKeyword) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return flattenMenuResults(items)
|
||||
.filter((item) => item.searchText.includes(normalizedKeyword))
|
||||
.slice(0, maxMenuResults)
|
||||
.map(({ searchText: _searchText, ...item }) => item);
|
||||
}
|
||||
|
||||
function flattenMenuResults(items = [], ancestors = []) {
|
||||
return items.flatMap((item) => {
|
||||
const label = item.label || item.title || item.code || "";
|
||||
const breadcrumb = [...ancestors, label].filter(Boolean);
|
||||
const target = item.path || findFirstMenuPath(item.children);
|
||||
const current = target
|
||||
? [
|
||||
{
|
||||
id: item.code || item.id || breadcrumb.join("/"),
|
||||
searchText: normalizeSearchText([label, item.code, item.path].filter(Boolean).join(" ")),
|
||||
subtitle: breadcrumb.join(" / "),
|
||||
target,
|
||||
title: label,
|
||||
type: "菜单"
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return [...current, ...flattenMenuResults(item.children || [], breadcrumb)];
|
||||
});
|
||||
}
|
||||
|
||||
function findFirstMenuPath(items = []) {
|
||||
for (const item of items) {
|
||||
if (item.path) {
|
||||
return item.path;
|
||||
}
|
||||
|
||||
const childPath = findFirstMenuPath(item.children || []);
|
||||
if (childPath) {
|
||||
return childPath;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
42
src/features/search/components/SearchDialog.test.jsx
Normal file
42
src/features/search/components/SearchDialog.test.jsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { searchMenus } from "./SearchDialog.jsx";
|
||||
|
||||
describe("searchMenus", () => {
|
||||
const menus = [
|
||||
{
|
||||
code: "overview",
|
||||
label: "系统概览",
|
||||
path: "/overview"
|
||||
},
|
||||
{
|
||||
code: "app-users",
|
||||
label: "用户管理",
|
||||
children: [{ code: "app-users-list", label: "用户列表", path: "/app/users" }]
|
||||
},
|
||||
{
|
||||
code: "system",
|
||||
label: "后台设置",
|
||||
children: [
|
||||
{ code: "system-users", label: "后台用户", path: "/system/users" },
|
||||
{ code: "system-roles", label: "角色配置", path: "/system/roles" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
test("matches visible menu labels and returns navigable targets", () => {
|
||||
expect(searchMenus(menus, "后台")).toEqual([
|
||||
expect.objectContaining({ target: "/system/users", title: "后台设置", type: "菜单" }),
|
||||
expect.objectContaining({ target: "/system/users", title: "后台用户", type: "菜单" })
|
||||
]);
|
||||
});
|
||||
|
||||
test("matches menu code and keeps breadcrumb for context", () => {
|
||||
expect(searchMenus(menus, "app-users-list")).toEqual([
|
||||
expect.objectContaining({ subtitle: "用户管理 / 用户列表", target: "/app/users", title: "用户列表" })
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns nothing for blank keyword", () => {
|
||||
expect(searchMenus(menus, " ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -1,44 +0,0 @@
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { FilterChip } from "@/shared/ui/FilterChip.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
|
||||
const statusFilters = [
|
||||
["all", "全部"],
|
||||
["active", "启用"],
|
||||
["locked", "锁定"],
|
||||
["disabled", "停用"]
|
||||
];
|
||||
|
||||
export function UserFilters({
|
||||
canBatchStatus,
|
||||
onBatchStatus,
|
||||
onQueryChange,
|
||||
onRoleFilterChange,
|
||||
onStatusFilterChange,
|
||||
query,
|
||||
roleFilter,
|
||||
roleFilters,
|
||||
statusFilter
|
||||
}) {
|
||||
return (
|
||||
<section className="filters-panel user-filters-panel">
|
||||
<SearchBox value={query} onChange={onQueryChange} placeholder="搜索用户、账号、角色、团队..." />
|
||||
<div className="filter-row" aria-label="角色筛选">
|
||||
{roleFilters.map(([value, label]) => (
|
||||
<FilterChip active={roleFilter === value} key={value} label={label} onClick={() => onRoleFilterChange(value)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="filter-row" aria-label="状态筛选">
|
||||
{statusFilters.map(([value, label]) => (
|
||||
<FilterChip active={statusFilter === value} key={value} label={label} onClick={() => onStatusFilterChange(value)} />
|
||||
))}
|
||||
</div>
|
||||
{canBatchStatus ? (
|
||||
<div className="batch-actions">
|
||||
<Button onClick={() => onBatchStatus("active")}>批量启用</Button>
|
||||
<Button variant="danger" onClick={() => onBatchStatus("disabled")}>批量停用</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import VerifiedUserOutlined from "@mui/icons-material/VerifiedUserOutlined";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { UserStatusBadge } from "./UserStatusBadge.jsx";
|
||||
|
||||
export function UserTable({
|
||||
@ -16,11 +17,19 @@ export function UserTable({
|
||||
canStatus,
|
||||
onEdit,
|
||||
onOpenProfile = () => {},
|
||||
onQueryChange,
|
||||
onResetPassword,
|
||||
onRoleFilterChange,
|
||||
onStatusFilterChange,
|
||||
onToggleDisabled,
|
||||
onToggleLocked,
|
||||
query,
|
||||
roleFilter,
|
||||
roleFilters = [],
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
statusFilter,
|
||||
statusFilters = [],
|
||||
users
|
||||
}) {
|
||||
const allChecked = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
|
||||
@ -51,28 +60,55 @@ export function UserTable({
|
||||
key: "user",
|
||||
label: "用户",
|
||||
width: "minmax(180px, 1.4fr)",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户、账号、角色、团队",
|
||||
value: query,
|
||||
onChange: onQueryChange
|
||||
}),
|
||||
render: (user) => (
|
||||
<div className="user-main">
|
||||
<button
|
||||
aria-label={`查看 ${user.name || user.account || user.id} 信息`}
|
||||
className="user-avatar-button"
|
||||
className="user-main-button"
|
||||
type="button"
|
||||
onClick={() => onOpenProfile(user)}
|
||||
>
|
||||
<span className="user-main">
|
||||
<span className="user-avatar">
|
||||
<Person fontSize="small" />
|
||||
</span>
|
||||
<span className="user-main__text">
|
||||
<span className="service-name">{user.name}</span>
|
||||
<span className="service-desc">{user.account}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div>
|
||||
<div className="service-name">{user.name}</div>
|
||||
<div className="service-desc">{user.account}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: "role", label: "角色", width: "minmax(120px, 1fr)", render: (user) => user.role || "-" },
|
||||
{
|
||||
key: "role",
|
||||
label: "角色",
|
||||
width: "minmax(120px, 1fr)",
|
||||
filter: createOptionsColumnFilter({
|
||||
options: roleFilters,
|
||||
placeholder: "搜索角色",
|
||||
value: roleFilter,
|
||||
onChange: onRoleFilterChange
|
||||
}),
|
||||
render: (user) => user.role || "-"
|
||||
},
|
||||
{ key: "team", label: "团队", width: "minmax(120px, 1fr)", render: (user) => user.team || "-" },
|
||||
{ key: "status", label: "状态", width: "minmax(96px, 0.72fr)", render: (user) => <UserStatusBadge status={user.status} /> },
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(96px, 0.72fr)",
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: statusFilter,
|
||||
onChange: onStatusFilterChange
|
||||
}),
|
||||
render: (user) => <UserStatusBadge status={user.status} />
|
||||
},
|
||||
{ key: "mfa", label: "MFA", width: "minmax(80px, 0.72fr)" },
|
||||
{ key: "lastLogin", label: "最后登录", width: "minmax(120px, 0.9fr)", className: "muted" },
|
||||
{
|
||||
|
||||
@ -14,7 +14,7 @@ const baseUser = {
|
||||
team: "平台"
|
||||
};
|
||||
|
||||
test("opens user profile from avatar", async () => {
|
||||
test("opens user profile from user identity", async () => {
|
||||
const onOpenProfile = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus
|
||||
updateUserStatus,
|
||||
} from "@/features/users/api";
|
||||
import { useUserAbilities } from "@/features/users/permissions.js";
|
||||
import { userCreateFormSchema, userUpdateFormSchema } from "@/features/users/schema";
|
||||
@ -30,7 +30,7 @@ const emptyForm = {
|
||||
roleIds: [],
|
||||
status: "active",
|
||||
team: "",
|
||||
username: ""
|
||||
username: "",
|
||||
};
|
||||
|
||||
export function useUsersPage() {
|
||||
@ -50,13 +50,13 @@ export function useUsersPage() {
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => toPageQuery({ keyword: query, page, pageSize, role: roleFilter, status: statusFilter }),
|
||||
[page, query, roleFilter, statusFilter]
|
||||
[page, query, roleFilter, statusFilter],
|
||||
);
|
||||
|
||||
const queryFn = useCallback(async () => {
|
||||
const [userData, roleData] = await Promise.all([
|
||||
listUsers(requestQuery),
|
||||
abilities.canViewRoles ? listRoles() : []
|
||||
abilities.canViewRoles ? listRoles() : [],
|
||||
]);
|
||||
setSelectedIds([]);
|
||||
return { roles: roleData || [], users: userData || emptyUsers };
|
||||
@ -66,7 +66,7 @@ export function useUsersPage() {
|
||||
errorMessage: "加载用户失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["users", requestQuery, abilities.canViewRoles]
|
||||
queryKey: ["users", requestQuery, abilities.canViewRoles],
|
||||
});
|
||||
|
||||
const users = data?.users || emptyUsers;
|
||||
@ -97,6 +97,13 @@ export function useUsersPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setRoleFilter("");
|
||||
setStatusFilter("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm(emptyForm);
|
||||
@ -112,7 +119,7 @@ export function useUsersPage() {
|
||||
roleIds: user.roleIds || [],
|
||||
status: user.status,
|
||||
team: user.team || "",
|
||||
username: user.account
|
||||
username: user.account,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
@ -154,7 +161,7 @@ export function useUsersPage() {
|
||||
confirmText: user.status === "locked" ? "解锁" : "锁定",
|
||||
message: `${user.name} 的登录状态会立即变更。`,
|
||||
title: user.status === "locked" ? "解锁用户" : "锁定用户",
|
||||
tone: user.status === "locked" ? "primary" : "danger"
|
||||
tone: user.status === "locked" ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -170,7 +177,7 @@ export function useUsersPage() {
|
||||
confirmText: user.status === "disabled" ? "启用" : "停用",
|
||||
message: `${user.name} 的账号状态会立即变更。`,
|
||||
title: user.status === "disabled" ? "启用用户" : "停用用户",
|
||||
tone: user.status === "disabled" ? "primary" : "danger"
|
||||
tone: user.status === "disabled" ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -185,7 +192,7 @@ export function useUsersPage() {
|
||||
confirmText: "重置",
|
||||
message: `${user.name} 的密码会重置为系统生成的初始密码。`,
|
||||
title: "重置密码",
|
||||
tone: "danger"
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -203,7 +210,7 @@ export function useUsersPage() {
|
||||
confirmText: "确认",
|
||||
message: `将更新 ${selectedIds.length} 个用户状态。`,
|
||||
title: "批量更新状态",
|
||||
tone: status === "active" ? "primary" : "danger"
|
||||
tone: status === "active" ? "primary" : "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
@ -216,14 +223,14 @@ export function useUsersPage() {
|
||||
const downloadUsers = () => {
|
||||
return downloadCsv(
|
||||
() => exportUsers({ keyword: query, role: roleFilter, status: statusFilter }),
|
||||
"hyapp-users.csv"
|
||||
"hyapp-users.csv",
|
||||
);
|
||||
};
|
||||
|
||||
const setRoleChecked = (roleId, checked) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId)
|
||||
roleIds: checked ? [...current.roleIds, roleId] : current.roleIds.filter((id) => id !== roleId),
|
||||
}));
|
||||
};
|
||||
|
||||
@ -250,6 +257,7 @@ export function useUsersPage() {
|
||||
query,
|
||||
reload,
|
||||
resetPassword,
|
||||
resetFilters,
|
||||
roleFilter,
|
||||
roleFilters,
|
||||
roles,
|
||||
@ -263,6 +271,6 @@ export function useUsersPage() {
|
||||
submitUser,
|
||||
toggleDisabled,
|
||||
toggleLocked,
|
||||
users
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,11 +7,10 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminSearchBox
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { UserFormDrawer } from "@/features/users/components/UserFormDrawer.jsx";
|
||||
import { UserProfileDrawer } from "@/features/users/components/UserProfileDrawer.jsx";
|
||||
@ -26,14 +25,13 @@ export function UserManagementPage() {
|
||||
<>
|
||||
<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={(
|
||||
filters={
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.roleFilter && !page.statusFilter}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{page.abilities.canViewRoles ? (
|
||||
<AdminActionIconButton label="角色配置" onClick={() => page.navigate("/system/roles")}>
|
||||
@ -45,7 +43,11 @@ export function UserManagementPage() {
|
||||
<AdminActionIconButton label="批量启用" onClick={() => page.batchStatus("active")}>
|
||||
<VerifiedUserOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton label="批量停用" tone="danger" onClick={() => page.batchStatus("disabled")}>
|
||||
<AdminActionIconButton
|
||||
label="批量停用"
|
||||
tone="danger"
|
||||
onClick={() => page.batchStatus("disabled")}
|
||||
>
|
||||
<PersonRemoveOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</>
|
||||
@ -61,7 +63,7 @@ export function UserManagementPage() {
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
@ -75,11 +77,24 @@ export function UserManagementPage() {
|
||||
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} />
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
pageSize={page.users.pageSize || 10}
|
||||
total={page.users.total || 0}
|
||||
onPageChange={page.setPage}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
@ -94,11 +109,7 @@ export function UserManagementPage() {
|
||||
roles={page.roles}
|
||||
setForm={page.setForm}
|
||||
/>
|
||||
<UserProfileDrawer
|
||||
onClose={page.closeProfile}
|
||||
open={Boolean(page.profileUser)}
|
||||
user={page.profileUser}
|
||||
/>
|
||||
<UserProfileDrawer onClose={page.closeProfile} open={Boolean(page.profileUser)} user={page.profileUser} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const usersRoutes = [
|
||||
{
|
||||
label: "用户管理",
|
||||
label: "后台用户",
|
||||
loader: () => import("./pages/UserManagementPage.jsx").then((module) => module.UserManagementPage),
|
||||
menuCode: MENU_CODES.systemUsers,
|
||||
pageKey: "users",
|
||||
|
||||
@ -19,6 +19,59 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.user-main-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-base) var(--ease-standard),
|
||||
box-shadow var(--motion-base) var(--ease-standard),
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.user-main-button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.user-main-button:hover .service-name {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.user-main-button:focus-visible {
|
||||
outline: 2px solid var(--primary-border-strong);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.user-main__text {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-main__text .service-name,
|
||||
.user-main__text .service-desc {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-main__text .service-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-main__text .service-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
@ -33,24 +86,6 @@
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.user-avatar-button {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-avatar-button:focus-visible {
|
||||
outline: 2px solid var(--primary-border-strong);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin-row:hover .user-avatar {
|
||||
background: var(--primary-surface-strong);
|
||||
transform: scale(1.08);
|
||||
|
||||
@ -38,6 +38,7 @@ export const API_OPERATIONS = {
|
||||
createRole: "createRole",
|
||||
createUser: "createUser",
|
||||
createUserExportJob: "createUserExportJob",
|
||||
creditCoinSellerStock: "creditCoinSellerStock",
|
||||
dashboardOverview: "dashboardOverview",
|
||||
deleteBanner: "deleteBanner",
|
||||
deleteCountry: "deleteCountry",
|
||||
@ -126,7 +127,7 @@ export const API_OPERATIONS = {
|
||||
updateUser: "updateUser",
|
||||
updateUserStatus: "updateUserStatus",
|
||||
uploadFile: "uploadFile",
|
||||
uploadImage: "uploadImage"
|
||||
uploadImage: "uploadImage",
|
||||
} as const;
|
||||
|
||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||
@ -137,783 +138,790 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
operationId: API_OPERATIONS.appBanUser,
|
||||
path: "/v1/app/users/{id}/ban",
|
||||
permission: "app-user:status",
|
||||
permissions: ["app-user:status"]
|
||||
permissions: ["app-user:status"],
|
||||
},
|
||||
appGetUser: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.appGetUser,
|
||||
path: "/v1/app/users/{id}",
|
||||
permission: "app-user:view",
|
||||
permissions: ["app-user:view"]
|
||||
permissions: ["app-user:view"],
|
||||
},
|
||||
appListUsers: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.appListUsers,
|
||||
path: "/v1/app/users",
|
||||
permission: "app-user:view",
|
||||
permissions: ["app-user:view"]
|
||||
permissions: ["app-user:view"],
|
||||
},
|
||||
appSetPassword: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.appSetPassword,
|
||||
path: "/v1/app/users/{id}/password",
|
||||
permission: "app-user:password",
|
||||
permissions: ["app-user:password"]
|
||||
permissions: ["app-user:password"],
|
||||
},
|
||||
appUnbanUser: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.appUnbanUser,
|
||||
path: "/v1/app/users/{id}/unban",
|
||||
permission: "app-user:status",
|
||||
permissions: ["app-user:status"]
|
||||
permissions: ["app-user:status"],
|
||||
},
|
||||
appUpdateUser: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.appUpdateUser,
|
||||
path: "/v1/app/users/{id}",
|
||||
permission: "app-user:update",
|
||||
permissions: ["app-user:update"]
|
||||
permissions: ["app-user:update"],
|
||||
},
|
||||
batchUpdateUserStatus: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.batchUpdateUserStatus,
|
||||
path: "/v1/users/batch/status",
|
||||
permission: "user:status",
|
||||
permissions: ["user:status"]
|
||||
permissions: ["user:status"],
|
||||
},
|
||||
cancelJob: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.cancelJob,
|
||||
path: "/v1/jobs/{id}/cancel",
|
||||
permission: "job:cancel",
|
||||
permissions: ["job:cancel"]
|
||||
permissions: ["job:cancel"],
|
||||
},
|
||||
changePassword: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.changePassword,
|
||||
path: "/v1/auth/change-password"
|
||||
path: "/v1/auth/change-password",
|
||||
},
|
||||
closeAgency: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.closeAgency,
|
||||
path: "/v1/admin/agencies/{agency_id}/close",
|
||||
permission: "agency:status",
|
||||
permissions: ["agency:status"]
|
||||
permissions: ["agency:status"],
|
||||
},
|
||||
createAgency: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createAgency,
|
||||
path: "/v1/admin/agencies",
|
||||
permission: "agency:create",
|
||||
permissions: ["agency:create"]
|
||||
permissions: ["agency:create"],
|
||||
},
|
||||
createBanner: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createBanner,
|
||||
path: "/v1/admin/app-config/banners",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
permissions: ["app-config:update"],
|
||||
},
|
||||
createBD: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createBD,
|
||||
path: "/v1/admin/bds",
|
||||
permission: "bd:create",
|
||||
permissions: ["bd:create"]
|
||||
permissions: ["bd:create"],
|
||||
},
|
||||
createBDLeader: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createBDLeader,
|
||||
path: "/v1/admin/bd-leaders",
|
||||
permission: "bd:create",
|
||||
permissions: ["bd:create"]
|
||||
permissions: ["bd:create"],
|
||||
},
|
||||
createCoinSeller: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createCoinSeller,
|
||||
path: "/v1/admin/coin-sellers",
|
||||
permission: "coin-seller:create",
|
||||
permissions: ["coin-seller:create"]
|
||||
permissions: ["coin-seller:create"],
|
||||
},
|
||||
createCountry: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createCountry,
|
||||
path: "/v1/admin/countries",
|
||||
permission: "country:create",
|
||||
permissions: ["country:create"]
|
||||
permissions: ["country:create"],
|
||||
},
|
||||
createGift: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createGift,
|
||||
path: "/v1/admin/gifts",
|
||||
permission: "gift:create",
|
||||
permissions: ["gift:create"]
|
||||
permissions: ["gift:create"],
|
||||
},
|
||||
createMenu: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createMenu,
|
||||
path: "/v1/system/menus",
|
||||
permission: "menu:create",
|
||||
permissions: ["menu:create"]
|
||||
permissions: ["menu:create"],
|
||||
},
|
||||
createPermission: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createPermission,
|
||||
path: "/v1/permissions",
|
||||
permission: "permission:create",
|
||||
permissions: ["permission:create"]
|
||||
permissions: ["permission:create"],
|
||||
},
|
||||
createRegion: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createRegion,
|
||||
path: "/v1/admin/regions",
|
||||
permission: "region:create",
|
||||
permissions: ["region:create"]
|
||||
permissions: ["region:create"],
|
||||
},
|
||||
createResource: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createResource,
|
||||
path: "/v1/admin/resources",
|
||||
permission: "resource:create",
|
||||
permissions: ["resource:create"]
|
||||
permissions: ["resource:create"],
|
||||
},
|
||||
createResourceGroup: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createResourceGroup,
|
||||
path: "/v1/admin/resource-groups",
|
||||
permission: "resource-group:create",
|
||||
permissions: ["resource-group:create"]
|
||||
permissions: ["resource-group:create"],
|
||||
},
|
||||
createRole: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createRole,
|
||||
path: "/v1/roles",
|
||||
permission: "role:create",
|
||||
permissions: ["role:create","role:manage"]
|
||||
permissions: ["role:create", "role:manage"],
|
||||
},
|
||||
createUser: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createUser,
|
||||
path: "/v1/users",
|
||||
permission: "user:create",
|
||||
permissions: ["user:create"]
|
||||
permissions: ["user:create"],
|
||||
},
|
||||
createUserExportJob: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createUserExportJob,
|
||||
path: "/v1/exports/users",
|
||||
permission: "export:create",
|
||||
permissions: ["export:create"]
|
||||
permissions: ["export:create"],
|
||||
},
|
||||
creditCoinSellerStock: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.creditCoinSellerStock,
|
||||
path: "/v1/admin/coin-sellers/{user_id}/stock-credits",
|
||||
permission: "coin-seller:stock-credit",
|
||||
permissions: ["coin-seller:stock-credit"],
|
||||
},
|
||||
dashboardOverview: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.dashboardOverview,
|
||||
path: "/v1/dashboard/overview",
|
||||
permission: "overview:view",
|
||||
permissions: ["overview:view"]
|
||||
permissions: ["overview:view"],
|
||||
},
|
||||
deleteBanner: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteBanner,
|
||||
path: "/v1/admin/app-config/banners/{banner_id}",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
permissions: ["app-config:update"],
|
||||
},
|
||||
deleteCountry: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteCountry,
|
||||
path: "/v1/admin/countries/{country_id}",
|
||||
permission: "country:status",
|
||||
permissions: ["country:status"]
|
||||
permissions: ["country:status"],
|
||||
},
|
||||
deleteMenu: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteMenu,
|
||||
path: "/v1/system/menus/{id}",
|
||||
permission: "menu:delete",
|
||||
permissions: ["menu:delete"]
|
||||
permissions: ["menu:delete"],
|
||||
},
|
||||
deleteNotification: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteNotification,
|
||||
path: "/v1/notifications/{id}",
|
||||
permission: "notification:delete",
|
||||
permissions: ["notification:delete"]
|
||||
permissions: ["notification:delete"],
|
||||
},
|
||||
deletePermission: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deletePermission,
|
||||
path: "/v1/permissions/{id}",
|
||||
permission: "permission:delete",
|
||||
permissions: ["permission:delete"]
|
||||
permissions: ["permission:delete"],
|
||||
},
|
||||
deleteRole: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteRole,
|
||||
path: "/v1/roles/{id}",
|
||||
permission: "role:delete",
|
||||
permissions: ["role:delete","role:manage"]
|
||||
permissions: ["role:delete", "role:manage"],
|
||||
},
|
||||
deleteRoom: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteRoom,
|
||||
path: "/v1/admin/rooms/{room_id}",
|
||||
permission: "room:delete",
|
||||
permissions: ["room:delete"]
|
||||
permissions: ["room:delete"],
|
||||
},
|
||||
disableCountry: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.disableCountry,
|
||||
path: "/v1/admin/countries/{country_id}/disable",
|
||||
permission: "country:status",
|
||||
permissions: ["country:status"]
|
||||
permissions: ["country:status"],
|
||||
},
|
||||
disableGift: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.disableGift,
|
||||
path: "/v1/admin/gifts/{gift_id}/disable",
|
||||
permission: "gift:status",
|
||||
permissions: ["gift:status"]
|
||||
permissions: ["gift:status"],
|
||||
},
|
||||
disableRegion: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.disableRegion,
|
||||
path: "/v1/admin/regions/{region_id}",
|
||||
permission: "region:status",
|
||||
permissions: ["region:status"]
|
||||
permissions: ["region:status"],
|
||||
},
|
||||
disableResource: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.disableResource,
|
||||
path: "/v1/admin/resources/{resource_id}/disable",
|
||||
permission: "resource:update",
|
||||
permissions: ["resource:update"]
|
||||
permissions: ["resource:update"],
|
||||
},
|
||||
disableResourceGroup: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.disableResourceGroup,
|
||||
path: "/v1/admin/resource-groups/{group_id}/disable",
|
||||
permission: "resource-group:update",
|
||||
permissions: ["resource-group:update"]
|
||||
permissions: ["resource-group:update"],
|
||||
},
|
||||
downloadJobArtifact: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.downloadJobArtifact,
|
||||
path: "/v1/jobs/{id}/artifact",
|
||||
permission: "job:view",
|
||||
permissions: ["job:view"]
|
||||
permissions: ["job:view"],
|
||||
},
|
||||
enableCountry: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.enableCountry,
|
||||
path: "/v1/admin/countries/{country_id}/enable",
|
||||
permission: "country:status",
|
||||
permissions: ["country:status"]
|
||||
permissions: ["country:status"],
|
||||
},
|
||||
enableGift: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.enableGift,
|
||||
path: "/v1/admin/gifts/{gift_id}/enable",
|
||||
permission: "gift:status",
|
||||
permissions: ["gift:status"]
|
||||
permissions: ["gift:status"],
|
||||
},
|
||||
enableRegion: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.enableRegion,
|
||||
path: "/v1/admin/regions/{region_id}/enable",
|
||||
permission: "region:status",
|
||||
permissions: ["region:status"]
|
||||
permissions: ["region:status"],
|
||||
},
|
||||
enableResource: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.enableResource,
|
||||
path: "/v1/admin/resources/{resource_id}/enable",
|
||||
permission: "resource:update",
|
||||
permissions: ["resource:update"]
|
||||
permissions: ["resource:update"],
|
||||
},
|
||||
enableResourceGroup: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.enableResourceGroup,
|
||||
path: "/v1/admin/resource-groups/{group_id}/enable",
|
||||
permission: "resource-group:update",
|
||||
permissions: ["resource-group:update"]
|
||||
permissions: ["resource-group:update"],
|
||||
},
|
||||
exportLoginLogs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.exportLoginLogs,
|
||||
path: "/v1/logs/login/export",
|
||||
permission: "log:export",
|
||||
permissions: ["log:export"]
|
||||
permissions: ["log:export"],
|
||||
},
|
||||
exportOperationLogs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.exportOperationLogs,
|
||||
path: "/v1/logs/operations/export",
|
||||
permission: "log:export",
|
||||
permissions: ["log:export"]
|
||||
permissions: ["log:export"],
|
||||
},
|
||||
exportUsers: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.exportUsers,
|
||||
path: "/v1/users/export",
|
||||
permission: "user:export",
|
||||
permissions: ["user:export"]
|
||||
permissions: ["user:export"],
|
||||
},
|
||||
getCountry: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getCountry,
|
||||
path: "/v1/admin/countries/{country_id}",
|
||||
permission: "country:view",
|
||||
permissions: ["country:view"]
|
||||
permissions: ["country:view"],
|
||||
},
|
||||
getRegion: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getRegion,
|
||||
path: "/v1/admin/regions/{region_id}",
|
||||
permission: "region:view",
|
||||
permissions: ["region:view"]
|
||||
permissions: ["region:view"],
|
||||
},
|
||||
getResource: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getResource,
|
||||
path: "/v1/admin/resources/{resource_id}",
|
||||
permission: "resource:view",
|
||||
permissions: ["resource:view"]
|
||||
permissions: ["resource:view"],
|
||||
},
|
||||
getResourceGroup: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getResourceGroup,
|
||||
path: "/v1/admin/resource-groups/{group_id}",
|
||||
permission: "resource-group:view",
|
||||
permissions: ["resource-group:view"]
|
||||
permissions: ["resource-group:view"],
|
||||
},
|
||||
getRoleDataScopes: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getRoleDataScopes,
|
||||
path: "/v1/roles/{id}/data-scopes",
|
||||
permission: "role:data-scope",
|
||||
permissions: ["role:data-scope"]
|
||||
permissions: ["role:data-scope"],
|
||||
},
|
||||
getUser: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getUser,
|
||||
path: "/v1/users/{id}",
|
||||
permission: "user:view",
|
||||
permissions: ["user:view"]
|
||||
permissions: ["user:view"],
|
||||
},
|
||||
grantResource: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.grantResource,
|
||||
path: "/v1/admin/resource-grants/resource",
|
||||
permission: "resource-grant:create",
|
||||
permissions: ["resource-grant:create"]
|
||||
permissions: ["resource-grant:create"],
|
||||
},
|
||||
grantResourceGroup: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.grantResourceGroup,
|
||||
path: "/v1/admin/resource-grants/group",
|
||||
permission: "resource-grant:create",
|
||||
permissions: ["resource-grant:create"]
|
||||
permissions: ["resource-grant:create"],
|
||||
},
|
||||
listAgencies: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listAgencies,
|
||||
path: "/v1/admin/agencies",
|
||||
permission: "agency:view",
|
||||
permissions: ["agency:view"]
|
||||
permissions: ["agency:view"],
|
||||
},
|
||||
listApps: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listApps,
|
||||
path: "/v1/admin/apps"
|
||||
path: "/v1/admin/apps",
|
||||
},
|
||||
listBanners: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listBanners,
|
||||
path: "/v1/admin/app-config/banners",
|
||||
permission: "app-config:view",
|
||||
permissions: ["app-config:view"]
|
||||
permissions: ["app-config:view"],
|
||||
},
|
||||
listBDLeaders: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listBDLeaders,
|
||||
path: "/v1/admin/bd-leaders",
|
||||
permission: "bd:view",
|
||||
permissions: ["bd:view"]
|
||||
permissions: ["bd:view"],
|
||||
},
|
||||
listBDs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listBDs,
|
||||
path: "/v1/admin/bds",
|
||||
permission: "bd:view",
|
||||
permissions: ["bd:view"]
|
||||
permissions: ["bd:view"],
|
||||
},
|
||||
listCoinSellers: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listCoinSellers,
|
||||
path: "/v1/admin/coin-sellers",
|
||||
permission: "coin-seller:view",
|
||||
permissions: ["coin-seller:view"]
|
||||
permissions: ["coin-seller:view"],
|
||||
},
|
||||
listCountries: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listCountries,
|
||||
path: "/v1/admin/countries",
|
||||
permission: "country:view",
|
||||
permissions: ["country:view"]
|
||||
permissions: ["country:view"],
|
||||
},
|
||||
listGifts: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listGifts,
|
||||
path: "/v1/admin/gifts",
|
||||
permission: "gift:view",
|
||||
permissions: ["gift:view"]
|
||||
permissions: ["gift:view"],
|
||||
},
|
||||
listH5Links: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listH5Links,
|
||||
path: "/v1/admin/app-config/h5-links",
|
||||
permission: "app-config:view",
|
||||
permissions: ["app-config:view"]
|
||||
permissions: ["app-config:view"],
|
||||
},
|
||||
listHosts: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listHosts,
|
||||
path: "/v1/admin/hosts",
|
||||
permission: "host:view",
|
||||
permissions: ["host:view"]
|
||||
permissions: ["host:view"],
|
||||
},
|
||||
listJobs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listJobs,
|
||||
path: "/v1/jobs",
|
||||
permission: "job:view",
|
||||
permissions: ["job:view"]
|
||||
permissions: ["job:view"],
|
||||
},
|
||||
listLoginLogs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLoginLogs,
|
||||
path: "/v1/logs/login",
|
||||
permission: "log:view",
|
||||
permissions: ["log:view"]
|
||||
permissions: ["log:view"],
|
||||
},
|
||||
listNotifications: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listNotifications,
|
||||
path: "/v1/notifications",
|
||||
permission: "notification:view",
|
||||
permissions: ["notification:view"]
|
||||
permissions: ["notification:view"],
|
||||
},
|
||||
listOperationLogs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listOperationLogs,
|
||||
path: "/v1/logs/operations",
|
||||
permission: "log:view",
|
||||
permissions: ["log:view"]
|
||||
permissions: ["log:view"],
|
||||
},
|
||||
listPermissions: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listPermissions,
|
||||
path: "/v1/permissions",
|
||||
permission: "permission:view",
|
||||
permissions: ["permission:view","role:permission","role:manage"]
|
||||
permissions: ["permission:view", "role:permission", "role:manage"],
|
||||
},
|
||||
listRechargeBills: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRechargeBills,
|
||||
path: "/v1/admin/payment/recharge-bills",
|
||||
permission: "payment-bill:view",
|
||||
permissions: ["payment-bill:view"]
|
||||
permissions: ["payment-bill:view"],
|
||||
},
|
||||
listRegions: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRegions,
|
||||
path: "/v1/admin/regions",
|
||||
permission: "region:view",
|
||||
permissions: ["region:view"]
|
||||
permissions: ["region:view"],
|
||||
},
|
||||
listResourceGrants: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listResourceGrants,
|
||||
path: "/v1/admin/resource-grants",
|
||||
permission: "resource-grant:view",
|
||||
permissions: ["resource-grant:view"]
|
||||
permissions: ["resource-grant:view"],
|
||||
},
|
||||
listResourceGroups: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listResourceGroups,
|
||||
path: "/v1/admin/resource-groups",
|
||||
permission: "resource-group:view",
|
||||
permissions: ["resource-group:view"]
|
||||
permissions: ["resource-group:view"],
|
||||
},
|
||||
listResources: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listResources,
|
||||
path: "/v1/admin/resources",
|
||||
permission: "resource:view",
|
||||
permissions: ["resource:view"]
|
||||
permissions: ["resource:view"],
|
||||
},
|
||||
listRoles: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRoles,
|
||||
path: "/v1/roles",
|
||||
permission: "role:view",
|
||||
permissions: ["role:view"]
|
||||
permissions: ["role:view"],
|
||||
},
|
||||
listRooms: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRooms,
|
||||
path: "/v1/admin/rooms",
|
||||
permission: "room:view",
|
||||
permissions: ["room:view"]
|
||||
permissions: ["room:view"],
|
||||
},
|
||||
listSystemMenus: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSystemMenus,
|
||||
path: "/v1/system/menus",
|
||||
permission: "menu:view",
|
||||
permissions: ["menu:view"]
|
||||
permissions: ["menu:view"],
|
||||
},
|
||||
listUsers: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listUsers,
|
||||
path: "/v1/users",
|
||||
permission: "user:view",
|
||||
permissions: ["user:view"]
|
||||
permissions: ["user:view"],
|
||||
},
|
||||
login: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.login,
|
||||
path: "/v1/auth/login"
|
||||
path: "/v1/auth/login",
|
||||
},
|
||||
logout: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.logout,
|
||||
path: "/v1/auth/logout"
|
||||
path: "/v1/auth/logout",
|
||||
},
|
||||
markAllNotificationsRead: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.markAllNotificationsRead,
|
||||
path: "/v1/notifications/read-all",
|
||||
permission: "notification:read-all",
|
||||
permissions: ["notification:read-all"]
|
||||
permissions: ["notification:read-all"],
|
||||
},
|
||||
markNotificationRead: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.markNotificationRead,
|
||||
path: "/v1/notifications/{id}/read",
|
||||
permission: "notification:read",
|
||||
permissions: ["notification:read"]
|
||||
permissions: ["notification:read"],
|
||||
},
|
||||
me: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.me,
|
||||
path: "/v1/auth/me"
|
||||
path: "/v1/auth/me",
|
||||
},
|
||||
navigationMenus: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.navigationMenus,
|
||||
path: "/v1/navigation/menus"
|
||||
path: "/v1/navigation/menus",
|
||||
},
|
||||
refresh: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.refresh,
|
||||
path: "/v1/auth/refresh"
|
||||
path: "/v1/auth/refresh",
|
||||
},
|
||||
replaceRegionCountries: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.replaceRegionCountries,
|
||||
path: "/v1/admin/regions/{region_id}/countries",
|
||||
permission: "region:update",
|
||||
permissions: ["region:update"]
|
||||
permissions: ["region:update"],
|
||||
},
|
||||
replaceRoleDataScopes: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.replaceRoleDataScopes,
|
||||
path: "/v1/roles/{id}/data-scopes",
|
||||
permission: "role:data-scope",
|
||||
permissions: ["role:data-scope"]
|
||||
permissions: ["role:data-scope"],
|
||||
},
|
||||
replaceRolePermissions: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.replaceRolePermissions,
|
||||
path: "/v1/roles/{id}/permissions",
|
||||
permission: "role:permission",
|
||||
permissions: ["role:permission","role:manage"]
|
||||
permissions: ["role:permission", "role:manage"],
|
||||
},
|
||||
resetUserPassword: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.resetUserPassword,
|
||||
path: "/v1/users/{id}/reset-password",
|
||||
permission: "user:reset-password",
|
||||
permissions: ["user:reset-password"]
|
||||
permissions: ["user:reset-password"],
|
||||
},
|
||||
search: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.search,
|
||||
path: "/v1/search"
|
||||
path: "/v1/search",
|
||||
},
|
||||
setAgencyJoinEnabled: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.setAgencyJoinEnabled,
|
||||
path: "/v1/admin/agencies/{agency_id}/join-enabled",
|
||||
permission: "agency:status",
|
||||
permissions: ["agency:status"]
|
||||
permissions: ["agency:status"],
|
||||
},
|
||||
setBDLeaderStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.setBDLeaderStatus,
|
||||
path: "/v1/admin/bd-leaders/{user_id}/status",
|
||||
permission: "bd:update",
|
||||
permissions: ["bd:update"]
|
||||
permissions: ["bd:update"],
|
||||
},
|
||||
setBDStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.setBDStatus,
|
||||
path: "/v1/admin/bds/{user_id}/status",
|
||||
permission: "bd:update",
|
||||
permissions: ["bd:update"]
|
||||
permissions: ["bd:update"],
|
||||
},
|
||||
setCoinSellerStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.setCoinSellerStatus,
|
||||
path: "/v1/admin/coin-sellers/{user_id}/status",
|
||||
permission: "coin-seller:update",
|
||||
permissions: ["coin-seller:update"]
|
||||
permissions: ["coin-seller:update"],
|
||||
},
|
||||
sortMenus: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.sortMenus,
|
||||
path: "/v1/system/menus/sort",
|
||||
permission: "menu:sort",
|
||||
permissions: ["menu:sort"]
|
||||
permissions: ["menu:sort"],
|
||||
},
|
||||
syncPermissions: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.syncPermissions,
|
||||
path: "/v1/permissions/sync",
|
||||
permission: "permission:sync",
|
||||
permissions: ["permission:sync"]
|
||||
permissions: ["permission:sync"],
|
||||
},
|
||||
updateBanner: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateBanner,
|
||||
path: "/v1/admin/app-config/banners/{banner_id}",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
permissions: ["app-config:update"],
|
||||
},
|
||||
updateCountry: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateCountry,
|
||||
path: "/v1/admin/countries/{country_id}",
|
||||
permission: "country:update",
|
||||
permissions: ["country:update"]
|
||||
permissions: ["country:update"],
|
||||
},
|
||||
updateGift: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateGift,
|
||||
path: "/v1/admin/gifts/{gift_id}",
|
||||
permission: "gift:update",
|
||||
permissions: ["gift:update"]
|
||||
permissions: ["gift:update"],
|
||||
},
|
||||
updateH5Links: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateH5Links,
|
||||
path: "/v1/admin/app-config/h5-links",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
permissions: ["app-config:update"],
|
||||
},
|
||||
updateMenu: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateMenu,
|
||||
path: "/v1/system/menus/{id}",
|
||||
permission: "menu:update",
|
||||
permissions: ["menu:update"]
|
||||
permissions: ["menu:update"],
|
||||
},
|
||||
updateMenuVisible: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateMenuVisible,
|
||||
path: "/v1/system/menus/{id}/visible",
|
||||
permission: "menu:visible",
|
||||
permissions: ["menu:visible"]
|
||||
permissions: ["menu:visible"],
|
||||
},
|
||||
updatePermission: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updatePermission,
|
||||
path: "/v1/permissions/{id}",
|
||||
permission: "permission:update",
|
||||
permissions: ["permission:update"]
|
||||
permissions: ["permission:update"],
|
||||
},
|
||||
updateRegion: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateRegion,
|
||||
path: "/v1/admin/regions/{region_id}",
|
||||
permission: "region:update",
|
||||
permissions: ["region:update"]
|
||||
permissions: ["region:update"],
|
||||
},
|
||||
updateResource: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateResource,
|
||||
path: "/v1/admin/resources/{resource_id}",
|
||||
permission: "resource:update",
|
||||
permissions: ["resource:update"]
|
||||
permissions: ["resource:update"],
|
||||
},
|
||||
updateResourceGroup: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateResourceGroup,
|
||||
path: "/v1/admin/resource-groups/{group_id}",
|
||||
permission: "resource-group:update",
|
||||
permissions: ["resource-group:update"]
|
||||
permissions: ["resource-group:update"],
|
||||
},
|
||||
updateResourceGroupItems: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateResourceGroupItems,
|
||||
path: "/v1/admin/resource-groups/{group_id}/items",
|
||||
permission: "resource-group:update",
|
||||
permissions: ["resource-group:update"]
|
||||
permissions: ["resource-group:update"],
|
||||
},
|
||||
updateRole: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateRole,
|
||||
path: "/v1/roles/{id}",
|
||||
permission: "role:update",
|
||||
permissions: ["role:update","role:manage"]
|
||||
permissions: ["role:update", "role:manage"],
|
||||
},
|
||||
updateRoom: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateRoom,
|
||||
path: "/v1/admin/rooms/{room_id}",
|
||||
permission: "room:update",
|
||||
permissions: ["room:update"]
|
||||
permissions: ["room:update"],
|
||||
},
|
||||
updateUser: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateUser,
|
||||
path: "/v1/users/{id}",
|
||||
permission: "user:update",
|
||||
permissions: ["user:update"]
|
||||
permissions: ["user:update"],
|
||||
},
|
||||
updateUserStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateUserStatus,
|
||||
path: "/v1/users/{id}/status",
|
||||
permission: "user:status",
|
||||
permissions: ["user:status"]
|
||||
permissions: ["user:status"],
|
||||
},
|
||||
uploadFile: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.uploadFile,
|
||||
path: "/v1/admin/files/upload",
|
||||
permission: "upload:create",
|
||||
permissions: ["upload:create"]
|
||||
permissions: ["upload:create"],
|
||||
},
|
||||
uploadImage: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.uploadImage,
|
||||
path: "/v1/admin/files/image/upload",
|
||||
permission: "upload:create",
|
||||
permissions: ["upload:create"]
|
||||
}
|
||||
permissions: ["upload:create"],
|
||||
},
|
||||
};
|
||||
|
||||
export const API_OPERATION_IDS = Object.values(API_OPERATIONS);
|
||||
@ -922,8 +930,8 @@ export const API_PERMISSION_CODES = Array.from(
|
||||
new Set(
|
||||
Object.values(API_ENDPOINTS)
|
||||
.flatMap((endpoint) => endpoint.permissions || (endpoint.permission ? [endpoint.permission] : []))
|
||||
.filter((permission): permission is PermissionCode => Boolean(permission))
|
||||
)
|
||||
.filter((permission): permission is PermissionCode => Boolean(permission)),
|
||||
),
|
||||
);
|
||||
|
||||
export type ApiPathParams = Record<string, number | string>;
|
||||
|
||||
30
src/shared/api/generated/schema.d.ts
vendored
30
src/shared/api/generated/schema.d.ts
vendored
@ -212,6 +212,22 @@ export interface paths {
|
||||
patch: operations["setCoinSellerStatus"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/coin-sellers/{user_id}/stock-credits": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["creditCoinSellerStock"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/countries": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2103,6 +2119,20 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
creditCoinSellerStock: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
user_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listCountries: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -217,14 +217,25 @@ export interface RechargeBillDto {
|
||||
}
|
||||
|
||||
export interface BDProfileDto {
|
||||
avatar?: string;
|
||||
createdAtMs?: number;
|
||||
createdByAvatar?: string;
|
||||
createdByDisplayUserId?: string;
|
||||
createdByUsername?: string;
|
||||
createdByUserId?: number;
|
||||
displayUserId?: string;
|
||||
parentLeaderUserId?: number;
|
||||
parentLeaderDisplayUserId?: string;
|
||||
parentLeaderUsername?: string;
|
||||
parentLeaderAvatar?: string;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
subBdCount?: number;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface AgencyDto {
|
||||
@ -234,27 +245,40 @@ export interface AgencyDto {
|
||||
joinEnabled?: boolean;
|
||||
maxHosts?: number;
|
||||
name?: string;
|
||||
ownerAvatar?: string;
|
||||
ownerDisplayUserId?: string;
|
||||
ownerUsername?: string;
|
||||
ownerUserId?: number;
|
||||
parentBdAvatar?: string;
|
||||
parentBdDisplayUserId?: string;
|
||||
parentBdUsername?: string;
|
||||
parentBdUserId?: number;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface HostProfileDto {
|
||||
avatar?: string;
|
||||
createdAtMs?: number;
|
||||
currentAgencyId?: number;
|
||||
currentAgencyName?: string;
|
||||
currentMembershipId?: number;
|
||||
displayUserId?: string;
|
||||
firstBecameHostAtMs?: number;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface CoinSellerDto {
|
||||
avatar?: string;
|
||||
contact?: string;
|
||||
createdAtMs?: number;
|
||||
createdByUserId?: number;
|
||||
displayUserId?: string;
|
||||
@ -268,6 +292,26 @@ export interface CoinSellerDto {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface CoinSellerStockCreditPayload {
|
||||
coinAmount: number;
|
||||
commandId: string;
|
||||
reason?: string;
|
||||
rechargeAmount?: string;
|
||||
type: "usdt_purchase" | "coin_compensation";
|
||||
}
|
||||
|
||||
export interface CoinSellerStockCreditDto {
|
||||
balanceAfter: number;
|
||||
coinAmount: number;
|
||||
countsAsSellerRecharge: boolean;
|
||||
createdAtMs: number;
|
||||
paidAmountMicro: number;
|
||||
paidCurrencyCode: string;
|
||||
sellerUserId: string;
|
||||
stockType: string;
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
export interface AppUserDto {
|
||||
avatar?: string;
|
||||
coin?: number;
|
||||
@ -440,10 +484,13 @@ export interface BDStatusPayload extends HostCommandPayload {
|
||||
}
|
||||
|
||||
export interface CreateCoinSellerPayload extends HostCommandPayload {
|
||||
contact?: string;
|
||||
targetUserId: number;
|
||||
}
|
||||
|
||||
export type CoinSellerStatusPayload = BDStatusPayload;
|
||||
export interface CoinSellerStatusPayload extends BDStatusPayload {
|
||||
contact?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgencyPayload extends HostCommandPayload {
|
||||
joinEnabled: boolean;
|
||||
|
||||
@ -3,6 +3,8 @@ import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import styles from "@/shared/ui/AdminFormDialog.module.css";
|
||||
|
||||
@ -66,6 +68,43 @@ export function AdminFormFieldGrid({ children, columns }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFormReadOnlyField({ label, value }) {
|
||||
const displayValue = value === undefined || value === null || value === "" ? "-" : value;
|
||||
|
||||
return (
|
||||
<div className={styles.readOnlyField}>
|
||||
<span className={styles.readOnlyLabel}>{label}</span>
|
||||
<span className={styles.readOnlyValue}>{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFormAmountField({ className = "", inputProps, InputProps, slotProps, unit, ...props }) {
|
||||
const inputSlotProps = { ...(slotProps?.input || {}), ...(InputProps || {}) };
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
className={[styles.amountField, className].filter(Boolean).join(" ")}
|
||||
slotProps={{
|
||||
...slotProps,
|
||||
htmlInput: { inputMode: "decimal", ...slotProps?.htmlInput, ...inputProps },
|
||||
input: {
|
||||
...inputSlotProps,
|
||||
endAdornment: unit ? (
|
||||
<InputAdornment position="end">
|
||||
<span className={styles.inputUnit}>{unit}</span>
|
||||
</InputAdornment>
|
||||
) : (
|
||||
inputSlotProps.endAdornment
|
||||
)
|
||||
}
|
||||
}}
|
||||
type={props.type || "text"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFormInlineActions({ children }) {
|
||||
return <div className={styles.inlineActions}>{children}</div>;
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important;
|
||||
max-width: min(var(--admin-form-dialog-width), calc(100vw - 32px)) !important;
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.compact {
|
||||
@ -77,9 +78,14 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
min-height: 20px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
@ -87,9 +93,16 @@
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.section > :global(.MuiTextField-root),
|
||||
.section > :global(.MuiFormControl-root),
|
||||
.section > :global(.MuiAutocomplete-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sectionActions,
|
||||
@ -109,10 +122,62 @@
|
||||
|
||||
.fieldGrid > :global(.MuiTextField-root),
|
||||
.fieldGrid > :global(.MuiFormControl-root),
|
||||
.fieldGrid > :global(.MuiAutocomplete-root) {
|
||||
.fieldGrid > :global(.MuiAutocomplete-root),
|
||||
.fieldGrid > .readOnlyField {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.readOnlyField {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
align-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 8px var(--space-3);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.readOnlyLabel {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.readOnlyValue {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 760;
|
||||
line-height: var(--admin-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amountField :global(.MuiOutlinedInput-root) {
|
||||
background: var(--bg-input-strong);
|
||||
}
|
||||
|
||||
.amountField :global(.MuiOutlinedInput-root.Mui-focused) {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.amountField :global(.MuiInputBase-input) {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.inputUnit {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@ -135,8 +200,13 @@
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-3) var(--admin-form-content-x) var(--space-5);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
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";
|
||||
@ -36,7 +38,22 @@ export function AdminSearchBox(props) {
|
||||
}
|
||||
|
||||
export function AdminFilterSelect(props) {
|
||||
return <StatusSelect {...props} className={[styles.statusSelect, props.className || ""].filter(Boolean).join(" ")} />;
|
||||
return (
|
||||
<StatusSelect {...props} className={[styles.statusSelect, props.className || ""].filter(Boolean).join(" ")} />
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFilterResetButton({ disabled = false, label = "重置", onClick }) {
|
||||
return (
|
||||
<Button
|
||||
className={styles.resetButton}
|
||||
disabled={disabled}
|
||||
startIcon={<RestartAltOutlined fontSize="small" />}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminActionIconButton({ children, label, primary = false, tooltip = label, ...props }) {
|
||||
@ -65,6 +82,6 @@ const primaryActionSx = {
|
||||
"&:hover": {
|
||||
borderColor: "var(--primary-strong)",
|
||||
backgroundColor: "var(--primary-strong)",
|
||||
color: "var(--active-contrast)"
|
||||
}
|
||||
color: "var(--active-contrast)",
|
||||
},
|
||||
};
|
||||
|
||||
@ -77,6 +77,10 @@
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.listBlock {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@ -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,6 +203,27 @@ export function DataTable({
|
||||
onClose={closeColumnFilter}
|
||||
>
|
||||
{activeFilterColumn ? (
|
||||
activeFilterIsText ? (
|
||||
<form className="admin-table-filter" onSubmit={submitTextFilter}>
|
||||
<TextField
|
||||
autoFocus
|
||||
className="admin-table-filter__search"
|
||||
disabled={activeFilter.loading}
|
||||
placeholder={activeFilter.placeholder || `搜索${activeFilterColumn.label || ""}`}
|
||||
size="small"
|
||||
value={filterQuery}
|
||||
onChange={(event) => setFilterQuery(event.target.value)}
|
||||
/>
|
||||
<div className="admin-table-filter__actions">
|
||||
<Button disabled={activeFilter.loading || !String(activeFilter.value ?? "").trim()} onClick={clearTextFilter}>
|
||||
清空
|
||||
</Button>
|
||||
<Button disabled={activeFilter.loading} type="submit" variant="primary">
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="admin-table-filter">
|
||||
<TextField
|
||||
autoFocus
|
||||
@ -199,7 +234,7 @@ export function DataTable({
|
||||
value={filterQuery}
|
||||
onChange={(event) => setFilterQuery(event.target.value)}
|
||||
/>
|
||||
<div className="admin-table-filter__options">
|
||||
<MenuList className="admin-table-filter__options">
|
||||
{filterOptions.length ? (
|
||||
filterOptions.map((option) => (
|
||||
<MenuItem
|
||||
@ -213,8 +248,9 @@ export function DataTable({
|
||||
) : (
|
||||
<div className="admin-table-filter__empty">{activeFilter.loading ? "加载中..." : "无匹配选项"}</div>
|
||||
)}
|
||||
</MenuList>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Popover>
|
||||
</section>
|
||||
@ -232,6 +268,7 @@ function HeaderLabel({ column, onOpenFilter }) {
|
||||
}
|
||||
|
||||
const active = isFilterActive(column.filter);
|
||||
const filterValueLabel = active ? filterDisplayValue(column.filter) : "";
|
||||
return (
|
||||
<button
|
||||
className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""].filter(Boolean).join(" ")}
|
||||
@ -239,7 +276,10 @@ function HeaderLabel({ column, onOpenFilter }) {
|
||||
onClick={(event) => onOpenFilter(event, column)}
|
||||
>
|
||||
<SearchOutlined className="admin-cell__head-icon" fontSize="inherit" />
|
||||
<span className="admin-cell__head-label">{content}</span>
|
||||
<span className="admin-cell__head-label">
|
||||
{content}
|
||||
{filterValueLabel ? <span className="admin-cell__head-filter">({filterValueLabel})</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -320,6 +360,19 @@ function isFilterActive(filter) {
|
||||
return filter && filter.value !== undefined && filter.value !== null && String(filter.value) !== "";
|
||||
}
|
||||
|
||||
function filterDisplayValue(filter) {
|
||||
const value = String(filter?.value ?? "");
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (filter?.type === "text") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const matchedOption = normalizeFilterOptions(filter).find((option) => option.value === value);
|
||||
return matchedOption?.label || value;
|
||||
}
|
||||
|
||||
function filterVisibleOptions(filter, query) {
|
||||
if (!filter) {
|
||||
return [];
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
|
||||
export function RegionFilterControl({
|
||||
className = "",
|
||||
@ -10,14 +8,10 @@ export function RegionFilterControl({
|
||||
loading = false,
|
||||
onChange,
|
||||
options = [],
|
||||
resetLabel = "重置区域",
|
||||
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
|
||||
@ -30,18 +24,6 @@ export function RegionFilterControl({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -52,14 +34,14 @@ export function createRegionColumnFilter({
|
||||
onChange,
|
||||
options = [],
|
||||
placeholder = "搜索区域",
|
||||
value
|
||||
value,
|
||||
}) {
|
||||
return {
|
||||
return createOptionsColumnFilter({
|
||||
emptyLabel,
|
||||
loading,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
value
|
||||
};
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
20
src/shared/ui/tableFilters.js
Normal file
20
src/shared/ui/tableFilters.js
Normal file
@ -0,0 +1,20 @@
|
||||
export function createTextColumnFilter({ loading = false, onChange, placeholder, value }) {
|
||||
return {
|
||||
loading,
|
||||
onChange,
|
||||
placeholder,
|
||||
type: "text",
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
export function createOptionsColumnFilter({ emptyLabel, loading = false, onChange, options = [], placeholder, value }) {
|
||||
return {
|
||||
emptyLabel,
|
||||
loading,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
value
|
||||
};
|
||||
}
|
||||
@ -130,11 +130,6 @@
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
.region-filter-control__reset.icon-button {
|
||||
flex: 0 0 var(--control-height);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) {
|
||||
height: var(--control-height);
|
||||
min-height: var(--control-height);
|
||||
@ -416,7 +411,6 @@
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
@ -677,9 +671,7 @@
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: var(--space-6);
|
||||
background:
|
||||
var(--login-gradient),
|
||||
var(--bg-page);
|
||||
background: var(--login-gradient), var(--bg-page);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
@ -918,7 +910,7 @@
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
--admin-table-cell-x: var(--space-2);
|
||||
--admin-table-cell-x: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: max(100%, var(--admin-table-min-width, 900px));
|
||||
@ -986,6 +978,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-cell__head-filter {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-cell__head-trigger {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
@ -1105,6 +1102,12 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-table-filter__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-table-filter__options .MuiMenuItem-root {
|
||||
min-height: 34px;
|
||||
border-radius: var(--radius-control);
|
||||
|
||||
11
src/theme.js
11
src/theme.js
@ -130,6 +130,13 @@ export const theme = createTheme({
|
||||
},
|
||||
input: {
|
||||
fontSize: 14,
|
||||
"&[type='number']": {
|
||||
MozAppearance: "textfield"
|
||||
},
|
||||
"&[type='number']::-webkit-outer-spin-button, &[type='number']::-webkit-inner-spin-button": {
|
||||
WebkitAppearance: "none",
|
||||
margin: 0
|
||||
},
|
||||
"&::placeholder": {
|
||||
color: token("text-tertiary"),
|
||||
opacity: 1
|
||||
@ -237,6 +244,10 @@ export const theme = createTheme({
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: 14
|
||||
},
|
||||
asterisk: {
|
||||
color: token("danger"),
|
||||
marginLeft: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user