完善相关接口

This commit is contained in:
zhx 2026-05-25 19:36:44 +08:00
parent d87cc49797
commit f97aad449a
37 changed files with 6953 additions and 6084 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -27,3 +27,8 @@
.formWideField { .formWideField {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.statusCell {
display: inline-flex;
align-items: center;
}

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

View File

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

View File

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

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

View File

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

View File

@ -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: () =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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+$/, "")}%`;
} }

View File

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

View File

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

View File

@ -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", "头像框"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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