完善主播侧

This commit is contained in:
z 2026-05-05 03:05:26 +08:00
parent f58c5d2e43
commit 3698b05159
51 changed files with 2721 additions and 255 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -6,6 +6,7 @@ export function useAppConfigAbilities() {
return {
canUpdate: can(PERMISSIONS.appConfigUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.appConfigView)
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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
}
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -56,7 +56,7 @@
}
.regionInput {
flex: 0 0 150px;
flex: 0 0 auto;
}
.listBlock {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -38,5 +38,7 @@ select:focus-visible {
}
#app {
height: 100vh;
height: 100dvh;
min-height: 100vh;
}

View File

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

View File

@ -5,7 +5,15 @@
.app-shell,
.body-grid {
height: auto;
min-height: auto;
overflow: visible;
}
.sidebar,
.nav-list {
height: auto;
overflow: visible;
}
.workspace {

View File

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

View File

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

View File

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