完善主播侧
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": {
|
||||
"get": {
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "listRegions",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import ExpandMore from "@mui/icons-material/ExpandMore";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import LockReset from "@mui/icons-material/LockReset";
|
||||
import Logout from "@mui/icons-material/Logout";
|
||||
import Menu from "@mui/icons-material/Menu";
|
||||
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 { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
import { changePassword } from "@/features/auth/api";
|
||||
import { listNotifications } from "@/features/notifications/api";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
|
||||
const emptyPasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
|
||||
|
||||
export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchMounted, setSearchMounted] = useState(false);
|
||||
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 { can, logout, user } = useAuth();
|
||||
const appScope = useAppScope();
|
||||
@ -60,12 +68,68 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
||||
setUserMenuAnchor(null);
|
||||
};
|
||||
|
||||
const openPasswordDialog = () => {
|
||||
closeUserMenu();
|
||||
setPasswordForm(emptyPasswordForm);
|
||||
setPasswordError("");
|
||||
setPasswordDialogOpen(true);
|
||||
};
|
||||
|
||||
const closePasswordDialog = () => {
|
||||
if (changingPassword) {
|
||||
return;
|
||||
}
|
||||
setPasswordDialogOpen(false);
|
||||
setPasswordError("");
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
closeUserMenu();
|
||||
await logout();
|
||||
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) => {
|
||||
if (result?.target) {
|
||||
navigate(result.target);
|
||||
@ -145,6 +209,10 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
|
||||
>
|
||||
<MenuItem className="user-menu__item" onClick={openPasswordDialog}>
|
||||
<LockReset fontSize="small" />
|
||||
修改密码
|
||||
</MenuItem>
|
||||
<MenuItem className="user-menu__item" onClick={handleLogout}>
|
||||
<Logout fontSize="small" />
|
||||
退出
|
||||
@ -158,6 +226,52 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
|
||||
</Suspense>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
||||
import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||
import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined";
|
||||
@ -32,6 +33,7 @@ const iconMap = {
|
||||
network: HubOutlined,
|
||||
team: GroupsOutlined,
|
||||
history: HistoryOutlined,
|
||||
image: ImageOutlined,
|
||||
login: LoginOutlined,
|
||||
map: MapOutlined,
|
||||
menu: MenuOpenOutlined,
|
||||
@ -84,7 +86,8 @@ export const fallbackNavigation = [
|
||||
id: "app-config",
|
||||
label: "APP配置",
|
||||
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 })
|
||||
]
|
||||
},
|
||||
{
|
||||
code: "payment",
|
||||
icon: WalletOutlined,
|
||||
id: "payment",
|
||||
label: "支付管理",
|
||||
children: [
|
||||
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })
|
||||
]
|
||||
},
|
||||
{
|
||||
code: "logs",
|
||||
icon: HistoryOutlined,
|
||||
|
||||
@ -24,6 +24,7 @@ export const PERMISSIONS = {
|
||||
coinSellerView: "coin-seller:view",
|
||||
coinSellerCreate: "coin-seller:create",
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
paymentBillView: "payment-bill:view",
|
||||
roleView: "role:view",
|
||||
roleCreate: "role:create",
|
||||
roleUpdate: "role:update",
|
||||
@ -95,6 +96,7 @@ export const MENU_CODES = {
|
||||
roomList: "room-list",
|
||||
appConfig: "app-config",
|
||||
appConfigH5: "app-config-h5",
|
||||
appConfigBanners: "app-config-banners",
|
||||
resources: "resources",
|
||||
resourceList: "resource-list",
|
||||
resourceGroupList: "resource-group-list",
|
||||
@ -109,6 +111,8 @@ export const MENU_CODES = {
|
||||
hostOrgBds: "host-org-bds",
|
||||
hostOrgHosts: "host-org-hosts",
|
||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||
payment: "payment",
|
||||
paymentBillList: "payment-bill-list",
|
||||
jobs: "jobs"
|
||||
} as const;
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||
import { logsRoutes } from "@/features/logs/routes.js";
|
||||
import { menusRoutes } from "@/features/menus/routes.js";
|
||||
import { notificationsRoutes } from "@/features/notifications/routes.js";
|
||||
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||
import { resourceRoutes } from "@/features/resources/routes.js";
|
||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||
@ -21,6 +22,7 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...roomRoutes,
|
||||
...appConfigRoutes,
|
||||
...resourceRoutes,
|
||||
...paymentRoutes,
|
||||
...usersRoutes,
|
||||
...hostOrgRoutes,
|
||||
...rolesRoutes,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
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>> {
|
||||
const endpoint = API_ENDPOINTS.listH5Links;
|
||||
@ -16,3 +16,34 @@ export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiLi
|
||||
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;
|
||||
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 {
|
||||
canUpdate: can(PERMISSIONS.appConfigUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.appConfigView)
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,5 +8,13 @@ export const appConfigRoutes = [
|
||||
pageKey: "app-config-h5",
|
||||
path: "/app-config/h5",
|
||||
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 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 { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
@ -27,7 +27,7 @@ export function HostOrgFilters({
|
||||
onChange={onStatusChange}
|
||||
/>
|
||||
<div className={styles.filterFields}>
|
||||
<RegionSelect
|
||||
<RegionFilterControl
|
||||
emptyLabel="全部区域"
|
||||
loading={loadingRegions}
|
||||
options={regionOptions}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
|
||||
export function HostOrgTable(props) {
|
||||
return <DataTable {...props} />;
|
||||
export function HostOrgTable({ columns, regionFilter, ...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
|
||||
});
|
||||
const emptyData = { items: [], total: 0 };
|
||||
const globalRegionCode = "GLOBAL";
|
||||
|
||||
export function useHostRegionsPage() {
|
||||
const abilities = useRegionAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionForm, setRegionForm] = useState(emptyRegionForm);
|
||||
const [regionForm, setRegionFormState] = useState(emptyRegionForm);
|
||||
const [editingRegion, setEditingRegion] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const filters = useMemo(() => ({ status }), [status]);
|
||||
const queryFn = useCallback(() => listRegions(filters), [filters]);
|
||||
const queryFn = useCallback(() => listRegions(), []);
|
||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载区域列表失败",
|
||||
initialData: emptyData,
|
||||
keepPreviousData: true,
|
||||
queryKey: ["host-org", "regions", filters]
|
||||
queryKey: ["host-org", "regions"]
|
||||
});
|
||||
const countryQueryFn = useCallback(() => listCountries(), []);
|
||||
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
|
||||
@ -46,10 +46,23 @@ export function useHostRegionsPage() {
|
||||
queryKey: ["host-org", "countries", "region-options"],
|
||||
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(() => {
|
||||
return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))].sort();
|
||||
}, [countriesData?.items]);
|
||||
return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))]
|
||||
.filter((countryCode) => !assignedCountries.has(countryCode))
|
||||
.sort();
|
||||
}, [assignedCountries, countriesData?.items]);
|
||||
const countryLabels = useMemo(() => {
|
||||
return (countriesData?.items || []).reduce((labels, country) => {
|
||||
if (country.countryCode) {
|
||||
@ -58,6 +71,12 @@ export function useHostRegionsPage() {
|
||||
return labels;
|
||||
}, {});
|
||||
}, [countriesData?.items]);
|
||||
const isGlobalRegionForm = isGlobalRegionCode(regionForm.regionCode);
|
||||
|
||||
const setRegionForm = (nextForm) => {
|
||||
const normalized = isGlobalRegionCode(nextForm.regionCode) ? { ...nextForm, countries: [] } : nextForm;
|
||||
setRegionFormState(normalized);
|
||||
};
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
@ -69,13 +88,13 @@ export function useHostRegionsPage() {
|
||||
|
||||
const openCreateRegion = () => {
|
||||
setEditingRegion(null);
|
||||
setRegionForm(emptyRegionForm());
|
||||
setRegionFormState(emptyRegionForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEditRegion = (region) => {
|
||||
setEditingRegion(region);
|
||||
setRegionForm({
|
||||
setRegionFormState({
|
||||
countries: region.countries || [],
|
||||
name: region.name || "",
|
||||
regionCode: region.regionCode || "",
|
||||
@ -90,7 +109,7 @@ export function useHostRegionsPage() {
|
||||
await runAction(isEdit ? "region-edit" : "region-create", isEdit ? "区域已更新" : "区域已创建", async () => {
|
||||
const payload = parseForm(isEdit ? regionUpdateSchema : regionCreateSchema, regionForm);
|
||||
const data = isEdit ? await updateExistingRegion(editingRegion, payload) : await createRegion(payload);
|
||||
setRegionForm(emptyRegionForm());
|
||||
setRegionFormState(emptyRegionForm());
|
||||
setEditingRegion(null);
|
||||
setActiveAction("");
|
||||
await reload();
|
||||
@ -99,7 +118,7 @@ export function useHostRegionsPage() {
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
|
||||
@ -135,6 +154,7 @@ export function useHostRegionsPage() {
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
isGlobalRegionForm,
|
||||
openCreateRegion,
|
||||
openEditRegion,
|
||||
query,
|
||||
@ -162,14 +182,21 @@ function hasCountryChanges(previousCountries, nextCountries) {
|
||||
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();
|
||||
if (!keyword) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => {
|
||||
if (status && item.status !== status) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
return [item.regionCode, item.name, ...(item.countries || [])]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
||||
});
|
||||
}
|
||||
|
||||
function isGlobalRegionCode(regionCode) {
|
||||
return String(regionCode || "").trim().toUpperCase() === globalRegionCode;
|
||||
}
|
||||
|
||||
@ -99,27 +99,6 @@
|
||||
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 {
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
@ -81,6 +82,12 @@ export function HostAgenciesPage() {
|
||||
<HostOrgTable
|
||||
columns={agencyColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => item.agencyId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
|
||||
@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
@ -62,6 +63,12 @@ export function HostBdLeadersPage() {
|
||||
<HostOrgTable
|
||||
columns={bdLeaderColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => `bd-leader-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
|
||||
@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
@ -70,6 +71,12 @@ export function HostBdsPage() {
|
||||
<HostOrgTable
|
||||
columns={bdColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => `bd-${item.userId}`}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
|
||||
@ -8,6 +8,7 @@ import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
@ -78,7 +79,19 @@ export function HostCoinSellersPage() {
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable columns={columns} context={{ regionOptions: page.regionOptions }} items={items} minWidth="1040px" 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}
|
||||
</div>
|
||||
</DataState>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
@ -55,6 +56,12 @@ export function HostHostsPage() {
|
||||
<HostOrgTable
|
||||
columns={hostColumns}
|
||||
items={items}
|
||||
regionFilter={createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
})}
|
||||
rowKey={(item) => item.userId}
|
||||
/>
|
||||
{total > 0 ? <PaginationBar page={page.page} pageSize={page.data.pageSize || 10} total={total} onPageChange={page.setPage} /> : null}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
import { regionStatusFilters } from "@/features/host-org/constants.js";
|
||||
@ -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="排序" type="number" value={page.regionForm.sortOrder} onChange={(event) => page.setRegionForm({ ...page.regionForm, sortOrder: event.target.value })} />
|
||||
<CountryCodeSelect
|
||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||
disabled={page.isGlobalRegionForm || (page.activeAction === "edit" ? updateDisabled : createDisabled)}
|
||||
label={page.isGlobalRegionForm ? "国家码" : "国家码 *"}
|
||||
labels={page.countryLabels}
|
||||
loading={page.loadingCountries}
|
||||
options={page.countryOptions}
|
||||
@ -120,6 +121,9 @@ export function HostRegionsPage() {
|
||||
}
|
||||
|
||||
function RegionActions({ item, page }) {
|
||||
if (item.regionCode === "GLOBAL") {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
{page.abilities.canUpdate ? (
|
||||
@ -137,44 +141,42 @@ function RegionActions({ item, page }) {
|
||||
|
||||
function RegionStatusSwitch({ item, page }) {
|
||||
const checked = item.status === "active";
|
||||
const isGlobal = item.regionCode === "GLOBAL";
|
||||
return (
|
||||
<Switch
|
||||
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 ? "停用区域" : "启用区域" }}
|
||||
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 mergedOptions = [...new Set([...options, ...selected])].sort();
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
disableCloseOnSelect
|
||||
<MultiValueAutocomplete
|
||||
disabled={disabled}
|
||||
filterSelectedOptions
|
||||
getOptionLabel={(option) => labels[option] || option}
|
||||
label={label}
|
||||
labels={labels}
|
||||
loading={loading}
|
||||
options={mergedOptions}
|
||||
renderInput={(params) => <TextField {...params} label="国家码 *" />}
|
||||
size="small"
|
||||
value={selected}
|
||||
onChange={(_, countries) => onChange(countries)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryCodes({ countries = [] }) {
|
||||
if (!countries.length) {
|
||||
const items = Array.isArray(countries) ? countries : [];
|
||||
if (!items.length) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.countryList}>
|
||||
{countries.map((country) => (
|
||||
<span className={styles.countryBadge} key={country}>
|
||||
<div className="admin-tag-list">
|
||||
{items.map((country) => (
|
||||
<span className="admin-tag" key={country}>
|
||||
{country}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@ -19,15 +19,41 @@ const countryCodesSchema = z.array(z.string()).transform((values, ctx) => {
|
||||
const countries = values
|
||||
.map((item) => item.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
if (!countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "请选择国家码" });
|
||||
return countries;
|
||||
}
|
||||
if (new Set(countries).size !== countries.length) {
|
||||
ctx.addIssue({ code: "custom", message: "国家码不能重复" });
|
||||
}
|
||||
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({
|
||||
regionId: regionIdSchema,
|
||||
@ -88,22 +114,12 @@ export const countryUpdateSchema = countryCreateSchema.omit({
|
||||
enabled: true
|
||||
});
|
||||
|
||||
export const regionCreateSchema = z.object({
|
||||
countries: countryCodesSchema,
|
||||
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
|
||||
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
|
||||
sortOrder: sortOrderSchema
|
||||
});
|
||||
export const regionCreateSchema = regionBaseSchema;
|
||||
|
||||
export const regionUpdateSchema = z.object({
|
||||
countries: countryCodesSchema,
|
||||
name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
|
||||
regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
|
||||
sortOrder: sortOrderSchema
|
||||
});
|
||||
export const regionUpdateSchema = regionBaseSchema;
|
||||
|
||||
export const regionCountriesSchema = z.object({
|
||||
countries: countryCodesSchema
|
||||
countries: requiredCountryCodesSchema
|
||||
});
|
||||
|
||||
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 createGift({
|
||||
chargeAssetType: "COIN",
|
||||
coinPrice: 10,
|
||||
effectiveAtMs: 1777766400000,
|
||||
effectiveFromMs: 0,
|
||||
effectiveToMs: 0,
|
||||
effectTypes: ["animation"],
|
||||
giftId: "rose",
|
||||
giftPointAmount: 1,
|
||||
giftTypeCode: "normal",
|
||||
heatValue: 1,
|
||||
name: "Rose",
|
||||
presentationJson: "{}",
|
||||
@ -103,10 +108,15 @@ test("resource APIs use generated admin paths", async () => {
|
||||
await enableResourceGroup(22);
|
||||
await disableResourceGroup(22);
|
||||
await updateGift("rose", {
|
||||
chargeAssetType: "COIN",
|
||||
coinPrice: 10,
|
||||
effectiveAtMs: 1777766400000,
|
||||
effectiveFromMs: 0,
|
||||
effectiveToMs: 0,
|
||||
effectTypes: ["animation"],
|
||||
giftId: "rose",
|
||||
giftPointAmount: 1,
|
||||
giftTypeCode: "normal",
|
||||
heatValue: 1,
|
||||
name: "Rose",
|
||||
presentationJson: "{}",
|
||||
|
||||
@ -69,9 +69,14 @@ export interface GiftDto {
|
||||
sortOrder?: number;
|
||||
presentationJson?: string;
|
||||
priceVersion?: string;
|
||||
giftTypeCode?: string;
|
||||
chargeAssetType?: string;
|
||||
coinPrice?: number;
|
||||
giftPointAmount?: number;
|
||||
heatValue?: number;
|
||||
effectiveFromMs?: number;
|
||||
effectiveToMs?: number;
|
||||
effectTypes?: string[];
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
regionIds?: number[];
|
||||
@ -85,10 +90,15 @@ export interface GiftPayload {
|
||||
sortOrder: number;
|
||||
presentationJson: string;
|
||||
priceVersion: string;
|
||||
giftTypeCode: string;
|
||||
chargeAssetType: string;
|
||||
coinPrice: number;
|
||||
giftPointAmount: number;
|
||||
heatValue: number;
|
||||
effectiveAtMs: number;
|
||||
effectiveFromMs: number;
|
||||
effectiveToMs: number;
|
||||
effectTypes: string[];
|
||||
regionIds: number[];
|
||||
}
|
||||
|
||||
|
||||
@ -68,10 +68,15 @@ const emptyGroupWalletAssetItem = (walletAssetType) => ({
|
||||
walletAssetType,
|
||||
});
|
||||
const emptyGiftForm = (gift = {}) => ({
|
||||
chargeAssetType: gift.chargeAssetType || "COIN",
|
||||
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,
|
||||
giftId: gift.giftId || "",
|
||||
giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0",
|
||||
giftTypeCode: gift.giftTypeCode || "normal",
|
||||
heatValue: gift.heatValue === 0 || gift.heatValue ? String(gift.heatValue) : "0",
|
||||
name: gift.name || "",
|
||||
presentationJson: gift.presentationJson || "{}",
|
||||
@ -727,10 +732,15 @@ function buildResourceGroupPayload(form) {
|
||||
|
||||
function buildGiftPayload(form) {
|
||||
return {
|
||||
chargeAssetType: form.chargeAssetType,
|
||||
coinPrice: Number(form.coinPrice),
|
||||
effectiveAtMs: Date.now(),
|
||||
effectiveFromMs: datetimeLocalToMs(form.effectiveFrom),
|
||||
effectiveToMs: datetimeLocalToMs(form.effectiveTo),
|
||||
effectTypes: form.effectTypes || [],
|
||||
giftId: form.giftId.trim(),
|
||||
giftPointAmount: Number(form.giftPointAmount || 0),
|
||||
giftTypeCode: form.giftTypeCode,
|
||||
heatValue: Number(form.heatValue || 0),
|
||||
name: form.name.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) {
|
||||
const targetUserId = Number(form.targetUserId);
|
||||
const reason = form.reason.trim();
|
||||
|
||||
@ -1,14 +1,23 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||
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 IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Popover from "@mui/material/Popover";
|
||||
import Switch from "@mui/material/Switch";
|
||||
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 { 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 {
|
||||
AdminActionIconButton,
|
||||
AdminFilterSelect,
|
||||
@ -24,6 +33,30 @@ import { resourceStatusFilters } from "@/features/resources/constants.js";
|
||||
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||
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 = [
|
||||
{
|
||||
key: "gift",
|
||||
@ -39,9 +72,14 @@ const baseColumns = [
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "价格",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (gift) => formatNumber(gift.coinPrice)
|
||||
label: "类型 / 价格",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
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",
|
||||
@ -57,7 +95,18 @@ const baseColumns = [
|
||||
{
|
||||
key: "regions",
|
||||
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",
|
||||
@ -88,6 +137,12 @@ export function GiftListPage() {
|
||||
if (column.key === "regions") {
|
||||
return {
|
||||
...column,
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: giftRegionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId
|
||||
}),
|
||||
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />
|
||||
};
|
||||
}
|
||||
@ -113,7 +168,7 @@ export function GiftListPage() {
|
||||
<>
|
||||
<AdminSearchBox value={page.query} onChange={page.changeQuery} placeholder="搜索礼物名称、礼物 ID..." />
|
||||
<AdminFilterSelect options={resourceStatusFilters} value={page.status} onChange={page.changeStatus} />
|
||||
<RegionSelect
|
||||
<RegionFilterControl
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={giftRegionOptions}
|
||||
@ -132,7 +187,7 @@ export function GiftListPage() {
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<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 ? (
|
||||
<PaginationBar
|
||||
page={page.page}
|
||||
@ -190,132 +245,314 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={submitDisabled}
|
||||
title={mode === "edit" ? "编辑礼物" : "添加礼物"}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || mode === "edit"}
|
||||
label="礼物 ID"
|
||||
required
|
||||
value={form.giftId}
|
||||
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || page.resourceOptionsLoading}
|
||||
label="礼物资源"
|
||||
required
|
||||
select
|
||||
value={form.resourceId}
|
||||
onChange={(event) => page.setForm({ ...form, resourceId: event.target.value })}
|
||||
>
|
||||
{page.resourceOptions.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="价格"
|
||||
required
|
||||
type="number"
|
||||
value={form.coinPrice}
|
||||
onChange={(event) => page.setForm({ ...form, coinPrice: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="积分"
|
||||
type="number"
|
||||
value={form.giftPointAmount}
|
||||
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="热度"
|
||||
type="number"
|
||||
value={form.heatValue}
|
||||
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="价格版本"
|
||||
required
|
||||
value={form.priceVersion}
|
||||
onChange={(event) => page.setForm({ ...form, priceVersion: event.target.value })}
|
||||
/>
|
||||
<GiftRegionSelect
|
||||
disabled={disabled || page.loadingRegions}
|
||||
labels={buildRegionLabelMap(regionOptions)}
|
||||
options={regionOptions}
|
||||
value={form.regionIds}
|
||||
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="展示配置 JSON"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.presentationJson}
|
||||
onChange={(event) => page.setForm({ ...form, presentationJson: event.target.value })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
disabled={disabled}
|
||||
onChange={(event) => page.setForm({ ...form, enabled: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={form.enabled ? "启用" : "禁用"}
|
||||
/>
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物名称"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || mode === "edit"}
|
||||
label="礼物 ID"
|
||||
required
|
||||
value={form.giftId}
|
||||
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物类型"
|
||||
required
|
||||
select
|
||||
value={form.giftTypeCode}
|
||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||
>
|
||||
{giftTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled || page.resourceOptionsLoading}
|
||||
label="礼物资源"
|
||||
required
|
||||
select
|
||||
value={form.resourceId}
|
||||
onChange={(event) => page.setForm({ ...form, resourceId: event.target.value })}
|
||||
>
|
||||
{page.resourceOptions.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="收费类型"
|
||||
required
|
||||
select
|
||||
value={form.chargeAssetType}
|
||||
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
|
||||
>
|
||||
{chargeAssetOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="价格"
|
||||
required
|
||||
type="number"
|
||||
value={form.coinPrice}
|
||||
onChange={(event) => page.setForm({ ...form, coinPrice: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="积分"
|
||||
type="number"
|
||||
value={form.giftPointAmount}
|
||||
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="热度"
|
||||
type="number"
|
||||
value={form.heatValue}
|
||||
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
|
||||
/>
|
||||
<GiftRegionSelect
|
||||
disabled={disabled || page.loadingRegions}
|
||||
labels={buildRegionLabelMap(regionOptions)}
|
||||
options={regionOptions}
|
||||
value={form.regionIds}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const selected = value || [];
|
||||
const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort(
|
||||
(left, right) => Number(left) - Number(right)
|
||||
);
|
||||
return (
|
||||
<TextField
|
||||
<MultiValueAutocomplete
|
||||
disabled={disabled}
|
||||
label="区域"
|
||||
labels={labels}
|
||||
options={mergedOptions}
|
||||
required
|
||||
select
|
||||
SelectProps={{
|
||||
multiple: true,
|
||||
renderValue: (selectedValue) =>
|
||||
selectedValue.map((regionId) => labels[String(regionId)] || regionId).join("、")
|
||||
}}
|
||||
value={selected}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
onChange(typeof nextValue === "string" ? nextValue.split(",") : nextValue);
|
||||
}}
|
||||
>
|
||||
{mergedOptions.map((regionId) => (
|
||||
<MenuItem key={regionId} value={regionId}>
|
||||
{labels[String(regionId)] || regionId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GiftEffectSelect({ disabled, onChange, value }) {
|
||||
const selected = value || [];
|
||||
return (
|
||||
<MultiValueAutocomplete
|
||||
disabled={disabled}
|
||||
label="特效"
|
||||
labels={effectLabels}
|
||||
options={effectOptions.map(([optionValue]) => optionValue)}
|
||||
value={selected}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -325,9 +562,9 @@ function RegionTags({ regionIds = [], regionLabelById }) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.tagList}>
|
||||
<div className="admin-tag-list">
|
||||
{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>
|
||||
);
|
||||
@ -370,6 +607,94 @@ function formatNumber(value) {
|
||||
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 = []) {
|
||||
return options.reduce((acc, option) => {
|
||||
acc[option.value] = shortRegionLabel(option.label);
|
||||
|
||||
@ -344,13 +344,13 @@ function GroupItems({ group }) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.tagList}>
|
||||
<div className="admin-tag-list">
|
||||
{items.slice(0, 4).map((item) => {
|
||||
if (item.itemType === "wallet_asset") {
|
||||
const label = resourceGroupAssetLabels[item.walletAssetType] || item.walletAssetType || "钱包资产";
|
||||
return (
|
||||
<span
|
||||
className={styles.tag}
|
||||
className="admin-tag"
|
||||
key={item.groupItemId || `${item.walletAssetType}-${item.walletAssetAmount}`}
|
||||
>
|
||||
{label} {formatNumber(item.walletAssetAmount)}
|
||||
@ -360,12 +360,12 @@ function GroupItems({ group }) {
|
||||
const resource = item.resource || {};
|
||||
const type = resourceTypeLabels[resource.resourceType] || resource.resourceType || "资源";
|
||||
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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -293,9 +293,9 @@ function TagList({ values = [] }) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.tagList}>
|
||||
<div className="admin-tag-list">
|
||||
{items.map((value) => (
|
||||
<span className={styles.tag} key={value}>
|
||||
<span className="admin-tag" key={value}>
|
||||
{value}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@ -48,25 +48,87 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
.dateTimeField {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--neutral-surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: 1;
|
||||
padding: 5px 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.calendarTitle {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendarGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
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
|
||||
.object({
|
||||
chargeAssetType: z.enum(["COIN", "DIAMOND"]),
|
||||
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(),
|
||||
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
|
||||
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(),
|
||||
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
|
||||
presentationJson: z.string().trim().optional(),
|
||||
@ -128,6 +133,8 @@ export const giftFormSchema = z
|
||||
const coinPrice = Number(value.coinPrice);
|
||||
const giftPointAmount = Number(value.giftPointAmount || 0);
|
||||
const heatValue = Number(value.heatValue || 0);
|
||||
const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom);
|
||||
const effectiveToMs = datetimeLocalToMs(value.effectiveTo);
|
||||
|
||||
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
||||
context.addIssue({
|
||||
@ -157,6 +164,27 @@ export const giftFormSchema = z
|
||||
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) => {
|
||||
const normalizedRegionId = Number(regionId);
|
||||
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
|
||||
.object({
|
||||
durationDays: z.union([z.string(), z.number()]).optional(),
|
||||
|
||||
@ -49,10 +49,15 @@ describe("resource form schema", () => {
|
||||
|
||||
test("validates editable gift form fields", () => {
|
||||
const payload = parseForm(giftFormSchema, {
|
||||
chargeAssetType: "COIN",
|
||||
coinPrice: "10",
|
||||
effectTypes: ["animation"],
|
||||
effectiveFrom: "",
|
||||
effectiveTo: "",
|
||||
enabled: true,
|
||||
giftId: "rose",
|
||||
giftPointAmount: "1",
|
||||
giftTypeCode: "normal",
|
||||
heatValue: "2",
|
||||
name: "Rose",
|
||||
presentationJson: "{}",
|
||||
|
||||
@ -10,6 +10,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
||||
import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
|
||||
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||
@ -64,7 +65,17 @@ export function RoomListPage() {
|
||||
const total = page.data.total || 0;
|
||||
const tableColumns = [
|
||||
...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,
|
||||
render: (room) => (
|
||||
@ -93,7 +104,7 @@ export function RoomListPage() {
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
<RegionSelect
|
||||
<RegionFilterControl
|
||||
className={styles.regionInput}
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.regionInput {
|
||||
flex: 0 0 150px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.listBlock {
|
||||
|
||||
@ -24,6 +24,7 @@ export const API_OPERATIONS = {
|
||||
changePassword: "changePassword",
|
||||
closeAgency: "closeAgency",
|
||||
createAgency: "createAgency",
|
||||
createBanner: "createBanner",
|
||||
createBD: "createBD",
|
||||
createBDLeader: "createBDLeader",
|
||||
createCoinSeller: "createCoinSeller",
|
||||
@ -38,6 +39,7 @@ export const API_OPERATIONS = {
|
||||
createUser: "createUser",
|
||||
createUserExportJob: "createUserExportJob",
|
||||
dashboardOverview: "dashboardOverview",
|
||||
deleteBanner: "deleteBanner",
|
||||
deleteCountry: "deleteCountry",
|
||||
deleteMenu: "deleteMenu",
|
||||
deleteNotification: "deleteNotification",
|
||||
@ -68,6 +70,7 @@ export const API_OPERATIONS = {
|
||||
grantResourceGroup: "grantResourceGroup",
|
||||
listAgencies: "listAgencies",
|
||||
listApps: "listApps",
|
||||
listBanners: "listBanners",
|
||||
listBDLeaders: "listBDLeaders",
|
||||
listBDs: "listBDs",
|
||||
listCoinSellers: "listCoinSellers",
|
||||
@ -80,6 +83,7 @@ export const API_OPERATIONS = {
|
||||
listNotifications: "listNotifications",
|
||||
listOperationLogs: "listOperationLogs",
|
||||
listPermissions: "listPermissions",
|
||||
listRechargeBills: "listRechargeBills",
|
||||
listRegions: "listRegions",
|
||||
listResourceGrants: "listResourceGrants",
|
||||
listResourceGroups: "listResourceGroups",
|
||||
@ -106,6 +110,7 @@ export const API_OPERATIONS = {
|
||||
setCoinSellerStatus: "setCoinSellerStatus",
|
||||
sortMenus: "sortMenus",
|
||||
syncPermissions: "syncPermissions",
|
||||
updateBanner: "updateBanner",
|
||||
updateCountry: "updateCountry",
|
||||
updateGift: "updateGift",
|
||||
updateH5Links: "updateH5Links",
|
||||
@ -202,6 +207,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createBD,
|
||||
@ -300,6 +312,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteCountry,
|
||||
@ -508,6 +527,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
operationId: API_OPERATIONS.listApps,
|
||||
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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listBDLeaders,
|
||||
@ -592,6 +618,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "permission:view",
|
||||
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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listRegions,
|
||||
@ -762,6 +795,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "PATCH",
|
||||
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;
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -356,6 +388,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1840,6 +1888,58 @@ export interface operations {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2206,6 +2306,18 @@ export interface operations {
|
||||
200: components["responses"]["HostProfilePageResponse"];
|
||||
};
|
||||
};
|
||||
listRechargeBills: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRegions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -195,6 +195,27 @@ export interface ChangePasswordPayload {
|
||||
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 {
|
||||
createdAtMs?: number;
|
||||
createdByUserId?: number;
|
||||
@ -350,6 +371,34 @@ export interface H5LinkConfigUpdatePayload {
|
||||
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 {
|
||||
agencyId?: number;
|
||||
createdAtMs?: number;
|
||||
|
||||
@ -14,13 +14,15 @@ export function useRegionOptions() {
|
||||
});
|
||||
|
||||
const regionOptions = useMemo(() => {
|
||||
return (data?.items || []).map((region) => ({
|
||||
label: formatRegionLabel(region),
|
||||
name: region.name,
|
||||
regionCode: region.regionCode,
|
||||
regionId: region.regionId,
|
||||
value: String(region.regionId)
|
||||
}));
|
||||
return (data?.items || [])
|
||||
.filter((region) => region.regionCode !== "GLOBAL")
|
||||
.map((region) => ({
|
||||
label: formatRegionLabel(region),
|
||||
name: region.name,
|
||||
regionCode: region.regionCode,
|
||||
regionId: region.regionId,
|
||||
value: String(region.regionId)
|
||||
}));
|
||||
}, [data?.items]);
|
||||
|
||||
return { loadingRegions, regionOptions };
|
||||
|
||||
@ -16,11 +16,21 @@
|
||||
--admin-form-field-width: 220px;
|
||||
}
|
||||
|
||||
.narrow {
|
||||
--admin-form-dialog-width: 420px;
|
||||
--admin-form-field-width: 100%;
|
||||
}
|
||||
|
||||
.wide {
|
||||
--admin-form-dialog-width: 680px;
|
||||
--admin-form-field-width: 220px;
|
||||
}
|
||||
|
||||
.large {
|
||||
--admin-form-dialog-width: 760px;
|
||||
--admin-form-field-width: 240px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
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({
|
||||
className = "",
|
||||
columns,
|
||||
context,
|
||||
emptyLabel = "当前无数据",
|
||||
fixedEdges = true,
|
||||
getRowProps,
|
||||
items,
|
||||
minWidth = "980px",
|
||||
onColumnWidthsChange,
|
||||
resizableColumns = true,
|
||||
rowKey,
|
||||
tableClassName = "",
|
||||
title,
|
||||
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 (
|
||||
<section className={["table-frame", className].filter(Boolean).join(" ")}>
|
||||
@ -23,25 +135,38 @@ export function DataTable({
|
||||
) : null}
|
||||
<div className="table-scroll">
|
||||
<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 }}
|
||||
>
|
||||
<div className="admin-row admin-row--head">
|
||||
{columns.map((column) => (
|
||||
<div className={column.className || ""} key={column.key}>
|
||||
{column.header || column.label}
|
||||
{preparedColumns.map((column) => (
|
||||
<div
|
||||
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>
|
||||
{items.length ? (
|
||||
items.map((item, index) => {
|
||||
{safeItems.length ? (
|
||||
safeItems.map((item, index) => {
|
||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||
|
||||
return (
|
||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||
{columns.map((column) => (
|
||||
<div className={["admin-cell", column.className || ""].filter(Boolean).join(" ")} key={column.key}>
|
||||
{preparedColumns.map((column) => (
|
||||
<div className={cellClassName(column)} key={column.key}>
|
||||
{column.render ? column.render(item, index, context) : displayValue(item[column.key])}
|
||||
</div>
|
||||
))}
|
||||
@ -55,6 +180,43 @@ export function DataTable({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -62,3 +224,126 @@ export function DataTable({
|
||||
function displayValue(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 {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: var(--header-height) 1fr;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
@ -394,15 +397,20 @@
|
||||
.body-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition: grid-template-columns var(--motion-slow) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
z-index: var(--z-sidebar);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-sidebar);
|
||||
@ -442,6 +450,7 @@
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
overflow: visible;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
}
|
||||
|
||||
@ -454,7 +463,20 @@
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
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 {
|
||||
|
||||
@ -5,7 +5,15 @@
|
||||
|
||||
.app-shell,
|
||||
.body-grid {
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.nav-list {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
|
||||
@ -117,6 +117,24 @@
|
||||
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) {
|
||||
height: var(--control-height);
|
||||
min-height: var(--control-height);
|
||||
@ -239,7 +257,6 @@
|
||||
}
|
||||
|
||||
.MuiAutocomplete-root {
|
||||
--admin-autocomplete-chip-height: 28px;
|
||||
--admin-autocomplete-input-height: 24px;
|
||||
}
|
||||
|
||||
@ -269,16 +286,18 @@
|
||||
|
||||
.MuiAutocomplete-root .MuiAutocomplete-tag {
|
||||
max-width: calc(100% - var(--space-2));
|
||||
height: var(--admin-autocomplete-chip-height);
|
||||
height: var(--admin-tag-height);
|
||||
margin: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--neutral-surface-strong);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
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);
|
||||
}
|
||||
|
||||
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-label {
|
||||
padding: 0 var(--space-2);
|
||||
padding: 0 var(--admin-tag-padding-x);
|
||||
}
|
||||
|
||||
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-deleteIcon {
|
||||
@ -288,6 +307,32 @@
|
||||
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 {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
@ -360,6 +405,18 @@
|
||||
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 {
|
||||
@ -592,6 +649,17 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -850,11 +918,12 @@
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
--admin-table-cell-x: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
width: max(100%, var(--admin-table-min-width, 900px));
|
||||
min-height: 100%;
|
||||
min-width: var(--admin-table-min-width, 900px);
|
||||
min-width: max(100%, var(--admin-table-min-width, 900px));
|
||||
}
|
||||
|
||||
.admin-row {
|
||||
@ -863,10 +932,10 @@
|
||||
align-items: center;
|
||||
grid-template-columns: var(--admin-table-columns);
|
||||
min-height: var(--table-row-height);
|
||||
padding: 0 var(--space-5);
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--row-border);
|
||||
color: var(--text-secondary);
|
||||
column-gap: var(--space-4);
|
||||
column-gap: 0;
|
||||
font-size: var(--admin-font-size);
|
||||
animation: row-enter var(--motion-slow) var(--ease-emphasized) both;
|
||||
}
|
||||
@ -884,10 +953,176 @@
|
||||
}
|
||||
|
||||
.admin-cell {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: 0 var(--admin-table-cell-x);
|
||||
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 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
--admin-line-height: 20px;
|
||||
--control-height: 36px;
|
||||
--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-row-height: 58px;
|
||||
--switch-width: 46px;
|
||||
@ -77,6 +82,9 @@
|
||||
--neutral-surface: rgba(100, 116, 139, 0.1);
|
||||
--neutral-surface-strong: rgba(100, 116, 139, 0.16);
|
||||
--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-green: #16a34a;
|
||||
|
||||
15
src/theme.js
15
src/theme.js
@ -93,10 +93,13 @@ export const theme = createTheme({
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: token("status-height"),
|
||||
borderRadius: token("radius-pill"),
|
||||
fontSize: 14,
|
||||
backgroundColor: token("neutral-surface"),
|
||||
height: token("admin-tag-height"),
|
||||
border: `1px solid ${token("admin-tag-border")}`,
|
||||
borderRadius: token("admin-tag-radius"),
|
||||
backgroundColor: token("admin-tag-bg"),
|
||||
color: token("admin-tag-color"),
|
||||
fontSize: token("admin-tag-font-size"),
|
||||
fontWeight: token("admin-tag-font-weight"),
|
||||
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)"
|
||||
},
|
||||
@ -105,8 +108,8 @@ export const theme = createTheme({
|
||||
marginRight: -2
|
||||
},
|
||||
label: {
|
||||
paddingLeft: 8,
|
||||
paddingRight: 10
|
||||
paddingLeft: token("admin-tag-padding-x"),
|
||||
paddingRight: token("admin-tag-padding-x")
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user