完善主播侧
This commit is contained in:
parent
f58c5d2e43
commit
3698b05159
@ -104,6 +104,78 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/app-config/banners": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listBanners",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "app-config:view",
|
||||||
|
"x-permissions": [
|
||||||
|
"app-config:view"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "createBanner",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "app-config:update",
|
||||||
|
"x-permissions": [
|
||||||
|
"app-config:update"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/app-config/banners/{banner_id}": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateBanner",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "banner_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "app-config:update",
|
||||||
|
"x-permissions": [
|
||||||
|
"app-config:update"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "deleteBanner",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "banner_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "app-config:update",
|
||||||
|
"x-permissions": [
|
||||||
|
"app-config:update"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/app-config/h5-links": {
|
"/admin/app-config/h5-links": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listH5Links",
|
"operationId": "listH5Links",
|
||||||
@ -629,6 +701,20 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/payment/recharge-bills": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listRechargeBills",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "payment-bill:view",
|
||||||
|
"x-permissions": [
|
||||||
|
"payment-bill:view"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/regions": {
|
"/admin/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRegions",
|
"operationId": "listRegions",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import ExpandMore from "@mui/icons-material/ExpandMore";
|
import ExpandMore from "@mui/icons-material/ExpandMore";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
|
import LockReset from "@mui/icons-material/LockReset";
|
||||||
import Logout from "@mui/icons-material/Logout";
|
import Logout from "@mui/icons-material/Logout";
|
||||||
import Menu from "@mui/icons-material/Menu";
|
import Menu from "@mui/icons-material/Menu";
|
||||||
import MenuOpen from "@mui/icons-material/MenuOpen";
|
import MenuOpen from "@mui/icons-material/MenuOpen";
|
||||||
@ -15,17 +16,24 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { PERMISSIONS } from "@/app/permissions";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
import { changePassword } from "@/features/auth/api";
|
||||||
import { listNotifications } from "@/features/notifications/api";
|
import { listNotifications } from "@/features/notifications/api";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
|
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
|
||||||
|
const emptyPasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
|
||||||
|
|
||||||
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) {
|
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) {
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [searchMounted, setSearchMounted] = useState(false);
|
const [searchMounted, setSearchMounted] = useState(false);
|
||||||
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
|
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
|
||||||
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
|
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
||||||
|
const [passwordError, setPasswordError] = useState("");
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { can, logout, user } = useAuth();
|
const { can, logout, user } = useAuth();
|
||||||
const appScope = useAppScope();
|
const appScope = useAppScope();
|
||||||
@ -60,12 +68,68 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
|||||||
setUserMenuAnchor(null);
|
setUserMenuAnchor(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPasswordDialog = () => {
|
||||||
|
closeUserMenu();
|
||||||
|
setPasswordForm(emptyPasswordForm);
|
||||||
|
setPasswordError("");
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePasswordDialog = () => {
|
||||||
|
if (changingPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
setPasswordError("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
closeUserMenu();
|
closeUserMenu();
|
||||||
await logout();
|
await logout();
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePasswordFieldChange = (field) => (event) => {
|
||||||
|
setPasswordForm((current) => ({ ...current, [field]: event.target.value }));
|
||||||
|
if (passwordError) {
|
||||||
|
setPasswordError("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const { oldPassword, newPassword, confirmPassword } = passwordForm;
|
||||||
|
|
||||||
|
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||||
|
setPasswordError("请填写原密码、新密码和确认密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setPasswordError("新密码至少 6 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length > 72) {
|
||||||
|
setPasswordError("新密码不能超过 72 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordError("两次输入的新密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangingPassword(true);
|
||||||
|
try {
|
||||||
|
await changePassword({ oldPassword, newPassword });
|
||||||
|
showToast("密码已修改,请重新登录", "success");
|
||||||
|
await logout();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordError(err.message || "修改密码失败");
|
||||||
|
} finally {
|
||||||
|
setChangingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchSelect = (result) => {
|
const handleSearchSelect = (result) => {
|
||||||
if (result?.target) {
|
if (result?.target) {
|
||||||
navigate(result.target);
|
navigate(result.target);
|
||||||
@ -145,6 +209,10 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
|||||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
||||||
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
|
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
|
||||||
>
|
>
|
||||||
|
<MenuItem className="user-menu__item" onClick={openPasswordDialog}>
|
||||||
|
<LockReset fontSize="small" />
|
||||||
|
修改密码
|
||||||
|
</MenuItem>
|
||||||
<MenuItem className="user-menu__item" onClick={handleLogout}>
|
<MenuItem className="user-menu__item" onClick={handleLogout}>
|
||||||
<Logout fontSize="small" />
|
<Logout fontSize="small" />
|
||||||
退出
|
退出
|
||||||
@ -158,6 +226,52 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
|||||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={changingPassword}
|
||||||
|
onClose={closePasswordDialog}
|
||||||
|
onSubmit={handlePasswordSubmit}
|
||||||
|
open={passwordDialogOpen}
|
||||||
|
size="narrow"
|
||||||
|
submitDisabled={
|
||||||
|
changingPassword ||
|
||||||
|
!passwordForm.oldPassword ||
|
||||||
|
!passwordForm.newPassword ||
|
||||||
|
!passwordForm.confirmPassword
|
||||||
|
}
|
||||||
|
submitLabel="确认修改"
|
||||||
|
title="修改密码"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
disabled={changingPassword}
|
||||||
|
error={Boolean(passwordError)}
|
||||||
|
helperText={passwordError || "修改成功后将自动退出登录"}
|
||||||
|
label="原密码"
|
||||||
|
onChange={handlePasswordFieldChange("oldPassword")}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.oldPassword}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={changingPassword}
|
||||||
|
label="新密码"
|
||||||
|
onChange={handlePasswordFieldChange("newPassword")}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.newPassword}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={changingPassword}
|
||||||
|
label="确认新密码"
|
||||||
|
onChange={handlePasswordFieldChange("confirmPassword")}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.confirmPassword}
|
||||||
|
/>
|
||||||
|
</AdminFormDialog>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
|||||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||||
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
||||||
import HubOutlined from "@mui/icons-material/HubOutlined";
|
import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||||
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||||
@ -32,6 +33,7 @@ const iconMap = {
|
|||||||
network: HubOutlined,
|
network: HubOutlined,
|
||||||
team: GroupsOutlined,
|
team: GroupsOutlined,
|
||||||
history: HistoryOutlined,
|
history: HistoryOutlined,
|
||||||
|
image: ImageOutlined,
|
||||||
login: LoginOutlined,
|
login: LoginOutlined,
|
||||||
map: MapOutlined,
|
map: MapOutlined,
|
||||||
menu: MenuOpenOutlined,
|
menu: MenuOpenOutlined,
|
||||||
@ -84,7 +86,8 @@ export const fallbackNavigation = [
|
|||||||
id: "app-config",
|
id: "app-config",
|
||||||
label: "APP配置",
|
label: "APP配置",
|
||||||
children: [
|
children: [
|
||||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined })
|
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||||
|
routeNavItem("app-config-banners", { icon: ImageOutlined })
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -99,6 +102,15 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("resource-grant-list", { icon: SendOutlined })
|
routeNavItem("resource-grant-list", { icon: SendOutlined })
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: "payment",
|
||||||
|
icon: WalletOutlined,
|
||||||
|
id: "payment",
|
||||||
|
label: "支付管理",
|
||||||
|
children: [
|
||||||
|
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: "logs",
|
code: "logs",
|
||||||
icon: HistoryOutlined,
|
icon: HistoryOutlined,
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export const PERMISSIONS = {
|
|||||||
coinSellerView: "coin-seller:view",
|
coinSellerView: "coin-seller:view",
|
||||||
coinSellerCreate: "coin-seller:create",
|
coinSellerCreate: "coin-seller:create",
|
||||||
coinSellerUpdate: "coin-seller:update",
|
coinSellerUpdate: "coin-seller:update",
|
||||||
|
paymentBillView: "payment-bill:view",
|
||||||
roleView: "role:view",
|
roleView: "role:view",
|
||||||
roleCreate: "role:create",
|
roleCreate: "role:create",
|
||||||
roleUpdate: "role:update",
|
roleUpdate: "role:update",
|
||||||
@ -95,6 +96,7 @@ export const MENU_CODES = {
|
|||||||
roomList: "room-list",
|
roomList: "room-list",
|
||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
appConfigH5: "app-config-h5",
|
appConfigH5: "app-config-h5",
|
||||||
|
appConfigBanners: "app-config-banners",
|
||||||
resources: "resources",
|
resources: "resources",
|
||||||
resourceList: "resource-list",
|
resourceList: "resource-list",
|
||||||
resourceGroupList: "resource-group-list",
|
resourceGroupList: "resource-group-list",
|
||||||
@ -109,6 +111,8 @@ export const MENU_CODES = {
|
|||||||
hostOrgBds: "host-org-bds",
|
hostOrgBds: "host-org-bds",
|
||||||
hostOrgHosts: "host-org-hosts",
|
hostOrgHosts: "host-org-hosts",
|
||||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||||
|
payment: "payment",
|
||||||
|
paymentBillList: "payment-bill-list",
|
||||||
jobs: "jobs"
|
jobs: "jobs"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
|||||||
import { logsRoutes } from "@/features/logs/routes.js";
|
import { logsRoutes } from "@/features/logs/routes.js";
|
||||||
import { menusRoutes } from "@/features/menus/routes.js";
|
import { menusRoutes } from "@/features/menus/routes.js";
|
||||||
import { notificationsRoutes } from "@/features/notifications/routes.js";
|
import { notificationsRoutes } from "@/features/notifications/routes.js";
|
||||||
|
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||||
import { resourceRoutes } from "@/features/resources/routes.js";
|
import { resourceRoutes } from "@/features/resources/routes.js";
|
||||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
@ -21,6 +22,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...roomRoutes,
|
...roomRoutes,
|
||||||
...appConfigRoutes,
|
...appConfigRoutes,
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
|
...paymentRoutes,
|
||||||
...usersRoutes,
|
...usersRoutes,
|
||||||
...hostOrgRoutes,
|
...hostOrgRoutes,
|
||||||
...rolesRoutes,
|
...rolesRoutes,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type { ApiList, H5LinkConfigDto, H5LinkConfigUpdatePayload } from "@/shared/api/types";
|
import type { ApiList, AppBannerDto, AppBannerPayload, EntityId, H5LinkConfigDto, H5LinkConfigUpdatePayload, PageQuery } from "@/shared/api/types";
|
||||||
|
|
||||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listH5Links;
|
const endpoint = API_ENDPOINTS.listH5Links;
|
||||||
@ -16,3 +16,34 @@ export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiLi
|
|||||||
method: endpoint.method
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listBanners(query: PageQuery = {}): Promise<ApiList<AppBannerDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listBanners;
|
||||||
|
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBanner(payload: AppBannerPayload): Promise<AppBannerDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createBanner;
|
||||||
|
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.createBanner), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBanner(bannerId: EntityId, payload: AppBannerPayload): Promise<AppBannerDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateBanner;
|
||||||
|
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteBanner;
|
||||||
|
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteBanner, { banner_id: bannerId }), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -5,3 +5,21 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bannerCover,
|
||||||
|
.bannerCoverEmpty {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 88px;
|
||||||
|
height: 50px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerCover {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|||||||
179
src/features/app-config/hooks/useBannerConfigPage.js
Normal file
179
src/features/app-config/hooks/useBannerConfigPage.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { createBanner, deleteBanner, listBanners, updateBanner } from "@/features/app-config/api";
|
||||||
|
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||||
|
import { appBannerSchema } from "@/features/app-config/schema";
|
||||||
|
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const emptyData = { items: [], total: 0 };
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
bannerType: "h5",
|
||||||
|
countryCode: "",
|
||||||
|
coverUrl: "",
|
||||||
|
description: "",
|
||||||
|
param: "",
|
||||||
|
platform: "android",
|
||||||
|
regionId: "",
|
||||||
|
sortOrder: "0",
|
||||||
|
status: "active"
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useBannerConfigPage() {
|
||||||
|
const abilities = useAppConfigAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||||
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [platform, setPlatform] = useState("");
|
||||||
|
const [regionId, setRegionId] = useState("");
|
||||||
|
const [countryCode, setCountryCode] = useState("");
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [form, setFormState] = useState(emptyForm);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
countryCode,
|
||||||
|
keyword,
|
||||||
|
platform,
|
||||||
|
regionId,
|
||||||
|
status
|
||||||
|
}),
|
||||||
|
[countryCode, keyword, platform, regionId, status]
|
||||||
|
);
|
||||||
|
const queryFn = useCallback(() => listBanners(filters), [filters]);
|
||||||
|
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||||
|
errorMessage: "加载 BANNER 配置失败",
|
||||||
|
initialData: emptyData,
|
||||||
|
queryKey: ["app-config", "banners", filters]
|
||||||
|
});
|
||||||
|
|
||||||
|
const setForm = (patch) => {
|
||||||
|
setFormState((current) => ({ ...current, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormState(emptyForm());
|
||||||
|
setActiveAction("create");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setFormState(formFromBanner(item));
|
||||||
|
setActiveAction("edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setActiveAction("");
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormState(emptyForm());
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitBanner = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = parseForm(appBannerSchema, normalizeForm(form));
|
||||||
|
const action = editingItem ? "edit" : "create";
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateBanner(editingItem.id, payload);
|
||||||
|
showToast("BANNER 已更新", "success");
|
||||||
|
} else {
|
||||||
|
await createBanner(payload);
|
||||||
|
showToast("BANNER 已新增", "success");
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBanner = async (item) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "删除",
|
||||||
|
message: item.description || item.param || `BANNER ${item.id}`,
|
||||||
|
title: "删除BANNER",
|
||||||
|
tone: "danger"
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction(`delete:${item.id}`);
|
||||||
|
try {
|
||||||
|
await deleteBanner(item.id);
|
||||||
|
await reload();
|
||||||
|
showToast("BANNER 已删除", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "删除失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
activeAction,
|
||||||
|
closeDialog,
|
||||||
|
countryCode,
|
||||||
|
countryOptions,
|
||||||
|
data,
|
||||||
|
editingItem,
|
||||||
|
error,
|
||||||
|
form,
|
||||||
|
keyword,
|
||||||
|
loading,
|
||||||
|
loadingAction,
|
||||||
|
loadingCountries,
|
||||||
|
loadingRegions,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
platform,
|
||||||
|
regionId,
|
||||||
|
regionOptions,
|
||||||
|
reload,
|
||||||
|
removeBanner,
|
||||||
|
setCountryCode,
|
||||||
|
setForm,
|
||||||
|
setKeyword,
|
||||||
|
setPlatform,
|
||||||
|
setRegionId,
|
||||||
|
setStatus,
|
||||||
|
status,
|
||||||
|
submitBanner
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForm(form) {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
countryCode: form.countryCode || "",
|
||||||
|
regionId: form.regionId || 0,
|
||||||
|
sortOrder: form.sortOrder || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromBanner(item) {
|
||||||
|
return {
|
||||||
|
bannerType: item.bannerType || "h5",
|
||||||
|
countryCode: item.countryCode || "",
|
||||||
|
coverUrl: item.coverUrl || "",
|
||||||
|
description: item.description || "",
|
||||||
|
param: item.param || "",
|
||||||
|
platform: item.platform || "android",
|
||||||
|
regionId: item.regionId ? String(item.regionId) : "",
|
||||||
|
sortOrder: String(item.sortOrder || 0),
|
||||||
|
status: item.status || "active"
|
||||||
|
};
|
||||||
|
}
|
||||||
308
src/features/app-config/pages/BannerConfigPage.jsx
Normal file
308
src/features/app-config/pages/BannerConfigPage.jsx
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
|
||||||
|
import styles from "@/features/app-config/app-config.module.css";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
AdminRowActions,
|
||||||
|
AdminSearchBox,
|
||||||
|
adminListClasses
|
||||||
|
} 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 { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||||
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["active", "启用"],
|
||||||
|
["disabled", "关闭"]
|
||||||
|
];
|
||||||
|
|
||||||
|
const platformOptions = [
|
||||||
|
["", "全部平台"],
|
||||||
|
["android", "安卓"],
|
||||||
|
["ios", "iOS"]
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BannerConfigPage() {
|
||||||
|
const page = useBannerConfigPage();
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const columns = useMemo(() => bannerColumns(page), [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||||
|
新增BANNER
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminSearchBox value={page.keyword} onChange={page.setKeyword} placeholder="搜索参数、国家、描述..." />
|
||||||
|
<AdminFilterSelect label="平台" options={platformOptions} value={page.platform} onChange={page.setPlatform} />
|
||||||
|
<AdminFilterSelect label="状态" options={statusOptions} value={page.status} onChange={page.setStatus} />
|
||||||
|
<RegionFilterControl
|
||||||
|
emptyLabel="全部区域"
|
||||||
|
loading={page.loadingRegions}
|
||||||
|
options={page.regionOptions}
|
||||||
|
value={page.regionId}
|
||||||
|
onChange={page.setRegionId}
|
||||||
|
/>
|
||||||
|
<CountrySelect
|
||||||
|
className={adminListClasses.statusSelect}
|
||||||
|
disabled={page.loadingCountries}
|
||||||
|
emptyLabel="全部国家"
|
||||||
|
options={page.countryOptions}
|
||||||
|
value={page.countryCode}
|
||||||
|
onChange={page.setCountryCode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable columns={columns} items={items} minWidth="1240px" rowKey={(item) => item.id} />
|
||||||
|
<div className="pagination-bar">
|
||||||
|
<span>{items.length} 条</span>
|
||||||
|
</div>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||||
|
open={Boolean(page.activeAction)}
|
||||||
|
size="narrow"
|
||||||
|
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||||
|
title={page.editingItem ? "编辑BANNER" : "新增BANNER"}
|
||||||
|
onClose={page.closeDialog}
|
||||||
|
onSubmit={page.submitBanner}
|
||||||
|
>
|
||||||
|
<UploadField
|
||||||
|
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||||
|
label="封面图"
|
||||||
|
value={page.form.coverUrl}
|
||||||
|
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||||
|
/>
|
||||||
|
<TextField disabled={!page.abilities.canUpdate} label="类型" required select value={page.form.bannerType} onChange={(event) => page.setForm({ bannerType: event.target.value })}>
|
||||||
|
<MenuItem value="h5">H5</MenuItem>
|
||||||
|
<MenuItem value="app">APP</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
||||||
|
required={page.form.bannerType === "h5"}
|
||||||
|
value={page.form.param}
|
||||||
|
onChange={(event) => page.setForm({ param: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField disabled={!page.abilities.canUpdate} label="状态" required select value={page.form.status} onChange={(event) => page.setForm({ status: event.target.value })}>
|
||||||
|
<MenuItem value="active">启用</MenuItem>
|
||||||
|
<MenuItem value="disabled">关闭</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField disabled={!page.abilities.canUpdate} label="平台" required select value={page.form.platform} onChange={(event) => page.setForm({ platform: event.target.value })}>
|
||||||
|
<MenuItem value="android">安卓</MenuItem>
|
||||||
|
<MenuItem value="ios">iOS</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
inputProps={{ step: 1 }}
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={page.form.sortOrder}
|
||||||
|
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||||
|
/>
|
||||||
|
<RegionSelect
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
emptyLabel="全部区域"
|
||||||
|
loading={page.loadingRegions}
|
||||||
|
options={page.regionOptions}
|
||||||
|
value={page.form.regionId}
|
||||||
|
onChange={(regionId) => page.setForm({ regionId })}
|
||||||
|
/>
|
||||||
|
<CountrySelect
|
||||||
|
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||||
|
emptyLabel="全部国家"
|
||||||
|
label="国家"
|
||||||
|
options={page.countryOptions}
|
||||||
|
value={page.form.countryCode}
|
||||||
|
onChange={(countryCode) => page.setForm({ countryCode })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="描述"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={page.form.description}
|
||||||
|
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormDialog>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bannerColumns(page) {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "cover",
|
||||||
|
label: "封面图",
|
||||||
|
render: (item) => <BannerCover src={item.coverUrl} />,
|
||||||
|
width: "minmax(112px, 0.55fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
label: "类型 / 参数",
|
||||||
|
render: (item) => <Stack primary={typeLabel(item.bannerType)} secondary={item.param || "-"} />,
|
||||||
|
width: "minmax(260px, 1.3fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "state",
|
||||||
|
label: "平台 / 状态",
|
||||||
|
render: (item) => <Stack primary={platformLabel(item.platform)} secondary={<StatusBadge status={item.status} />} />,
|
||||||
|
width: "minmax(140px, 0.75fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scope",
|
||||||
|
label: "区域 / 国家",
|
||||||
|
filter: createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: page.regionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.setRegionId
|
||||||
|
}),
|
||||||
|
render: (item) => <Stack primary={regionLabel(page.regionOptions, item.regionId)} secondary={countryLabel(page.countryOptions, item.countryCode)} />,
|
||||||
|
width: "minmax(180px, 0.95fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sortOrder",
|
||||||
|
label: "排序",
|
||||||
|
width: "minmax(80px, 0.4fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "描述",
|
||||||
|
render: (item) => item.description || "-",
|
||||||
|
width: "minmax(180px, 0.9fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAtMs",
|
||||||
|
label: "更新时间",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs),
|
||||||
|
width: "minmax(160px, 0.8fr)"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!page.abilities.canUpdate) {
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...columns,
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
render: (item) => <BannerActions item={item} page={page} />,
|
||||||
|
width: "minmax(96px, 0.45fr)"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function BannerCover({ src }) {
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<span className={styles.bannerCoverEmpty}>
|
||||||
|
<ImageOutlined fontSize="small" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img alt="" className={styles.bannerCover} src={src} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BannerActions({ item, page }) {
|
||||||
|
const deleting = page.loadingAction === `delete:${item.id}`;
|
||||||
|
return (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton disabled={Boolean(page.loadingAction)} label="编辑" onClick={() => page.openEdit(item)}>
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton disabled={deleting || Boolean(page.loadingAction)} label="删除" onClick={() => page.removeBanner(item)}>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</AdminRowActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) {
|
||||||
|
return (
|
||||||
|
<TextField className={className} disabled={disabled} label={label} select size="small" value={value || ""} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
<MenuItem value="">{emptyLabel}</MenuItem>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stack({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<span>{primary || "-"}</span>
|
||||||
|
<span className="muted">{secondary || "-"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const tone = status === "active" ? "succeeded" : "stopped";
|
||||||
|
return (
|
||||||
|
<span className={`status-badge status-badge--${tone}`}>
|
||||||
|
<span className="status-point" />
|
||||||
|
{status === "active" ? "启用" : "关闭"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(value) {
|
||||||
|
return value === "app" ? "APP" : "H5";
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformLabel(value) {
|
||||||
|
if (value === "android") {
|
||||||
|
return "安卓";
|
||||||
|
}
|
||||||
|
if (value === "ios") {
|
||||||
|
return "iOS";
|
||||||
|
}
|
||||||
|
return value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionLabel(options, value) {
|
||||||
|
if (!value) {
|
||||||
|
return "全部区域";
|
||||||
|
}
|
||||||
|
return options.find((option) => option.value === String(value))?.label || `区域 ${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryLabel(options, value) {
|
||||||
|
if (!value) {
|
||||||
|
return "全部国家";
|
||||||
|
}
|
||||||
|
return options.find((option) => option.value === value)?.label || value;
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ export function useAppConfigAbilities() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
||||||
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.appConfigView)
|
canView: can(PERMISSIONS.appConfigView)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,5 +8,13 @@ export const appConfigRoutes = [
|
|||||||
pageKey: "app-config-h5",
|
pageKey: "app-config-h5",
|
||||||
path: "/app-config/h5",
|
path: "/app-config/h5",
|
||||||
permission: PERMISSIONS.appConfigView
|
permission: PERMISSIONS.appConfigView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "BANNER配置",
|
||||||
|
loader: () => import("./pages/BannerConfigPage.jsx").then((module) => module.BannerConfigPage),
|
||||||
|
menuCode: MENU_CODES.appConfigBanners,
|
||||||
|
pageKey: "app-config-banners",
|
||||||
|
path: "/app-config/banners",
|
||||||
|
permission: PERMISSIONS.appConfigView
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -13,3 +13,28 @@ export const h5LinkUpdateSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
|
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
|
||||||
|
|
||||||
|
export const appBannerSchema = z.object({
|
||||||
|
bannerType: z.enum(["h5", "app"]),
|
||||||
|
countryCode: z.string().trim().transform((value) => value.toUpperCase())
|
||||||
|
.refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"),
|
||||||
|
coverUrl: z.string().trim().min(1, "请上传封面图").max(1024, "封面图地址不能超过 1024 个字符"),
|
||||||
|
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
|
||||||
|
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
|
||||||
|
platform: z.enum(["android", "ios"]),
|
||||||
|
regionId: z.coerce.number().int().min(0, "区域不正确").default(0),
|
||||||
|
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
|
||||||
|
status: z.enum(["active", "disabled"])
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.bannerType !== "h5") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value.param) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] });
|
||||||
|
}
|
||||||
|
if (/\s/.test(value.param)) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppBannerForm = z.infer<typeof appBannerSchema>;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||||
import styles from "@/features/host-org/host-org.module.css";
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
@ -27,7 +27,7 @@ export function HostOrgFilters({
|
|||||||
onChange={onStatusChange}
|
onChange={onStatusChange}
|
||||||
/>
|
/>
|
||||||
<div className={styles.filterFields}>
|
<div className={styles.filterFields}>
|
||||||
<RegionSelect
|
<RegionFilterControl
|
||||||
emptyLabel="全部区域"
|
emptyLabel="全部区域"
|
||||||
loading={loadingRegions}
|
loading={loadingRegions}
|
||||||
options={regionOptions}
|
options={regionOptions}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
|
||||||
export function HostOrgTable(props) {
|
export function HostOrgTable({ columns, regionFilter, ...props }) {
|
||||||
return <DataTable {...props} />;
|
const tableColumns = regionFilter
|
||||||
|
? columns.map((column) => (column.key === "regionId" ? { ...column, filter: regionFilter } : column))
|
||||||
|
: columns;
|
||||||
|
|
||||||
|
return <DataTable {...props} columns={tableColumns} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,23 +21,23 @@ const emptyRegionForm = () => ({
|
|||||||
sortOrder: 0
|
sortOrder: 0
|
||||||
});
|
});
|
||||||
const emptyData = { items: [], total: 0 };
|
const emptyData = { items: [], total: 0 };
|
||||||
|
const globalRegionCode = "GLOBAL";
|
||||||
|
|
||||||
export function useHostRegionsPage() {
|
export function useHostRegionsPage() {
|
||||||
const abilities = useRegionAbilities();
|
const abilities = useRegionAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [regionForm, setRegionForm] = useState(emptyRegionForm);
|
const [regionForm, setRegionFormState] = useState(emptyRegionForm);
|
||||||
const [editingRegion, setEditingRegion] = useState(null);
|
const [editingRegion, setEditingRegion] = useState(null);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const filters = useMemo(() => ({ status }), [status]);
|
const queryFn = useCallback(() => listRegions(), []);
|
||||||
const queryFn = useCallback(() => listRegions(filters), [filters]);
|
|
||||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||||
errorMessage: "加载区域列表失败",
|
errorMessage: "加载区域列表失败",
|
||||||
initialData: emptyData,
|
initialData: emptyData,
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
queryKey: ["host-org", "regions", filters]
|
queryKey: ["host-org", "regions"]
|
||||||
});
|
});
|
||||||
const countryQueryFn = useCallback(() => listCountries(), []);
|
const countryQueryFn = useCallback(() => listCountries(), []);
|
||||||
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
|
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
|
||||||
@ -46,10 +46,23 @@ export function useHostRegionsPage() {
|
|||||||
queryKey: ["host-org", "countries", "region-options"],
|
queryKey: ["host-org", "countries", "region-options"],
|
||||||
staleTime: 5 * 60 * 1000
|
staleTime: 5 * 60 * 1000
|
||||||
});
|
});
|
||||||
const items = useMemo(() => filterRegions(data?.items || [], query), [data?.items, query]);
|
const items = useMemo(() => filterRegions(data?.items || [], query, status), [data?.items, query, status]);
|
||||||
|
const assignedCountries = useMemo(() => {
|
||||||
|
const currentRegionId = editingRegion?.regionId;
|
||||||
|
const assigned = new Set();
|
||||||
|
(data?.items || []).forEach((region) => {
|
||||||
|
if (region.status !== "active" || region.regionId === currentRegionId || isGlobalRegionCode(region.regionCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(region.countries || []).forEach((country) => assigned.add(country));
|
||||||
|
});
|
||||||
|
return assigned;
|
||||||
|
}, [data?.items, editingRegion?.regionId]);
|
||||||
const countryOptions = useMemo(() => {
|
const countryOptions = useMemo(() => {
|
||||||
return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))].sort();
|
return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))]
|
||||||
}, [countriesData?.items]);
|
.filter((countryCode) => !assignedCountries.has(countryCode))
|
||||||
|
.sort();
|
||||||
|
}, [assignedCountries, countriesData?.items]);
|
||||||
const countryLabels = useMemo(() => {
|
const countryLabels = useMemo(() => {
|
||||||
return (countriesData?.items || []).reduce((labels, country) => {
|
return (countriesData?.items || []).reduce((labels, country) => {
|
||||||
if (country.countryCode) {
|
if (country.countryCode) {
|
||||||
@ -58,6 +71,12 @@ export function useHostRegionsPage() {
|
|||||||
return labels;
|
return labels;
|
||||||
}, {});
|
}, {});
|
||||||
}, [countriesData?.items]);
|
}, [countriesData?.items]);
|
||||||
|
const isGlobalRegionForm = isGlobalRegionCode(regionForm.regionCode);
|
||||||
|
|
||||||
|
const setRegionForm = (nextForm) => {
|
||||||
|
const normalized = isGlobalRegionCode(nextForm.regionCode) ? { ...nextForm, countries: [] } : nextForm;
|
||||||
|
setRegionFormState(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
const changeQuery = (value) => {
|
const changeQuery = (value) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
@ -69,13 +88,13 @@ export function useHostRegionsPage() {
|
|||||||
|
|
||||||
const openCreateRegion = () => {
|
const openCreateRegion = () => {
|
||||||
setEditingRegion(null);
|
setEditingRegion(null);
|
||||||
setRegionForm(emptyRegionForm());
|
setRegionFormState(emptyRegionForm());
|
||||||
setActiveAction("create");
|
setActiveAction("create");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditRegion = (region) => {
|
const openEditRegion = (region) => {
|
||||||
setEditingRegion(region);
|
setEditingRegion(region);
|
||||||
setRegionForm({
|
setRegionFormState({
|
||||||
countries: region.countries || [],
|
countries: region.countries || [],
|
||||||
name: region.name || "",
|
name: region.name || "",
|
||||||
regionCode: region.regionCode || "",
|
regionCode: region.regionCode || "",
|
||||||
@ -90,7 +109,7 @@ export function useHostRegionsPage() {
|
|||||||
await runAction(isEdit ? "region-edit" : "region-create", isEdit ? "区域已更新" : "区域已创建", async () => {
|
await runAction(isEdit ? "region-edit" : "region-create", isEdit ? "区域已更新" : "区域已创建", async () => {
|
||||||
const payload = parseForm(isEdit ? regionUpdateSchema : regionCreateSchema, regionForm);
|
const payload = parseForm(isEdit ? regionUpdateSchema : regionCreateSchema, regionForm);
|
||||||
const data = isEdit ? await updateExistingRegion(editingRegion, payload) : await createRegion(payload);
|
const data = isEdit ? await updateExistingRegion(editingRegion, payload) : await createRegion(payload);
|
||||||
setRegionForm(emptyRegionForm());
|
setRegionFormState(emptyRegionForm());
|
||||||
setEditingRegion(null);
|
setEditingRegion(null);
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
await reload();
|
await reload();
|
||||||
@ -99,7 +118,7 @@ export function useHostRegionsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleRegion = async (region, nextActive = region.status !== "active") => {
|
const toggleRegion = async (region, nextActive = region.status !== "active") => {
|
||||||
if (!abilities.canStatus || (region.status === "active") === nextActive) {
|
if (isGlobalRegionCode(region.regionCode) || !abilities.canStatus || (region.status === "active") === nextActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
|
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
|
||||||
@ -135,6 +154,7 @@ export function useHostRegionsPage() {
|
|||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
loadingCountries,
|
loadingCountries,
|
||||||
|
isGlobalRegionForm,
|
||||||
openCreateRegion,
|
openCreateRegion,
|
||||||
openEditRegion,
|
openEditRegion,
|
||||||
query,
|
query,
|
||||||
@ -162,14 +182,21 @@ function hasCountryChanges(previousCountries, nextCountries) {
|
|||||||
return previous.length !== next.length || previous.some((country, index) => country !== next[index]);
|
return previous.length !== next.length || previous.some((country, index) => country !== next[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRegions(items, query) {
|
function filterRegions(items, query, status) {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
if (!keyword) {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
|
if (status && item.status !== status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return [item.regionCode, item.name, ...(item.countries || [])]
|
return [item.regionCode, item.name, ...(item.countries || [])]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
.some((value) => String(value).toLowerCase().includes(keyword));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGlobalRegionCode(regionCode) {
|
||||||
|
return String(regionCode || "").trim().toUpperCase() === globalRegionCode;
|
||||||
|
}
|
||||||
|
|||||||
@ -99,27 +99,6 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.countryList {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.countryBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
height: var(--status-height);
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--bg-input);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--admin-font-size);
|
|
||||||
font-weight: 650;
|
|
||||||
padding: 0 var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flagCell {
|
.flagCell {
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import TextField from "@mui/material/TextField";
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
@ -81,6 +82,12 @@ export function HostAgenciesPage() {
|
|||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={agencyColumns}
|
columns={agencyColumns}
|
||||||
items={items}
|
items={items}
|
||||||
|
regionFilter={createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: page.regionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.changeRegionId
|
||||||
|
})}
|
||||||
rowKey={(item) => item.agencyId}
|
rowKey={(item) => item.agencyId}
|
||||||
/>
|
/>
|
||||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
@ -62,6 +63,12 @@ export function HostBdLeadersPage() {
|
|||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={bdLeaderColumns}
|
columns={bdLeaderColumns}
|
||||||
items={items}
|
items={items}
|
||||||
|
regionFilter={createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: page.regionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.changeRegionId
|
||||||
|
})}
|
||||||
rowKey={(item) => `bd-leader-${item.userId}`}
|
rowKey={(item) => `bd-leader-${item.userId}`}
|
||||||
/>
|
/>
|
||||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
@ -70,6 +71,12 @@ export function HostBdsPage() {
|
|||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={bdColumns}
|
columns={bdColumns}
|
||||||
items={items}
|
items={items}
|
||||||
|
regionFilter={createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: page.regionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.changeRegionId
|
||||||
|
})}
|
||||||
rowKey={(item) => `bd-${item.userId}`}
|
rowKey={(item) => `bd-${item.userId}`}
|
||||||
/>
|
/>
|
||||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
@ -78,7 +79,19 @@ export function HostCoinSellersPage() {
|
|||||||
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
<HostOrgTable columns={columns} context={{ regionOptions: page.regionOptions }} items={items} minWidth="1040px" rowKey={(item) => item.userId} />
|
<HostOrgTable
|
||||||
|
columns={columns}
|
||||||
|
context={{ regionOptions: page.regionOptions }}
|
||||||
|
items={items}
|
||||||
|
minWidth="1040px"
|
||||||
|
regionFilter={createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: page.regionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.changeRegionId
|
||||||
|
})}
|
||||||
|
rowKey={(item) => item.userId}
|
||||||
|
/>
|
||||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
@ -55,6 +56,12 @@ export function HostHostsPage() {
|
|||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={hostColumns}
|
columns={hostColumns}
|
||||||
items={items}
|
items={items}
|
||||||
|
regionFilter={createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: page.regionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.changeRegionId
|
||||||
|
})}
|
||||||
rowKey={(item) => item.userId}
|
rowKey={(item) => item.userId}
|
||||||
/>
|
/>
|
||||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
|
||||||
import Switch from "@mui/material/Switch";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||||
import { regionStatusFilters } from "@/features/host-org/constants.js";
|
import { regionStatusFilters } from "@/features/host-org/constants.js";
|
||||||
@ -106,7 +106,8 @@ export function HostRegionsPage() {
|
|||||||
<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="区域名称" 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="排序" type="number" value={page.regionForm.sortOrder} onChange={(event) => page.setRegionForm({ ...page.regionForm, sortOrder: event.target.value })} />
|
||||||
<CountryCodeSelect
|
<CountryCodeSelect
|
||||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
disabled={page.isGlobalRegionForm || (page.activeAction === "edit" ? updateDisabled : createDisabled)}
|
||||||
|
label={page.isGlobalRegionForm ? "国家码" : "国家码 *"}
|
||||||
labels={page.countryLabels}
|
labels={page.countryLabels}
|
||||||
loading={page.loadingCountries}
|
loading={page.loadingCountries}
|
||||||
options={page.countryOptions}
|
options={page.countryOptions}
|
||||||
@ -120,6 +121,9 @@ export function HostRegionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RegionActions({ item, page }) {
|
function RegionActions({ item, page }) {
|
||||||
|
if (item.regionCode === "GLOBAL") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.rowActions}>
|
<div className={styles.rowActions}>
|
||||||
{page.abilities.canUpdate ? (
|
{page.abilities.canUpdate ? (
|
||||||
@ -137,44 +141,42 @@ function RegionActions({ item, page }) {
|
|||||||
|
|
||||||
function RegionStatusSwitch({ item, page }) {
|
function RegionStatusSwitch({ item, page }) {
|
||||||
const checked = item.status === "active";
|
const checked = item.status === "active";
|
||||||
|
const isGlobal = item.regionCode === "GLOBAL";
|
||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={!page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
|
disabled={isGlobal || !page.abilities.canStatus || page.loadingAction === `region-status-${item.regionId}`}
|
||||||
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}
|
inputProps={{ "aria-label": checked ? "停用区域" : "启用区域" }}
|
||||||
onChange={(event) => page.toggleRegion(item, event.target.checked)}
|
onChange={(event) => page.toggleRegion(item, event.target.checked)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CountryCodeSelect({ disabled, labels, loading, onChange, options, value }) {
|
function CountryCodeSelect({ disabled, label, labels, loading, onChange, options, value }) {
|
||||||
const selected = value || [];
|
const selected = value || [];
|
||||||
const mergedOptions = [...new Set([...options, ...selected])].sort();
|
const mergedOptions = [...new Set([...options, ...selected])].sort();
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<MultiValueAutocomplete
|
||||||
multiple
|
|
||||||
disableCloseOnSelect
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
filterSelectedOptions
|
label={label}
|
||||||
getOptionLabel={(option) => labels[option] || option}
|
labels={labels}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
options={mergedOptions}
|
options={mergedOptions}
|
||||||
renderInput={(params) => <TextField {...params} label="国家码 *" />}
|
|
||||||
size="small"
|
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(_, countries) => onChange(countries)}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CountryCodes({ countries = [] }) {
|
function CountryCodes({ countries = [] }) {
|
||||||
if (!countries.length) {
|
const items = Array.isArray(countries) ? countries : [];
|
||||||
|
if (!items.length) {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.countryList}>
|
<div className="admin-tag-list">
|
||||||
{countries.map((country) => (
|
{items.map((country) => (
|
||||||
<span className={styles.countryBadge} key={country}>
|
<span className="admin-tag" key={country}>
|
||||||
{country}
|
{country}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -19,15 +19,41 @@ const countryCodesSchema = z.array(z.string()).transform((values, ctx) => {
|
|||||||
const countries = values
|
const countries = values
|
||||||
.map((item) => item.trim().toUpperCase())
|
.map((item) => item.trim().toUpperCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (!countries.length) {
|
|
||||||
ctx.addIssue({ code: "custom", message: "请选择国家码" });
|
|
||||||
return countries;
|
|
||||||
}
|
|
||||||
if (new Set(countries).size !== countries.length) {
|
if (new Set(countries).size !== countries.length) {
|
||||||
ctx.addIssue({ code: "custom", message: "国家码不能重复" });
|
ctx.addIssue({ code: "custom", message: "国家码不能重复" });
|
||||||
}
|
}
|
||||||
return countries;
|
return countries;
|
||||||
});
|
});
|
||||||
|
const retiredRegionCodes = new Set([
|
||||||
|
"AUSTRALIA AND NEW ZEALAND",
|
||||||
|
"CARIBBEAN",
|
||||||
|
"MELANESIA",
|
||||||
|
"MICRONESIA",
|
||||||
|
"POLYNESIA",
|
||||||
|
"SOUTHERN AFRICA",
|
||||||
|
"UNSPECIFIED"
|
||||||
|
]);
|
||||||
|
const requiredCountryCodesSchema = countryCodesSchema.superRefine((countries, ctx) => {
|
||||||
|
if (!countries.length) {
|
||||||
|
ctx.addIssue({ code: "custom", message: "请选择国家码" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const regionBaseSchema = z.object({
|
||||||
|
countries: countryCodesSchema,
|
||||||
|
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
|
||||||
|
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
|
||||||
|
sortOrder: sortOrderSchema
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (retiredRegionCodes.has(value.regionCode)) {
|
||||||
|
ctx.addIssue({ code: "custom", message: "该区域已移除", path: ["regionCode"] });
|
||||||
|
}
|
||||||
|
if (value.regionCode !== "GLOBAL" && !value.countries.length) {
|
||||||
|
ctx.addIssue({ code: "custom", message: "请选择国家码", path: ["countries"] });
|
||||||
|
}
|
||||||
|
if (value.regionCode === "GLOBAL" && value.countries.length) {
|
||||||
|
ctx.addIssue({ code: "custom", message: "GLOBAL 区域不需要选择国家", path: ["countries"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const createBDLeaderSchema = commandContactSchema.extend({
|
export const createBDLeaderSchema = commandContactSchema.extend({
|
||||||
regionId: regionIdSchema,
|
regionId: regionIdSchema,
|
||||||
@ -88,22 +114,12 @@ export const countryUpdateSchema = countryCreateSchema.omit({
|
|||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
|
|
||||||
export const regionCreateSchema = z.object({
|
export const regionCreateSchema = regionBaseSchema;
|
||||||
countries: countryCodesSchema,
|
|
||||||
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
|
|
||||||
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
|
|
||||||
sortOrder: sortOrderSchema
|
|
||||||
});
|
|
||||||
|
|
||||||
export const regionUpdateSchema = z.object({
|
export const regionUpdateSchema = regionBaseSchema;
|
||||||
countries: countryCodesSchema,
|
|
||||||
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
|
|
||||||
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
|
|
||||||
sortOrder: sortOrderSchema
|
|
||||||
});
|
|
||||||
|
|
||||||
export const regionCountriesSchema = z.object({
|
export const regionCountriesSchema = z.object({
|
||||||
countries: countryCodesSchema
|
countries: requiredCountryCodesSchema
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateBDLeaderForm = z.infer<typeof createBDLeaderSchema>;
|
export type CreateBDLeaderForm = z.infer<typeof createBDLeaderSchema>;
|
||||||
|
|||||||
11
src/features/payment/api.ts
Normal file
11
src/features/payment/api.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import type { ApiPage, PageQuery, RechargeBillDto } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listRechargeBills;
|
||||||
|
return apiRequest<ApiPage<RechargeBillDto>>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
}
|
||||||
208
src/features/payment/pages/PaymentBillListPage.jsx
Normal file
208
src/features/payment/pages/PaymentBillListPage.jsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { listRechargeBills } from "@/features/payment/api";
|
||||||
|
import {
|
||||||
|
AdminFilterSelect,
|
||||||
|
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 { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["succeeded", "成功"]
|
||||||
|
];
|
||||||
|
const rechargeTypeOptions = [
|
||||||
|
["", "全部途径"],
|
||||||
|
["coin_seller_transfer", "币商充值"]
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "bill",
|
||||||
|
label: "账单",
|
||||||
|
width: "minmax(240px, 1.4fr)",
|
||||||
|
render: (bill) => (
|
||||||
|
<Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "users",
|
||||||
|
label: "用户 / 来源",
|
||||||
|
width: "minmax(180px, 0.9fr)",
|
||||||
|
render: (bill) => (
|
||||||
|
<Stack primary={`用户 ${bill.userId || "-"}`} secondary={bill.sellerUserId ? `币商 ${bill.sellerUserId}` : "-"} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "amount",
|
||||||
|
label: "金币 / 金额",
|
||||||
|
width: "minmax(150px, 0.75fr)",
|
||||||
|
render: (bill) => (
|
||||||
|
<Stack primary={formatNumber(bill.coinAmount)} secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "policy",
|
||||||
|
label: "兑换口径",
|
||||||
|
width: "minmax(170px, 0.9fr)",
|
||||||
|
render: (bill) => (
|
||||||
|
<Stack
|
||||||
|
primary={bill.policyVersion || "-"}
|
||||||
|
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "region",
|
||||||
|
label: "区域",
|
||||||
|
width: "minmax(110px, 0.55fr)",
|
||||||
|
render: (bill) => <Stack primary={bill.targetRegionId ? `区域 ${bill.targetRegionId}` : "-"} secondary={bill.sellerRegionId ? `来源 ${bill.sellerRegionId}` : "-"} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "state",
|
||||||
|
label: "途径 / 状态",
|
||||||
|
width: "minmax(150px, 0.75fr)",
|
||||||
|
render: (bill) => (
|
||||||
|
<Stack primary={rechargeTypeLabel(bill.rechargeType)} secondary={<StatusBadge status={bill.status} />} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "time",
|
||||||
|
label: "创建时间",
|
||||||
|
width: "minmax(160px, 0.8fr)",
|
||||||
|
render: (bill) => formatMillis(bill.createdAtMs)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PaymentBillListPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [rechargeType, setRechargeType] = useState("");
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [sellerUserId, setSellerUserId] = useState("");
|
||||||
|
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
keyword,
|
||||||
|
recharge_type: rechargeType,
|
||||||
|
seller_user_id: sellerUserId,
|
||||||
|
status,
|
||||||
|
user_id: userId
|
||||||
|
}),
|
||||||
|
[keyword, rechargeType, sellerUserId, status, userId]
|
||||||
|
);
|
||||||
|
const query = usePaginatedQuery({
|
||||||
|
errorMessage: "获取账单列表失败",
|
||||||
|
fetcher: listRechargeBills,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["payment-bills", filters, page]
|
||||||
|
});
|
||||||
|
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||||
|
const items = data.items || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
const resetPage = (setter) => (value) => {
|
||||||
|
setter(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminSearchBox value={keyword} onChange={resetPage(setKeyword)} placeholder="搜索账单号、命令号、外部单号..." />
|
||||||
|
<TextField
|
||||||
|
className={adminListClasses.statusSelect}
|
||||||
|
label="用户 ID"
|
||||||
|
size="small"
|
||||||
|
value={userId}
|
||||||
|
onChange={(event) => resetPage(setUserId)(digitsOnly(event.target.value))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={adminListClasses.statusSelect}
|
||||||
|
label="币商 ID"
|
||||||
|
size="small"
|
||||||
|
value={sellerUserId}
|
||||||
|
onChange={(event) => resetPage(setSellerUserId)(digitsOnly(event.target.value))}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect label="充值途径" options={rechargeTypeOptions} value={rechargeType} onChange={resetPage(setRechargeType)} />
|
||||||
|
<AdminFilterSelect label="状态" options={statusOptions} value={status} onChange={resetPage(setStatus)} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable columns={columns} items={items} minWidth="1180px" rowKey={(bill) => bill.transactionId} />
|
||||||
|
{total > 0 ? <PaginationBar page={page} pageSize={data.pageSize || pageSize} total={total} onPageChange={setPage} /> : null}
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stack({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<span>{primary || "-"}</span>
|
||||||
|
<span className="muted">{secondary || "-"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped";
|
||||||
|
return (
|
||||||
|
<span className={`status-badge status-badge--${tone}`}>
|
||||||
|
<span className="status-point" />
|
||||||
|
{statusLabel(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rechargeTypeLabel(value) {
|
||||||
|
return rechargeTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(value) {
|
||||||
|
if (value === "succeeded") {
|
||||||
|
return "成功";
|
||||||
|
}
|
||||||
|
if (value === "failed") {
|
||||||
|
return "失败";
|
||||||
|
}
|
||||||
|
return value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
if (value === 0 || value) {
|
||||||
|
return Number(value).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value, currency = "USD") {
|
||||||
|
if (!(value === 0 || value)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digitsOnly(value) {
|
||||||
|
return value.replace(/\D/g, "");
|
||||||
|
}
|
||||||
12
src/features/payment/routes.js
Normal file
12
src/features/payment/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const paymentRoutes = [
|
||||||
|
{
|
||||||
|
label: "账单列表",
|
||||||
|
loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
|
||||||
|
menuCode: MENU_CODES.paymentBillList,
|
||||||
|
pageKey: "payment-bill-list",
|
||||||
|
path: "/payment/bills",
|
||||||
|
permission: PERMISSIONS.paymentBillView
|
||||||
|
}
|
||||||
|
];
|
||||||
@ -56,10 +56,15 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
});
|
});
|
||||||
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
|
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
|
||||||
await createGift({
|
await createGift({
|
||||||
|
chargeAssetType: "COIN",
|
||||||
coinPrice: 10,
|
coinPrice: 10,
|
||||||
effectiveAtMs: 1777766400000,
|
effectiveAtMs: 1777766400000,
|
||||||
|
effectiveFromMs: 0,
|
||||||
|
effectiveToMs: 0,
|
||||||
|
effectTypes: ["animation"],
|
||||||
giftId: "rose",
|
giftId: "rose",
|
||||||
giftPointAmount: 1,
|
giftPointAmount: 1,
|
||||||
|
giftTypeCode: "normal",
|
||||||
heatValue: 1,
|
heatValue: 1,
|
||||||
name: "Rose",
|
name: "Rose",
|
||||||
presentationJson: "{}",
|
presentationJson: "{}",
|
||||||
@ -103,10 +108,15 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
await enableResourceGroup(22);
|
await enableResourceGroup(22);
|
||||||
await disableResourceGroup(22);
|
await disableResourceGroup(22);
|
||||||
await updateGift("rose", {
|
await updateGift("rose", {
|
||||||
|
chargeAssetType: "COIN",
|
||||||
coinPrice: 10,
|
coinPrice: 10,
|
||||||
effectiveAtMs: 1777766400000,
|
effectiveAtMs: 1777766400000,
|
||||||
|
effectiveFromMs: 0,
|
||||||
|
effectiveToMs: 0,
|
||||||
|
effectTypes: ["animation"],
|
||||||
giftId: "rose",
|
giftId: "rose",
|
||||||
giftPointAmount: 1,
|
giftPointAmount: 1,
|
||||||
|
giftTypeCode: "normal",
|
||||||
heatValue: 1,
|
heatValue: 1,
|
||||||
name: "Rose",
|
name: "Rose",
|
||||||
presentationJson: "{}",
|
presentationJson: "{}",
|
||||||
|
|||||||
@ -69,9 +69,14 @@ export interface GiftDto {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
presentationJson?: string;
|
presentationJson?: string;
|
||||||
priceVersion?: string;
|
priceVersion?: string;
|
||||||
|
giftTypeCode?: string;
|
||||||
|
chargeAssetType?: string;
|
||||||
coinPrice?: number;
|
coinPrice?: number;
|
||||||
giftPointAmount?: number;
|
giftPointAmount?: number;
|
||||||
heatValue?: number;
|
heatValue?: number;
|
||||||
|
effectiveFromMs?: number;
|
||||||
|
effectiveToMs?: number;
|
||||||
|
effectTypes?: string[];
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
regionIds?: number[];
|
regionIds?: number[];
|
||||||
@ -85,10 +90,15 @@ export interface GiftPayload {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
presentationJson: string;
|
presentationJson: string;
|
||||||
priceVersion: string;
|
priceVersion: string;
|
||||||
|
giftTypeCode: string;
|
||||||
|
chargeAssetType: string;
|
||||||
coinPrice: number;
|
coinPrice: number;
|
||||||
giftPointAmount: number;
|
giftPointAmount: number;
|
||||||
heatValue: number;
|
heatValue: number;
|
||||||
effectiveAtMs: number;
|
effectiveAtMs: number;
|
||||||
|
effectiveFromMs: number;
|
||||||
|
effectiveToMs: number;
|
||||||
|
effectTypes: string[];
|
||||||
regionIds: number[];
|
regionIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,10 +68,15 @@ const emptyGroupWalletAssetItem = (walletAssetType) => ({
|
|||||||
walletAssetType,
|
walletAssetType,
|
||||||
});
|
});
|
||||||
const emptyGiftForm = (gift = {}) => ({
|
const emptyGiftForm = (gift = {}) => ({
|
||||||
|
chargeAssetType: gift.chargeAssetType || "COIN",
|
||||||
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
|
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
|
||||||
|
effectTypes: gift.effectTypes || [],
|
||||||
|
effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
|
||||||
|
effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
|
||||||
enabled: gift.status ? gift.status === "active" : true,
|
enabled: gift.status ? gift.status === "active" : true,
|
||||||
giftId: gift.giftId || "",
|
giftId: gift.giftId || "",
|
||||||
giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0",
|
giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0",
|
||||||
|
giftTypeCode: gift.giftTypeCode || "normal",
|
||||||
heatValue: gift.heatValue === 0 || gift.heatValue ? String(gift.heatValue) : "0",
|
heatValue: gift.heatValue === 0 || gift.heatValue ? String(gift.heatValue) : "0",
|
||||||
name: gift.name || "",
|
name: gift.name || "",
|
||||||
presentationJson: gift.presentationJson || "{}",
|
presentationJson: gift.presentationJson || "{}",
|
||||||
@ -727,10 +732,15 @@ function buildResourceGroupPayload(form) {
|
|||||||
|
|
||||||
function buildGiftPayload(form) {
|
function buildGiftPayload(form) {
|
||||||
return {
|
return {
|
||||||
|
chargeAssetType: form.chargeAssetType,
|
||||||
coinPrice: Number(form.coinPrice),
|
coinPrice: Number(form.coinPrice),
|
||||||
effectiveAtMs: Date.now(),
|
effectiveAtMs: Date.now(),
|
||||||
|
effectiveFromMs: datetimeLocalToMs(form.effectiveFrom),
|
||||||
|
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
|
||||||
|
effectTypes: form.effectTypes || [],
|
||||||
giftId: form.giftId.trim(),
|
giftId: form.giftId.trim(),
|
||||||
giftPointAmount: Number(form.giftPointAmount || 0),
|
giftPointAmount: Number(form.giftPointAmount || 0),
|
||||||
|
giftTypeCode: form.giftTypeCode,
|
||||||
heatValue: Number(form.heatValue || 0),
|
heatValue: Number(form.heatValue || 0),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
presentationJson: form.presentationJson?.trim() || "{}",
|
presentationJson: form.presentationJson?.trim() || "{}",
|
||||||
@ -742,6 +752,27 @@ function buildGiftPayload(form) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function msToDatetimeLocal(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const date = new Date(Number(value));
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function datetimeLocalToMs(value) {
|
||||||
|
const trimmed = String(value || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed).getTime();
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function buildGrantPayload(form) {
|
function buildGrantPayload(form) {
|
||||||
const targetUserId = Number(form.targetUserId);
|
const targetUserId = Number(form.targetUserId);
|
||||||
const reason = form.reason.trim();
|
const reason = form.reason.trim();
|
||||||
|
|||||||
@ -1,14 +1,23 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
|
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
|
||||||
|
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Popover from "@mui/material/Popover";
|
||||||
import Switch from "@mui/material/Switch";
|
import Switch from "@mui/material/Switch";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
import { useMemo, useState } from "react";
|
||||||
|
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||||
|
import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
AdminFilterSelect,
|
AdminFilterSelect,
|
||||||
@ -24,6 +33,30 @@ import { resourceStatusFilters } from "@/features/resources/constants.js";
|
|||||||
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
|
|
||||||
|
const giftTypeOptions = [
|
||||||
|
["normal", "普通礼物"],
|
||||||
|
["cp", "CP礼物"],
|
||||||
|
["lucky", "幸运礼物"],
|
||||||
|
["super_lucky", "超级幸运礼物"],
|
||||||
|
["exclusive", "专属礼物"],
|
||||||
|
["noble", "贵族礼物"],
|
||||||
|
["flag", "国旗礼物"],
|
||||||
|
["activity", "活动礼物"],
|
||||||
|
["magic", "魔法礼物"],
|
||||||
|
["custom", "定制礼物"]
|
||||||
|
];
|
||||||
|
const chargeAssetOptions = [
|
||||||
|
["COIN", "金币"],
|
||||||
|
["DIAMOND", "钻石"]
|
||||||
|
];
|
||||||
|
const effectOptions = [
|
||||||
|
["animation", "动画"],
|
||||||
|
["music", "音乐"],
|
||||||
|
["global_broadcast", "全局广播"]
|
||||||
|
];
|
||||||
|
const effectLabels = Object.fromEntries(effectOptions);
|
||||||
|
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{
|
{
|
||||||
key: "gift",
|
key: "gift",
|
||||||
@ -39,9 +72,14 @@ const baseColumns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "price",
|
key: "price",
|
||||||
label: "价格",
|
label: "类型 / 价格",
|
||||||
width: "minmax(120px, 0.65fr)",
|
width: "minmax(150px, 0.75fr)",
|
||||||
render: (gift) => formatNumber(gift.coinPrice)
|
render: (gift) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{giftTypeLabel(gift.giftTypeCode)}</span>
|
||||||
|
<span className={styles.meta}>{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "income",
|
key: "income",
|
||||||
@ -57,7 +95,18 @@ const baseColumns = [
|
|||||||
{
|
{
|
||||||
key: "regions",
|
key: "regions",
|
||||||
label: "区域",
|
label: "区域",
|
||||||
width: "minmax(180px, 0.95fr)"
|
width: "minmax(160px, 0.8fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "effects",
|
||||||
|
label: "有效期 / 特效",
|
||||||
|
width: "minmax(210px, 1.1fr)",
|
||||||
|
render: (gift) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{effectiveRangeLabel(gift)}</span>
|
||||||
|
<span className={styles.meta}>{effectTypesLabel(gift.effectTypes)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
@ -88,6 +137,12 @@ export function GiftListPage() {
|
|||||||
if (column.key === "regions") {
|
if (column.key === "regions") {
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
|
filter: createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: giftRegionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.changeRegionId
|
||||||
|
}),
|
||||||
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />
|
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -113,7 +168,7 @@ export function GiftListPage() {
|
|||||||
<>
|
<>
|
||||||
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索礼物名称、礼物 ID..." />
|
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索礼物名称、礼物 ID..." />
|
||||||
<AdminFilterSelect options={resourceStatusFilters} value={page.status} onChange={page.changeStatus} />
|
<AdminFilterSelect options={resourceStatusFilters} value={page.status} onChange={page.changeStatus} />
|
||||||
<RegionSelect
|
<RegionFilterControl
|
||||||
emptyLabel="全部区域"
|
emptyLabel="全部区域"
|
||||||
loading={page.loadingRegions}
|
loading={page.loadingRegions}
|
||||||
options={giftRegionOptions}
|
options={giftRegionOptions}
|
||||||
@ -132,7 +187,7 @@ export function GiftListPage() {
|
|||||||
/>
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable columns={tableColumns} items={items} minWidth="1260px" rowKey={(gift) => gift.giftId} />
|
<DataTable columns={tableColumns} items={items} minWidth="1480px" rowKey={(gift) => gift.giftId} />
|
||||||
{total > 0 ? (
|
{total > 0 ? (
|
||||||
<PaginationBar
|
<PaginationBar
|
||||||
page={page.page}
|
page={page.page}
|
||||||
@ -190,132 +245,314 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={loading}
|
loading={loading}
|
||||||
open={open}
|
open={open}
|
||||||
|
size="large"
|
||||||
submitDisabled={submitDisabled}
|
submitDisabled={submitDisabled}
|
||||||
title={mode === "edit" ? "编辑礼物" : "添加礼物"}
|
title={mode === "edit" ? "编辑礼物" : "添加礼物"}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<TextField
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
disabled={disabled}
|
<TextField
|
||||||
label="礼物名称"
|
disabled={disabled}
|
||||||
required
|
label="礼物名称"
|
||||||
value={form.name}
|
required
|
||||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
value={form.name}
|
||||||
/>
|
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||||
<TextField
|
/>
|
||||||
disabled={disabled || mode === "edit"}
|
<TextField
|
||||||
label="礼物 ID"
|
disabled={disabled || mode === "edit"}
|
||||||
required
|
label="礼物 ID"
|
||||||
value={form.giftId}
|
required
|
||||||
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
value={form.giftId}
|
||||||
/>
|
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
||||||
<TextField
|
/>
|
||||||
disabled={disabled || page.resourceOptionsLoading}
|
<TextField
|
||||||
label="礼物资源"
|
disabled={disabled}
|
||||||
required
|
label="礼物类型"
|
||||||
select
|
required
|
||||||
value={form.resourceId}
|
select
|
||||||
onChange={(event) => page.setForm({ ...form, resourceId: event.target.value })}
|
value={form.giftTypeCode}
|
||||||
>
|
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||||
{page.resourceOptions.map((resource) => (
|
>
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
{giftTypeOptions.map(([value, label]) => (
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</TextField>
|
||||||
</TextField>
|
<TextField
|
||||||
<TextField
|
disabled={disabled || page.resourceOptionsLoading}
|
||||||
disabled={disabled}
|
label="礼物资源"
|
||||||
label="价格"
|
required
|
||||||
required
|
select
|
||||||
type="number"
|
value={form.resourceId}
|
||||||
value={form.coinPrice}
|
onChange={(event) => page.setForm({ ...form, resourceId: event.target.value })}
|
||||||
onChange={(event) => page.setForm({ ...form, coinPrice: event.target.value })}
|
>
|
||||||
/>
|
{page.resourceOptions.map((resource) => (
|
||||||
<TextField
|
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||||
disabled={disabled}
|
{resource.name || resource.resourceCode || resource.resourceId}
|
||||||
label="积分"
|
</MenuItem>
|
||||||
type="number"
|
))}
|
||||||
value={form.giftPointAmount}
|
</TextField>
|
||||||
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
<TextField
|
||||||
/>
|
disabled={disabled}
|
||||||
<TextField
|
label="收费类型"
|
||||||
disabled={disabled}
|
required
|
||||||
label="热度"
|
select
|
||||||
type="number"
|
value={form.chargeAssetType}
|
||||||
value={form.heatValue}
|
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
||||||
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
>
|
||||||
/>
|
{chargeAssetOptions.map(([value, label]) => (
|
||||||
<TextField
|
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||||
disabled={disabled}
|
))}
|
||||||
label="排序"
|
</TextField>
|
||||||
type="number"
|
<TextField
|
||||||
value={form.sortOrder}
|
disabled={disabled}
|
||||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
label="价格"
|
||||||
/>
|
required
|
||||||
<TextField
|
type="number"
|
||||||
disabled={disabled}
|
value={form.coinPrice}
|
||||||
label="价格版本"
|
onChange={(event) => page.setForm({ ...form, coinPrice: event.target.value })}
|
||||||
required
|
/>
|
||||||
value={form.priceVersion}
|
<TextField
|
||||||
onChange={(event) => page.setForm({ ...form, priceVersion: event.target.value })}
|
disabled={disabled}
|
||||||
/>
|
label="积分"
|
||||||
<GiftRegionSelect
|
type="number"
|
||||||
disabled={disabled || page.loadingRegions}
|
value={form.giftPointAmount}
|
||||||
labels={buildRegionLabelMap(regionOptions)}
|
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
||||||
options={regionOptions}
|
/>
|
||||||
value={form.regionIds}
|
<TextField
|
||||||
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
disabled={disabled}
|
||||||
/>
|
label="热度"
|
||||||
<TextField
|
type="number"
|
||||||
disabled={disabled}
|
value={form.heatValue}
|
||||||
label="展示配置 JSON"
|
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
||||||
multiline
|
/>
|
||||||
minRows={3}
|
<TextField
|
||||||
value={form.presentationJson}
|
disabled={disabled}
|
||||||
onChange={(event) => page.setForm({ ...form, presentationJson: event.target.value })}
|
label="排序"
|
||||||
/>
|
type="number"
|
||||||
<FormControlLabel
|
value={form.sortOrder}
|
||||||
control={
|
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||||
<Switch
|
/>
|
||||||
checked={form.enabled}
|
<GiftRegionSelect
|
||||||
disabled={disabled}
|
disabled={disabled || page.loadingRegions}
|
||||||
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
|
labels={buildRegionLabelMap(regionOptions)}
|
||||||
/>
|
options={regionOptions}
|
||||||
}
|
value={form.regionIds}
|
||||||
label={form.enabled ? "启用" : "禁用"}
|
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
||||||
/>
|
/>
|
||||||
|
<DateTimeField
|
||||||
|
disabled={disabled}
|
||||||
|
label="有效开始时间"
|
||||||
|
value={form.effectiveFrom}
|
||||||
|
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
|
||||||
|
/>
|
||||||
|
<DateTimeField
|
||||||
|
disabled={disabled}
|
||||||
|
label="有效结束时间"
|
||||||
|
value={form.effectiveTo}
|
||||||
|
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
|
||||||
|
/>
|
||||||
|
<GiftEffectSelect
|
||||||
|
disabled={disabled}
|
||||||
|
value={form.effectTypes}
|
||||||
|
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
className={styles.giftSwitch}
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={form.enabled}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={form.enabled ? "启用" : "禁用"}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DateTimeField({ disabled, label, onChange, value }) {
|
||||||
|
const parsedValue = parseLocalDateTime(value);
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const [draftDate, setDraftDate] = useState(parsedValue || new Date());
|
||||||
|
const [viewDate, setViewDate] = useState(parsedValue || new Date());
|
||||||
|
const days = useMemo(() => calendarDays(viewDate), [viewDate]);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
const openPicker = (event) => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseDate = parseLocalDateTime(value) || new Date();
|
||||||
|
setDraftDate(baseDate);
|
||||||
|
setViewDate(new Date(baseDate.getFullYear(), baseDate.getMonth(), 1));
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePicker = () => setAnchorEl(null);
|
||||||
|
const changeMonth = (offset) => {
|
||||||
|
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()
|
||||||
|
));
|
||||||
|
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||||
|
};
|
||||||
|
const changeTime = (unit, nextValue) => {
|
||||||
|
setDraftDate((current) => {
|
||||||
|
const nextDate = new Date(current);
|
||||||
|
if (unit === "hour") {
|
||||||
|
nextDate.setHours(Number(nextValue));
|
||||||
|
} else {
|
||||||
|
nextDate.setMinutes(Number(nextValue));
|
||||||
|
}
|
||||||
|
return nextDate;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
className={styles.dateTimeField}
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ readOnly: true }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<CalendarMonthOutlined fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
label={label}
|
||||||
|
placeholder="长期有效"
|
||||||
|
value={formatDateTimeInputLabel(value)}
|
||||||
|
onClick={openPicker}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
|
||||||
|
open={open}
|
||||||
|
transformOrigin={{ horizontal: "left", vertical: "top" }}
|
||||||
|
onClose={closePicker}
|
||||||
|
>
|
||||||
|
<div className={styles.datePopover}>
|
||||||
|
<div className={styles.calendarHeader}>
|
||||||
|
<IconButton aria-label="上个月" size="small" onClick={() => changeMonth(-1)}>
|
||||||
|
<ChevronLeftOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<span className={styles.calendarTitle}>{monthTitle(viewDate)}</span>
|
||||||
|
<IconButton aria-label="下个月" size="small" onClick={() => changeMonth(1)}>
|
||||||
|
<ChevronRightOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className={styles.calendarGrid}>
|
||||||
|
{weekLabels.map((weekday) => (
|
||||||
|
<span className={styles.weekday} key={weekday}>{weekday}</span>
|
||||||
|
))}
|
||||||
|
{days.map((day) => {
|
||||||
|
const isOutside = day.getMonth() !== viewDate.getMonth();
|
||||||
|
const isSelected = sameDay(day, draftDate);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={[
|
||||||
|
styles.dayButton,
|
||||||
|
isOutside ? styles.dayOutside : "",
|
||||||
|
isSelected ? styles.daySelected : ""
|
||||||
|
].filter(Boolean).join(" ")}
|
||||||
|
key={day.toISOString()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeDay(day)}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={styles.timeGrid}>
|
||||||
|
<TextField
|
||||||
|
label="时"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={String(draftDate.getHours())}
|
||||||
|
onChange={(event) => changeTime("hour", event.target.value)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, hour) => (
|
||||||
|
<MenuItem key={hour} value={String(hour)}>{twoDigits(hour)}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="分"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={String(draftDate.getMinutes())}
|
||||||
|
onChange={(event) => changeTime("minute", event.target.value)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 60 }, (_, minute) => (
|
||||||
|
<MenuItem key={minute} value={String(minute)}>{twoDigits(minute)}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</div>
|
||||||
|
<div className={styles.popoverActions}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onChange("");
|
||||||
|
closePicker();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(toDatetimeLocalValue(draftDate));
|
||||||
|
closePicker();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GiftRegionSelect({ disabled, labels, onChange, options, value }) {
|
function GiftRegionSelect({ disabled, labels, onChange, options, value }) {
|
||||||
const selected = value || [];
|
const selected = value || [];
|
||||||
const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort(
|
const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort(
|
||||||
(left, right) => Number(left) - Number(right)
|
(left, right) => Number(left) - Number(right)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<TextField
|
<MultiValueAutocomplete
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="区域"
|
label="区域"
|
||||||
|
labels={labels}
|
||||||
|
options={mergedOptions}
|
||||||
required
|
required
|
||||||
select
|
|
||||||
SelectProps={{
|
|
||||||
multiple: true,
|
|
||||||
renderValue: (selectedValue) =>
|
|
||||||
selectedValue.map((regionId) => labels[String(regionId)] || regionId).join("、")
|
|
||||||
}}
|
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(event) => {
|
onChange={onChange}
|
||||||
const nextValue = event.target.value;
|
/>
|
||||||
onChange(typeof nextValue === "string" ? nextValue.split(",") : nextValue);
|
);
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
{mergedOptions.map((regionId) => (
|
function GiftEffectSelect({ disabled, onChange, value }) {
|
||||||
<MenuItem key={regionId} value={regionId}>
|
const selected = value || [];
|
||||||
{labels[String(regionId)] || regionId}
|
return (
|
||||||
</MenuItem>
|
<MultiValueAutocomplete
|
||||||
))}
|
disabled={disabled}
|
||||||
</TextField>
|
label="特效"
|
||||||
|
labels={effectLabels}
|
||||||
|
options={effectOptions.map(([optionValue]) => optionValue)}
|
||||||
|
value={selected}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,9 +562,9 @@ function RegionTags({ regionIds = [], regionLabelById }) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.tagList}>
|
<div className="admin-tag-list">
|
||||||
{items.map((regionId) => (
|
{items.map((regionId) => (
|
||||||
<span className={styles.tag} key={regionId}>{regionLabelById[String(regionId)] || regionId}</span>
|
<span className="admin-tag" key={regionId}>{regionLabelById[String(regionId)] || regionId}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -370,6 +607,94 @@ function formatNumber(value) {
|
|||||||
return Number(value || 0).toLocaleString("zh-CN");
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function giftTypeLabel(value) {
|
||||||
|
return giftTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "普通礼物";
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargeAssetLabel(value) {
|
||||||
|
return chargeAssetOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "金币";
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectTypeLabel(value) {
|
||||||
|
return effectOptions.find(([optionValue]) => optionValue === value)?.[1] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectTypesLabel(values = []) {
|
||||||
|
return values.length ? values.map(effectTypeLabel).join("、") : "无特效";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDateTime(value) {
|
||||||
|
const normalizedValue = String(value || "").trim();
|
||||||
|
if (!normalizedValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [datePart, timePart = "00:00"] = normalizedValue.split("T");
|
||||||
|
const [year, month, day] = datePart.split("-").map(Number);
|
||||||
|
const [hour = 0, minute = 0] = timePart.split(":").map(Number);
|
||||||
|
if (![year, month, day, hour, minute].every(Number.isFinite)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(year, month - 1, day, hour, minute);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocalValue(date) {
|
||||||
|
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}T${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeInputLabel(value) {
|
||||||
|
const date = parseLocalDateTime(value);
|
||||||
|
if (!date) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDays(viewDate) {
|
||||||
|
const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
|
||||||
|
const offset = (firstDay.getDay() + 6) % 7;
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
startDate.setDate(firstDay.getDate() - offset);
|
||||||
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const day = new Date(startDate);
|
||||||
|
day.setDate(startDate.getDate() + index);
|
||||||
|
return day;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDay(left, right) {
|
||||||
|
return left.getFullYear() === right.getFullYear()
|
||||||
|
&& left.getMonth() === right.getMonth()
|
||||||
|
&& left.getDate() === right.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthTitle(date) {
|
||||||
|
return `${date.getFullYear()}年 ${date.getMonth() + 1}月`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function twoDigits(value) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveRangeLabel(gift) {
|
||||||
|
if (!gift.effectiveFromMs && !gift.effectiveToMs) {
|
||||||
|
return "长期有效";
|
||||||
|
}
|
||||||
|
return `${shortDateTime(gift.effectiveFromMs) || "不限"} - ${shortDateTime(gift.effectiveToMs) || "不限"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDateTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(new Date(Number(value)));
|
||||||
|
}
|
||||||
|
|
||||||
function buildRegionLabelMap(options = []) {
|
function buildRegionLabelMap(options = []) {
|
||||||
return options.reduce((acc, option) => {
|
return options.reduce((acc, option) => {
|
||||||
acc[option.value] = shortRegionLabel(option.label);
|
acc[option.value] = shortRegionLabel(option.label);
|
||||||
|
|||||||
@ -344,13 +344,13 @@ function GroupItems({ group }) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.tagList}>
|
<div className="admin-tag-list">
|
||||||
{items.slice(0, 4).map((item) => {
|
{items.slice(0, 4).map((item) => {
|
||||||
if (item.itemType === "wallet_asset") {
|
if (item.itemType === "wallet_asset") {
|
||||||
const label = resourceGroupAssetLabels[item.walletAssetType] || item.walletAssetType || "钱包资产";
|
const label = resourceGroupAssetLabels[item.walletAssetType] || item.walletAssetType || "钱包资产";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={styles.tag}
|
className="admin-tag"
|
||||||
key={item.groupItemId || `${item.walletAssetType}-${item.walletAssetAmount}`}
|
key={item.groupItemId || `${item.walletAssetType}-${item.walletAssetAmount}`}
|
||||||
>
|
>
|
||||||
{label} {formatNumber(item.walletAssetAmount)}
|
{label} {formatNumber(item.walletAssetAmount)}
|
||||||
@ -360,12 +360,12 @@ function GroupItems({ group }) {
|
|||||||
const resource = item.resource || {};
|
const resource = item.resource || {};
|
||||||
const type = resourceTypeLabels[resource.resourceType] || resource.resourceType || "资源";
|
const type = resourceTypeLabels[resource.resourceType] || resource.resourceType || "资源";
|
||||||
return (
|
return (
|
||||||
<span className={styles.tag} key={item.groupItemId || item.resourceId}>
|
<span className="admin-tag" key={item.groupItemId || item.resourceId}>
|
||||||
{resource.name || item.resourceId} · {formatDurationDays(item.durationMs)}天 · {type}
|
{resource.name || item.resourceId} · {formatDurationDays(item.durationMs)}天 · {type}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{items.length > 4 ? <span className={styles.tag}>+{items.length - 4}</span> : null}
|
{items.length > 4 ? <span className="admin-tag">+{items.length - 4}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -293,9 +293,9 @@ function TagList({ values = [] }) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.tagList}>
|
<div className="admin-tag-list">
|
||||||
{items.map((value) => (
|
{items.map((value) => (
|
||||||
<span className={styles.tag} key={value}>
|
<span className="admin-tag" key={value}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -48,25 +48,87 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagList {
|
.dateTimeField {
|
||||||
display: flex;
|
width: 100%;
|
||||||
min-width: 0;
|
}
|
||||||
flex-wrap: wrap;
|
|
||||||
|
.giftSwitch {
|
||||||
|
min-height: var(--control-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datePopover {
|
||||||
|
display: grid;
|
||||||
|
width: 320px;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendarHeader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--control-height) 1fr var(--control-height);
|
||||||
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.calendarTitle {
|
||||||
display: inline-flex;
|
color: var(--text-primary);
|
||||||
max-width: 100%;
|
font-weight: 700;
|
||||||
align-items: center;
|
text-align: center;
|
||||||
overflow: hidden;
|
}
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
.calendarGrid {
|
||||||
background: var(--neutral-surface);
|
display: grid;
|
||||||
color: var(--text-secondary);
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
font-size: var(--admin-font-size);
|
gap: 4px;
|
||||||
line-height: 1;
|
}
|
||||||
padding: 5px 8px;
|
|
||||||
text-overflow: ellipsis;
|
.weekday {
|
||||||
white-space: nowrap;
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayButton {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayButton:hover {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayOutside {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySelected,
|
||||||
|
.daySelected:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,10 +111,15 @@ export const resourceGroupCreateFormSchema = z
|
|||||||
|
|
||||||
export const giftFormSchema = z
|
export const giftFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
chargeAssetType: z.enum(["COIN", "DIAMOND"]),
|
||||||
coinPrice: z.union([z.string(), z.number()]),
|
coinPrice: z.union([z.string(), z.number()]),
|
||||||
|
effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
|
||||||
|
effectiveFrom: z.string().optional(),
|
||||||
|
effectiveTo: z.string().optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
||||||
giftPointAmount: z.union([z.string(), z.number()]).optional(),
|
giftPointAmount: z.union([z.string(), z.number()]).optional(),
|
||||||
|
giftTypeCode: z.enum(["normal", "cp", "lucky", "super_lucky", "exclusive", "noble", "flag", "activity", "magic", "custom"]),
|
||||||
heatValue: z.union([z.string(), z.number()]).optional(),
|
heatValue: z.union([z.string(), z.number()]).optional(),
|
||||||
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
||||||
presentationJson: z.string().trim().optional(),
|
presentationJson: z.string().trim().optional(),
|
||||||
@ -128,6 +133,8 @@ export const giftFormSchema = z
|
|||||||
const coinPrice = Number(value.coinPrice);
|
const coinPrice = Number(value.coinPrice);
|
||||||
const giftPointAmount = Number(value.giftPointAmount || 0);
|
const giftPointAmount = Number(value.giftPointAmount || 0);
|
||||||
const heatValue = Number(value.heatValue || 0);
|
const heatValue = Number(value.heatValue || 0);
|
||||||
|
const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom);
|
||||||
|
const effectiveToMs = datetimeLocalToMs(value.effectiveTo);
|
||||||
|
|
||||||
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@ -157,6 +164,27 @@ export const giftFormSchema = z
|
|||||||
path: ["heatValue"],
|
path: ["heatValue"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (effectiveFromMs < 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请选择有效开始时间",
|
||||||
|
path: ["effectiveFrom"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (effectiveToMs < 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请选择有效结束时间",
|
||||||
|
path: ["effectiveTo"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "有效结束时间必须晚于开始时间",
|
||||||
|
path: ["effectiveTo"],
|
||||||
|
});
|
||||||
|
}
|
||||||
value.regionIds.forEach((regionId, index) => {
|
value.regionIds.forEach((regionId, index) => {
|
||||||
const normalizedRegionId = Number(regionId);
|
const normalizedRegionId = Number(regionId);
|
||||||
if (!Number.isInteger(normalizedRegionId) || normalizedRegionId < 0) {
|
if (!Number.isInteger(normalizedRegionId) || normalizedRegionId < 0) {
|
||||||
@ -180,6 +208,15 @@ export const giftFormSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function datetimeLocalToMs(value) {
|
||||||
|
const trimmed = String(value || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed).getTime();
|
||||||
|
return Number.isFinite(parsed) ? parsed : -1;
|
||||||
|
}
|
||||||
|
|
||||||
export const resourceGrantFormSchema = z
|
export const resourceGrantFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
durationDays: z.union([z.string(), z.number()]).optional(),
|
durationDays: z.union([z.string(), z.number()]).optional(),
|
||||||
|
|||||||
@ -49,10 +49,15 @@ describe("resource form schema", () => {
|
|||||||
|
|
||||||
test("validates editable gift form fields", () => {
|
test("validates editable gift form fields", () => {
|
||||||
const payload = parseForm(giftFormSchema, {
|
const payload = parseForm(giftFormSchema, {
|
||||||
|
chargeAssetType: "COIN",
|
||||||
coinPrice: "10",
|
coinPrice: "10",
|
||||||
|
effectTypes: ["animation"],
|
||||||
|
effectiveFrom: "",
|
||||||
|
effectiveTo: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
giftId: "rose",
|
giftId: "rose",
|
||||||
giftPointAmount: "1",
|
giftPointAmount: "1",
|
||||||
|
giftTypeCode: "normal",
|
||||||
heatValue: "2",
|
heatValue: "2",
|
||||||
name: "Rose",
|
name: "Rose",
|
||||||
presentationJson: "{}",
|
presentationJson: "{}",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
|||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||||
|
import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||||
@ -64,7 +65,17 @@ export function RoomListPage() {
|
|||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const tableColumns = [
|
const tableColumns = [
|
||||||
...columns.map((column) =>
|
...columns.map((column) =>
|
||||||
column.key === "status"
|
column.key === "region"
|
||||||
|
? {
|
||||||
|
...column,
|
||||||
|
filter: createRegionColumnFilter({
|
||||||
|
loading: page.loadingRegions,
|
||||||
|
options: page.regionOptions,
|
||||||
|
value: page.regionId,
|
||||||
|
onChange: page.changeRegionId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: column.key === "status"
|
||||||
? {
|
? {
|
||||||
...column,
|
...column,
|
||||||
render: (room) => (
|
render: (room) => (
|
||||||
@ -93,7 +104,7 @@ export function RoomListPage() {
|
|||||||
value={page.status}
|
value={page.status}
|
||||||
onChange={page.changeStatus}
|
onChange={page.changeStatus}
|
||||||
/>
|
/>
|
||||||
<RegionSelect
|
<RegionFilterControl
|
||||||
className={styles.regionInput}
|
className={styles.regionInput}
|
||||||
emptyLabel="全部区域"
|
emptyLabel="全部区域"
|
||||||
loading={page.loadingRegions}
|
loading={page.loadingRegions}
|
||||||
|
|||||||
@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.regionInput {
|
.regionInput {
|
||||||
flex: 0 0 150px;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listBlock {
|
.listBlock {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export const API_OPERATIONS = {
|
|||||||
changePassword: "changePassword",
|
changePassword: "changePassword",
|
||||||
closeAgency: "closeAgency",
|
closeAgency: "closeAgency",
|
||||||
createAgency: "createAgency",
|
createAgency: "createAgency",
|
||||||
|
createBanner: "createBanner",
|
||||||
createBD: "createBD",
|
createBD: "createBD",
|
||||||
createBDLeader: "createBDLeader",
|
createBDLeader: "createBDLeader",
|
||||||
createCoinSeller: "createCoinSeller",
|
createCoinSeller: "createCoinSeller",
|
||||||
@ -38,6 +39,7 @@ export const API_OPERATIONS = {
|
|||||||
createUser: "createUser",
|
createUser: "createUser",
|
||||||
createUserExportJob: "createUserExportJob",
|
createUserExportJob: "createUserExportJob",
|
||||||
dashboardOverview: "dashboardOverview",
|
dashboardOverview: "dashboardOverview",
|
||||||
|
deleteBanner: "deleteBanner",
|
||||||
deleteCountry: "deleteCountry",
|
deleteCountry: "deleteCountry",
|
||||||
deleteMenu: "deleteMenu",
|
deleteMenu: "deleteMenu",
|
||||||
deleteNotification: "deleteNotification",
|
deleteNotification: "deleteNotification",
|
||||||
@ -68,6 +70,7 @@ export const API_OPERATIONS = {
|
|||||||
grantResourceGroup: "grantResourceGroup",
|
grantResourceGroup: "grantResourceGroup",
|
||||||
listAgencies: "listAgencies",
|
listAgencies: "listAgencies",
|
||||||
listApps: "listApps",
|
listApps: "listApps",
|
||||||
|
listBanners: "listBanners",
|
||||||
listBDLeaders: "listBDLeaders",
|
listBDLeaders: "listBDLeaders",
|
||||||
listBDs: "listBDs",
|
listBDs: "listBDs",
|
||||||
listCoinSellers: "listCoinSellers",
|
listCoinSellers: "listCoinSellers",
|
||||||
@ -80,6 +83,7 @@ export const API_OPERATIONS = {
|
|||||||
listNotifications: "listNotifications",
|
listNotifications: "listNotifications",
|
||||||
listOperationLogs: "listOperationLogs",
|
listOperationLogs: "listOperationLogs",
|
||||||
listPermissions: "listPermissions",
|
listPermissions: "listPermissions",
|
||||||
|
listRechargeBills: "listRechargeBills",
|
||||||
listRegions: "listRegions",
|
listRegions: "listRegions",
|
||||||
listResourceGrants: "listResourceGrants",
|
listResourceGrants: "listResourceGrants",
|
||||||
listResourceGroups: "listResourceGroups",
|
listResourceGroups: "listResourceGroups",
|
||||||
@ -106,6 +110,7 @@ export const API_OPERATIONS = {
|
|||||||
setCoinSellerStatus: "setCoinSellerStatus",
|
setCoinSellerStatus: "setCoinSellerStatus",
|
||||||
sortMenus: "sortMenus",
|
sortMenus: "sortMenus",
|
||||||
syncPermissions: "syncPermissions",
|
syncPermissions: "syncPermissions",
|
||||||
|
updateBanner: "updateBanner",
|
||||||
updateCountry: "updateCountry",
|
updateCountry: "updateCountry",
|
||||||
updateGift: "updateGift",
|
updateGift: "updateGift",
|
||||||
updateH5Links: "updateH5Links",
|
updateH5Links: "updateH5Links",
|
||||||
@ -202,6 +207,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "agency:create",
|
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"]
|
||||||
|
},
|
||||||
createBD: {
|
createBD: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createBD,
|
operationId: API_OPERATIONS.createBD,
|
||||||
@ -300,6 +312,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "overview:view",
|
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"]
|
||||||
|
},
|
||||||
deleteCountry: {
|
deleteCountry: {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
operationId: API_OPERATIONS.deleteCountry,
|
operationId: API_OPERATIONS.deleteCountry,
|
||||||
@ -508,6 +527,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.listApps,
|
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"]
|
||||||
|
},
|
||||||
listBDLeaders: {
|
listBDLeaders: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listBDLeaders,
|
operationId: API_OPERATIONS.listBDLeaders,
|
||||||
@ -592,6 +618,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "permission:view",
|
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"]
|
||||||
|
},
|
||||||
listRegions: {
|
listRegions: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listRegions,
|
operationId: API_OPERATIONS.listRegions,
|
||||||
@ -762,6 +795,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "permission: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"]
|
||||||
|
},
|
||||||
updateCountry: {
|
updateCountry: {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
operationId: API_OPERATIONS.updateCountry,
|
operationId: API_OPERATIONS.updateCountry,
|
||||||
|
|||||||
112
src/shared/api/generated/schema.d.ts
vendored
112
src/shared/api/generated/schema.d.ts
vendored
@ -52,6 +52,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/app-config/banners": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listBanners"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createBanner"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/app-config/banners/{banner_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateBanner"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteBanner"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/app-config/h5-links": {
|
"/admin/app-config/h5-links": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -356,6 +388,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/payment/recharge-bills": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listRechargeBills"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/regions": {
|
"/admin/regions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1840,6 +1888,58 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listBanners: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createBanner: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateBanner: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
banner_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteBanner: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
banner_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listH5Links: {
|
listH5Links: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2206,6 +2306,18 @@ export interface operations {
|
|||||||
200: components["responses"]["HostProfilePageResponse"];
|
200: components["responses"]["HostProfilePageResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listRechargeBills: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listRegions: {
|
listRegions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -195,6 +195,27 @@ export interface ChangePasswordPayload {
|
|||||||
oldPassword: string;
|
oldPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RechargeBillDto {
|
||||||
|
appCode?: string;
|
||||||
|
coinAmount: number;
|
||||||
|
commandId?: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
currencyCode?: string;
|
||||||
|
exchangeCoinAmount?: number;
|
||||||
|
exchangeUsdMinorAmount?: number;
|
||||||
|
externalRef?: string;
|
||||||
|
policyId?: number;
|
||||||
|
policyVersion?: string;
|
||||||
|
rechargeType?: string;
|
||||||
|
sellerRegionId?: number;
|
||||||
|
sellerUserId?: number;
|
||||||
|
status?: string;
|
||||||
|
targetRegionId?: number;
|
||||||
|
transactionId: string;
|
||||||
|
usdMinorAmount: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BDProfileDto {
|
export interface BDProfileDto {
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
createdByUserId?: number;
|
createdByUserId?: number;
|
||||||
@ -350,6 +371,34 @@ export interface H5LinkConfigUpdatePayload {
|
|||||||
items: H5LinkConfigPayload[];
|
items: H5LinkConfigPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppBannerDto {
|
||||||
|
appCode?: string;
|
||||||
|
bannerType: "h5" | "app" | string;
|
||||||
|
countryCode?: string;
|
||||||
|
coverUrl: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
description?: string;
|
||||||
|
id: number;
|
||||||
|
param?: string;
|
||||||
|
platform: "android" | "ios" | string;
|
||||||
|
regionId?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
status: "active" | "disabled" | string;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppBannerPayload {
|
||||||
|
bannerType: "h5" | "app";
|
||||||
|
countryCode?: string;
|
||||||
|
coverUrl: string;
|
||||||
|
description?: string;
|
||||||
|
param?: string;
|
||||||
|
platform: "android" | "ios";
|
||||||
|
regionId?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
status: "active" | "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgencyMembershipDto {
|
export interface AgencyMembershipDto {
|
||||||
agencyId?: number;
|
agencyId?: number;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
|
|||||||
@ -14,13 +14,15 @@ export function useRegionOptions() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const regionOptions = useMemo(() => {
|
const regionOptions = useMemo(() => {
|
||||||
return (data?.items || []).map((region) => ({
|
return (data?.items || [])
|
||||||
label: formatRegionLabel(region),
|
.filter((region) => region.regionCode !== "GLOBAL")
|
||||||
name: region.name,
|
.map((region) => ({
|
||||||
regionCode: region.regionCode,
|
label: formatRegionLabel(region),
|
||||||
regionId: region.regionId,
|
name: region.name,
|
||||||
value: String(region.regionId)
|
regionCode: region.regionCode,
|
||||||
}));
|
regionId: region.regionId,
|
||||||
|
value: String(region.regionId)
|
||||||
|
}));
|
||||||
}, [data?.items]);
|
}, [data?.items]);
|
||||||
|
|
||||||
return { loadingRegions, regionOptions };
|
return { loadingRegions, regionOptions };
|
||||||
|
|||||||
@ -16,11 +16,21 @@
|
|||||||
--admin-form-field-width: 220px;
|
--admin-form-field-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
--admin-form-dialog-width: 420px;
|
||||||
|
--admin-form-field-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.wide {
|
.wide {
|
||||||
--admin-form-dialog-width: 680px;
|
--admin-form-dialog-width: 680px;
|
||||||
--admin-form-field-width: 220px;
|
--admin-form-field-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
--admin-form-dialog-width: 760px;
|
||||||
|
--admin-form-field-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: calc(100vh - 64px);
|
max-height: calc(100vh - 64px);
|
||||||
|
|||||||
@ -1,17 +1,129 @@
|
|||||||
|
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Popover from "@mui/material/Popover";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
|
||||||
|
const DEFAULT_RESIZE_MIN_WIDTH = 72;
|
||||||
|
const DEFAULT_RESIZE_MAX_WIDTH = 960;
|
||||||
|
|
||||||
export function DataTable({
|
export function DataTable({
|
||||||
className = "",
|
className = "",
|
||||||
columns,
|
columns,
|
||||||
context,
|
context,
|
||||||
emptyLabel = "当前无数据",
|
emptyLabel = "当前无数据",
|
||||||
|
fixedEdges = true,
|
||||||
getRowProps,
|
getRowProps,
|
||||||
items,
|
items,
|
||||||
minWidth = "980px",
|
minWidth = "980px",
|
||||||
|
onColumnWidthsChange,
|
||||||
|
resizableColumns = true,
|
||||||
rowKey,
|
rowKey,
|
||||||
tableClassName = "",
|
tableClassName = "",
|
||||||
title,
|
title,
|
||||||
total
|
total
|
||||||
}) {
|
}) {
|
||||||
const gridTemplate = columns.map((column) => column.width || "minmax(120px, 1fr)").join(" ");
|
const [columnWidths, setColumnWidths] = useState({});
|
||||||
|
const [filterMenu, setFilterMenu] = useState({ anchorEl: null, columnKey: "" });
|
||||||
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
|
const [resizeSession, setResizeSession] = useState(null);
|
||||||
|
const safeItems = Array.isArray(items) ? items : [];
|
||||||
|
|
||||||
|
const preparedColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
columns.map((column, index) => ({
|
||||||
|
...column,
|
||||||
|
fixed: resolveColumnFixed(column, index, columns.length, fixedEdges),
|
||||||
|
width: columnWidths[column.key] || column.width || DEFAULT_COLUMN_WIDTH
|
||||||
|
})),
|
||||||
|
[columnWidths, columns, fixedEdges]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 filterOptions = useMemo(() => filterVisibleOptions(activeFilter, filterQuery), [activeFilter, filterQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizeSession) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveColumn = (event) => {
|
||||||
|
const delta = event.clientX - resizeSession.startX;
|
||||||
|
const nextWidth = clamp(resizeSession.startWidth + delta, resizeSession.minWidth, resizeSession.maxWidth);
|
||||||
|
const cssWidth = `${Math.round(nextWidth)}px`;
|
||||||
|
|
||||||
|
setColumnWidths((current) => {
|
||||||
|
if (current[resizeSession.key] === cssWidth) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = { ...current, [resizeSession.key]: cssWidth };
|
||||||
|
onColumnWidthsChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishResize = () => {
|
||||||
|
setResizeSession(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
window.addEventListener("pointermove", moveColumn);
|
||||||
|
window.addEventListener("pointerup", finishResize, { once: true });
|
||||||
|
window.addEventListener("pointercancel", finishResize, { once: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
window.removeEventListener("pointermove", moveColumn);
|
||||||
|
window.removeEventListener("pointerup", finishResize);
|
||||||
|
window.removeEventListener("pointercancel", finishResize);
|
||||||
|
};
|
||||||
|
}, [onColumnWidthsChange, resizeSession]);
|
||||||
|
|
||||||
|
const startResize = (event, column) => {
|
||||||
|
if (!resizableColumns || column.resizable === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerCell = event.currentTarget.closest("[data-table-header-cell]");
|
||||||
|
const startWidth = headerCell?.getBoundingClientRect().width;
|
||||||
|
if (!startWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setResizeSession({
|
||||||
|
key: column.key,
|
||||||
|
maxWidth: resolveResizeMaxWidth(column),
|
||||||
|
minWidth: resolveResizeMinWidth(column),
|
||||||
|
startWidth,
|
||||||
|
startX: event.clientX
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openColumnFilter = (event, column) => {
|
||||||
|
if (!column.filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
setFilterMenu({ anchorEl: event.currentTarget, columnKey: column.key });
|
||||||
|
setFilterQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeColumnFilter = () => {
|
||||||
|
setFilterMenu({ anchorEl: null, columnKey: "" });
|
||||||
|
setFilterQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeFilter = (value) => {
|
||||||
|
activeFilter?.onChange?.(value);
|
||||||
|
closeColumnFilter();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={["table-frame", className].filter(Boolean).join(" ")}>
|
<section className={["table-frame", className].filter(Boolean).join(" ")}>
|
||||||
@ -23,25 +135,38 @@ export function DataTable({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<div
|
<div
|
||||||
className={["admin-table", tableClassName].filter(Boolean).join(" ")}
|
className={["admin-table", resizeSession ? "admin-table--resizing" : "", tableClassName].filter(Boolean).join(" ")}
|
||||||
style={{ "--admin-table-columns": gridTemplate, "--admin-table-min-width": minWidth }}
|
style={{ "--admin-table-columns": gridTemplate, "--admin-table-min-width": minWidth }}
|
||||||
>
|
>
|
||||||
<div className="admin-row admin-row--head">
|
<div className="admin-row admin-row--head">
|
||||||
{columns.map((column) => (
|
{preparedColumns.map((column) => (
|
||||||
<div className={column.className || ""} key={column.key}>
|
<div
|
||||||
{column.header || column.label}
|
className={cellClassName(column, ["admin-cell--head", resizeSession?.key === column.key ? "admin-cell--resizing" : ""])}
|
||||||
|
data-table-header-cell
|
||||||
|
key={column.key}
|
||||||
|
>
|
||||||
|
<HeaderLabel column={column} onOpenFilter={openColumnFilter} />
|
||||||
|
{resizableColumns && column.resizable !== false ? (
|
||||||
|
<button
|
||||||
|
aria-label={`调整${typeof column.label === "string" ? column.label : column.key}列宽`}
|
||||||
|
className="admin-column-resizer"
|
||||||
|
tabIndex={-1}
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(event) => startResize(event, column)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{items.length ? (
|
{safeItems.length ? (
|
||||||
items.map((item, index) => {
|
safeItems.map((item, index) => {
|
||||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||||
{columns.map((column) => (
|
{preparedColumns.map((column) => (
|
||||||
<div className={["admin-cell", column.className || ""].filter(Boolean).join(" ")} key={column.key}>
|
<div className={cellClassName(column)} key={column.key}>
|
||||||
{column.render ? column.render(item, index, context) : displayValue(item[column.key])}
|
{column.render ? column.render(item, index, context) : displayValue(item[column.key])}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -55,6 +180,43 @@ export function DataTable({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Popover
|
||||||
|
anchorEl={filterMenu.anchorEl}
|
||||||
|
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
|
||||||
|
className="admin-table-filter-popover"
|
||||||
|
open={Boolean(activeFilterColumn)}
|
||||||
|
transformOrigin={{ horizontal: "left", vertical: "top" }}
|
||||||
|
onClose={closeColumnFilter}
|
||||||
|
>
|
||||||
|
{activeFilterColumn ? (
|
||||||
|
<div className="admin-table-filter">
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
className="admin-table-filter__search"
|
||||||
|
disabled={activeFilter.loading}
|
||||||
|
placeholder={activeFilter.placeholder || `搜索${activeFilterColumn.label || ""}`}
|
||||||
|
size="small"
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(event) => setFilterQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="admin-table-filter__options">
|
||||||
|
{filterOptions.length ? (
|
||||||
|
filterOptions.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
selected={String(activeFilter.value ?? "") === option.value}
|
||||||
|
onClick={() => changeFilter(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="admin-table-filter__empty">{activeFilter.loading ? "加载中..." : "无匹配选项"}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Popover>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -62,3 +224,126 @@ export function DataTable({
|
|||||||
function displayValue(value) {
|
function displayValue(value) {
|
||||||
return value === 0 || value === false || value ? value : "-";
|
return value === 0 || value === false || value ? value : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HeaderLabel({ column, onOpenFilter }) {
|
||||||
|
const content = column.header || column.label;
|
||||||
|
if (!column.filter) {
|
||||||
|
return <span className="admin-cell__head-label">{content}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = isFilterActive(column.filter);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""].filter(Boolean).join(" ")}
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => onOpenFilter(event, column)}
|
||||||
|
>
|
||||||
|
<SearchOutlined className="admin-cell__head-icon" fontSize="inherit" />
|
||||||
|
<span className="admin-cell__head-label">{content}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellClassName(column, extraClassName = "") {
|
||||||
|
const extraClassNames = Array.isArray(extraClassName) ? extraClassName : [extraClassName];
|
||||||
|
return [
|
||||||
|
"admin-cell",
|
||||||
|
column.fixed === "left" ? "admin-cell--fixed-left" : "",
|
||||||
|
column.fixed === "right" ? "admin-cell--fixed-right" : "",
|
||||||
|
column.className || "",
|
||||||
|
...extraClassNames
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveColumnFixed(column, index, total, fixedEdges) {
|
||||||
|
if (column.fixed === "left" || column.fixed === "right") {
|
||||||
|
return column.fixed;
|
||||||
|
}
|
||||||
|
if (!fixedEdges || total < 2) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (index === 0) {
|
||||||
|
return "left";
|
||||||
|
}
|
||||||
|
if (index === total - 1) {
|
||||||
|
return "right";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResizeMinWidth(column) {
|
||||||
|
return readPixelWidth(column.resizeMinWidth) || readPixelWidth(column.minWidth) || readMinWidth(column.width) || DEFAULT_RESIZE_MIN_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResizeMaxWidth(column) {
|
||||||
|
return readPixelWidth(column.resizeMaxWidth) || readPixelWidth(column.maxWidth) || DEFAULT_RESIZE_MAX_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMinWidth(width) {
|
||||||
|
const pixelWidth = readPixelWidth(width);
|
||||||
|
if (pixelWidth) {
|
||||||
|
return pixelWidth;
|
||||||
|
}
|
||||||
|
if (typeof width === "number") {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
if (typeof width !== "string") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minmaxMatch = width.match(/minmax\(\s*(\d+(?:\.\d+)?)px/i);
|
||||||
|
if (minmaxMatch) {
|
||||||
|
return Number(minmaxMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPixelWidth(width) {
|
||||||
|
if (typeof width === "number") {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
if (typeof width !== "string") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const pixelMatch = width.match(/^(\d+(?:\.\d+)?)px$/i);
|
||||||
|
return pixelMatch ? Number(pixelMatch[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFilterActive(filter) {
|
||||||
|
return filter && filter.value !== undefined && filter.value !== null && String(filter.value) !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterVisibleOptions(filter, query) {
|
||||||
|
if (!filter) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedOptions = normalizeFilterOptions(filter);
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return normalizedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery) || option.value.toLowerCase().includes(normalizedQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilterOptions(filter) {
|
||||||
|
const options = (filter.options || []).map((option) => {
|
||||||
|
if (Array.isArray(option)) {
|
||||||
|
return { label: String(option[1] ?? option[0] ?? ""), value: String(option[0] ?? "") };
|
||||||
|
}
|
||||||
|
return { label: String(option.label ?? option.name ?? option.value ?? ""), value: String(option.value ?? "") };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filter.emptyLabel !== undefined && !options.some((option) => option.value === "")) {
|
||||||
|
return [{ label: filter.emptyLabel, value: "" }, ...options];
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|||||||
46
src/shared/ui/MultiValueAutocomplete.jsx
Normal file
46
src/shared/ui/MultiValueAutocomplete.jsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function MultiValueAutocomplete({
|
||||||
|
disabled = false,
|
||||||
|
getOptionLabel,
|
||||||
|
label,
|
||||||
|
labels,
|
||||||
|
loading = false,
|
||||||
|
onChange,
|
||||||
|
options = [],
|
||||||
|
required = false,
|
||||||
|
value
|
||||||
|
}) {
|
||||||
|
const selected = useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
||||||
|
const mergedOptions = useMemo(() => {
|
||||||
|
const seen = new Set();
|
||||||
|
return [...options, ...selected].filter((option) => {
|
||||||
|
const key = String(option);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [options, selected]);
|
||||||
|
const formatOption = getOptionLabel || ((option) => labels?.[String(option)] || String(option));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
disableCloseOnSelect
|
||||||
|
disabled={disabled}
|
||||||
|
filterSelectedOptions
|
||||||
|
getOptionLabel={formatOption}
|
||||||
|
isOptionEqualToValue={(option, selectedOption) => String(option) === String(selectedOption)}
|
||||||
|
loading={loading}
|
||||||
|
options={mergedOptions}
|
||||||
|
renderInput={(params) => <TextField {...params} label={label} required={required} />}
|
||||||
|
size="small"
|
||||||
|
value={selected}
|
||||||
|
onChange={(_, nextValue) => onChange(nextValue)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/shared/ui/RegionFilterControl.jsx
Normal file
65
src/shared/ui/RegionFilterControl.jsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export function RegionFilterControl({
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
emptyLabel = "全部区域",
|
||||||
|
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
|
||||||
|
{...props}
|
||||||
|
className={["region-filter-control__select", selectClassName].filter(Boolean).join(" ")}
|
||||||
|
disabled={disabled}
|
||||||
|
emptyLabel={emptyLabel}
|
||||||
|
loading={loading}
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<Tooltip arrow title={resetLabel}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
className="region-filter-control__reset"
|
||||||
|
disabled={!canReset}
|
||||||
|
label={resetLabel}
|
||||||
|
onClick={() => onChange?.("")}
|
||||||
|
>
|
||||||
|
<RestartAltOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRegionColumnFilter({
|
||||||
|
emptyLabel = "全部区域",
|
||||||
|
loading = false,
|
||||||
|
onChange,
|
||||||
|
options = [],
|
||||||
|
placeholder = "搜索区域",
|
||||||
|
value
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
emptyLabel,
|
||||||
|
loading,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -38,5 +38,7 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: var(--header-height) 1fr;
|
grid-template-rows: var(--header-height) 1fr;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -394,15 +397,20 @@
|
|||||||
.body-grid {
|
.body-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 248px 1fr;
|
grid-template-columns: 248px 1fr;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
transition: grid-template-columns var(--motion-slow) var(--ease-emphasized);
|
transition: grid-template-columns var(--motion-slow) var(--ease-emphasized);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--z-sidebar);
|
z-index: var(--z-sidebar);
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: visible;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
padding: var(--space-4) var(--space-3);
|
padding: var(--space-4) var(--space-3);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: var(--bg-sidebar);
|
background: var(--bg-sidebar);
|
||||||
@ -442,6 +450,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar--collapsed {
|
.sidebar--collapsed {
|
||||||
|
overflow: visible;
|
||||||
padding: var(--space-4) var(--space-3);
|
padding: var(--space-4) var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,7 +463,20 @@
|
|||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
min-height: 0;
|
||||||
|
align-content: start;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-right: var(--space-1);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-list {
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group {
|
.nav-group {
|
||||||
|
|||||||
@ -5,7 +5,15 @@
|
|||||||
|
|
||||||
.app-shell,
|
.app-shell,
|
||||||
.body-grid {
|
.body-grid {
|
||||||
|
height: auto;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.nav-list {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
|
|||||||
@ -117,6 +117,24 @@
|
|||||||
flex: 0 0 180px;
|
flex: 0 0 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.region-filter-control {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-filter-control .region-select.MuiTextField-root,
|
||||||
|
.region-filter-control .region-select.MuiFormControl-root {
|
||||||
|
flex: 0 0 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-filter-control__reset.icon-button {
|
||||||
|
flex: 0 0 var(--control-height);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) {
|
.MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) {
|
||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
min-height: var(--control-height);
|
min-height: var(--control-height);
|
||||||
@ -239,7 +257,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.MuiAutocomplete-root {
|
.MuiAutocomplete-root {
|
||||||
--admin-autocomplete-chip-height: 28px;
|
|
||||||
--admin-autocomplete-input-height: 24px;
|
--admin-autocomplete-input-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,16 +286,18 @@
|
|||||||
|
|
||||||
.MuiAutocomplete-root .MuiAutocomplete-tag {
|
.MuiAutocomplete-root .MuiAutocomplete-tag {
|
||||||
max-width: calc(100% - var(--space-2));
|
max-width: calc(100% - var(--space-2));
|
||||||
height: var(--admin-autocomplete-chip-height);
|
height: var(--admin-tag-height);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: var(--radius-pill);
|
border: 1px solid var(--admin-tag-border);
|
||||||
background: var(--neutral-surface-strong);
|
border-radius: var(--admin-tag-radius);
|
||||||
color: var(--text-primary);
|
background: var(--admin-tag-bg);
|
||||||
font-size: var(--admin-font-size);
|
color: var(--admin-tag-color);
|
||||||
|
font-size: var(--admin-tag-font-size);
|
||||||
|
font-weight: var(--admin-tag-font-weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-label {
|
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-label {
|
||||||
padding: 0 var(--space-2);
|
padding: 0 var(--admin-tag-padding-x);
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-deleteIcon {
|
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-deleteIcon {
|
||||||
@ -288,6 +307,32 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-tag-list {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
height: var(--admin-tag-height);
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--admin-tag-border);
|
||||||
|
border-radius: var(--admin-tag-radius);
|
||||||
|
background: var(--admin-tag-bg);
|
||||||
|
color: var(--admin-tag-color);
|
||||||
|
font-size: var(--admin-tag-font-size);
|
||||||
|
font-weight: var(--admin-tag-font-weight);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 var(--admin-tag-padding-x);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.MuiAutocomplete-popper .MuiAutocomplete-paper {
|
.MuiAutocomplete-popper .MuiAutocomplete-paper {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@ -360,6 +405,18 @@
|
|||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.region-filter-control {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-filter-control .region-select.MuiTextField-root,
|
||||||
|
.region-filter-control .region-select.MuiFormControl-root {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-card {
|
.kpi-card {
|
||||||
@ -592,6 +649,17 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-stack {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-stack > * {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -850,11 +918,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
|
--admin-table-cell-x: var(--space-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: max(100%, var(--admin-table-min-width, 900px));
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
min-width: var(--admin-table-min-width, 900px);
|
min-width: max(100%, var(--admin-table-min-width, 900px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row {
|
.admin-row {
|
||||||
@ -863,10 +932,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
grid-template-columns: var(--admin-table-columns);
|
grid-template-columns: var(--admin-table-columns);
|
||||||
min-height: var(--table-row-height);
|
min-height: var(--table-row-height);
|
||||||
padding: 0 var(--space-5);
|
padding: 0;
|
||||||
border-bottom: 1px solid var(--row-border);
|
border-bottom: 1px solid var(--row-border);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
column-gap: var(--space-4);
|
column-gap: 0;
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
animation: row-enter var(--motion-slow) var(--ease-emphasized) both;
|
animation: row-enter var(--motion-slow) var(--ease-emphasized) both;
|
||||||
}
|
}
|
||||||
@ -884,10 +953,176 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-cell {
|
.admin-cell {
|
||||||
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 0 var(--admin-table-cell-x);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-cell:first-child {
|
||||||
|
padding-left: var(--admin-table-cell-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell:last-child {
|
||||||
|
padding-right: var(--admin-table-cell-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell--head {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--table-head-height);
|
||||||
|
align-items: center;
|
||||||
|
padding-right: calc(var(--admin-table-cell-x) + 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell--head:last-child {
|
||||||
|
padding-right: calc(var(--admin-table-cell-x) + 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell__head-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell__head-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell__head-trigger:hover,
|
||||||
|
.admin-cell__head-trigger--active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell__head-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell__head-trigger--active .admin-cell__head-icon {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell--fixed-left,
|
||||||
|
.admin-cell--fixed-right {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--table-row-height);
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell--fixed-left {
|
||||||
|
left: 0;
|
||||||
|
box-shadow: 10px 0 18px -18px rgb(30 41 59 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell--fixed-right {
|
||||||
|
right: 0;
|
||||||
|
box-shadow: -10px 0 18px -18px rgb(30 41 59 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row--head .admin-cell--fixed-left,
|
||||||
|
.admin-row--head .admin-cell--fixed-right {
|
||||||
|
z-index: 4;
|
||||||
|
min-height: var(--table-head-height);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-column-resizer {
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 9px;
|
||||||
|
width: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: col-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-column-resizer::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-column-resizer:hover::after,
|
||||||
|
.admin-cell--resizing .admin-column-resizer::after {
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--primary-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table--resizing {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-filter-popover .MuiPopover-paper {
|
||||||
|
width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-flyout);
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-filter {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-filter__search.MuiTextField-root {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-filter__options {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-filter__options .MuiMenuItem-root {
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-filter__options .MuiMenuItem-root.Mui-selected {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-filter__empty {
|
||||||
|
padding: var(--space-4) var(--space-2);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table .empty-state--table {
|
.admin-table .empty-state--table {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,11 @@
|
|||||||
--admin-line-height: 20px;
|
--admin-line-height: 20px;
|
||||||
--control-height: 36px;
|
--control-height: 36px;
|
||||||
--status-height: 24px;
|
--status-height: 24px;
|
||||||
|
--admin-tag-height: var(--status-height);
|
||||||
|
--admin-tag-radius: var(--radius-pill);
|
||||||
|
--admin-tag-padding-x: var(--space-2);
|
||||||
|
--admin-tag-font-size: var(--admin-font-size);
|
||||||
|
--admin-tag-font-weight: 650;
|
||||||
--table-head-height: 44px;
|
--table-head-height: 44px;
|
||||||
--table-row-height: 58px;
|
--table-row-height: 58px;
|
||||||
--switch-width: 46px;
|
--switch-width: 46px;
|
||||||
@ -77,6 +82,9 @@
|
|||||||
--neutral-surface: rgba(100, 116, 139, 0.1);
|
--neutral-surface: rgba(100, 116, 139, 0.1);
|
||||||
--neutral-surface-strong: rgba(100, 116, 139, 0.16);
|
--neutral-surface-strong: rgba(100, 116, 139, 0.16);
|
||||||
--neutral-border: rgba(100, 116, 139, 0.22);
|
--neutral-border: rgba(100, 116, 139, 0.22);
|
||||||
|
--admin-tag-bg: var(--neutral-surface);
|
||||||
|
--admin-tag-border: var(--neutral-border);
|
||||||
|
--admin-tag-color: var(--text-secondary);
|
||||||
|
|
||||||
--chart-blue: #2563eb;
|
--chart-blue: #2563eb;
|
||||||
--chart-green: #16a34a;
|
--chart-green: #16a34a;
|
||||||
|
|||||||
15
src/theme.js
15
src/theme.js
@ -93,10 +93,13 @@ export const theme = createTheme({
|
|||||||
MuiChip: {
|
MuiChip: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
height: token("status-height"),
|
height: token("admin-tag-height"),
|
||||||
borderRadius: token("radius-pill"),
|
border: `1px solid ${token("admin-tag-border")}`,
|
||||||
fontSize: 14,
|
borderRadius: token("admin-tag-radius"),
|
||||||
backgroundColor: token("neutral-surface"),
|
backgroundColor: token("admin-tag-bg"),
|
||||||
|
color: token("admin-tag-color"),
|
||||||
|
fontSize: token("admin-tag-font-size"),
|
||||||
|
fontWeight: token("admin-tag-font-weight"),
|
||||||
transition:
|
transition:
|
||||||
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), color 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), color 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||||
},
|
},
|
||||||
@ -105,8 +108,8 @@ export const theme = createTheme({
|
|||||||
marginRight: -2
|
marginRight: -2
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
paddingLeft: 8,
|
paddingLeft: token("admin-tag-padding-x"),
|
||||||
paddingRight: 10
|
paddingRight: token("admin-tag-padding-x")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user