完善相关接口
This commit is contained in:
parent
d87cc49797
commit
f97aad449a
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,7 @@ const iconMap = {
|
|||||||
category: CategoryOutlined,
|
category: CategoryOutlined,
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
event_available: EventAvailableOutlined,
|
event_available: EventAvailableOutlined,
|
||||||
|
explore: MapOutlined,
|
||||||
gift: CardGiftcardOutlined,
|
gift: CardGiftcardOutlined,
|
||||||
inventory: Inventory2Outlined,
|
inventory: Inventory2Outlined,
|
||||||
leaderboard: LeaderboardOutlined,
|
leaderboard: LeaderboardOutlined,
|
||||||
@ -114,6 +115,7 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||||
|
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
||||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -146,6 +146,7 @@ export const MENU_CODES = {
|
|||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
appConfigH5: "app-config-h5",
|
appConfigH5: "app-config-h5",
|
||||||
appConfigBanners: "app-config-banners",
|
appConfigBanners: "app-config-banners",
|
||||||
|
appConfigExplore: "app-config-explore",
|
||||||
appConfigVersions: "app-config-versions",
|
appConfigVersions: "app-config-versions",
|
||||||
resources: "resources",
|
resources: "resources",
|
||||||
resourceList: "resource-list",
|
resourceList: "resource-list",
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import type {
|
|||||||
AppVersionDto,
|
AppVersionDto,
|
||||||
AppVersionPayload,
|
AppVersionPayload,
|
||||||
EntityId,
|
EntityId,
|
||||||
|
ExploreTabDto,
|
||||||
|
ExploreTabPayload,
|
||||||
H5LinkConfigDto,
|
H5LinkConfigDto,
|
||||||
|
H5LinkConfigPayload,
|
||||||
H5LinkConfigUpdatePayload,
|
H5LinkConfigUpdatePayload,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
@ -27,6 +30,60 @@ export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiLi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createH5Link;
|
||||||
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateH5Link(key: EntityId, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateH5Link;
|
||||||
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteH5Link;
|
||||||
|
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listExploreTabs(query: PageQuery = {}): Promise<ApiList<ExploreTabDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listExploreTabs;
|
||||||
|
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExploreTab(payload: ExploreTabPayload): Promise<ExploreTabDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createExploreTab;
|
||||||
|
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.createExploreTab), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExploreTab(tabId: EntityId, payload: ExploreTabPayload): Promise<ExploreTabDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateExploreTab;
|
||||||
|
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteExploreTab(tabId: EntityId): Promise<{ deleted: boolean }> {
|
||||||
|
const endpoint = API_ENDPOINTS.deleteExploreTab;
|
||||||
|
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteExploreTab, { tab_id: tabId }), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listBanners(query: PageQuery = {}): Promise<ApiList<AppBannerDto>> {
|
export function listBanners(query: PageQuery = {}): Promise<ApiList<AppBannerDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listBanners;
|
const endpoint = API_ENDPOINTS.listBanners;
|
||||||
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
||||||
|
|||||||
@ -27,3 +27,8 @@
|
|||||||
.formWideField {
|
.formWideField {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusCell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|||||||
171
src/features/app-config/hooks/useExploreConfigPage.js
Normal file
171
src/features/app-config/hooks/useExploreConfigPage.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { createExploreTab, deleteExploreTab, listExploreTabs, updateExploreTab } from "@/features/app-config/api";
|
||||||
|
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||||
|
import { exploreTabSchema } from "@/features/app-config/schema";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const emptyData = { items: [], total: 0 };
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
enabled: true,
|
||||||
|
h5Url: "",
|
||||||
|
sortOrder: "0",
|
||||||
|
tab: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useExploreConfigPage() {
|
||||||
|
const abilities = useAppConfigAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState("");
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [form, setFormState] = useState(emptyForm);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
|
||||||
|
const filters = useMemo(() => ({ enabled, keyword }), [enabled, keyword]);
|
||||||
|
const queryFn = useCallback(() => listExploreTabs(filters), [filters]);
|
||||||
|
const {
|
||||||
|
data = emptyData,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
} = useAdminQuery(queryFn, {
|
||||||
|
errorMessage: "加载 Explore 配置失败",
|
||||||
|
initialData: emptyData,
|
||||||
|
queryKey: ["app-config", "explore-tabs", filters],
|
||||||
|
});
|
||||||
|
|
||||||
|
const setForm = (patch) => {
|
||||||
|
setFormState((current) => ({ ...current, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormState(emptyForm());
|
||||||
|
setActiveAction("create");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setFormState(formFromExploreTab(item));
|
||||||
|
setActiveAction("edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setActiveAction("");
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormState(emptyForm());
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setKeyword("");
|
||||||
|
setEnabled("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitExploreTab = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = parseForm(exploreTabSchema, normalizeForm(form));
|
||||||
|
const action = editingItem ? "edit" : "create";
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateExploreTab(editingItem.id, payload);
|
||||||
|
showToast("Explore 配置已更新", "success");
|
||||||
|
} else {
|
||||||
|
await createExploreTab(payload);
|
||||||
|
showToast("Explore 配置已新增", "success");
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExploreTab = async (item, nextEnabled) => {
|
||||||
|
setLoadingAction(`toggle:${item.id}`);
|
||||||
|
try {
|
||||||
|
await updateExploreTab(item.id, {
|
||||||
|
enabled: nextEnabled,
|
||||||
|
h5Url: item.h5Url || "",
|
||||||
|
sortOrder: item.sortOrder || 0,
|
||||||
|
tab: item.tab || "",
|
||||||
|
});
|
||||||
|
await reload();
|
||||||
|
showToast(nextEnabled ? "Explore tab 已启用" : "Explore tab 已关闭", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExploreTab = async (item) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "删除",
|
||||||
|
message: item.tab || `Explore ${item.id}`,
|
||||||
|
title: "删除 Explore tab",
|
||||||
|
tone: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction(`delete:${item.id}`);
|
||||||
|
try {
|
||||||
|
await deleteExploreTab(item.id);
|
||||||
|
await reload();
|
||||||
|
showToast("Explore 配置已删除", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "删除失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
activeAction,
|
||||||
|
closeDialog,
|
||||||
|
data,
|
||||||
|
editingItem,
|
||||||
|
enabled,
|
||||||
|
error,
|
||||||
|
form,
|
||||||
|
keyword,
|
||||||
|
loading,
|
||||||
|
loadingAction,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
reload,
|
||||||
|
removeExploreTab,
|
||||||
|
resetFilters,
|
||||||
|
setEnabled,
|
||||||
|
setForm,
|
||||||
|
setKeyword,
|
||||||
|
submitExploreTab,
|
||||||
|
toggleExploreTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForm(form) {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
sortOrder: form.sortOrder || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromExploreTab(item) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(item.enabled),
|
||||||
|
h5Url: item.h5Url || "",
|
||||||
|
sortOrder: String(item.sortOrder || 0),
|
||||||
|
tab: item.tab || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,17 +1,20 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { listH5Links, updateH5Links } from "@/features/app-config/api";
|
import { createH5Link, deleteH5Link, listH5Links, updateH5Link } from "@/features/app-config/api";
|
||||||
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||||
import { h5LinkUpdateSchema } from "@/features/app-config/schema";
|
import { h5LinkUpdateSchema } from "@/features/app-config/schema";
|
||||||
|
|
||||||
const emptyData = { items: [], total: 0 };
|
const emptyData = { items: [], total: 0 };
|
||||||
const emptyForm = () => ({ url: "" });
|
const emptyForm = () => ({ key: "", label: "", url: "" });
|
||||||
|
|
||||||
export function useH5ConfigPage() {
|
export function useH5ConfigPage() {
|
||||||
const abilities = useAppConfigAbilities();
|
const abilities = useAppConfigAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
@ -22,12 +25,20 @@ export function useH5ConfigPage() {
|
|||||||
queryKey: ["app-config", "h5-links"]
|
queryKey: ["app-config", "h5-links"]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setActiveAction("create");
|
||||||
|
};
|
||||||
|
|
||||||
const openEdit = (item) => {
|
const openEdit = (item) => {
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
setForm({ url: item.url || "" });
|
setForm({ key: item.key || "", label: item.label || "", url: item.url || "" });
|
||||||
|
setActiveAction("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEdit = () => {
|
const closeEdit = () => {
|
||||||
|
setActiveAction("");
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setForm(emptyForm());
|
setForm(emptyForm());
|
||||||
};
|
};
|
||||||
@ -35,12 +46,24 @@ export function useH5ConfigPage() {
|
|||||||
const submitEdit = async (event) => {
|
const submitEdit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!editingItem) {
|
if (!editingItem) {
|
||||||
|
const payload = parseForm(h5LinkUpdateSchema, form);
|
||||||
|
setLoadingAction("create");
|
||||||
|
try {
|
||||||
|
await createH5Link(payload);
|
||||||
|
closeEdit();
|
||||||
|
await reload();
|
||||||
|
showToast("H5配置已新增", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = parseForm(h5LinkUpdateSchema, { key: editingItem.key, url: form.url });
|
const payload = parseForm(h5LinkUpdateSchema, { ...form, key: editingItem.key });
|
||||||
setLoadingAction("edit");
|
setLoadingAction("edit");
|
||||||
try {
|
try {
|
||||||
await updateH5Links({ items: [payload] });
|
await updateH5Link(editingItem.key, payload);
|
||||||
closeEdit();
|
closeEdit();
|
||||||
await reload();
|
await reload();
|
||||||
showToast("H5配置已更新", "success");
|
showToast("H5配置已更新", "success");
|
||||||
@ -51,7 +74,30 @@ export function useH5ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeH5Link = async (item) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "删除",
|
||||||
|
message: item.label || item.key,
|
||||||
|
title: "删除H5配置",
|
||||||
|
tone: "danger"
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction(`delete:${item.key}`);
|
||||||
|
try {
|
||||||
|
await deleteH5Link(item.key);
|
||||||
|
await reload();
|
||||||
|
showToast("H5配置已删除", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "删除失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activeAction,
|
||||||
abilities,
|
abilities,
|
||||||
closeEdit,
|
closeEdit,
|
||||||
data,
|
data,
|
||||||
@ -60,8 +106,10 @@ export function useH5ConfigPage() {
|
|||||||
form,
|
form,
|
||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
reload,
|
reload,
|
||||||
|
removeH5Link,
|
||||||
setForm,
|
setForm,
|
||||||
submitEdit
|
submitEdit
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useMemo } from "react";
|
|||||||
import { APP_BANNER_DISPLAY_SCOPE_OPTIONS } from "@/features/app-config/constants";
|
import { APP_BANNER_DISPLAY_SCOPE_OPTIONS } from "@/features/app-config/constants";
|
||||||
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
|
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js";
|
||||||
import styles from "@/features/app-config/app-config.module.css";
|
import styles from "@/features/app-config/app-config.module.css";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
|
||||||
import {
|
import {
|
||||||
AdminFormDialog,
|
AdminFormDialog,
|
||||||
AdminFormFieldGrid,
|
AdminFormFieldGrid,
|
||||||
@ -59,13 +58,9 @@ export function BannerConfigPage() {
|
|||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
actions={
|
actions={
|
||||||
page.abilities.canUpdate ? (
|
page.abilities.canUpdate ? (
|
||||||
<Button
|
<AdminActionIconButton label="新增BANNER" primary onClick={page.openCreate}>
|
||||||
startIcon={<AddOutlined fontSize="small" />}
|
<AddOutlined fontSize="small" />
|
||||||
variant="primary"
|
</AdminActionIconButton>
|
||||||
onClick={page.openCreate}
|
|
||||||
>
|
|
||||||
新增BANNER
|
|
||||||
</Button>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -73,14 +68,16 @@ export function BannerConfigPage() {
|
|||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
fixedEdges={false}
|
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1480px"
|
minWidth="1480px"
|
||||||
|
pagination={{
|
||||||
|
itemCount: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: items.length || 1,
|
||||||
|
total: page.data.total ?? items.length,
|
||||||
|
}}
|
||||||
rowKey={(item) => item.id}
|
rowKey={(item) => item.id}
|
||||||
/>
|
/>
|
||||||
<div className="pagination-bar">
|
|
||||||
<span>{items.length} 条</span>
|
|
||||||
</div>
|
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
|
||||||
|
|||||||
216
src/features/app-config/pages/ExploreConfigPage.jsx
Normal file
216
src/features/app-config/pages/ExploreConfigPage.jsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useExploreConfigPage } from "@/features/app-config/hooks/useExploreConfigPage.js";
|
||||||
|
import styles from "@/features/app-config/app-config.module.css";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import {
|
||||||
|
AdminFormDialog,
|
||||||
|
AdminFormFieldGrid,
|
||||||
|
AdminFormSection,
|
||||||
|
AdminFormSwitchField,
|
||||||
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
AdminRowActions,
|
||||||
|
AdminSearchBox,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const enabledOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["true", "启用"],
|
||||||
|
["false", "关闭"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ExploreConfigPage() {
|
||||||
|
const page = useExploreConfigPage();
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const columns = useMemo(() => exploreColumns(page), [page]);
|
||||||
|
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||||
|
新增 Explore tab
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminSearchBox
|
||||||
|
placeholder="搜索 tab / H5 链接"
|
||||||
|
value={page.keyword}
|
||||||
|
onChange={page.setKeyword}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="状态"
|
||||||
|
options={enabledOptions}
|
||||||
|
value={page.enabled}
|
||||||
|
onChange={page.setEnabled}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton disabled={!page.keyword && !page.enabled} onClick={page.resetFilters} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1080px"
|
||||||
|
pagination={{
|
||||||
|
itemCount: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: Math.max(items.length, 1),
|
||||||
|
total: page.data.total ?? items.length,
|
||||||
|
}}
|
||||||
|
rowKey={(item) => item.id}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={saving}
|
||||||
|
open={Boolean(page.activeAction)}
|
||||||
|
submitDisabled={!page.abilities.canUpdate || saving}
|
||||||
|
title={page.editingItem ? "编辑 Explore tab" : "新增 Explore tab"}
|
||||||
|
onClose={page.closeDialog}
|
||||||
|
onSubmit={page.submitExploreTab}
|
||||||
|
>
|
||||||
|
<AdminFormSection title="tab 配置">
|
||||||
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="tab"
|
||||||
|
required
|
||||||
|
value={page.form.tab}
|
||||||
|
onChange={(event) => page.setForm({ tab: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
inputProps={{ step: 1 }}
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={page.form.sortOrder}
|
||||||
|
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.formWideField}
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="H5 链接"
|
||||||
|
required
|
||||||
|
value={page.form.h5Url}
|
||||||
|
onChange={(event) => page.setForm({ h5Url: event.target.value })}
|
||||||
|
/>
|
||||||
|
<AdminFormSwitchField
|
||||||
|
checked={page.form.enabled}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="状态"
|
||||||
|
switchProps={{ inputProps: { "aria-label": "Explore tab 启用状态" } }}
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={(checked) => page.setForm({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
</AdminFormSection>
|
||||||
|
</AdminFormDialog>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exploreColumns(page) {
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
key: "tab",
|
||||||
|
label: "tab",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "h5Url",
|
||||||
|
label: "H5链接",
|
||||||
|
render: (item) => <span className={styles.linkText}>{item.h5Url || "-"}</span>,
|
||||||
|
width: "minmax(360px, 1.6fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enabled",
|
||||||
|
label: "状态",
|
||||||
|
render: (item) => <ExploreStatusSwitch item={item} page={page} />,
|
||||||
|
width: "minmax(120px, 0.5fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sortOrder",
|
||||||
|
label: "排序",
|
||||||
|
render: (item) => item.sortOrder ?? 0,
|
||||||
|
width: "minmax(100px, 0.4fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAtMs",
|
||||||
|
label: "更新时间",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs),
|
||||||
|
width: "minmax(170px, 0.7fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!page.abilities.canUpdate) {
|
||||||
|
return baseColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...baseColumns,
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
render: (item) => <ExploreRowActions item={item} page={page} />,
|
||||||
|
width: "minmax(124px, 0.5fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExploreStatusSwitch({ item, page }) {
|
||||||
|
const disabled = !page.abilities.canUpdate || page.loadingAction === `toggle:${item.id}`;
|
||||||
|
return (
|
||||||
|
<div className={styles.statusCell}>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={Boolean(item.enabled)}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ "aria-label": item.enabled ? "关闭 Explore tab" : "启用 Explore tab" }}
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={(event) => page.toggleExploreTab(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExploreRowActions({ item, page }) {
|
||||||
|
return (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
|
||||||
|
{item.h5Url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
label="删除"
|
||||||
|
disabled={page.loadingAction === `delete:${item.id}`}
|
||||||
|
onClick={() => page.removeExploreTab(item)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</AdminRowActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +1,18 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListPage,
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
AdminRowActions
|
AdminRowActions
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { useH5ConfigPage } from "@/features/app-config/hooks/useH5ConfigPage.js";
|
import { useH5ConfigPage } from "@/features/app-config/hooks/useH5ConfigPage.js";
|
||||||
@ -42,6 +46,7 @@ const baseColumns = [
|
|||||||
export function H5ConfigPage() {
|
export function H5ConfigPage() {
|
||||||
const page = useH5ConfigPage();
|
const page = useH5ConfigPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
|
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||||
const columns = page.abilities.canUpdate
|
const columns = page.abilities.canUpdate
|
||||||
? [
|
? [
|
||||||
...baseColumns,
|
...baseColumns,
|
||||||
@ -49,39 +54,73 @@ export function H5ConfigPage() {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item) => <H5LinkActions item={item} page={page} />,
|
render: (item) => <H5LinkActions item={item} page={page} />,
|
||||||
width: "minmax(88px, 0.4fr)"
|
width: "minmax(124px, 0.5fr)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: baseColumns;
|
: baseColumns;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
|
||||||
|
新增H5配置
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable columns={columns} items={items} minWidth="980px" rowKey={(item) => item.key} />
|
<DataTable
|
||||||
<div className="pagination-bar">
|
columns={columns}
|
||||||
<span>{items.length} 条</span>
|
items={items}
|
||||||
</div>
|
minWidth="980px"
|
||||||
|
pagination={{
|
||||||
|
itemCount: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: Math.max(items.length, 1),
|
||||||
|
total: page.data.total ?? items.length
|
||||||
|
}}
|
||||||
|
rowKey={(item) => item.key}
|
||||||
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={page.loadingAction === "edit"}
|
loading={saving}
|
||||||
open={Boolean(page.editingItem)}
|
open={Boolean(page.activeAction)}
|
||||||
size="compact"
|
submitDisabled={!page.abilities.canUpdate || saving}
|
||||||
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
|
title={page.editingItem ? "编辑H5配置" : "新增H5配置"}
|
||||||
title="编辑H5链接"
|
|
||||||
onClose={page.closeEdit}
|
onClose={page.closeEdit}
|
||||||
onSubmit={page.submitEdit}
|
onSubmit={page.submitEdit}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="链接信息">
|
<AdminFormSection title="链接信息">
|
||||||
<TextField
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
autoFocus
|
<TextField
|
||||||
disabled={!page.abilities.canUpdate}
|
autoFocus
|
||||||
label={page.editingItem?.label || "H5链接"}
|
disabled={!page.abilities.canUpdate}
|
||||||
value={page.form.url}
|
label="配置项"
|
||||||
onChange={(event) => page.setForm({ url: event.target.value })}
|
required
|
||||||
/>
|
value={page.form.label}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, label: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate || Boolean(page.editingItem)}
|
||||||
|
label="key"
|
||||||
|
required
|
||||||
|
value={page.form.key}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, key: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.formWideField}
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
label="H5链接"
|
||||||
|
required
|
||||||
|
value={page.form.url}
|
||||||
|
onChange={(event) => page.setForm({ ...page.form, url: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
@ -94,6 +133,13 @@ function H5LinkActions({ item, page }) {
|
|||||||
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
|
<AdminActionIconButton label="编辑" onClick={() => page.openEdit(item)}>
|
||||||
{item.url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
|
{item.url ? <LinkOutlined fontSize="small" /> : <EditOutlined fontSize="small" />}
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
label="删除"
|
||||||
|
disabled={page.loadingAction === `delete:${item.key}`}
|
||||||
|
onClick={() => page.removeH5Link(item)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,14 @@ export const appConfigRoutes = [
|
|||||||
path: "/app-config/banners",
|
path: "/app-config/banners",
|
||||||
permission: PERMISSIONS.appConfigView
|
permission: PERMISSIONS.appConfigView
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Explore配置",
|
||||||
|
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),
|
||||||
|
menuCode: MENU_CODES.appConfigExplore,
|
||||||
|
pageKey: "app-config-explore",
|
||||||
|
path: "/app-config/explore",
|
||||||
|
permission: PERMISSIONS.appConfigView
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "内购配置",
|
label: "内购配置",
|
||||||
loader: () =>
|
loader: () =>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { appBannerSchema } from "@/features/app-config/schema";
|
import { appBannerSchema, exploreTabSchema, h5LinkUpdateSchema } from "@/features/app-config/schema";
|
||||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
|
|
||||||
const validBannerForm = {
|
const validBannerForm = {
|
||||||
@ -19,6 +19,27 @@ const validBannerForm = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("app config form schema", () => {
|
describe("app config form schema", () => {
|
||||||
|
test("validates dynamic h5 config item", () => {
|
||||||
|
const payload = parseForm(h5LinkUpdateSchema, {
|
||||||
|
key: "host-center.v2",
|
||||||
|
label: "Host Center",
|
||||||
|
url: "https://h5.example.com/host",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.key).toBe("host-center.v2");
|
||||||
|
expect(payload.label).toBe("Host Center");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects dynamic h5 config key with spaces", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseForm(h5LinkUpdateSchema, {
|
||||||
|
key: "host center",
|
||||||
|
label: "Host Center",
|
||||||
|
url: "https://h5.example.com/host",
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps banner display scope and delivery time fields", () => {
|
test("keeps banner display scope and delivery time fields", () => {
|
||||||
const payload = parseForm(appBannerSchema, validBannerForm);
|
const payload = parseForm(appBannerSchema, validBannerForm);
|
||||||
|
|
||||||
@ -46,4 +67,27 @@ describe("app config form schema", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(FormValidationError);
|
).toThrow(FormValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates explore tab h5 url and sort order", () => {
|
||||||
|
const payload = parseForm(exploreTabSchema, {
|
||||||
|
enabled: true,
|
||||||
|
h5Url: "https://h5.example.com/explore/live",
|
||||||
|
sortOrder: "3",
|
||||||
|
tab: "Live",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.sortOrder).toBe(3);
|
||||||
|
expect(payload.h5Url).toBe("https://h5.example.com/explore/live");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects explore tab url with whitespace", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseForm(exploreTabSchema, {
|
||||||
|
enabled: true,
|
||||||
|
h5Url: "https://h5.example.com/explore live",
|
||||||
|
sortOrder: "0",
|
||||||
|
tab: "Live",
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,20 +1,36 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { H5_LINK_KEYS } from "@/features/app-config/constants";
|
|
||||||
|
|
||||||
const h5LinkURLSchema = z
|
const requiredH5LinkURLSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
|
.min(1, "请填写 H5 链接")
|
||||||
.max(2048, "H5 链接不能超过 2048 个字符")
|
.max(2048, "H5 链接不能超过 2048 个字符")
|
||||||
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符")
|
.refine((value) => !/\s/.test(value), "H5 链接不能包含空白字符");
|
||||||
.default("");
|
|
||||||
|
const h5LinkKeySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "请填写 key")
|
||||||
|
.max(80, "key 不能超过 80 个字符")
|
||||||
|
.regex(/^[A-Za-z0-9_.:-]+$/, "key 只能包含字母、数字、下划线、中横线、点和冒号");
|
||||||
|
|
||||||
export const h5LinkUpdateSchema = z.object({
|
export const h5LinkUpdateSchema = z.object({
|
||||||
key: z.enum(H5_LINK_KEYS),
|
key: h5LinkKeySchema,
|
||||||
url: h5LinkURLSchema,
|
label: z.string().trim().min(1, "请填写配置项").max(80, "配置项不能超过 80 个字符"),
|
||||||
|
url: requiredH5LinkURLSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
|
export type H5LinkUpdateForm = z.infer<typeof h5LinkUpdateSchema>;
|
||||||
|
|
||||||
|
export const exploreTabSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
h5Url: requiredH5LinkURLSchema,
|
||||||
|
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
|
||||||
|
tab: z.string().trim().min(1, "请填写 tab").max(40, "tab 不能超过 40 个字符"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExploreTabForm = z.infer<typeof exploreTabSchema>;
|
||||||
|
|
||||||
export const appBannerSchema = z
|
export const appBannerSchema = z
|
||||||
.object({
|
.object({
|
||||||
bannerType: z.enum(["h5", "app"]),
|
bannerType: z.enum(["h5", "app"]),
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export interface GameCatalogPage {
|
|||||||
nextCursor?: string;
|
nextCursor?: string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
serverTimeMs?: number;
|
serverTimeMs?: number;
|
||||||
|
total?: number;
|
||||||
|
totalPages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GamePlatformPage {
|
export interface GamePlatformPage {
|
||||||
|
|||||||
@ -31,8 +31,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta,
|
.meta {
|
||||||
.tags {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -40,10 +39,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.secretMeta {
|
.secretMeta {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
deleteGameCatalog,
|
deleteGameCatalog,
|
||||||
listGameCatalog,
|
listGameCatalog,
|
||||||
@ -57,9 +57,12 @@ export function useGamesPage() {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [platformCode, setPlatformCode] = useState("");
|
const [platformCode, setPlatformCode] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageCursors, setPageCursors] = useState({ 1: "" });
|
|
||||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||||
|
const [catalogItems, setCatalogItems] = useState([]);
|
||||||
|
const [catalogMeta, setCatalogMeta] = useState(emptyCatalog);
|
||||||
|
const [catalogError, setCatalogError] = useState("");
|
||||||
|
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||||
|
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [editingGameId, setEditingGameId] = useState("");
|
const [editingGameId, setEditingGameId] = useState("");
|
||||||
const [editingPlatformCode, setEditingPlatformCode] = useState("");
|
const [editingPlatformCode, setEditingPlatformCode] = useState("");
|
||||||
@ -69,6 +72,7 @@ export function useGamesPage() {
|
|||||||
const [syncPlatform, setSyncPlatform] = useState(null);
|
const [syncPlatform, setSyncPlatform] = useState(null);
|
||||||
const [syncCandidates, setSyncCandidates] = useState([]);
|
const [syncCandidates, setSyncCandidates] = useState([]);
|
||||||
const [selectedSyncGameIds, setSelectedSyncGameIds] = useState([]);
|
const [selectedSyncGameIds, setSelectedSyncGameIds] = useState([]);
|
||||||
|
const catalogRequestRef = useRef(0);
|
||||||
|
|
||||||
const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
|
const platformsQueryFn = useCallback(() => listGamePlatforms(), []);
|
||||||
const {
|
const {
|
||||||
@ -82,26 +86,52 @@ export function useGamesPage() {
|
|||||||
queryKey: ["games", "platforms"],
|
queryKey: ["games", "platforms"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogQueryFn = useCallback(
|
const loadCatalog = useCallback(
|
||||||
() => listGameCatalog({ cursor: pageCursors[page] || "", platformCode, status, pageSize }),
|
async (cursor = "", append = false) => {
|
||||||
[page, pageCursors, pageSize, platformCode, status],
|
const requestId = catalogRequestRef.current + 1;
|
||||||
|
catalogRequestRef.current = requestId;
|
||||||
|
if (append) {
|
||||||
|
setCatalogLoadingMore(true);
|
||||||
|
} else {
|
||||||
|
setCatalogLoading(true);
|
||||||
|
}
|
||||||
|
setCatalogError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await listGameCatalog({ cursor, platformCode, status, pageSize });
|
||||||
|
if (catalogRequestRef.current !== requestId) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
setCatalogMeta(result);
|
||||||
|
setCatalogItems((current) => (append ? mergeCatalogItems(current, result.items || []) : result.items || []));
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (catalogRequestRef.current === requestId) {
|
||||||
|
setCatalogError(err.message || "加载游戏列表失败");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (catalogRequestRef.current === requestId) {
|
||||||
|
setCatalogLoading(false);
|
||||||
|
setCatalogLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pageSize, platformCode, status],
|
||||||
);
|
);
|
||||||
const {
|
|
||||||
data = emptyCatalog,
|
useEffect(() => {
|
||||||
error,
|
loadCatalog("", false);
|
||||||
loading,
|
}, [loadCatalog]);
|
||||||
reload,
|
|
||||||
} = useAdminQuery(catalogQueryFn, {
|
|
||||||
errorMessage: "加载游戏列表失败",
|
|
||||||
initialData: emptyCatalog,
|
|
||||||
keepPreviousData: true,
|
|
||||||
queryKey: ["games", "catalog", platformCode, status, pageSize, page, pageCursors[page] || ""],
|
|
||||||
});
|
|
||||||
|
|
||||||
const platformOptions = useMemo(
|
const platformOptions = useMemo(
|
||||||
() => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]),
|
() => (platformData.items || []).map((platform) => [platform.platformCode, platform.platformName]),
|
||||||
[platformData.items],
|
[platformData.items],
|
||||||
);
|
);
|
||||||
|
const resolveGameURL = useCallback(
|
||||||
|
(game) => resolveGameURLFromPlatforms(platformData.items || [], game),
|
||||||
|
[platformData.items],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") {
|
if (!gameForm.platformCode && platformOptions.length > 0 && activeAction === "create-game") {
|
||||||
@ -110,8 +140,8 @@ export function useGamesPage() {
|
|||||||
}, [activeAction, gameForm.platformCode, platformOptions]);
|
}, [activeAction, gameForm.platformCode, platformOptions]);
|
||||||
|
|
||||||
const resetCatalogPage = () => {
|
const resetCatalogPage = () => {
|
||||||
setPage(1);
|
setCatalogItems([]);
|
||||||
setPageCursors({ 1: "" });
|
setCatalogMeta(emptyCatalog);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePlatformCode = (value) => {
|
const changePlatformCode = (value) => {
|
||||||
@ -129,17 +159,15 @@ export function useGamesPage() {
|
|||||||
resetCatalogPage();
|
resetCatalogPage();
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePage = (nextPage) => {
|
const loadNextPage = () => {
|
||||||
if (nextPage < 1 || nextPage === page) {
|
if (!catalogMeta.nextCursor || catalogLoading || catalogLoadingMore) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if (nextPage === page + 1) {
|
return loadCatalog(catalogMeta.nextCursor, true);
|
||||||
if (!data.nextCursor) {
|
};
|
||||||
return;
|
|
||||||
}
|
const reloadCatalog = () => {
|
||||||
setPageCursors((current) => ({ ...current, [nextPage]: data.nextCursor }));
|
return loadCatalog("", false);
|
||||||
}
|
|
||||||
setPage(nextPage);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@ -196,7 +224,7 @@ export function useGamesPage() {
|
|||||||
await upsertGameCatalog(payload, editingGameId);
|
await upsertGameCatalog(payload, editingGameId);
|
||||||
await saveGameUrl(platformData.items || [], payload, gameForm.gameUrl);
|
await saveGameUrl(platformData.items || [], payload, gameForm.gameUrl);
|
||||||
await reloadPlatforms();
|
await reloadPlatforms();
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
|
showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
|
||||||
closeAction();
|
closeAction();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -219,7 +247,7 @@ export function useGamesPage() {
|
|||||||
try {
|
try {
|
||||||
await upsertGamePlatform(payload, editingPlatformCode);
|
await upsertGamePlatform(payload, editingPlatformCode);
|
||||||
await reloadPlatforms();
|
await reloadPlatforms();
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success");
|
showToast(editingPlatformCode ? "平台已更新" : "平台已创建", "success");
|
||||||
closeAction();
|
closeAction();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -234,7 +262,7 @@ export function useGamesPage() {
|
|||||||
setLoadingAction(`status-${game.gameId}`);
|
setLoadingAction(`status-${game.gameId}`);
|
||||||
try {
|
try {
|
||||||
await setGameStatus(game.gameId, nextStatus);
|
await setGameStatus(game.gameId, nextStatus);
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
showToast("游戏状态已更新", "success");
|
showToast("游戏状态已更新", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "状态更新失败", "error");
|
showToast(err.message || "状态更新失败", "error");
|
||||||
@ -256,7 +284,7 @@ export function useGamesPage() {
|
|||||||
setLoadingAction(`delete-${game.gameId}`);
|
setLoadingAction(`delete-${game.gameId}`);
|
||||||
try {
|
try {
|
||||||
await deleteGameCatalog(game.gameId);
|
await deleteGameCatalog(game.gameId);
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
showToast("游戏已删除", "success");
|
showToast("游戏已删除", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "删除游戏失败", "error");
|
showToast(err.message || "删除游戏失败", "error");
|
||||||
@ -315,7 +343,7 @@ export function useGamesPage() {
|
|||||||
status: "disabled",
|
status: "disabled",
|
||||||
});
|
});
|
||||||
await reloadPlatforms();
|
await reloadPlatforms();
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
showToast(`已添加 ${result.synced} 个游戏,默认下架`, "success");
|
showToast(`已添加 ${result.synced} 个游戏,默认下架`, "success");
|
||||||
closeAction();
|
closeAction();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -336,7 +364,7 @@ export function useGamesPage() {
|
|||||||
status: "disabled",
|
status: "disabled",
|
||||||
});
|
});
|
||||||
await reloadPlatforms();
|
await reloadPlatforms();
|
||||||
await reload();
|
await reloadCatalog();
|
||||||
setSelectedSyncGameIds((current) => current.filter((item) => item !== game.providerGameId));
|
setSelectedSyncGameIds((current) => current.filter((item) => item !== game.providerGameId));
|
||||||
setSyncCandidates((current) => current.filter((item) => item.providerGameId !== game.providerGameId));
|
setSyncCandidates((current) => current.filter((item) => item.providerGameId !== game.providerGameId));
|
||||||
showToast("游戏已添加,默认下架", "success");
|
showToast("游戏已添加,默认下架", "success");
|
||||||
@ -354,12 +382,18 @@ export function useGamesPage() {
|
|||||||
changeGameStatus,
|
changeGameStatus,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
closeAction,
|
closeAction,
|
||||||
data,
|
data: {
|
||||||
error: error || platformError,
|
...catalogMeta,
|
||||||
|
items: catalogItems,
|
||||||
|
loadedCount: catalogItems.length,
|
||||||
|
loadedPages: Math.max(1, Math.ceil(catalogItems.length / pageSize)),
|
||||||
|
},
|
||||||
|
error: catalogError || platformError,
|
||||||
gameForm,
|
gameForm,
|
||||||
loading: loading || platformsLoading,
|
loading: (catalogLoading && catalogItems.length === 0) || platformsLoading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
page,
|
loadingMore: catalogLoadingMore || (catalogLoading && catalogItems.length > 0),
|
||||||
|
page: Math.max(1, Math.ceil(catalogItems.length / pageSize)),
|
||||||
fetchPlatformGames,
|
fetchPlatformGames,
|
||||||
importSelectedSyncGames,
|
importSelectedSyncGames,
|
||||||
importSingleSyncGame,
|
importSingleSyncGame,
|
||||||
@ -373,9 +407,10 @@ export function useGamesPage() {
|
|||||||
platformData,
|
platformData,
|
||||||
platformForm,
|
platformForm,
|
||||||
platformOptions,
|
platformOptions,
|
||||||
reload,
|
loadNextPage,
|
||||||
|
reload: reloadCatalog,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
changePage,
|
resolveGameURL,
|
||||||
changePageSize,
|
changePageSize,
|
||||||
setGameForm,
|
setGameForm,
|
||||||
setPlatformCode: changePlatformCode,
|
setPlatformCode: changePlatformCode,
|
||||||
@ -392,6 +427,18 @@ export function useGamesPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeCatalogItems(current, nextItems) {
|
||||||
|
const seen = new Set();
|
||||||
|
return [...current, ...nextItems].filter((item) => {
|
||||||
|
const key = item.gameId || `${item.platformCode}:${item.providerGameId}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function saveGameUrl(platforms, game, gameUrl) {
|
async function saveGameUrl(platforms, game, gameUrl) {
|
||||||
const platform = platforms.find((item) => item.platformCode === game.platformCode);
|
const platform = platforms.find((item) => item.platformCode === game.platformCode);
|
||||||
if (!platform) {
|
if (!platform) {
|
||||||
|
|||||||
@ -168,16 +168,24 @@ export function GameListPage() {
|
|||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
infiniteScroll={{
|
||||||
|
doneLabel: "",
|
||||||
|
hasMore: Boolean(page.data.nextCursor),
|
||||||
|
loading: page.loadingMore,
|
||||||
|
loadingLabel: "加载中...",
|
||||||
|
onLoadMore: page.loadNextPage,
|
||||||
|
}}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1220px"
|
minWidth="1240px"
|
||||||
pagination={{
|
pagination={{
|
||||||
hasNextPage: Boolean(page.data.nextCursor),
|
hasNextPage: Boolean(page.data.nextCursor),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
onPageChange: page.changePage,
|
|
||||||
onPageSizeChange: page.changePageSize,
|
onPageSizeChange: page.changePageSize,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
pageSize: page.pageSize,
|
pageSize: page.pageSize,
|
||||||
pageSizeOptions: [20, 50, 100],
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
total: page.data.total,
|
||||||
|
totalPages: page.data.totalPages,
|
||||||
}}
|
}}
|
||||||
rowKey={(game) => game.gameId}
|
rowKey={(game) => game.gameId}
|
||||||
/>
|
/>
|
||||||
@ -222,7 +230,6 @@ function GameIdentity({ game }) {
|
|||||||
<div className={styles.identityText}>
|
<div className={styles.identityText}>
|
||||||
<span className={styles.name}>{game.gameName || game.gameId}</span>
|
<span className={styles.name}>{game.gameName || game.gameId}</span>
|
||||||
<span className={styles.meta}>{game.gameId}</span>
|
<span className={styles.meta}>{game.gameId}</span>
|
||||||
<span className={styles.tags}>{(game.tags || []).slice(0, 3).join(" / ")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
|
||||||
|
|
||||||
export interface LuckyGiftTierDto {
|
export interface LuckyGiftTierDto {
|
||||||
pool: string;
|
pool: string;
|
||||||
@ -99,32 +98,26 @@ export interface LuckyGiftConfigPayload {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LuckyGiftDrawDto {
|
export interface LuckyGiftDrawSummaryDto {
|
||||||
drawId: string;
|
|
||||||
commandId?: string;
|
|
||||||
poolId: string;
|
poolId: string;
|
||||||
giftId: string;
|
totalDraws: number;
|
||||||
ruleVersion: number;
|
uniqueUsers: number;
|
||||||
experiencePool: string;
|
uniqueRooms: number;
|
||||||
selectedTierId: string;
|
totalSpentCoins: number;
|
||||||
|
totalRewardCoins: number;
|
||||||
baseRewardCoins: number;
|
baseRewardCoins: number;
|
||||||
roomAtmosphereRewardCoins: number;
|
roomAtmosphereRewardCoins: number;
|
||||||
activitySubsidyCoins: number;
|
activitySubsidyCoins: number;
|
||||||
effectiveRewardCoins: number;
|
actualRTPPPM: number;
|
||||||
budgetSourcesJson?: string;
|
pendingDraws: number;
|
||||||
rewardStatus?: string;
|
grantedDraws: number;
|
||||||
rtpWindowIndex: number;
|
failedDraws: number;
|
||||||
giftRTPWindowIndex: number;
|
|
||||||
globalBaseRTPPPM: number;
|
|
||||||
giftBaseRTPPPM: number;
|
|
||||||
stageFeedback: boolean;
|
|
||||||
highMultiplier: boolean;
|
|
||||||
createdAtMs?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawConfig = LuckyGiftConfigDto & Record<string, unknown>;
|
type RawConfig = LuckyGiftConfigDto & Record<string, unknown>;
|
||||||
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
|
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
|
||||||
type RawDraw = LuckyGiftDrawDto & Record<string, unknown>;
|
type RawSummary = LuckyGiftDrawSummaryDto & Record<string, unknown>;
|
||||||
|
type LuckyGiftDrawSummaryQuery = Record<string, number | string | undefined>;
|
||||||
|
|
||||||
export function getLuckyGiftConfig(poolId: string): Promise<LuckyGiftConfigDto> {
|
export function getLuckyGiftConfig(poolId: string): Promise<LuckyGiftConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
|
const endpoint = API_ENDPOINTS.getLuckyGiftConfig;
|
||||||
@ -143,25 +136,19 @@ export function listLuckyGiftConfigs(): Promise<LuckyGiftConfigDto[]> {
|
|||||||
|
|
||||||
export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise<LuckyGiftConfigDto> {
|
export function updateLuckyGiftConfig(payload: LuckyGiftConfigPayload): Promise<LuckyGiftConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig;
|
const endpoint = API_ENDPOINTS.upsertLuckyGiftConfig;
|
||||||
return apiRequest<RawConfig, LuckyGiftConfigPayload>(
|
return apiRequest<RawConfig, LuckyGiftConfigPayload>(apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig), {
|
||||||
apiEndpointPath(API_OPERATIONS.upsertLuckyGiftConfig),
|
body: payload,
|
||||||
{
|
method: endpoint.method,
|
||||||
body: payload,
|
query: { pool_id: payload.pool_id },
|
||||||
method: endpoint.method,
|
}).then(normalizeConfig);
|
||||||
query: { pool_id: payload.pool_id },
|
|
||||||
},
|
|
||||||
).then(normalizeConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listLuckyGiftDraws(query: PageQuery = {}): Promise<ApiPage<LuckyGiftDrawDto>> {
|
export function getLuckyGiftDrawSummary(query: LuckyGiftDrawSummaryQuery = {}): Promise<LuckyGiftDrawSummaryDto> {
|
||||||
const endpoint = API_ENDPOINTS.listLuckyGiftDraws;
|
const endpoint = API_ENDPOINTS.getLuckyGiftDrawSummary;
|
||||||
return apiRequest<ApiPage<RawDraw>>(apiEndpointPath(API_OPERATIONS.listLuckyGiftDraws), {
|
return apiRequest<RawSummary>(apiEndpointPath(API_OPERATIONS.getLuckyGiftDrawSummary), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
query,
|
||||||
}).then((page) => ({
|
}).then(normalizeSummary);
|
||||||
...page,
|
|
||||||
items: (page.items || []).map(normalizeDraw),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
|
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
|
||||||
@ -221,30 +208,21 @@ function normalizeTier(raw: unknown): LuckyGiftTierDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraw(item: RawDraw): LuckyGiftDrawDto {
|
function normalizeSummary(item: RawSummary): LuckyGiftDrawSummaryDto {
|
||||||
return {
|
return {
|
||||||
drawId: stringValue(item.drawId ?? item.draw_id),
|
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
|
||||||
poolId: stringValue(item.poolId ?? item.pool_id),
|
poolId: stringValue(item.poolId ?? item.pool_id),
|
||||||
giftId: stringValue(item.giftId ?? item.gift_id),
|
totalDraws: numberValue(item.totalDraws ?? item.total_draws),
|
||||||
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
|
uniqueUsers: numberValue(item.uniqueUsers ?? item.unique_users),
|
||||||
experiencePool: stringValue(item.experiencePool ?? item.experience_pool),
|
uniqueRooms: numberValue(item.uniqueRooms ?? item.unique_rooms),
|
||||||
selectedTierId: stringValue(item.selectedTierId ?? item.selected_tier_id),
|
totalSpentCoins: numberValue(item.totalSpentCoins ?? item.total_spent_coins),
|
||||||
|
totalRewardCoins: numberValue(item.totalRewardCoins ?? item.total_reward_coins),
|
||||||
baseRewardCoins: numberValue(item.baseRewardCoins ?? item.base_reward_coins),
|
baseRewardCoins: numberValue(item.baseRewardCoins ?? item.base_reward_coins),
|
||||||
roomAtmosphereRewardCoins: numberValue(
|
roomAtmosphereRewardCoins: numberValue(item.roomAtmosphereRewardCoins ?? item.room_atmosphere_reward_coins),
|
||||||
item.roomAtmosphereRewardCoins ?? item.room_atmosphere_reward_coins,
|
|
||||||
),
|
|
||||||
activitySubsidyCoins: numberValue(item.activitySubsidyCoins ?? item.activity_subsidy_coins),
|
activitySubsidyCoins: numberValue(item.activitySubsidyCoins ?? item.activity_subsidy_coins),
|
||||||
effectiveRewardCoins: numberValue(item.effectiveRewardCoins ?? item.effective_reward_coins),
|
actualRTPPPM: numberValue(item.actualRTPPPM ?? item.actual_rtp_ppm),
|
||||||
budgetSourcesJson: stringValue(item.budgetSourcesJson ?? item.budget_sources_json),
|
pendingDraws: numberValue(item.pendingDraws ?? item.pending_draws),
|
||||||
rewardStatus: stringValue(item.rewardStatus ?? item.reward_status),
|
grantedDraws: numberValue(item.grantedDraws ?? item.granted_draws),
|
||||||
rtpWindowIndex: numberValue(item.rtpWindowIndex ?? item.rtp_window_index),
|
failedDraws: numberValue(item.failedDraws ?? item.failed_draws),
|
||||||
giftRTPWindowIndex: numberValue(item.giftRTPWindowIndex ?? item.gift_rtp_window_index),
|
|
||||||
globalBaseRTPPPM: numberValue(item.globalBaseRTPPPM ?? item.global_base_rtp_ppm),
|
|
||||||
giftBaseRTPPPM: numberValue(item.giftBaseRTPPPM ?? item.gift_base_rtp_ppm),
|
|
||||||
stageFeedback: booleanValue(item.stageFeedback ?? item.stage_feedback),
|
|
||||||
highMultiplier: booleanValue(item.highMultiplier ?? item.high_multiplier),
|
|
||||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||||
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
||||||
|
import ExpandLessOutlined from "@mui/icons-material/ExpandLessOutlined";
|
||||||
|
import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
@ -26,6 +28,7 @@ const defaultInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LuckyGiftSimulationPanel({ config }) {
|
export function LuckyGiftSimulationPanel({ config }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [input, setInput] = useState(defaultInput);
|
const [input, setInput] = useState(defaultInput);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [csvUrl, setCsvUrl] = useState("");
|
const [csvUrl, setCsvUrl] = useState("");
|
||||||
@ -59,61 +62,89 @@ export function LuckyGiftSimulationPanel({ config }) {
|
|||||||
<span className={styles.title}>随机模拟测试</span>
|
<span className={styles.title}>随机模拟测试</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.simulationActions}>
|
<div className={styles.simulationActions}>
|
||||||
<Button startIcon={<PlayArrowOutlined fontSize="small" />} type="button" variant="primary" onClick={runSimulation}>
|
{expanded ? (
|
||||||
测试
|
<>
|
||||||
</Button>
|
<Button
|
||||||
{csvUrl ? (
|
startIcon={<PlayArrowOutlined fontSize="small" />}
|
||||||
<Button component="a" download={csvFileName} href={csvUrl} startIcon={<DownloadOutlined fontSize="small" />}>
|
type="button"
|
||||||
CSV
|
variant="primary"
|
||||||
</Button>
|
onClick={runSimulation}
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
{csvUrl ? (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
download={csvFileName}
|
||||||
|
href={csvUrl}
|
||||||
|
startIcon={<DownloadOutlined fontSize="small" />}
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
aria-expanded={expanded}
|
||||||
|
endIcon={
|
||||||
|
expanded ? <ExpandLessOutlined fontSize="small" /> : <ExpandMoreOutlined fontSize="small" />
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
{expanded ? "收起" : "展开"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.simulationGrid}>
|
{expanded ? (
|
||||||
<NumberField
|
<>
|
||||||
help={simulationHelp.users}
|
<div className={styles.simulationGrid}>
|
||||||
label="用户数量"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.users}
|
||||||
value={input.users}
|
label="用户数量"
|
||||||
onChange={(value) => updateInput(setInput, "users", value)}
|
min={1}
|
||||||
/>
|
value={input.users}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "users", value)}
|
||||||
help={simulationHelp.rooms}
|
/>
|
||||||
label="房间数量"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.rooms}
|
||||||
value={input.rooms}
|
label="房间数量"
|
||||||
onChange={(value) => updateInput(setInput, "rooms", value)}
|
min={1}
|
||||||
/>
|
value={input.rooms}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "rooms", value)}
|
||||||
help={simulationHelp.costMin}
|
/>
|
||||||
label="单抽花费最小值"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.costMin}
|
||||||
value={input.costMin}
|
label="单抽花费最小值"
|
||||||
onChange={(value) => updateInput(setInput, "costMin", value)}
|
min={1}
|
||||||
/>
|
value={input.costMin}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "costMin", value)}
|
||||||
help={simulationHelp.costMax}
|
/>
|
||||||
label="单抽花费最大值"
|
<NumberField
|
||||||
min={1}
|
help={simulationHelp.costMax}
|
||||||
value={input.costMax}
|
label="单抽花费最大值"
|
||||||
onChange={(value) => updateInput(setInput, "costMax", value)}
|
min={1}
|
||||||
/>
|
value={input.costMax}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "costMax", value)}
|
||||||
help={simulationHelp.drawsMin}
|
/>
|
||||||
label="抽奖次数最小值"
|
<NumberField
|
||||||
min={0}
|
help={simulationHelp.drawsMin}
|
||||||
value={input.drawsMin}
|
label="抽奖次数最小值"
|
||||||
onChange={(value) => updateInput(setInput, "drawsMin", value)}
|
min={0}
|
||||||
/>
|
value={input.drawsMin}
|
||||||
<NumberField
|
onChange={(value) => updateInput(setInput, "drawsMin", value)}
|
||||||
help={simulationHelp.drawsMax}
|
/>
|
||||||
label="抽奖次数最大值"
|
<NumberField
|
||||||
min={0}
|
help={simulationHelp.drawsMax}
|
||||||
value={input.drawsMax}
|
label="抽奖次数最大值"
|
||||||
onChange={(value) => updateInput(setInput, "drawsMax", value)}
|
min={0}
|
||||||
/>
|
value={input.drawsMax}
|
||||||
</div>
|
onChange={(value) => updateInput(setInput, "drawsMax", value)}
|
||||||
{result ? <SimulationOverview overview={result.overview} /> : null}
|
/>
|
||||||
|
</div>
|
||||||
|
{result ? <SimulationOverview overview={result.overview} /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,32 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { getLuckyGiftConfig, listLuckyGiftConfigs, listLuckyGiftDraws, updateLuckyGiftConfig } from "@/features/lucky-gift/api";
|
import {
|
||||||
|
getLuckyGiftConfig,
|
||||||
|
getLuckyGiftDrawSummary,
|
||||||
|
listLuckyGiftConfigs,
|
||||||
|
updateLuckyGiftConfig,
|
||||||
|
} from "@/features/lucky-gift/api";
|
||||||
import { emptyLuckyGiftForm, formFromConfig, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
|
import { emptyLuckyGiftForm, formFromConfig, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js";
|
||||||
import { useLuckyGiftAbilities } from "@/features/lucky-gift/permissions.js";
|
import { useLuckyGiftAbilities } from "@/features/lucky-gift/permissions.js";
|
||||||
import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema";
|
import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema";
|
||||||
|
|
||||||
const pageSize = 10;
|
|
||||||
const emptyDraws = { items: [], page: 1, pageSize, total: 0 };
|
|
||||||
const defaultPoolId = "default";
|
const defaultPoolId = "default";
|
||||||
|
const emptySummary = {
|
||||||
|
actualRTPPPM: 0,
|
||||||
|
activitySubsidyCoins: 0,
|
||||||
|
baseRewardCoins: 0,
|
||||||
|
failedDraws: 0,
|
||||||
|
grantedDraws: 0,
|
||||||
|
pendingDraws: 0,
|
||||||
|
poolId: defaultPoolId,
|
||||||
|
roomAtmosphereRewardCoins: 0,
|
||||||
|
totalDraws: 0,
|
||||||
|
totalRewardCoins: 0,
|
||||||
|
totalSpentCoins: 0,
|
||||||
|
uniqueRooms: 0,
|
||||||
|
uniqueUsers: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export function useLuckyGiftPage() {
|
export function useLuckyGiftPage() {
|
||||||
const abilities = useLuckyGiftAbilities();
|
const abilities = useLuckyGiftAbilities();
|
||||||
@ -27,25 +44,14 @@ export function useLuckyGiftPage() {
|
|||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [roomId, setRoomId] = useState("");
|
const [roomId, setRoomId] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [drawSummary, setDrawSummary] = useState(emptySummary);
|
||||||
|
const [drawSummaryLoading, setDrawSummaryLoading] = useState(false);
|
||||||
|
const [drawSummaryError, setDrawSummaryError] = useState("");
|
||||||
|
|
||||||
const drawFilters = useMemo(
|
const drawFilters = useMemo(
|
||||||
() => ({ gift_id: giftId, pool_id: poolId, room_id: roomId, status, user_id: userId }),
|
() => ({ gift_id: giftId, pool_id: poolId, room_id: roomId, status, user_id: userId }),
|
||||||
[giftId, poolId, roomId, status, userId],
|
[giftId, poolId, roomId, status, userId],
|
||||||
);
|
);
|
||||||
const {
|
|
||||||
data: draws = emptyDraws,
|
|
||||||
error: drawsError,
|
|
||||||
loading: drawsLoading,
|
|
||||||
reload: reloadDraws,
|
|
||||||
} = usePaginatedQuery({
|
|
||||||
errorMessage: "加载幸运礼物记录失败",
|
|
||||||
fetcher: listLuckyGiftDraws,
|
|
||||||
filters: drawFilters,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
queryKey: ["lucky-gift-draws", drawFilters, page],
|
|
||||||
});
|
|
||||||
const poolOptions = useMemo(() => {
|
const poolOptions = useMemo(() => {
|
||||||
const ids = new Set([poolId]);
|
const ids = new Set([poolId]);
|
||||||
for (const item of configs) {
|
for (const item of configs) {
|
||||||
@ -56,6 +62,23 @@ export function useLuckyGiftPage() {
|
|||||||
return Array.from(ids);
|
return Array.from(ids);
|
||||||
}, [configs, poolId]);
|
}, [configs, poolId]);
|
||||||
|
|
||||||
|
const reloadDrawSummary = useCallback(async () => {
|
||||||
|
setDrawSummaryLoading(true);
|
||||||
|
setDrawSummaryError("");
|
||||||
|
try {
|
||||||
|
const summary = await getLuckyGiftDrawSummary(drawFilters);
|
||||||
|
setDrawSummary(summary);
|
||||||
|
} catch (err) {
|
||||||
|
setDrawSummaryError(err.message || "加载幸运礼物抽奖汇总失败");
|
||||||
|
} finally {
|
||||||
|
setDrawSummaryLoading(false);
|
||||||
|
}
|
||||||
|
}, [drawFilters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadDrawSummary();
|
||||||
|
}, [reloadDrawSummary]);
|
||||||
|
|
||||||
const reloadConfigs = useCallback(async () => {
|
const reloadConfigs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const nextConfigs = await listLuckyGiftConfigs();
|
const nextConfigs = await listLuckyGiftConfigs();
|
||||||
@ -111,7 +134,7 @@ export function useLuckyGiftPage() {
|
|||||||
setPoolId(saved.poolId || poolId);
|
setPoolId(saved.poolId || poolId);
|
||||||
setConfigDrawerOpen(false);
|
setConfigDrawerOpen(false);
|
||||||
showToast("幸运礼物配置已保存", "success");
|
showToast("幸运礼物配置已保存", "success");
|
||||||
await reloadDraws();
|
await reloadDrawSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存幸运礼物配置失败", "error");
|
showToast(err.message || "保存幸运礼物配置失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
@ -121,11 +144,9 @@ export function useLuckyGiftPage() {
|
|||||||
|
|
||||||
const changeGiftId = (value) => {
|
const changeGiftId = (value) => {
|
||||||
setGiftId(value);
|
setGiftId(value);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const changePoolId = (value) => {
|
const changePoolId = (value) => {
|
||||||
setPoolId(value.trim() || defaultPoolId);
|
setPoolId(value.trim() || defaultPoolId);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const openAddPool = () => {
|
const openAddPool = () => {
|
||||||
setAddPoolId("");
|
setAddPoolId("");
|
||||||
@ -148,26 +169,21 @@ export function useLuckyGiftPage() {
|
|||||||
setAddPoolOpen(false);
|
setAddPoolOpen(false);
|
||||||
setAddPoolId("");
|
setAddPoolId("");
|
||||||
setConfigDrawerOpen(true);
|
setConfigDrawerOpen(true);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const changeUserId = (value) => {
|
const changeUserId = (value) => {
|
||||||
setUserId(value);
|
setUserId(value);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const changeRoomId = (value) => {
|
const changeRoomId = (value) => {
|
||||||
setRoomId(value);
|
setRoomId(value);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const changeStatus = (value) => {
|
const changeStatus = (value) => {
|
||||||
setStatus(value);
|
setStatus(value);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const resetDrawFilters = () => {
|
const resetDrawFilters = () => {
|
||||||
setGiftId("");
|
setGiftId("");
|
||||||
setUserId("");
|
setUserId("");
|
||||||
setRoomId("");
|
setRoomId("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -185,24 +201,22 @@ export function useLuckyGiftPage() {
|
|||||||
configDrawerOpen,
|
configDrawerOpen,
|
||||||
configLoading,
|
configLoading,
|
||||||
configSaving,
|
configSaving,
|
||||||
draws,
|
drawSummary,
|
||||||
drawsError,
|
drawSummaryError,
|
||||||
drawsLoading,
|
drawSummaryLoading,
|
||||||
form,
|
form,
|
||||||
giftId,
|
giftId,
|
||||||
openConfigDrawer,
|
openConfigDrawer,
|
||||||
openAddPool,
|
openAddPool,
|
||||||
page,
|
|
||||||
poolId,
|
poolId,
|
||||||
poolOptions,
|
poolOptions,
|
||||||
reloadConfig,
|
reloadConfig,
|
||||||
reloadConfigs,
|
reloadConfigs,
|
||||||
reloadDraws,
|
reloadDrawSummary,
|
||||||
resetDrawFilters,
|
resetDrawFilters,
|
||||||
roomId,
|
roomId,
|
||||||
setForm,
|
setForm,
|
||||||
setAddPoolId,
|
setAddPoolId,
|
||||||
setPage,
|
|
||||||
status,
|
status,
|
||||||
submitConfig,
|
submitConfig,
|
||||||
submitAddPool,
|
submitAddPool,
|
||||||
|
|||||||
@ -237,6 +237,52 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawSummaryPanel {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawSummaryHeader {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawSummaryFilters {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawSummaryGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryError {
|
||||||
|
display: flex;
|
||||||
|
min-height: 120px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
.metricItem {
|
.metricItem {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -264,6 +310,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metricItem small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -306,8 +361,14 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawSummaryHeader {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.simulationGrid,
|
.simulationGrid,
|
||||||
.simulationOverview,
|
.simulationOverview,
|
||||||
|
.drawSummaryGrid,
|
||||||
.multiplierList {
|
.multiplierList {
|
||||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
}
|
}
|
||||||
@ -317,7 +378,8 @@
|
|||||||
.configGrid,
|
.configGrid,
|
||||||
.subsidyInputs,
|
.subsidyInputs,
|
||||||
.simulationGrid,
|
.simulationGrid,
|
||||||
.simulationOverview {
|
.simulationOverview,
|
||||||
|
.drawSummaryGrid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Dialog from "@mui/material/Dialog";
|
|||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
|
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
|
||||||
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
|
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
|
||||||
@ -9,90 +10,11 @@ import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/Lucky
|
|||||||
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
||||||
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
|
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
|
||||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||||
import {
|
import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
AdminFilterResetButton,
|
|
||||||
AdminFilterSelect,
|
|
||||||
AdminListBody,
|
|
||||||
AdminListPage,
|
|
||||||
AdminListToolbar,
|
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
|
||||||
|
|
||||||
const columnsBase = [
|
|
||||||
{
|
|
||||||
key: "draw",
|
|
||||||
label: "抽奖记录",
|
|
||||||
width: "minmax(260px, 1.2fr)",
|
|
||||||
render: (draw) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{draw.drawId}</span>
|
|
||||||
<span className={styles.meta}>
|
|
||||||
{draw.poolId ? `${draw.poolId} / ` : ""}
|
|
||||||
{draw.giftId || draw.commandId || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "reward",
|
|
||||||
label: "奖励",
|
|
||||||
width: "minmax(180px, 0.8fr)",
|
|
||||||
render: (draw) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{formatNumber(draw.effectiveRewardCoins)}</span>
|
|
||||||
<span className={styles.meta}>{draw.selectedTierId || "-"}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "pool",
|
|
||||||
label: "体验池",
|
|
||||||
width: "minmax(120px, 0.55fr)",
|
|
||||||
render: (draw) => poolLabel(draw.experiencePool),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
label: "状态",
|
|
||||||
width: "minmax(120px, 0.55fr)",
|
|
||||||
render: (draw) => <DrawStatus status={draw.rewardStatus} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rtp",
|
|
||||||
label: "窗口 RTP",
|
|
||||||
width: "minmax(160px, 0.7fr)",
|
|
||||||
render: (draw) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{formatPPM(draw.globalBaseRTPPPM)}</span>
|
|
||||||
<span className={styles.meta}>整体 {formatPPM(draw.giftBaseRTPPPM)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "source",
|
|
||||||
label: "预算来源",
|
|
||||||
width: "minmax(180px, 0.8fr)",
|
|
||||||
render: (draw) => budgetSourceLabel(draw),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "flags",
|
|
||||||
label: "标记",
|
|
||||||
width: "minmax(150px, 0.65fr)",
|
|
||||||
render: (draw) => flagLabel(draw),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "createdAtMs",
|
|
||||||
label: "时间",
|
|
||||||
width: "minmax(180px, 0.85fr)",
|
|
||||||
render: (draw) => formatMillis(draw.createdAtMs),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LuckyGiftConfigPage() {
|
export function LuckyGiftConfigPage() {
|
||||||
const page = useLuckyGiftPage();
|
const page = useLuckyGiftPage();
|
||||||
const total = page.draws.total || 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
@ -108,66 +30,7 @@ export function LuckyGiftConfigPage() {
|
|||||||
onRefresh={page.reloadConfig}
|
onRefresh={page.reloadConfig}
|
||||||
/>
|
/>
|
||||||
<LuckyGiftSimulationPanel config={page.config} />
|
<LuckyGiftSimulationPanel config={page.config} />
|
||||||
<AdminListToolbar
|
<LuckyGiftDrawSummaryPanel page={page} />
|
||||||
filters={
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
disabled
|
|
||||||
label="当前奖池"
|
|
||||||
value={page.poolId}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="礼物 ID"
|
|
||||||
value={page.giftId}
|
|
||||||
onChange={(event) => page.changeGiftId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="用户 ID"
|
|
||||||
value={page.userId}
|
|
||||||
onChange={(event) => page.changeUserId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className={styles.filterInput}
|
|
||||||
label="房间 ID"
|
|
||||||
value={page.roomId}
|
|
||||||
onChange={(event) => page.changeRoomId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<AdminFilterSelect
|
|
||||||
label="发放状态"
|
|
||||||
options={[["", "全部状态"], ...drawStatusOptions]}
|
|
||||||
value={page.status}
|
|
||||||
onChange={page.changeStatus}
|
|
||||||
/>
|
|
||||||
<AdminFilterResetButton
|
|
||||||
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
|
|
||||||
onClick={page.resetDrawFilters}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataState error={page.drawsError} loading={page.drawsLoading} onRetry={page.reloadDraws}>
|
|
||||||
<AdminListBody>
|
|
||||||
<DataTable
|
|
||||||
columns={columnsBase}
|
|
||||||
items={page.draws.items || []}
|
|
||||||
minWidth="1360px"
|
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page: page.page,
|
|
||||||
pageSize: page.draws.pageSize || 10,
|
|
||||||
total,
|
|
||||||
onPageChange: page.setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(draw) => draw.drawId}
|
|
||||||
/>
|
|
||||||
</AdminListBody>
|
|
||||||
</DataState>
|
|
||||||
<LuckyGiftConfigDrawer
|
<LuckyGiftConfigDrawer
|
||||||
abilities={page.abilities}
|
abilities={page.abilities}
|
||||||
configLoading={page.configLoading}
|
configLoading={page.configLoading}
|
||||||
@ -190,6 +53,112 @@ export function LuckyGiftConfigPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LuckyGiftDrawSummaryPanel({ page }) {
|
||||||
|
const summary = page.drawSummary || {};
|
||||||
|
const netSpent = Number(summary.totalSpentCoins || 0) - Number(summary.totalRewardCoins || 0);
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
hint: `房间 ${formatNumber(summary.uniqueRooms)}`,
|
||||||
|
label: "参与用户",
|
||||||
|
value: formatNumber(summary.uniqueUsers),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `成功 ${formatNumber(summary.grantedDraws)} / 待发放 ${formatNumber(summary.pendingDraws)} / 失败 ${formatNumber(summary.failedDraws)}`,
|
||||||
|
label: "抽奖次数",
|
||||||
|
value: formatNumber(summary.totalDraws),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `目标 ${formatPPM(page.config?.targetRTPPPM)}`,
|
||||||
|
label: "实际 RTP",
|
||||||
|
value: formatPPM(summary.actualRTPPPM),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `净消耗 ${formatNumber(netSpent)}`,
|
||||||
|
label: "消耗金币",
|
||||||
|
value: formatNumber(summary.totalSpentCoins),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `基础 ${formatNumber(summary.baseRewardCoins)}`,
|
||||||
|
label: "返还金币",
|
||||||
|
value: formatNumber(summary.totalRewardCoins),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hint: `房间 ${formatNumber(summary.roomAtmosphereRewardCoins)} / 活动 ${formatNumber(summary.activitySubsidyCoins)}`,
|
||||||
|
label: "额外预算",
|
||||||
|
value: formatNumber(
|
||||||
|
Number(summary.roomAtmosphereRewardCoins || 0) + Number(summary.activitySubsidyCoins || 0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.drawSummaryPanel}>
|
||||||
|
<header className={styles.drawSummaryHeader}>
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<h2 className={styles.sectionTitle}>抽奖数据概览</h2>
|
||||||
|
<span className={styles.meta}>奖池 {summary.poolId || page.poolId}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={page.drawSummaryLoading}
|
||||||
|
startIcon={<RefreshOutlined fontSize="small" />}
|
||||||
|
onClick={page.reloadDrawSummary}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className={styles.drawSummaryFilters}>
|
||||||
|
<TextField className={styles.filterInput} disabled label="当前奖池" value={page.poolId} />
|
||||||
|
<TextField
|
||||||
|
className={styles.filterInput}
|
||||||
|
label="礼物 ID"
|
||||||
|
value={page.giftId}
|
||||||
|
onChange={(event) => page.changeGiftId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.filterInput}
|
||||||
|
label="用户 ID"
|
||||||
|
value={page.userId}
|
||||||
|
onChange={(event) => page.changeUserId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.filterInput}
|
||||||
|
label="房间 ID"
|
||||||
|
value={page.roomId}
|
||||||
|
onChange={(event) => page.changeRoomId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="发放状态"
|
||||||
|
options={[["", "全部状态"], ...drawStatusOptions]}
|
||||||
|
value={page.status}
|
||||||
|
onChange={page.changeStatus}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
|
||||||
|
onClick={page.resetDrawFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{page.drawSummaryError ? (
|
||||||
|
<div className={styles.summaryError}>
|
||||||
|
<span>{page.drawSummaryError}</span>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={page.reloadDrawSummary}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.drawSummaryGrid} aria-busy={page.drawSummaryLoading}>
|
||||||
|
{stats.map((item) => (
|
||||||
|
<div className={styles.metricItem} key={item.label}>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{page.drawSummaryLoading ? "-" : item.value}</strong>
|
||||||
|
<small>{page.drawSummaryLoading ? "" : item.hint}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
|
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
|
||||||
return (
|
return (
|
||||||
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
|
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
|
||||||
@ -220,53 +189,6 @@ function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawStatus({ status }) {
|
|
||||||
const tone = status === "granted" ? "running" : status === "failed" ? "danger" : "warning";
|
|
||||||
return (
|
|
||||||
<span className={["status-badge", `status-badge--${tone}`].join(" ")}>
|
|
||||||
<span className="status-point" />
|
|
||||||
{drawStatusOptions.find(([value]) => value === status)?.[1] || status || "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function poolLabel(value) {
|
|
||||||
if (value === "novice") {
|
|
||||||
return "新手池";
|
|
||||||
}
|
|
||||||
if (value === "intermediate") {
|
|
||||||
return "中级池";
|
|
||||||
}
|
|
||||||
if (value === "advanced") {
|
|
||||||
return "高级池";
|
|
||||||
}
|
|
||||||
return value || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function flagLabel(draw) {
|
|
||||||
const labels = [];
|
|
||||||
if (draw.highMultiplier) {
|
|
||||||
labels.push("高倍");
|
|
||||||
}
|
|
||||||
if (draw.stageFeedback) {
|
|
||||||
labels.push("阶段反馈");
|
|
||||||
}
|
|
||||||
return labels.length ? labels.join(" / ") : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function budgetSourceLabel(draw) {
|
|
||||||
if (draw.activitySubsidyCoins > 0) {
|
|
||||||
return "活动补贴";
|
|
||||||
}
|
|
||||||
if (draw.roomAtmosphereRewardCoins > 0) {
|
|
||||||
return "房间气氛";
|
|
||||||
}
|
|
||||||
if (draw.baseRewardCoins > 0) {
|
|
||||||
return "基础 RTP";
|
|
||||||
}
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPPM(value) {
|
function formatPPM(value) {
|
||||||
return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`;
|
return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,24 +42,26 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
await createResource({
|
await createResource({
|
||||||
amount: 0,
|
amount: 0,
|
||||||
assetUrl: "https://media.haiyihy.com/resource/material.png",
|
assetUrl: "https://media.haiyihy.com/resource/material.png",
|
||||||
|
badgeForm: "strip",
|
||||||
coinPrice: 10,
|
coinPrice: 10,
|
||||||
name: "Rose",
|
name: "VIP Badge",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/cover.png",
|
previewUrl: "https://media.haiyihy.com/resource/cover.png",
|
||||||
priceType: "coin",
|
priceType: "coin",
|
||||||
resourceCode: "gift_rose_test",
|
resourceCode: "badge_vip_test",
|
||||||
resourceType: "gift",
|
resourceType: "badge",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await updateResource(11, {
|
await updateResource(11, {
|
||||||
amount: 0,
|
amount: 0,
|
||||||
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
|
assetUrl: "https://media.haiyihy.com/resource/material-updated.png",
|
||||||
|
badgeForm: "tile",
|
||||||
coinPrice: 20,
|
coinPrice: 20,
|
||||||
name: "Rose Updated",
|
name: "VIP Badge Updated",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
|
previewUrl: "https://media.haiyihy.com/resource/cover-updated.png",
|
||||||
priceType: "coin",
|
priceType: "coin",
|
||||||
resourceCode: "gift_rose_test",
|
resourceCode: "badge_vip_test",
|
||||||
resourceType: "gift",
|
resourceType: "badge",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
@ -177,9 +179,15 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
expect(listInit?.method).toBe("GET");
|
expect(listInit?.method).toBe("GET");
|
||||||
expect(String(createUrl)).toContain("/api/v1/admin/resources");
|
expect(String(createUrl)).toContain("/api/v1/admin/resources");
|
||||||
expect(createInit?.method).toBe("POST");
|
expect(createInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(createInit?.body))).toMatchObject({ resourceCode: "gift_rose_test", status: "active" });
|
expect(JSON.parse(String(createInit?.body))).toMatchObject({
|
||||||
|
badgeForm: "strip",
|
||||||
|
resourceCode: "badge_vip_test",
|
||||||
|
resourceType: "badge",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
expect(String(updateUrl)).toContain("/api/v1/admin/resources/11");
|
expect(String(updateUrl)).toContain("/api/v1/admin/resources/11");
|
||||||
expect(updateInit?.method).toBe("PUT");
|
expect(updateInit?.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(String(updateInit?.body))).toMatchObject({ badgeForm: "tile" });
|
||||||
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
expect(String(giftListUrl)).toContain("/api/v1/admin/gifts?");
|
||||||
expect(String(giftListUrl)).toContain("region_id=1001");
|
expect(String(giftListUrl)).toContain("region_id=1001");
|
||||||
expect(giftListInit?.method).toBe("GET");
|
expect(giftListInit?.method).toBe("GET");
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export interface ResourceDto {
|
|||||||
priceType?: string;
|
priceType?: string;
|
||||||
coinPrice?: number;
|
coinPrice?: number;
|
||||||
giftPointAmount?: number;
|
giftPointAmount?: number;
|
||||||
|
badgeForm?: string;
|
||||||
usageScopes?: string[];
|
usageScopes?: string[];
|
||||||
assetUrl?: string;
|
assetUrl?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
@ -30,6 +31,7 @@ export interface ResourcePayload {
|
|||||||
amount: number;
|
amount: number;
|
||||||
animationUrl?: string;
|
animationUrl?: string;
|
||||||
assetUrl: string;
|
assetUrl: string;
|
||||||
|
badgeForm?: string;
|
||||||
coinPrice: number;
|
coinPrice: number;
|
||||||
name: string;
|
name: string;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
|||||||
@ -48,6 +48,13 @@ export const resourcePriceTypeOptions = [
|
|||||||
|
|
||||||
export const resourcePriceTypeLabels = Object.fromEntries(resourcePriceTypeOptions);
|
export const resourcePriceTypeLabels = Object.fromEntries(resourcePriceTypeOptions);
|
||||||
|
|
||||||
|
export const badgeFormOptions = [
|
||||||
|
["strip", "长徽章"],
|
||||||
|
["tile", "短徽章"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const badgeFormLabels = Object.fromEntries(badgeFormOptions);
|
||||||
|
|
||||||
export const resourceTypeFilters = [
|
export const resourceTypeFilters = [
|
||||||
["", "全部类型"],
|
["", "全部类型"],
|
||||||
["avatar_frame", "头像框"],
|
["avatar_frame", "头像框"],
|
||||||
|
|||||||
@ -44,6 +44,7 @@ const dayMillis = 24 * 60 * 60 * 1000;
|
|||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyResourceForm = (resource = {}) => ({
|
const emptyResourceForm = (resource = {}) => ({
|
||||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||||
|
badgeForm: resource.resourceType === "badge" ? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile" : "tile",
|
||||||
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
||||||
enabled: resource.status ? resource.status === "active" : true,
|
enabled: resource.status ? resource.status === "active" : true,
|
||||||
name: resource.name || "",
|
name: resource.name || "",
|
||||||
@ -1022,6 +1023,7 @@ function buildResourcePayload(form) {
|
|||||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||||
animationUrl,
|
animationUrl,
|
||||||
assetUrl: animationUrl,
|
assetUrl: animationUrl,
|
||||||
|
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
|
||||||
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
previewUrl: form.previewUrl.trim(),
|
previewUrl: form.previewUrl.trim(),
|
||||||
@ -1033,6 +1035,22 @@ function buildResourcePayload(form) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badgeFormFromMetadata(metadataJson) {
|
||||||
|
if (!metadataJson) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(metadataJson);
|
||||||
|
const badgeForm = String(metadata?.badge_form || "").trim().toLowerCase();
|
||||||
|
if (badgeForm === "strip" || badgeForm === "tile") {
|
||||||
|
return badgeForm;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function buildEmojiPackPayload(form) {
|
function buildEmojiPackPayload(form) {
|
||||||
const regionIds = (form.regionIds || [])
|
const regionIds = (form.regionIds || [])
|
||||||
.map(Number)
|
.map(Number)
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import {
|
|||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
|
badgeFormLabels,
|
||||||
|
badgeFormOptions,
|
||||||
resourceStatusFilters,
|
resourceStatusFilters,
|
||||||
resourcePriceTypeLabels,
|
resourcePriceTypeLabels,
|
||||||
resourcePriceTypeOptions,
|
resourcePriceTypeOptions,
|
||||||
@ -53,6 +55,12 @@ const baseColumns = [
|
|||||||
width: "minmax(120px, 0.65fr)",
|
width: "minmax(120px, 0.65fr)",
|
||||||
render: (resource) => resourcePriceLabel(resource),
|
render: (resource) => resourcePriceLabel(resource),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "badgeForm",
|
||||||
|
label: "徽章属性",
|
||||||
|
width: "minmax(120px, 0.65fr)",
|
||||||
|
render: (resource) => badgeFormLabel(resource),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
@ -131,7 +139,7 @@ export function ResourceListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="820px"
|
minWidth="940px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -212,9 +220,15 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
required
|
required
|
||||||
select
|
select
|
||||||
value={form.resourceType}
|
value={form.resourceType}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setForm({ ...form, resourceType: event.target.value, walletAssetAmount: "" })
|
const resourceType = event.target.value;
|
||||||
}
|
setForm({
|
||||||
|
...form,
|
||||||
|
badgeForm: resourceType === "badge" ? form.badgeForm || "tile" : form.badgeForm,
|
||||||
|
resourceType,
|
||||||
|
walletAssetAmount: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{resourceTypeOptions.map(([value, label]) => (
|
{resourceTypeOptions.map(([value, label]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
@ -232,6 +246,22 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
|
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{form.resourceType === "badge" ? (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="徽章属性"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.badgeForm}
|
||||||
|
onChange={(event) => setForm({ ...form, badgeForm: event.target.value })}
|
||||||
|
>
|
||||||
|
{badgeFormOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="价格">
|
<AdminFormSection title="价格">
|
||||||
@ -335,6 +365,26 @@ function resourcePriceLabel(resource) {
|
|||||||
return "未配置";
|
return "未配置";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badgeFormLabel(resource) {
|
||||||
|
if (resource.resourceType !== "badge") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return badgeFormLabels[resource.badgeForm || badgeFormFromMetadata(resource.metadataJson)] || "未配置";
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeFormFromMetadata(metadataJson) {
|
||||||
|
if (!metadataJson) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(metadataJson);
|
||||||
|
const badgeForm = String(metadata?.badge_form || "").trim().toLowerCase();
|
||||||
|
return badgeForm === "strip" || badgeForm === "tile" ? badgeForm : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function imageURL(value) {
|
function imageURL(value) {
|
||||||
const url = String(value || "").trim();
|
const url = String(value || "").trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const resourceTypes = [
|
|||||||
];
|
];
|
||||||
const walletAssetTypes = ["COIN", "DIAMOND"];
|
const walletAssetTypes = ["COIN", "DIAMOND"];
|
||||||
const resourcePriceTypes = ["coin", "free"];
|
const resourcePriceTypes = ["coin", "free"];
|
||||||
|
const badgeForms = ["strip", "tile"];
|
||||||
const optionalWalletAssetTypeSchema = z
|
const optionalWalletAssetTypeSchema = z
|
||||||
.preprocess((value) => {
|
.preprocess((value) => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@ -28,6 +29,7 @@ const optionalWalletAssetTypeSchema = z
|
|||||||
export const resourceCreateFormSchema = z
|
export const resourceCreateFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
||||||
|
badgeForm: z.enum(badgeForms).optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
||||||
coinPrice: z.union([z.string(), z.number()]).optional(),
|
coinPrice: z.union([z.string(), z.number()]).optional(),
|
||||||
@ -39,15 +41,22 @@ export const resourceCreateFormSchema = z
|
|||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
if (value.resourceType !== "coin") {
|
if (value.resourceType !== "coin") {
|
||||||
return;
|
if (value.resourceType === "badge" && !value.badgeForm) {
|
||||||
}
|
context.addIssue({
|
||||||
const amount = Number(value.walletAssetAmount);
|
code: "custom",
|
||||||
if (!Number.isInteger(amount) || amount <= 0) {
|
message: "请选择徽章属性",
|
||||||
context.addIssue({
|
path: ["badgeForm"],
|
||||||
code: "custom",
|
});
|
||||||
message: "请输入金币数量",
|
}
|
||||||
path: ["walletAssetAmount"],
|
} else {
|
||||||
});
|
const amount = Number(value.walletAssetAmount);
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请输入金币数量",
|
||||||
|
path: ["walletAssetAmount"],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (value.priceType === "coin") {
|
if (value.priceType === "coin") {
|
||||||
const coinPrice = Number(value.coinPrice);
|
const coinPrice = Number(value.coinPrice);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ describe("resource form schema", () => {
|
|||||||
test("allows profile card resources", () => {
|
test("allows profile card resources", () => {
|
||||||
const payload = parseForm(resourceFormSchema, {
|
const payload = parseForm(resourceFormSchema, {
|
||||||
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
animationUrl: "https://media.haiyihy.com/resource/profile-card.pag",
|
||||||
|
badgeForm: "tile",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: "Profile Card",
|
name: "Profile Card",
|
||||||
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
previewUrl: "https://media.haiyihy.com/resource/profile-card-cover.png",
|
||||||
@ -24,6 +25,34 @@ describe("resource form schema", () => {
|
|||||||
expect(payload.resourceType).toBe("profile_card");
|
expect(payload.resourceType).toBe("profile_card");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requires badge form for badge resources", () => {
|
||||||
|
const payload = parseForm(resourceFormSchema, {
|
||||||
|
animationUrl: "https://media.haiyihy.com/resource/badge.pag",
|
||||||
|
badgeForm: "strip",
|
||||||
|
enabled: true,
|
||||||
|
name: "VIP Badge",
|
||||||
|
previewUrl: "https://media.haiyihy.com/resource/badge.png",
|
||||||
|
priceType: "free",
|
||||||
|
resourceCode: "badge_vip",
|
||||||
|
resourceType: "badge",
|
||||||
|
walletAssetAmount: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.badgeForm).toBe("strip");
|
||||||
|
expect(() =>
|
||||||
|
parseForm(resourceFormSchema, {
|
||||||
|
animationUrl: "https://media.haiyihy.com/resource/badge.pag",
|
||||||
|
enabled: true,
|
||||||
|
name: "VIP Badge",
|
||||||
|
previewUrl: "https://media.haiyihy.com/resource/badge.png",
|
||||||
|
priceType: "free",
|
||||||
|
resourceCode: "badge_vip",
|
||||||
|
resourceType: "badge",
|
||||||
|
walletAssetAmount: "",
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
test("allows resource group resource items without wallet asset type", () => {
|
test("allows resource group resource items without wallet asset type", () => {
|
||||||
const payload = parseForm(resourceGroupCreateFormSchema, {
|
const payload = parseForm(resourceGroupCreateFormSchema, {
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
170
src/shared/api/generated/schema.d.ts
vendored
170
src/shared/api/generated/schema.d.ts
vendored
@ -196,6 +196,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/lucky-gifts/draw-summary": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getLuckyGiftDrawSummary"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/first-recharge-reward/claims": {
|
"/admin/activity/first-recharge-reward/claims": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -397,13 +413,61 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get: operations["listH5Links"];
|
get: operations["listH5Links"];
|
||||||
put: operations["updateH5Links"];
|
put: operations["updateH5Links"];
|
||||||
post?: never;
|
post: operations["createH5Link"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/app-config/h5-links/{key}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateH5Link"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteH5Link"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/app-config/explore-tabs": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listExploreTabs"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createExploreTab"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/app-config/explore-tabs/{tab_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateExploreTab"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteExploreTab"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/app-config/versions": {
|
"/admin/app-config/versions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2654,6 +2718,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getLuckyGiftDrawSummary: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listFirstRechargeRewardClaims: {
|
listFirstRechargeRewardClaims: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2921,6 +2997,98 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
createH5Link: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateH5Link: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteH5Link: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listExploreTabs: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createExploreTab: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateExploreTab: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tab_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteExploreTab: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tab_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listAppVersions: {
|
listAppVersions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -618,13 +618,32 @@ export interface H5LinkConfigDto {
|
|||||||
|
|
||||||
export interface H5LinkConfigPayload {
|
export interface H5LinkConfigPayload {
|
||||||
key: string;
|
key: string;
|
||||||
url?: string;
|
label: string;
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface H5LinkConfigUpdatePayload {
|
export interface H5LinkConfigUpdatePayload {
|
||||||
items: H5LinkConfigPayload[];
|
items: H5LinkConfigPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExploreTabDto {
|
||||||
|
appCode?: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
enabled: boolean;
|
||||||
|
h5Url: string;
|
||||||
|
id: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
tab: string;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExploreTabPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
h5Url: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
tab: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppBannerDto {
|
export interface AppBannerDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
bannerType: "h5" | "app" | string;
|
bannerType: "h5" | "app" | string;
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export function DataTable({
|
|||||||
emptyLabel = "当前无数据",
|
emptyLabel = "当前无数据",
|
||||||
fixedEdges = true,
|
fixedEdges = true,
|
||||||
getRowProps,
|
getRowProps,
|
||||||
|
infiniteScroll,
|
||||||
items,
|
items,
|
||||||
minWidth = "980px",
|
minWidth = "980px",
|
||||||
onColumnWidthsChange,
|
onColumnWidthsChange,
|
||||||
@ -38,8 +39,11 @@ export function DataTable({
|
|||||||
const [filterQuery, setFilterQuery] = useState("");
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
|
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
|
||||||
const [resizeSession, setResizeSession] = useState(null);
|
const [resizeSession, setResizeSession] = useState(null);
|
||||||
|
const loadMoreRef = useRef(null);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const safeItems = Array.isArray(items) ? items : [];
|
const safeItems = Array.isArray(items) ? items : [];
|
||||||
|
const infiniteLoading = Boolean(infiniteScroll?.loading);
|
||||||
|
const infiniteHasMore = Boolean(infiniteScroll?.hasMore);
|
||||||
const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]);
|
const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]);
|
||||||
|
|
||||||
const preparedColumns = useMemo(
|
const preparedColumns = useMemo(
|
||||||
@ -132,6 +136,28 @@ export function DataTable({
|
|||||||
};
|
};
|
||||||
}, [onColumnWidthsChange, resizeSession]);
|
}, [onColumnWidthsChange, resizeSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scrollRef.current;
|
||||||
|
const target = loadMoreRef.current;
|
||||||
|
if (!root || !target || !infiniteScroll?.onLoadMore || !infiniteHasMore || infiniteLoading) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
infiniteScroll.onLoadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root, rootMargin: "0px 0px 96px", threshold: 0.01 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(target);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [infiniteHasMore, infiniteLoading, infiniteScroll, safeItems.length]);
|
||||||
|
|
||||||
const startResize = (event, column) => {
|
const startResize = (event, column) => {
|
||||||
if (!resizableColumns || column.resizable === false) {
|
if (!resizableColumns || column.resizable === false) {
|
||||||
return;
|
return;
|
||||||
@ -247,25 +273,48 @@ export function DataTable({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{safeItems.length ? (
|
{safeItems.length ? (
|
||||||
safeItems.map((item, index) => {
|
<>
|
||||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
{safeItems.map((item, index) => {
|
||||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||||
|
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||||
{preparedColumns.map((column) => (
|
{preparedColumns.map((column) => (
|
||||||
<div className={cellClassName(column)} key={column.key}>
|
<div className={cellClassName(column)} key={column.key}>
|
||||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{infiniteScroll ? (
|
||||||
|
<div
|
||||||
|
className={["admin-row", "admin-row--load-more", !infiniteHasMore && !infiniteLoading ? "admin-row--load-more-done" : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
ref={loadMoreRef}
|
||||||
|
>
|
||||||
|
<div className="admin-cell admin-cell--load-more">
|
||||||
|
{infiniteLoading ? infiniteScroll.loadingLabel || "加载中..." : infiniteHasMore ? "" : infiniteScroll.doneLabel || ""}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null}
|
||||||
})
|
</>
|
||||||
|
) : infiniteLoading ? (
|
||||||
|
<div className="admin-row admin-row--load-more" ref={loadMoreRef}>
|
||||||
|
<div className="admin-cell admin-cell--load-more">
|
||||||
|
{infiniteScroll?.loadingLabel || "加载中..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state empty-state--table">
|
<div className="empty-state empty-state--table">
|
||||||
<div className="empty-state__title">{emptyLabel}</div>
|
<div className="empty-state__title">{emptyLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{safeItems.length === 0 && infiniteScroll && !infiniteLoading ? (
|
||||||
|
<div className="admin-row--load-more-sentinel" ref={loadMoreRef} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{pagination ? <PaginationBar {...pagination} /> : null}
|
{pagination ? <PaginationBar {...pagination} /> : null}
|
||||||
|
|||||||
@ -12,46 +12,57 @@ export function PaginationBar({
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
pageSizeOptions = [],
|
pageSizeOptions = [],
|
||||||
total
|
total,
|
||||||
|
totalPages
|
||||||
}) {
|
}) {
|
||||||
const hasKnownTotal = Number.isFinite(Number(total));
|
const hasKnownTotal = Number.isFinite(Number(total));
|
||||||
const totalPages = hasKnownTotal ? Math.max(1, Math.ceil(total / pageSize)) : Math.max(1, page + (hasNextPage ? 1 : 0));
|
const resolvedTotalPages = Number.isFinite(Number(totalPages))
|
||||||
|
? Math.max(1, Number(totalPages))
|
||||||
|
: hasKnownTotal
|
||||||
|
? Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
: Math.max(1, page + (hasNextPage ? 1 : 0));
|
||||||
const start = hasKnownTotal ? (total === 0 ? 0 : (page - 1) * pageSize + 1) : 0;
|
const start = hasKnownTotal ? (total === 0 ? 0 : (page - 1) * pageSize + 1) : 0;
|
||||||
const end = hasKnownTotal ? Math.min(total, page * pageSize) : Number(itemCount || 0);
|
const end = hasKnownTotal ? Math.min(total, page * pageSize) : Number(itemCount || 0);
|
||||||
const nextDisabled = hasKnownTotal ? page >= totalPages : !hasNextPage;
|
const nextDisabled = hasKnownTotal ? page >= resolvedTotalPages : !hasNextPage;
|
||||||
|
const showPageActions = typeof onPageChange === "function";
|
||||||
|
const showPageSize = showPageActions && pageSizeOptions.length && onPageSizeChange;
|
||||||
|
const pageSummary = hasKnownTotal ? `共 ${resolvedTotalPages} 页 · 共 ${total} 条` : `已加载 ${Math.max(1, page)} 页 · ${end} 条`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pagination-bar">
|
<div className="pagination-bar">
|
||||||
<div className="pagination-bar__meta">
|
<div className="pagination-bar__meta">
|
||||||
{pageSizeOptions.length && onPageSizeChange ? (
|
{showPageSize ? (
|
||||||
<TextField
|
<span className="pagination-bar__page-size-wrap">
|
||||||
className="pagination-bar__page-size"
|
<span>每页</span>
|
||||||
label="每页"
|
<TextField
|
||||||
select
|
className="pagination-bar__page-size"
|
||||||
size="small"
|
inputProps={{ "aria-label": "每页条数" }}
|
||||||
value={pageSize}
|
select
|
||||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
size="small"
|
||||||
>
|
value={pageSize}
|
||||||
{pageSizeOptions.map((option) => (
|
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||||
<MenuItem key={option} value={option}>
|
>
|
||||||
{option}
|
{pageSizeOptions.map((option) => (
|
||||||
</MenuItem>
|
<MenuItem key={option} value={option}>
|
||||||
))}
|
{option}
|
||||||
</TextField>
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span>
|
<span>{showPageActions && hasKnownTotal ? `${start}-${end} / ${total}` : pageSummary}</span>
|
||||||
{hasKnownTotal ? `${start}-${end} / 共 ${total} 条` : `第 ${page} 页 · 本页 ${end} 条`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pagination-bar__actions">
|
|
||||||
<IconButton disabled={page <= 1} label="上一页" onClick={() => onPageChange(page - 1)}>
|
|
||||||
<KeyboardArrowLeft fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<span>{page} / {totalPages}</span>
|
|
||||||
<IconButton disabled={nextDisabled} label="下一页" onClick={() => onPageChange(page + 1)}>
|
|
||||||
<KeyboardArrowRight fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
{showPageActions ? (
|
||||||
|
<div className="pagination-bar__actions">
|
||||||
|
<IconButton disabled={page <= 1} label="上一页" onClick={() => onPageChange(page - 1)}>
|
||||||
|
<KeyboardArrowLeft fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<span>{page} / {resolvedTotalPages}</span>
|
||||||
|
<IconButton disabled={nextDisabled} label="下一页" onClick={() => onPageChange(page + 1)}>
|
||||||
|
<KeyboardArrowRight fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,33 +3,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer.drawer {
|
.drawer.drawer {
|
||||||
width: min(760px, 100vw);
|
width: min(640px, 100vw);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawerBody {
|
.drawerBody {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupGrid {
|
.groupGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
align-content: start;
|
||||||
gap: var(--space-3);
|
align-items: start;
|
||||||
|
grid-auto-rows: min-content;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: var(--space-1);
|
padding-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupCard {
|
.groupCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 136px;
|
min-height: 76px;
|
||||||
align-content: start;
|
align-items: start;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1) var(--space-3);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -45,8 +50,8 @@
|
|||||||
.groupCard:hover {
|
.groupCard:hover {
|
||||||
border-color: var(--primary-border);
|
border-color: var(--primary-border);
|
||||||
background: var(--primary-surface);
|
background: var(--primary-surface);
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: none;
|
||||||
transform: translateY(-1px);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupCard:focus-visible {
|
.groupCard:focus-visible {
|
||||||
@ -62,18 +67,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.emptyCard {
|
.emptyCard {
|
||||||
min-height: 104px;
|
min-height: 64px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupIcon {
|
.groupIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 44px;
|
grid-row: 1 / 4;
|
||||||
height: 44px;
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@ -105,6 +112,7 @@
|
|||||||
|
|
||||||
.groupItems {
|
.groupItems {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
grid-column: 2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@ -136,7 +144,7 @@
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.groupGrid {
|
.groupGrid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -994,27 +994,45 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-3);
|
min-height: 32px;
|
||||||
|
margin-top: var(--space-2);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-font-size);
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-bar__meta {
|
.pagination-bar__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__page-size-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-bar__page-size {
|
.pagination-bar__page-size {
|
||||||
width: 96px;
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__page-size .MuiInputBase-root {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__page-size .MuiSelect-select.MuiInputBase-input {
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-bar__actions {
|
.pagination-bar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-state {
|
.data-state {
|
||||||
@ -1125,7 +1143,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
--admin-table-cell-x: var(--space-4);
|
--admin-table-cell-x: var(--space-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: max(100%, var(--admin-table-min-width, 900px));
|
width: max(100%, var(--admin-table-min-width, 900px));
|
||||||
@ -1160,10 +1178,30 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-row--load-more {
|
||||||
|
min-height: 36px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row--load-more-done {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell--load-more {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row--load-more-sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-cell {
|
.admin-cell {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 var(--admin-table-cell-x);
|
padding: 0 var(--admin-table-cell-x);
|
||||||
|
overflow: hidden;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1180,11 +1218,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-height: var(--table-head-height);
|
min-height: var(--table-head-height);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-right: calc(var(--admin-table-cell-x) + 12px);
|
padding-right: calc(var(--admin-table-cell-x) + 10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-cell--head:last-child {
|
.admin-cell--head:last-child {
|
||||||
padding-right: calc(var(--admin-table-cell-x) + 12px);
|
padding-right: calc(var(--admin-table-cell-x) + 10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-cell__head-content {
|
.admin-cell__head-content {
|
||||||
|
|||||||
@ -9,8 +9,8 @@
|
|||||||
--admin-tag-gap: var(--space-1);
|
--admin-tag-gap: var(--space-1);
|
||||||
--admin-tag-font-size: var(--admin-font-size);
|
--admin-tag-font-size: var(--admin-font-size);
|
||||||
--admin-tag-font-weight: 650;
|
--admin-tag-font-weight: 650;
|
||||||
--table-head-height: 44px;
|
--table-head-height: 38px;
|
||||||
--table-row-height: 58px;
|
--table-row-height: 48px;
|
||||||
--switch-width: 46px;
|
--switch-width: 46px;
|
||||||
--switch-height: 26px;
|
--switch-height: 26px;
|
||||||
--switch-thumb-size: 20px;
|
--switch-thumb-size: 20px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user