新增功能
This commit is contained in:
parent
54fdc867fe
commit
7d6418e43c
@ -824,6 +824,70 @@
|
||||
"x-permissions": ["app-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/app-config/popups": {
|
||||
"get": {
|
||||
"operationId": "listPopups",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "app-config:view",
|
||||
"x-permissions": ["app-config:view"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPopup",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "app-config:update",
|
||||
"x-permissions": ["app-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/app-config/popups/{popup_id}": {
|
||||
"put": {
|
||||
"operationId": "updatePopup",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "popup_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "app-config:update",
|
||||
"x-permissions": ["app-config:update"]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deletePopup",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "popup_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "app-config:update",
|
||||
"x-permissions": ["app-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/app-config/h5-links": {
|
||||
"get": {
|
||||
"operationId": "listH5Links",
|
||||
@ -1917,6 +1981,26 @@
|
||||
],
|
||||
"x-permission": "gift:update",
|
||||
"x-permissions": ["gift:update"]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deleteGift",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "gift_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "gift:delete",
|
||||
"x-permissions": ["gift:delete"]
|
||||
}
|
||||
},
|
||||
"/admin/gifts/{gift_id}/disable": {
|
||||
|
||||
@ -23,6 +23,7 @@ import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
@ -64,6 +65,7 @@ const iconMap = {
|
||||
menu: MenuOpenOutlined,
|
||||
operations: ReceiptLongOutlined,
|
||||
paid: PaidOutlined,
|
||||
person_add: PersonAddAltOutlined,
|
||||
public: PublicOutlined,
|
||||
push_pin: PushPinOutlined,
|
||||
receipt: ReceiptLongOutlined,
|
||||
@ -150,6 +152,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||
routeNavItem("app-config-splash-screens", { icon: ImageOutlined }),
|
||||
routeNavItem("app-config-popups", { icon: ImageOutlined }),
|
||||
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||
],
|
||||
@ -203,6 +206,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
||||
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
|
||||
routeNavItem("invite-activity-reward", { icon: PersonAddAltOutlined }),
|
||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
||||
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
||||
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
||||
|
||||
@ -139,6 +139,12 @@ describe("navigation menu helpers", () => {
|
||||
label: "Explore配置",
|
||||
path: "/app-config/explore",
|
||||
},
|
||||
{
|
||||
code: "app-config-popups",
|
||||
id: "app-config-popups",
|
||||
label: "弹窗配置",
|
||||
path: "/app-config/popups",
|
||||
},
|
||||
{
|
||||
code: "app-config-versions",
|
||||
id: "app-config-versions",
|
||||
@ -160,6 +166,7 @@ describe("navigation menu helpers", () => {
|
||||
"app-config-h5",
|
||||
"app-config-banners",
|
||||
"app-config-splash-screens",
|
||||
"app-config-popups",
|
||||
"app-config-explore",
|
||||
"app-config-versions",
|
||||
]);
|
||||
|
||||
@ -117,6 +117,7 @@ export const PERMISSIONS = {
|
||||
giftCreate: "gift:create",
|
||||
giftUpdate: "gift:update",
|
||||
giftStatus: "gift:status",
|
||||
giftDelete: "gift:delete",
|
||||
emojiPackView: "emoji-pack:view",
|
||||
emojiPackCreate: "emoji-pack:create",
|
||||
dailyTaskView: "daily-task:view",
|
||||
@ -132,6 +133,8 @@ export const PERMISSIONS = {
|
||||
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
||||
cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
|
||||
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
|
||||
inviteActivityRewardView: "invite-activity-reward:view",
|
||||
inviteActivityRewardUpdate: "invite-activity-reward:update",
|
||||
sevenDayCheckInView: "seven-day-checkin:view",
|
||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||
luckyGiftView: "lucky-gift:view",
|
||||
@ -184,6 +187,7 @@ export const MENU_CODES = {
|
||||
appConfigH5: "app-config-h5",
|
||||
appConfigBanners: "app-config-banners",
|
||||
appConfigSplashScreens: "app-config-splash-screens",
|
||||
appConfigPopups: "app-config-popups",
|
||||
appConfigExplore: "app-config-explore",
|
||||
appConfigVersions: "app-config-versions",
|
||||
resources: "resources",
|
||||
@ -205,6 +209,7 @@ export const MENU_CODES = {
|
||||
registrationReward: "registration-reward",
|
||||
firstRechargeReward: "first-recharge-reward",
|
||||
cumulativeRechargeReward: "cumulative-recharge-reward",
|
||||
inviteActivityReward: "invite-activity-reward",
|
||||
achievementConfig: "achievement-config",
|
||||
sevenDayCheckIn: "seven-day-checkin",
|
||||
roomRocket: "room-rocket",
|
||||
|
||||
@ -10,6 +10,7 @@ import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/rout
|
||||
import { gameRoutes } from "@/features/games/routes.js";
|
||||
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
|
||||
import { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||
import { inviteActivityRewardRoutes } from "@/features/invite-activity-reward/routes.js";
|
||||
import { levelConfigRoutes } from "@/features/level-config/routes.js";
|
||||
import { logsRoutes } from "@/features/logs/routes.js";
|
||||
import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
|
||||
@ -46,6 +47,7 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...registrationRewardRoutes,
|
||||
...firstRechargeRewardRoutes,
|
||||
...cumulativeRechargeRewardRoutes,
|
||||
...inviteActivityRewardRoutes,
|
||||
...sevenDayCheckInRoutes,
|
||||
...roomRocketRoutes,
|
||||
...roomTurnoverRewardRoutes,
|
||||
|
||||
@ -4,6 +4,8 @@ import type {
|
||||
ApiList,
|
||||
AppBannerDto,
|
||||
AppBannerPayload,
|
||||
AppPopupDto,
|
||||
AppPopupPayload,
|
||||
AppSplashScreenDto,
|
||||
AppSplashScreenPayload,
|
||||
AppVersionDto,
|
||||
@ -163,6 +165,40 @@ export function deleteSplashScreen(splashId: EntityId): Promise<{ deleted: boole
|
||||
);
|
||||
}
|
||||
|
||||
export function listPopups(query: PageQuery = {}): Promise<ApiList<AppPopupDto>> {
|
||||
const endpoint = API_ENDPOINTS.listPopups;
|
||||
return apiRequest<ApiList<AppPopupDto>>(apiEndpointPath(API_OPERATIONS.listPopups), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPopup(payload: AppPopupPayload): Promise<AppPopupDto> {
|
||||
const endpoint = API_ENDPOINTS.createPopup;
|
||||
return apiRequest<AppPopupDto, AppPopupPayload>(apiEndpointPath(API_OPERATIONS.createPopup), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePopup(popupId: EntityId, payload: AppPopupPayload): Promise<AppPopupDto> {
|
||||
const endpoint = API_ENDPOINTS.updatePopup;
|
||||
return apiRequest<AppPopupDto, AppPopupPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updatePopup, { popup_id: popupId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePopup(popupId: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deletePopup;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deletePopup, { popup_id: popupId }), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function listAppVersions(query: PageQuery = {}): Promise<ApiList<AppVersionDto>> {
|
||||
const endpoint = API_ENDPOINTS.listAppVersions;
|
||||
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {
|
||||
|
||||
@ -24,6 +24,24 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.popupCover,
|
||||
.popupCoverEmpty {
|
||||
display: inline-flex;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.popupCover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.splashCover,
|
||||
.splashCoverEmpty {
|
||||
display: inline-flex;
|
||||
@ -84,6 +102,40 @@
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.popupFormLayout {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.popupAssetPane,
|
||||
.popupFieldPane {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.popupAssetPane {
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.popupFieldPane {
|
||||
padding-left: var(--space-1);
|
||||
}
|
||||
|
||||
.popupUploadField {
|
||||
width: min(100%, 280px);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.popupUploadField .popupUploadPreview {
|
||||
aspect-ratio: 1 / 1;
|
||||
height: auto;
|
||||
max-height: min(42vh, 300px);
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -105,4 +157,20 @@
|
||||
justify-self: stretch;
|
||||
width: min(100%, 260px);
|
||||
}
|
||||
|
||||
.popupFormLayout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.popupAssetPane,
|
||||
.popupFieldPane {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.popupUploadField {
|
||||
justify-self: stretch;
|
||||
width: min(100%, 280px);
|
||||
}
|
||||
}
|
||||
|
||||
174
src/features/app-config/hooks/usePopupConfigPage.js
Normal file
174
src/features/app-config/hooks/usePopupConfigPage.js
Normal file
@ -0,0 +1,174 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { createPopup, deletePopup, listPopups, updatePopup } from "@/features/app-config/api";
|
||||
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||
import { appPopupSchema } 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 = () => ({
|
||||
code: "",
|
||||
displayPeriodDays: "0",
|
||||
endsAtMs: "",
|
||||
imageUrl: "",
|
||||
jumpType: "h5",
|
||||
jumpUrl: "",
|
||||
name: "",
|
||||
sortOrder: "0",
|
||||
startsAtMs: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
export function usePopupConfigPage() {
|
||||
const abilities = useAppConfigAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword,
|
||||
status,
|
||||
}),
|
||||
[keyword, status],
|
||||
);
|
||||
const queryFn = useCallback(() => listPopups(filters), [filters]);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载弹窗配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["app-config", "popups", filters],
|
||||
});
|
||||
|
||||
const setForm = (patch) => {
|
||||
setFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditingItem(item);
|
||||
setFormState(formFromPopup(item));
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const submitPopup = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = parseForm(appPopupSchema, normalizeForm(form));
|
||||
const action = editingItem ? "edit" : "create";
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updatePopup(editingItem.id, payload);
|
||||
showToast("弹窗配置已更新", "success");
|
||||
} else {
|
||||
await createPopup(payload);
|
||||
showToast("弹窗配置已新增", "success");
|
||||
}
|
||||
closeDialog();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const removePopup = async (item) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: item.name || item.jumpUrl || `弹窗配置 ${item.id}`,
|
||||
title: "删除弹窗配置",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`delete:${item.id}`);
|
||||
try {
|
||||
await deletePopup(item.id);
|
||||
await reload();
|
||||
showToast("弹窗配置已删除", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
closeDialog,
|
||||
data,
|
||||
editingItem,
|
||||
error,
|
||||
form,
|
||||
keyword,
|
||||
loading,
|
||||
loadingAction,
|
||||
openCreate,
|
||||
openEdit,
|
||||
reload,
|
||||
removePopup,
|
||||
resetFilters,
|
||||
setForm,
|
||||
setKeyword,
|
||||
setStatus,
|
||||
status,
|
||||
submitPopup,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
displayPeriodDays: form.displayPeriodDays || 0,
|
||||
endsAtMs: form.endsAtMs || 0,
|
||||
imageUrl: form.imageUrl || "",
|
||||
sortOrder: form.sortOrder || 0,
|
||||
startsAtMs: form.startsAtMs || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function formFromPopup(item) {
|
||||
return {
|
||||
code: item.code || "",
|
||||
displayPeriodDays: String(item.displayPeriodDays ?? 0),
|
||||
endsAtMs: item.endsAtMs || "",
|
||||
imageUrl: item.imageUrl || "",
|
||||
jumpType: item.jumpType || "h5",
|
||||
jumpUrl: item.jumpUrl || "",
|
||||
name: item.name || "",
|
||||
sortOrder: String(item.sortOrder || 0),
|
||||
startsAtMs: item.startsAtMs || "",
|
||||
status: item.status || "active",
|
||||
};
|
||||
}
|
||||
@ -27,6 +27,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
@ -116,17 +117,6 @@ export function BannerConfigPage() {
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="跳转信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="类型"
|
||||
required
|
||||
select
|
||||
value={page.form.bannerType}
|
||||
onChange={(event) => page.setForm({ bannerType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="平台"
|
||||
@ -138,13 +128,16 @@ export function BannerConfigPage() {
|
||||
<MenuItem value="android">安卓</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
<AppJumpConfigField
|
||||
appParamLabel="参数(APP公共跳转JSON)"
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.form.bannerType === "h5" ? "参数(H5链接)" : "参数"}
|
||||
required={page.form.bannerType === "h5"}
|
||||
value={page.form.param}
|
||||
onChange={(event) => page.setForm({ param: event.target.value })}
|
||||
h5Label="参数(H5链接)"
|
||||
paramValue={page.form.param}
|
||||
required
|
||||
typeLabel="类型"
|
||||
typeValue={page.form.bannerType}
|
||||
onChange={({ param, type }) => page.setForm({ bannerType: type, param })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
|
||||
326
src/features/app-config/pages/PopupConfigPage.jsx
Normal file
326
src/features/app-config/pages/PopupConfigPage.jsx
Normal file
@ -0,0 +1,326 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import styles from "@/features/app-config/app-config.module.css";
|
||||
import { usePopupConfigPage } from "@/features/app-config/hooks/usePopupConfigPage.js";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "关闭"],
|
||||
["expired", "过期"],
|
||||
];
|
||||
|
||||
export function PopupConfigPage() {
|
||||
const page = usePopupConfigPage();
|
||||
const items = page.data.items || [];
|
||||
const columns = useMemo(() => popupColumns(page), [page]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canUpdate ? (
|
||||
<AdminActionIconButton label="新增弹窗" primary onClick={page.openCreate}>
|
||||
<AddOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1180px"
|
||||
pagination={{
|
||||
itemCount: items.length,
|
||||
page: 1,
|
||||
pageSize: items.length || 1,
|
||||
total: page.data.total ?? items.length,
|
||||
}}
|
||||
rowKey={(item) => item.id}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="large"
|
||||
submitDisabled={
|
||||
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
|
||||
}
|
||||
title={page.editingItem ? "编辑弹窗配置" : "新增弹窗配置"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitPopup}
|
||||
>
|
||||
<div className={styles.popupFormLayout}>
|
||||
<div className={styles.popupAssetPane}>
|
||||
<AdminFormSection title="图片">
|
||||
<UploadField
|
||||
className={styles.popupUploadField}
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="弹窗图片"
|
||||
previewClassName={styles.popupUploadPreview}
|
||||
value={page.form.imageUrl}
|
||||
onChange={(imageUrl) => page.setForm({ imageUrl })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</div>
|
||||
<div className={styles.popupFieldPane}>
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="弹窗code"
|
||||
required
|
||||
value={page.form.code}
|
||||
onChange={(event) => page.setForm({ code: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="弹窗名字"
|
||||
required
|
||||
value={page.form.name}
|
||||
onChange={(event) => page.setForm({ name: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="展示周期(天)"
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
type="number"
|
||||
value={page.form.displayPeriodDays}
|
||||
onChange={(event) => page.setForm({ displayPeriodDays: event.target.value })}
|
||||
/>
|
||||
<AppJumpConfigField
|
||||
appParamLabel="跳转地址(APP公共跳转JSON)"
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
h5Label="跳转地址(H5链接)"
|
||||
paramValue={page.form.jumpUrl}
|
||||
required
|
||||
typeLabel="跳转类型"
|
||||
typeValue={page.form.jumpType}
|
||||
onChange={({ param, type }) => page.setForm({ jumpType: type, jumpUrl: param })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="生效设置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="排序"
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
type="number"
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={page.form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="弹窗状态"
|
||||
switchProps={{ inputProps: { "aria-label": "弹窗启用状态" } }}
|
||||
uncheckedLabel={page.form.status === "expired" ? "过期" : "关闭"}
|
||||
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
|
||||
/>
|
||||
<TimeRangeFilter
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="生效时间区间"
|
||||
value={{ endMs: page.form.endsAtMs, startMs: page.form.startsAtMs }}
|
||||
onChange={(range) =>
|
||||
page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs })
|
||||
}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function popupColumns(page) {
|
||||
const columns = [
|
||||
{
|
||||
key: "image",
|
||||
label: "图片",
|
||||
render: (item) => <PopupImage src={item.imageUrl} />,
|
||||
width: "minmax(96px, 0.45fr)",
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "弹窗名字",
|
||||
render: (item) => <Stack primary={item.name || "-"} secondary={item.code || "-"} />,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索code、名字、跳转地址",
|
||||
value: page.keyword,
|
||||
onChange: page.setKeyword,
|
||||
}),
|
||||
width: "minmax(200px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "jump",
|
||||
label: "跳转",
|
||||
render: (item) => <Stack primary={typeLabel(item.jumpType)} secondary={item.jumpUrl || "-"} />,
|
||||
width: "minmax(260px, 1.25fr)",
|
||||
},
|
||||
{
|
||||
key: "displayPeriodDays",
|
||||
label: "展示周期",
|
||||
render: (item) => periodLabel(item.displayPeriodDays),
|
||||
width: "minmax(110px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <StatusBadge status={item.status} />,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.setStatus,
|
||||
}),
|
||||
width: "minmax(100px, 0.5fr)",
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
label: "排序",
|
||||
width: "minmax(80px, 0.4fr)",
|
||||
},
|
||||
{
|
||||
key: "effectiveTime",
|
||||
label: "生效时间",
|
||||
render: (item) => effectiveTimeLabel(item.startsAtMs, item.endsAtMs),
|
||||
width: "minmax(220px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(160px, 0.75fr)",
|
||||
},
|
||||
];
|
||||
|
||||
if (!page.abilities.canUpdate) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <PopupActions item={item} page={page} />,
|
||||
width: "minmax(96px, 0.45fr)",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function PopupImage({ src }) {
|
||||
if (!src) {
|
||||
return (
|
||||
<span className={styles.popupCoverEmpty}>
|
||||
<ImageOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <img alt="" className={styles.popupCover} src={src} />;
|
||||
}
|
||||
|
||||
function PopupActions({ item, page }) {
|
||||
const deleting = page.loadingAction === `delete:${item.id}`;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={Boolean(page.loadingAction)}
|
||||
label="编辑"
|
||||
onClick={() => page.openEdit(item)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={deleting || Boolean(page.loadingAction)}
|
||||
label="删除"
|
||||
onClick={() => page.removePopup(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function typeLabel(value) {
|
||||
return value === "app" ? "APP" : "H5";
|
||||
}
|
||||
|
||||
function periodLabel(value) {
|
||||
const days = Number(value || 0);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return "每次打开";
|
||||
}
|
||||
return `${days} 天一次`;
|
||||
}
|
||||
|
||||
function effectiveTimeLabel(startsAtMs, endsAtMs) {
|
||||
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
|
||||
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === "active") {
|
||||
return "启用";
|
||||
}
|
||||
if (status === "expired") {
|
||||
return "过期";
|
||||
}
|
||||
return "关闭";
|
||||
}
|
||||
@ -24,6 +24,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
@ -102,17 +103,6 @@ export function SplashScreenConfigPage() {
|
||||
<div className={styles.splashFieldPane}>
|
||||
<AdminFormSection title="跳转信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="类型"
|
||||
required
|
||||
select
|
||||
value={page.form.splashType}
|
||||
onChange={(event) => page.setForm({ splashType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="平台"
|
||||
@ -124,13 +114,16 @@ export function SplashScreenConfigPage() {
|
||||
<MenuItem value="android">安卓</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
<AppJumpConfigField
|
||||
appParamLabel="参数(APP公共跳转JSON)"
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.form.splashType === "h5" ? "参数(H5链接)" : "参数"}
|
||||
required={page.form.splashType === "h5"}
|
||||
value={page.form.param}
|
||||
onChange={(event) => page.setForm({ param: event.target.value })}
|
||||
h5Label="参数(H5链接)"
|
||||
paramValue={page.form.param}
|
||||
required
|
||||
typeLabel="类型"
|
||||
typeValue={page.form.splashType}
|
||||
onChange={({ param, type }) => page.setForm({ param, splashType: type })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
|
||||
@ -25,6 +25,14 @@ export const appConfigRoutes = [
|
||||
path: "/app-config/splash-screens",
|
||||
permission: PERMISSIONS.appConfigView,
|
||||
},
|
||||
{
|
||||
label: "弹窗配置",
|
||||
loader: () => import("./pages/PopupConfigPage.jsx").then((module) => module.PopupConfigPage),
|
||||
menuCode: MENU_CODES.appConfigPopups,
|
||||
pageKey: "app-config-popups",
|
||||
path: "/app-config/popups",
|
||||
permission: PERMISSIONS.appConfigView,
|
||||
},
|
||||
{
|
||||
label: "Explore配置",
|
||||
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
appBannerSchema,
|
||||
appPopupSchema,
|
||||
appSplashScreenSchema,
|
||||
exploreTabSchema,
|
||||
h5LinkUpdateSchema,
|
||||
@ -39,6 +40,19 @@ const validSplashScreenForm = {
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const validPopupForm = {
|
||||
code: "new-user-popup",
|
||||
displayPeriodDays: "2",
|
||||
endsAtMs: "1800000000000",
|
||||
imageUrl: "",
|
||||
jumpType: "h5",
|
||||
jumpUrl: "https://h5.example.com/popup",
|
||||
name: "新人活动",
|
||||
sortOrder: "0",
|
||||
startsAtMs: "1700000000000",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
describe("app config form schema", () => {
|
||||
test("validates dynamic h5 config item", () => {
|
||||
const payload = parseForm(h5LinkUpdateSchema, {
|
||||
@ -69,6 +83,27 @@ describe("app config form schema", () => {
|
||||
expect(payload.endsAtMs).toBe(1800000000000);
|
||||
});
|
||||
|
||||
test("accepts public app jump json for banner", () => {
|
||||
const payload = parseForm(appBannerSchema, {
|
||||
...validBannerForm,
|
||||
bannerType: "app",
|
||||
param: '{"type":"room_random_game","game_id":"dice"}',
|
||||
});
|
||||
|
||||
expect(payload.bannerType).toBe("app");
|
||||
expect(payload.param).toBe('{"type":"room_random_game","game_id":"dice"}');
|
||||
});
|
||||
|
||||
test("rejects incomplete public app jump json for banner", () => {
|
||||
expect(() =>
|
||||
parseForm(appBannerSchema, {
|
||||
...validBannerForm,
|
||||
bannerType: "app",
|
||||
param: '{"type":"room_game","room_id":"room_1"}',
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("accepts multiple banner display scopes", () => {
|
||||
const payload = parseForm(appBannerSchema, {
|
||||
...validBannerForm,
|
||||
@ -109,6 +144,17 @@ describe("app config form schema", () => {
|
||||
expect("displayScopes" in payload).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts public app jump json for splash screen", () => {
|
||||
const payload = parseForm(appSplashScreenSchema, {
|
||||
...validSplashScreenForm,
|
||||
param: '{"type":"wallet"}',
|
||||
splashType: "app",
|
||||
});
|
||||
|
||||
expect(payload.splashType).toBe("app");
|
||||
expect(payload.param).toBe('{"type":"wallet"}');
|
||||
});
|
||||
|
||||
test("requires h5 link for splash screen h5 type", () => {
|
||||
expect(() =>
|
||||
parseForm(appSplashScreenSchema, {
|
||||
@ -137,6 +183,57 @@ describe("app config form schema", () => {
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("keeps popup optional image and display period", () => {
|
||||
const payload = parseForm(appPopupSchema, validPopupForm);
|
||||
|
||||
expect(payload.code).toBe("new-user-popup");
|
||||
expect(payload.imageUrl).toBe("");
|
||||
expect(payload.displayPeriodDays).toBe(2);
|
||||
expect(payload.startsAtMs).toBe(1700000000000);
|
||||
expect(payload.endsAtMs).toBe(1800000000000);
|
||||
});
|
||||
|
||||
test("accepts public app jump json for popup", () => {
|
||||
const payload = parseForm(appPopupSchema, {
|
||||
...validPopupForm,
|
||||
jumpType: "app",
|
||||
jumpUrl: '{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}',
|
||||
});
|
||||
|
||||
expect(payload.jumpType).toBe("app");
|
||||
expect(payload.jumpUrl).toBe(
|
||||
'{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects popup code with spaces", () => {
|
||||
expect(() =>
|
||||
parseForm(appPopupSchema, {
|
||||
...validPopupForm,
|
||||
code: "new user popup",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("requires popup jump address", () => {
|
||||
expect(() =>
|
||||
parseForm(appPopupSchema, {
|
||||
...validPopupForm,
|
||||
jumpUrl: "",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("rejects inverted popup effective time range", () => {
|
||||
expect(() =>
|
||||
parseForm(appPopupSchema, {
|
||||
...validPopupForm,
|
||||
endsAtMs: "1700000000000",
|
||||
startsAtMs: "1800000000000",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("validates explore tab h5 url and sort order", () => {
|
||||
const payload = parseForm(exploreTabSchema, {
|
||||
enabled: true,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||
|
||||
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
|
||||
|
||||
@ -70,6 +71,12 @@ export const appBannerSchema = z
|
||||
if (value.bannerType === "h5" && /\s/.test(value.param)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
|
||||
}
|
||||
if (value.bannerType === "app") {
|
||||
const validation = validatePublicAppJumpParam(value.param);
|
||||
if (!validation.valid) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] });
|
||||
}
|
||||
}
|
||||
if (value.displayScopes.includes("room") && !value.roomSmallImageUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@ -115,6 +122,12 @@ export const appSplashScreenSchema = z
|
||||
if (value.splashType === "h5" && /\s/.test(value.param)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
|
||||
}
|
||||
if (value.splashType === "app") {
|
||||
const validation = validatePublicAppJumpParam(value.param);
|
||||
if (!validation.valid) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] });
|
||||
}
|
||||
}
|
||||
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["endsAtMs"] });
|
||||
}
|
||||
@ -122,6 +135,45 @@ export const appSplashScreenSchema = z
|
||||
|
||||
export type AppSplashScreenForm = z.infer<typeof appSplashScreenSchema>;
|
||||
|
||||
export const appPopupSchema = z
|
||||
.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写弹窗code")
|
||||
.max(80, "弹窗code不能超过 80 个字符")
|
||||
.regex(/^[A-Za-z0-9_.:-]+$/, "弹窗code只能包含字母、数字、下划线、中横线、点和冒号"),
|
||||
displayPeriodDays: z.coerce.number().int("展示周期必须是整数天").min(0, "展示周期不能小于 0").default(0),
|
||||
endsAtMs: z.coerce.number().int().min(0, "生效结束时间不正确").default(0),
|
||||
imageUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1024, "图片地址不能超过 1024 个字符")
|
||||
.refine((value) => !/\s/.test(value), "图片地址不能包含空白字符"),
|
||||
jumpType: z.enum(["h5", "app"]),
|
||||
jumpUrl: z.string().trim().min(1, "请填写跳转地址").max(2048, "跳转地址不能超过 2048 个字符"),
|
||||
name: z.string().trim().min(1, "请填写弹窗名字").max(80, "弹窗名字不能超过 80 个字符"),
|
||||
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
|
||||
startsAtMs: z.coerce.number().int().min(0, "生效开始时间不正确").default(0),
|
||||
status: z.enum(["active", "disabled", "expired"]),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.jumpType === "h5" && /\s/.test(value.jumpUrl)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["jumpUrl"] });
|
||||
}
|
||||
if (value.jumpType === "app") {
|
||||
const validation = validatePublicAppJumpParam(value.jumpUrl);
|
||||
if (!validation.valid) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["jumpUrl"] });
|
||||
}
|
||||
}
|
||||
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "生效结束时间必须晚于开始时间", path: ["endsAtMs"] });
|
||||
}
|
||||
});
|
||||
|
||||
export type AppPopupForm = z.infer<typeof appPopupSchema>;
|
||||
|
||||
export const appVersionSchema = z.object({
|
||||
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
|
||||
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
|
||||
|
||||
@ -71,3 +71,36 @@
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.jumpConfigGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.72fr) minmax(320px, 1.28fr);
|
||||
align-items: start;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jumpConfigSide {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.iconUpload {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flutterJumpField {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flutterJumpField :global(.MuiInputBase-root) {
|
||||
min-height: 162px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.jumpConfigGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,27 +13,29 @@ import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
|
||||
|
||||
const pageSize = 50;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyForm = (task = {}) => ({
|
||||
actionParam: task.actionParam || "",
|
||||
actionPayloadJson: task.actionPayloadJson || "{}",
|
||||
actionType: task.actionType || "none",
|
||||
audienceType: task.audienceType || "all",
|
||||
category: task.category || "",
|
||||
description: task.description || "",
|
||||
dimensionFilterJson: task.dimensionFilterJson || "{}",
|
||||
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
|
||||
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
|
||||
iconKey: task.iconKey || "",
|
||||
iconUrl: task.iconUrl || "",
|
||||
metricType: task.metricType || "",
|
||||
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
|
||||
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
|
||||
status: task.status || "draft",
|
||||
targetUnit: task.targetUnit || "count",
|
||||
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
|
||||
taskType: task.taskType || "daily",
|
||||
title: task.title || "",
|
||||
});
|
||||
const emptyForm = (task = {}) => {
|
||||
const actionType = task.actionType || "none";
|
||||
const flutterJumpParam = taskFlutterJumpParam(actionType, task.actionParam, task.actionPayloadJson);
|
||||
|
||||
return {
|
||||
actionParam: task.actionParam || "",
|
||||
actionType,
|
||||
category: task.category || "",
|
||||
description: task.description || "",
|
||||
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
|
||||
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
|
||||
flutterJumpParam,
|
||||
iconUrl: task.iconUrl || "",
|
||||
metricType: task.metricType || "",
|
||||
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
|
||||
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
|
||||
status: task.status || "draft",
|
||||
targetUnit: task.targetUnit || "count",
|
||||
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
|
||||
taskType: task.taskType || "daily",
|
||||
title: task.title || "",
|
||||
};
|
||||
};
|
||||
|
||||
export function useDailyTasksPage() {
|
||||
const abilities = useDailyTaskAbilities();
|
||||
@ -41,7 +43,6 @@ export function useDailyTasksPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [taskType, setTaskType] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
@ -49,12 +50,11 @@ export function useDailyTasksPage() {
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
category,
|
||||
keyword: query,
|
||||
status,
|
||||
task_type: taskType,
|
||||
}),
|
||||
[category, query, status, taskType],
|
||||
[query, status, taskType],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
@ -73,13 +73,11 @@ export function useDailyTasksPage() {
|
||||
const changeQuery = resetSetter(setQuery, setPage);
|
||||
const changeStatus = resetSetter(setStatus, setPage);
|
||||
const changeTaskType = resetSetter(setTaskType, setPage);
|
||||
const changeCategory = resetSetter(setCategory, setPage);
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setTaskType("");
|
||||
setCategory("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@ -103,6 +101,15 @@ export function useDailyTasksPage() {
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const changeFormActionType = (actionType) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
actionParam: "",
|
||||
actionType,
|
||||
flutterJumpParam: defaultFlutterJumpParam(actionType),
|
||||
}));
|
||||
};
|
||||
|
||||
const submitTask = async (event) => {
|
||||
event.preventDefault();
|
||||
const editing = activeAction === "edit";
|
||||
@ -151,8 +158,7 @@ export function useDailyTasksPage() {
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
category,
|
||||
changeCategory,
|
||||
changeFormActionType,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
changeTaskType,
|
||||
@ -178,18 +184,25 @@ export function useDailyTasksPage() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskPayload(form) {
|
||||
export function buildTaskPayload(form) {
|
||||
// 表单不再暴露“跳转扩展 JSON”,但后端和 App 仍读取 action_payload_json。
|
||||
// 这里把可编辑的 Flutter 跳转参数压缩成对象 JSON,既保留后台配置能力,也避免 UI 暴露底层字段名。
|
||||
const actionPayloadJSON = normalizedJSONObject(form.flutterJumpParam || defaultFlutterJumpParam(form.actionType));
|
||||
|
||||
return {
|
||||
action_param: form.actionParam.trim(),
|
||||
action_payload_json: normalizedJSONObject(form.actionPayloadJson),
|
||||
// room_random_game / gift_panel_specific 的旧 App 合约仍从 action_param 读取 game_id/gift_id。
|
||||
// 新表单只让运营维护一份 Flutter 参数,所以提交前从对象里反抽标量,保证新旧字段保持同一含义。
|
||||
action_param: actionParamFromFlutterJump(form.actionType, actionPayloadJSON, form.actionParam),
|
||||
action_payload_json: actionPayloadJSON,
|
||||
action_type: form.actionType,
|
||||
audience_type: form.audienceType,
|
||||
category: form.category.trim(),
|
||||
// 人群、维度过滤、图标键已从表单移除:任务默认全量用户可见,不做事件维度限制,图标只使用上传 URL。
|
||||
audience_type: "all",
|
||||
category: hiddenTaskCategory(form),
|
||||
description: form.description.trim(),
|
||||
dimension_filter_json: normalizedJSONObject(form.dimensionFilterJson),
|
||||
dimension_filter_json: "{}",
|
||||
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
|
||||
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
|
||||
icon_key: form.iconKey.trim(),
|
||||
icon_key: "",
|
||||
icon_url: form.iconUrl.trim(),
|
||||
metric_type: form.metricType.trim(),
|
||||
reward_coin_amount: Number(form.rewardCoinAmount),
|
||||
@ -202,6 +215,52 @@ function buildTaskPayload(form) {
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultFlutterJumpParam(actionType) {
|
||||
const template =
|
||||
{
|
||||
gift_panel: { route: "gift_panel" },
|
||||
gift_panel_specific: { gift_id: "", route: "gift_panel" },
|
||||
none: {},
|
||||
room_random: { route: "room_random" },
|
||||
room_random_game: { game_id: "", route: "room_random", show_game: true },
|
||||
wallet: { route: "wallet" },
|
||||
}[actionType] || {};
|
||||
|
||||
return JSON.stringify(template, null, 2);
|
||||
}
|
||||
|
||||
function taskFlutterJumpParam(actionType, actionParam, actionPayloadJSON) {
|
||||
const parsed = parseObjectJSON(actionPayloadJSON);
|
||||
const payload = Object.keys(parsed).length > 0 ? parsed : parseObjectJSON(defaultFlutterJumpParam(actionType));
|
||||
const scalarParam = String(actionParam || "").trim();
|
||||
|
||||
if (actionType === "room_random_game" && scalarParam && !payload.game_id) {
|
||||
payload.game_id = scalarParam;
|
||||
}
|
||||
if (actionType === "gift_panel_specific" && scalarParam && !payload.gift_id) {
|
||||
payload.gift_id = scalarParam;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function actionParamFromFlutterJump(actionType, actionPayloadJSON, fallback = "") {
|
||||
const payload = parseObjectJSON(actionPayloadJSON);
|
||||
|
||||
if (actionType === "room_random_game") {
|
||||
return String(payload.game_id || payload.gameId || fallback || "").trim();
|
||||
}
|
||||
if (actionType === "gift_panel_specific") {
|
||||
return String(payload.gift_id || payload.giftId || fallback || "").trim();
|
||||
}
|
||||
return String(fallback || "").trim();
|
||||
}
|
||||
|
||||
function hiddenTaskCategory(form) {
|
||||
const value = String(form.category || form.metricType || form.title || "task").trim();
|
||||
return value.slice(0, 32) || "task";
|
||||
}
|
||||
|
||||
function normalizedJSONObject(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) {
|
||||
@ -218,6 +277,16 @@ function normalizedJSONObject(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseObjectJSON(value) {
|
||||
const normalized = normalizedJSONObject(value);
|
||||
try {
|
||||
const parsed = JSON.parse(normalized);
|
||||
return parsed && !Array.isArray(parsed) && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resetSetter(setter, setPage) {
|
||||
return (value) => {
|
||||
setter(value);
|
||||
|
||||
@ -21,12 +21,11 @@ import {
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
taskActionLabels,
|
||||
taskActionOptions,
|
||||
taskAudienceLabels,
|
||||
taskAudienceOptions,
|
||||
taskMetricOptions,
|
||||
taskStatusLabels,
|
||||
taskStatusOptions,
|
||||
@ -51,7 +50,9 @@ const baseColumns = [
|
||||
render: (task) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{task.metricType}</span>
|
||||
<span className={styles.meta}>{task.category}</span>
|
||||
<span className={styles.meta}>
|
||||
{formatNumber(task.targetValue)} {taskUnitLabel(task.targetUnit)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -61,11 +62,8 @@ const baseColumns = [
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (task) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{taskAudienceLabel(task.audienceType)}</span>
|
||||
<span className={styles.meta}>
|
||||
{taskActionLabel(task.actionType)}
|
||||
{task.actionParam ? `:${task.actionParam}` : ""}
|
||||
</span>
|
||||
<span>{taskActionLabel(task.actionType)}</span>
|
||||
<span className={styles.meta}>{task.actionParam || task.actionPayloadJson || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -141,14 +139,7 @@ export function DailyTaskListPage() {
|
||||
};
|
||||
}
|
||||
if (column.key === "metric") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索分类",
|
||||
value: page.category,
|
||||
onChange: page.changeCategory,
|
||||
}),
|
||||
};
|
||||
return column;
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
@ -183,7 +174,7 @@ export function DailyTaskListPage() {
|
||||
onChange={page.changeTaskType}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.query && !page.status && !page.taskType && !page.category}
|
||||
disabled={!page.query && !page.status && !page.taskType}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
</>
|
||||
@ -328,13 +319,6 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
value={form.title}
|
||||
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="任务分类"
|
||||
required
|
||||
value={form.category}
|
||||
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="目标与奖励">
|
||||
@ -393,70 +377,41 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="展示与跳转">
|
||||
<AdminFormFieldGrid>
|
||||
<div className={styles.jumpConfigGrid}>
|
||||
<div className={styles.jumpConfigSide}>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="跳转类型"
|
||||
required
|
||||
select
|
||||
value={form.actionType}
|
||||
onChange={(event) => page.changeFormActionType(event.target.value)}
|
||||
>
|
||||
{taskActionOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<UploadField
|
||||
className={styles.iconUpload}
|
||||
density="compact"
|
||||
disabled={disabled || !page.abilities.canUpload}
|
||||
label="任务图标"
|
||||
value={form.iconUrl}
|
||||
onChange={(iconUrl) => page.setForm({ ...form, iconUrl })}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
className={styles.flutterJumpField}
|
||||
disabled={disabled}
|
||||
label="适用人群"
|
||||
required
|
||||
select
|
||||
value={form.audienceType}
|
||||
onChange={(event) => page.setForm({ ...form, audienceType: event.target.value })}
|
||||
>
|
||||
{taskAudienceOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="跳转类型"
|
||||
required
|
||||
select
|
||||
value={form.actionType}
|
||||
onChange={(event) => page.setForm({ ...form, actionType: event.target.value })}
|
||||
>
|
||||
{taskActionOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="跳转参数"
|
||||
value={form.actionParam}
|
||||
onChange={(event) => page.setForm({ ...form, actionParam: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="图标键"
|
||||
value={form.iconKey}
|
||||
onChange={(event) => page.setForm({ ...form, iconKey: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="图标 URL"
|
||||
value={form.iconUrl}
|
||||
onChange={(event) => page.setForm({ ...form, iconUrl: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="维度过滤 JSON"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.dimensionFilterJson}
|
||||
onChange={(event) => page.setForm({ ...form, dimensionFilterJson: event.target.value })}
|
||||
minRows={5}
|
||||
label="Flutter 跳转参数"
|
||||
value={form.flutterJumpParam}
|
||||
onChange={(event) => page.setForm({ ...form, flutterJumpParam: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="跳转扩展 JSON"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.actionPayloadJson}
|
||||
onChange={(event) => page.setForm({ ...form, actionPayloadJson: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</div>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="有效期">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
@ -500,10 +455,6 @@ function taskStatusLabel(value) {
|
||||
return taskStatusLabels[value] || value || "-";
|
||||
}
|
||||
|
||||
function taskAudienceLabel(value) {
|
||||
return taskAudienceLabels[value] || value || "-";
|
||||
}
|
||||
|
||||
function taskActionLabel(value) {
|
||||
return taskActionLabels[value] || value || "-";
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ export function useDailyTaskAbilities() {
|
||||
canCreate: can(PERMISSIONS.dailyTaskCreate),
|
||||
canStatus: can(PERMISSIONS.dailyTaskStatus),
|
||||
canUpdate: can(PERMISSIONS.dailyTaskUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
canView: can(PERMISSIONS.dailyTaskView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,16 +2,13 @@ import { z } from "zod";
|
||||
|
||||
export const dailyTaskFormSchema = z
|
||||
.object({
|
||||
category: z.string().trim().min(1, "请输入任务分类").max(32, "任务分类不能超过 32 个字符"),
|
||||
actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符"),
|
||||
actionPayloadJson: z.string().trim().optional(),
|
||||
actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符").optional(),
|
||||
actionType: z.enum(["none", "room_random", "room_random_game", "wallet", "gift_panel", "gift_panel_specific"]),
|
||||
audienceType: z.enum(["all", "newbie"]),
|
||||
category: z.string().trim().max(32, "任务分类不能超过 32 个字符").optional(),
|
||||
description: z.string().trim().max(512, "描述不能超过 512 个字符"),
|
||||
dimensionFilterJson: z.string().trim().optional(),
|
||||
effectiveFrom: z.string().optional(),
|
||||
effectiveTo: z.string().optional(),
|
||||
iconKey: z.string().trim().max(64, "图标键不能超过 64 个字符"),
|
||||
flutterJumpParam: z.string().trim().optional(),
|
||||
iconUrl: z.string().trim().max(512, "图标 URL 不能超过 512 个字符"),
|
||||
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
|
||||
rewardCoinAmount: z.union([z.string(), z.number()]),
|
||||
@ -47,11 +44,12 @@ export const dailyTaskFormSchema = z
|
||||
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
||||
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
|
||||
}
|
||||
if (!isJSONObject(value.actionPayloadJson)) {
|
||||
context.addIssue({ code: "custom", message: "跳转扩展必须是 JSON 对象", path: ["actionPayloadJson"] });
|
||||
}
|
||||
if (!isJSONObject(value.dimensionFilterJson)) {
|
||||
context.addIssue({ code: "custom", message: "维度过滤必须是 JSON 对象", path: ["dimensionFilterJson"] });
|
||||
if (!isJSONObject(value.flutterJumpParam)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Flutter 跳转参数必须是对象格式",
|
||||
path: ["flutterJumpParam"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
38
src/features/daily-tasks/taskPayload.test.js
Normal file
38
src/features/daily-tasks/taskPayload.test.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { buildTaskPayload, defaultFlutterJumpParam } from "./hooks/useDailyTasksPage.js";
|
||||
import { dailyTaskFormSchema } from "./schema";
|
||||
|
||||
test("daily task payload hides removed admin fields and keeps Flutter jump params editable", () => {
|
||||
const form = parseForm(dailyTaskFormSchema, {
|
||||
actionType: "room_random_game",
|
||||
description: "",
|
||||
effectiveFrom: "",
|
||||
effectiveTo: "",
|
||||
flutterJumpParam: JSON.stringify({ game_id: "dice", route: "room_random", show_game: true }, null, 2),
|
||||
iconUrl: "https://media.haiyihy.com/admin/images/task-game.png",
|
||||
metricType: "game_spend_coin",
|
||||
rewardCoinAmount: "20",
|
||||
sortOrder: "3",
|
||||
status: "active",
|
||||
targetUnit: "coin",
|
||||
targetValue: "100",
|
||||
taskType: "daily",
|
||||
title: "玩游戏消耗金币",
|
||||
});
|
||||
|
||||
const payload = buildTaskPayload(form);
|
||||
|
||||
expect(payload.category).toBe("game_spend_coin");
|
||||
expect(payload.audience_type).toBe("all");
|
||||
expect(payload.dimension_filter_json).toBe("{}");
|
||||
expect(payload.icon_key).toBe("");
|
||||
expect(payload.icon_url).toBe("https://media.haiyihy.com/admin/images/task-game.png");
|
||||
expect(payload.action_param).toBe("dice");
|
||||
expect(payload.action_payload_json).toBe('{"game_id":"dice","route":"room_random","show_game":true}');
|
||||
});
|
||||
|
||||
test("daily task action type selection provides concrete Flutter jump template", () => {
|
||||
expect(defaultFlutterJumpParam("gift_panel_specific")).toContain('"gift_id": ""');
|
||||
expect(defaultFlutterJumpParam("wallet")).toContain('"route": "wallet"');
|
||||
});
|
||||
178
src/features/invite-activity-reward/api.ts
Normal file
178
src/features/invite-activity-reward/api.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { apiRequest } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
||||
|
||||
export interface InviteActivityRewardTierDto {
|
||||
tierId: number;
|
||||
rewardType: string;
|
||||
tierCode: string;
|
||||
tierName: string;
|
||||
thresholdCoinAmount: number;
|
||||
thresholdValidInviteCount: number;
|
||||
rewardCoinAmount: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface InviteActivityRewardConfigDto {
|
||||
appCode?: string;
|
||||
enabled: boolean;
|
||||
tiers: InviteActivityRewardTierDto[];
|
||||
updatedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface InviteActivityRewardConfigPayload {
|
||||
enabled: boolean;
|
||||
tiers: Array<{
|
||||
tier_id?: number;
|
||||
reward_type: string;
|
||||
tier_code: string;
|
||||
tier_name: string;
|
||||
threshold_coin_amount: number;
|
||||
threshold_valid_invite_count: number;
|
||||
reward_coin_amount: number;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InviteActivityRewardClaimUserDto {
|
||||
userId?: number;
|
||||
displayUserId?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface InviteActivityRewardClaimDto {
|
||||
claimId: string;
|
||||
appCode?: string;
|
||||
cycleKey?: string;
|
||||
userId: number;
|
||||
user?: InviteActivityRewardClaimUserDto;
|
||||
rewardType?: string;
|
||||
commandId?: string;
|
||||
tierId?: number;
|
||||
tierCode?: string;
|
||||
tierName?: string;
|
||||
thresholdCoinAmount?: number;
|
||||
thresholdValidInviteCount?: number;
|
||||
reachedValue?: number;
|
||||
rewardCoinAmount?: number;
|
||||
status?: string;
|
||||
walletCommandId?: string;
|
||||
walletTransactionId?: string;
|
||||
failureReason?: string;
|
||||
grantedAtMs?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
type RawConfig = InviteActivityRewardConfigDto & Record<string, unknown>;
|
||||
type RawTier = InviteActivityRewardTierDto & Record<string, unknown>;
|
||||
type RawClaim = InviteActivityRewardClaimDto & Record<string, unknown>;
|
||||
|
||||
export function getInviteActivityRewardConfig(): Promise<InviteActivityRewardConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig;
|
||||
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getInviteActivityRewardConfig), {
|
||||
method: endpoint.method,
|
||||
}).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function updateInviteActivityRewardConfig(
|
||||
payload: InviteActivityRewardConfigPayload,
|
||||
): Promise<InviteActivityRewardConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.updateInviteActivityRewardConfig;
|
||||
return apiRequest<RawConfig, InviteActivityRewardConfigPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateInviteActivityRewardConfig),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function listInviteActivityRewardClaims(
|
||||
query: PageQuery = {},
|
||||
): Promise<ApiPage<InviteActivityRewardClaimDto>> {
|
||||
const endpoint = API_ENDPOINTS.listInviteActivityRewardClaims;
|
||||
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listInviteActivityRewardClaims), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeClaim),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto {
|
||||
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||
return {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
enabled: Boolean(item.enabled),
|
||||
tiers: rawTiers.map(normalizeTier),
|
||||
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTier(item: RawTier): InviteActivityRewardTierDto {
|
||||
return {
|
||||
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||
rewardType: stringValue(item.rewardType ?? item.reward_type),
|
||||
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||
tierName: stringValue(item.tierName ?? item.tier_name),
|
||||
thresholdCoinAmount: numberValue(item.thresholdCoinAmount ?? item.threshold_coin_amount),
|
||||
thresholdValidInviteCount: numberValue(item.thresholdValidInviteCount ?? item.threshold_valid_invite_count),
|
||||
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||
status: stringValue(item.status) || "active",
|
||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClaim(item: RawClaim): InviteActivityRewardClaimDto {
|
||||
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||
return {
|
||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
cycleKey: stringValue(item.cycleKey ?? item.cycle_key),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
user: {
|
||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
username: stringValue(rawUser.username),
|
||||
avatar: stringValue(rawUser.avatar),
|
||||
},
|
||||
rewardType: stringValue(item.rewardType ?? item.reward_type),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||
tierName: stringValue(item.tierName ?? item.tier_name),
|
||||
thresholdCoinAmount: numberValue(item.thresholdCoinAmount ?? item.threshold_coin_amount),
|
||||
thresholdValidInviteCount: numberValue(item.thresholdValidInviteCount ?? item.threshold_valid_invite_count),
|
||||
reachedValue: numberValue(item.reachedValue ?? item.reached_value),
|
||||
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||
status: stringValue(item.status),
|
||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
|
||||
import styles from "@/features/invite-activity-reward/invite-activity-reward.module.css";
|
||||
|
||||
const rewardTypeOptions = [
|
||||
["recharge", "累计充值"],
|
||||
["valid_invite", "有效人数"],
|
||||
];
|
||||
|
||||
export function InviteActivityRewardConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
configSaving,
|
||||
form,
|
||||
onClose,
|
||||
onSubmit,
|
||||
open,
|
||||
setForm,
|
||||
}) {
|
||||
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||
|
||||
const addTier = () => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
tiers: [
|
||||
...(current.tiers || []),
|
||||
{
|
||||
tierId: 0,
|
||||
rewardType: "recharge",
|
||||
tierCode: `invite_reward_${Date.now()}_${(current.tiers || []).length + 1}`,
|
||||
tierName: "",
|
||||
thresholdCoinAmount: "",
|
||||
thresholdValidInviteCount: "",
|
||||
rewardCoinAmount: "",
|
||||
status: "active",
|
||||
sortOrder: String((current.tiers || []).length),
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateTier = (index, patch) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
tiers: (current.tiers || []).map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier)),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeTier = (index) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
tiers: (current.tiers || []).filter((_, tierIndex) => tierIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
|
||||
<h2>邀请活动配置</h2>
|
||||
<div className="form-drawer__content">
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">活动状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label="邀请活动状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<RewardTierEditorTable
|
||||
columns={[
|
||||
{
|
||||
key: "tier",
|
||||
label: "档位",
|
||||
render: (_tier, index) => (
|
||||
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rewardType",
|
||||
label: "类型",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
value={tier.rewardType || "recharge"}
|
||||
onChange={(event) => updateTier(index, { rewardType: event.target.value })}
|
||||
>
|
||||
{rewardTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "threshold",
|
||||
label: "门槛",
|
||||
render: (tier, index) => {
|
||||
const isRecharge = (tier.rewardType || "recharge") === "recharge";
|
||||
return (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
placeholder={isRecharge ? "80000" : "1"}
|
||||
type="number"
|
||||
value={
|
||||
isRecharge
|
||||
? tier.thresholdCoinAmount
|
||||
: tier.thresholdValidInviteCount
|
||||
}
|
||||
onChange={(event) =>
|
||||
updateTier(
|
||||
index,
|
||||
isRecharge
|
||||
? { thresholdCoinAmount: event.target.value }
|
||||
: { thresholdValidInviteCount: event.target.value },
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "rewardCoinAmount",
|
||||
label: "奖励金币",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
placeholder="1000"
|
||||
type="number"
|
||||
value={tier.rewardCoinAmount}
|
||||
onChange={(event) => updateTier(index, { rewardCoinAmount: event.target.value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (tier, index) => (
|
||||
<AdminSwitch
|
||||
checked={tier.status !== "inactive"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label={`档位 ${index + 1} 状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
updateTier(index, { status: event.target.checked ? "active" : "inactive" })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
emptyText="暂无档位"
|
||||
gridTemplateColumns="72px minmax(130px, 0.7fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) 120px 52px"
|
||||
rows={form.tiers || []}
|
||||
onRemoveTier={removeTier}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="form-drawer__actions">
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import styles from "@/features/invite-activity-reward/invite-activity-reward.module.css";
|
||||
|
||||
export function InviteActivityRewardConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
|
||||
const tiers = config?.tiers || [];
|
||||
const activeTiers = tiers.filter((tier) => tier.status === "active");
|
||||
const rechargeTiers = activeTiers.filter((tier) => tier.rewardType === "recharge");
|
||||
const validInviteTiers = activeTiers.filter((tier) => tier.rewardType === "valid_invite");
|
||||
|
||||
return (
|
||||
<div className={styles.summaryPanel}>
|
||||
<div className={styles.summaryItems}>
|
||||
<SummaryItem label="状态">
|
||||
<span
|
||||
className={[
|
||||
styles.statusBadge,
|
||||
config?.enabled ? styles.statusActive : styles.statusInactive,
|
||||
].join(" ")}
|
||||
>
|
||||
{config?.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</SummaryItem>
|
||||
<SummaryItem label="累计充值档位">{thresholdSummary(rechargeTiers, "thresholdCoinAmount", "金币")}</SummaryItem>
|
||||
<SummaryItem label="有效人数档位">
|
||||
{thresholdSummary(validInviteTiers, "thresholdValidInviteCount", "人")}
|
||||
</SummaryItem>
|
||||
<SummaryItem label="奖励">{thresholdSummary(activeTiers, "rewardCoinAmount", "金币")}</SummaryItem>
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
disabled={configLoading}
|
||||
startIcon={<EditOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={onEdit}
|
||||
>
|
||||
编辑配置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ children, label }) {
|
||||
return (
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>{label}</span>
|
||||
<span className={styles.summaryValue}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function thresholdSummary(tiers, field, unit) {
|
||||
if (!tiers.length) {
|
||||
return "-";
|
||||
}
|
||||
const values = tiers.map((tier) => Number(tier[field] || 0)).filter((value) => value > 0);
|
||||
if (!values.length) {
|
||||
return "-";
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return min === max ? `${formatNumber(min)} ${unit}` : `${formatNumber(min)} - ${formatNumber(max)} ${unit}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getInviteActivityRewardConfig,
|
||||
listInviteActivityRewardClaims,
|
||||
updateInviteActivityRewardConfig,
|
||||
} from "@/features/invite-activity-reward/api";
|
||||
import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const pageSize = 50;
|
||||
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyForm = { enabled: true, tiers: [] };
|
||||
|
||||
export function useInviteActivityRewardPage() {
|
||||
const abilities = useInviteActivityRewardAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [config, setConfig] = useState(null);
|
||||
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [cycleKey, setCycleKey] = useState("");
|
||||
const [rewardType, setRewardType] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(
|
||||
() => ({ keyword: query, cycle_key: cycleKey, reward_type: rewardType, status }),
|
||||
[cycleKey, query, rewardType, status],
|
||||
);
|
||||
const {
|
||||
data: claims = emptyClaims,
|
||||
error: claimsError,
|
||||
loading: claimsLoading,
|
||||
reload: reloadClaims,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载邀请活动领取记录失败",
|
||||
fetcher: listInviteActivityRewardClaims,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["invite-activity-reward-claims", filters, page],
|
||||
});
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const loaded = await getInviteActivityRewardConfig();
|
||||
setConfig(loaded);
|
||||
setForm(formFromConfig(loaded));
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载邀请活动配置失败", "error");
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
|
||||
const openConfigDrawer = useCallback(() => {
|
||||
setForm(config ? formFromConfig(config) : emptyForm);
|
||||
setConfigDrawerOpen(true);
|
||||
}, [config]);
|
||||
|
||||
const closeConfigDrawer = useCallback(() => {
|
||||
setConfigDrawerOpen(false);
|
||||
setForm(config ? formFromConfig(config) : emptyForm);
|
||||
}, [config]);
|
||||
|
||||
const changeQuery = (value) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeCycleKey = (value) => {
|
||||
setCycleKey(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeRewardType = (value) => {
|
||||
setRewardType(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
return;
|
||||
}
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const saved = await updateInviteActivityRewardConfig(payloadFromForm(form));
|
||||
setConfig(saved);
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("邀请活动配置已保存", "success");
|
||||
await reloadClaims();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存邀请活动配置失败", "error");
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeCycleKey,
|
||||
changeQuery,
|
||||
changeRewardType,
|
||||
changeStatus,
|
||||
claims,
|
||||
claimsError,
|
||||
claimsLoading,
|
||||
closeConfigDrawer,
|
||||
config,
|
||||
configDrawerOpen,
|
||||
configLoading,
|
||||
configSaving,
|
||||
cycleKey,
|
||||
form,
|
||||
openConfigDrawer,
|
||||
page,
|
||||
query,
|
||||
reloadClaims,
|
||||
reloadConfig,
|
||||
rewardType,
|
||||
setForm,
|
||||
setPage,
|
||||
status,
|
||||
submitConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function formFromConfig(config) {
|
||||
return {
|
||||
enabled: Boolean(config.enabled),
|
||||
tiers: (config.tiers || []).map((tier) => ({
|
||||
tierId: tier.tierId || 0,
|
||||
rewardType: tier.rewardType || "recharge",
|
||||
tierCode: tier.tierCode || "",
|
||||
tierName: tier.tierName || "",
|
||||
thresholdCoinAmount: tier.thresholdCoinAmount ? String(tier.thresholdCoinAmount) : "",
|
||||
thresholdValidInviteCount: tier.thresholdValidInviteCount ? String(tier.thresholdValidInviteCount) : "",
|
||||
rewardCoinAmount: tier.rewardCoinAmount ? String(tier.rewardCoinAmount) : "",
|
||||
status: tier.status || "active",
|
||||
sortOrder: String(tier.sortOrder || 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
const tiers = (form.tiers || []).map((tier, index) => {
|
||||
const rewardType = tier.rewardType === "valid_invite" ? "valid_invite" : "recharge";
|
||||
const thresholdCoinAmount = rewardType === "recharge" ? Number(tier.thresholdCoinAmount || 0) : 0;
|
||||
const thresholdValidInviteCount = rewardType === "valid_invite" ? Number(tier.thresholdValidInviteCount || 0) : 0;
|
||||
const rewardCoinAmount = Number(tier.rewardCoinAmount || 0);
|
||||
if (rewardType === "recharge" && thresholdCoinAmount <= 0) {
|
||||
throw new Error("累计充值金币门槛必须大于 0");
|
||||
}
|
||||
if (rewardType === "valid_invite" && thresholdValidInviteCount <= 0) {
|
||||
throw new Error("有效人数门槛必须大于 0");
|
||||
}
|
||||
if (rewardCoinAmount <= 0) {
|
||||
throw new Error("奖励金币必须大于 0");
|
||||
}
|
||||
const tierCode = String(tier.tierCode || "").trim() || `invite_${rewardType}_${Date.now()}_${index + 1}`;
|
||||
const tierName =
|
||||
String(tier.tierName || "").trim() ||
|
||||
(rewardType === "recharge"
|
||||
? `累计充值 ${formatNumber(thresholdCoinAmount)} 金币`
|
||||
: `有效邀请 ${formatNumber(thresholdValidInviteCount)} 人`);
|
||||
return {
|
||||
tier_id: Number(tier.tierId || 0),
|
||||
reward_type: rewardType,
|
||||
tier_code: tierCode,
|
||||
tier_name: tierName,
|
||||
threshold_coin_amount: thresholdCoinAmount,
|
||||
threshold_valid_invite_count: thresholdValidInviteCount,
|
||||
reward_coin_amount: rewardCoinAmount,
|
||||
status: tier.status === "inactive" ? "inactive" : "active",
|
||||
sort_order: index,
|
||||
};
|
||||
});
|
||||
if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) {
|
||||
throw new Error("开启后至少需要一个启用档位");
|
||||
}
|
||||
return { enabled: Boolean(form.enabled), tiers };
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
.summaryPanel {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summaryActions,
|
||||
.summaryItems {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.summaryItems {
|
||||
flex: 1;
|
||||
gap: var(--space-7);
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
height: 26px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.statusActive {
|
||||
background: var(--success-surface);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.statusInactive {
|
||||
background: var(--fill-secondary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawerSectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.summaryPanel {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summaryItems {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx";
|
||||
import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
|
||||
import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js";
|
||||
import styles from "@/features/invite-activity-reward/invite-activity-reward.module.css";
|
||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const claimStatusOptions = [
|
||||
["pending", "发放中"],
|
||||
["granted", "已发放"],
|
||||
["failed", "发放失败"],
|
||||
];
|
||||
|
||||
const rewardTypeOptions = [
|
||||
["recharge", "累计充值"],
|
||||
["valid_invite", "有效人数"],
|
||||
];
|
||||
|
||||
const columnsBase = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(260px, 1.1fr)",
|
||||
render: (claim) => <ClaimUser claim={claim} />,
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期",
|
||||
width: "minmax(140px, 0.6fr)",
|
||||
render: (claim) => claim.cycleKey || "-",
|
||||
},
|
||||
{
|
||||
key: "rewardType",
|
||||
label: "奖励类型",
|
||||
width: "minmax(130px, 0.6fr)",
|
||||
render: (claim) => rewardTypeLabel(claim.rewardType),
|
||||
},
|
||||
{
|
||||
key: "tier",
|
||||
label: "档位",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.tierName || claim.tierCode || "-"}</span>
|
||||
<span className={styles.meta}>{thresholdLabel(claim)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reached",
|
||||
label: "达成值",
|
||||
width: "minmax(150px, 0.65fr)",
|
||||
render: (claim) => reachedLabel(claim),
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "奖励金币",
|
||||
width: "minmax(130px, 0.6fr)",
|
||||
render: (claim) => formatNumber(claim.rewardCoinAmount),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (claim) => claimStatusLabel(claim.status),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "创建时间",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => formatMillis(claim.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "claimId",
|
||||
label: "记录",
|
||||
width: "minmax(280px, 1fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.claimId}</span>
|
||||
<span className={styles.meta}>{claim.walletTransactionId || claim.walletCommandId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "failure",
|
||||
label: "失败原因",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => claim.failureReason || "-",
|
||||
},
|
||||
];
|
||||
|
||||
export function InviteActivityRewardPage() {
|
||||
const page = useInviteActivityRewardPage();
|
||||
const total = page.claims.total || 0;
|
||||
const columns = columnsBase.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、短号、名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "cycle") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "周期,如 2026-06",
|
||||
value: page.cycleKey,
|
||||
onChange: page.changeCycleKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "rewardType") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部类型"], ...rewardTypeOptions],
|
||||
placeholder: "奖励类型",
|
||||
value: page.rewardType,
|
||||
onChange: page.changeRewardType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部状态"], ...claimStatusOptions],
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<InviteActivityRewardConfigSummary
|
||||
canUpdate={page.abilities.canUpdate}
|
||||
config={page.config}
|
||||
configLoading={page.configLoading}
|
||||
onEdit={page.openConfigDrawer}
|
||||
onRefresh={page.reloadConfig}
|
||||
/>
|
||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={page.claims.items || []}
|
||||
minWidth="1580px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.claims.pageSize || 50,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(claim) => claim.claimId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<InviteActivityRewardConfigDrawer
|
||||
abilities={page.abilities}
|
||||
configLoading={page.configLoading}
|
||||
configSaving={page.configSaving}
|
||||
form={page.form}
|
||||
open={page.configDrawerOpen}
|
||||
setForm={page.setForm}
|
||||
onClose={page.closeConfigDrawer}
|
||||
onSubmit={page.submitConfig}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimUser({ claim }) {
|
||||
const user = claim.user || {};
|
||||
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
|
||||
}
|
||||
|
||||
function rewardTypeLabel(type) {
|
||||
return rewardTypeOptions.find(([value]) => value === type)?.[1] || type || "-";
|
||||
}
|
||||
|
||||
function claimStatusLabel(status) {
|
||||
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||
}
|
||||
|
||||
function thresholdLabel(claim) {
|
||||
if (claim.rewardType === "valid_invite") {
|
||||
return `${formatNumber(claim.thresholdValidInviteCount)} 人`;
|
||||
}
|
||||
return `${formatNumber(claim.thresholdCoinAmount)} 金币`;
|
||||
}
|
||||
|
||||
function reachedLabel(claim) {
|
||||
const unit = claim.rewardType === "valid_invite" ? "人" : "金币";
|
||||
return `${formatNumber(claim.reachedValue)} ${unit}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
11
src/features/invite-activity-reward/permissions.js
Normal file
11
src/features/invite-activity-reward/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useInviteActivityRewardAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canUpdate: can(PERMISSIONS.inviteActivityRewardUpdate),
|
||||
canView: can(PERMISSIONS.inviteActivityRewardView),
|
||||
};
|
||||
}
|
||||
12
src/features/invite-activity-reward/routes.js
Normal file
12
src/features/invite-activity-reward/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const inviteActivityRewardRoutes = [
|
||||
{
|
||||
label: "邀请活动",
|
||||
loader: () => import("./pages/InviteActivityRewardPage.jsx").then((module) => module.InviteActivityRewardPage),
|
||||
menuCode: MENU_CODES.inviteActivityReward,
|
||||
pageKey: "invite-activity-reward",
|
||||
path: "/activities/invite-reward",
|
||||
permission: PERMISSIONS.inviteActivityRewardView,
|
||||
},
|
||||
];
|
||||
@ -9,6 +9,7 @@ import {
|
||||
disableGift,
|
||||
disableResource,
|
||||
disableResourceGroup,
|
||||
deleteGift,
|
||||
enableResourceShopItem,
|
||||
enableGift,
|
||||
enableResource,
|
||||
@ -150,6 +151,7 @@ test("resource APIs use generated admin paths", async () => {
|
||||
});
|
||||
await enableGift("rose");
|
||||
await disableGift("rose");
|
||||
await deleteGift("rose");
|
||||
await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" });
|
||||
await lookupResourceGrantTarget("163000");
|
||||
await grantResource({
|
||||
@ -181,10 +183,11 @@ test("resource APIs use generated admin paths", async () => {
|
||||
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
|
||||
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
|
||||
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
|
||||
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[14];
|
||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[15];
|
||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[16];
|
||||
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[17];
|
||||
const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14];
|
||||
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15];
|
||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
|
||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17];
|
||||
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18];
|
||||
|
||||
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
|
||||
expect(String(listUrl)).toContain("resource_type=gift");
|
||||
@ -239,6 +242,8 @@ test("resource APIs use generated admin paths", async () => {
|
||||
expect(enableGiftInit?.method).toBe("POST");
|
||||
expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable");
|
||||
expect(disableGiftInit?.method).toBe("POST");
|
||||
expect(String(deleteGiftUrl)).toContain("/api/v1/admin/gifts/rose");
|
||||
expect(deleteGiftInit?.method).toBe("DELETE");
|
||||
expect(String(grantListUrl)).toContain("/api/v1/admin/resource-grants?");
|
||||
expect(String(grantListUrl)).toContain("target_user_id=1001");
|
||||
expect(grantListInit?.method).toBe("GET");
|
||||
@ -257,7 +262,10 @@ test("resource APIs use generated admin paths", async () => {
|
||||
});
|
||||
|
||||
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))),
|
||||
);
|
||||
|
||||
await updateResourceMp4Layouts([
|
||||
{
|
||||
|
||||
@ -466,6 +466,13 @@ export function updateGift(giftId: string, payload: GiftPayload): Promise<GiftDt
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteGift(giftId: string): Promise<GiftDto> {
|
||||
const endpoint = API_ENDPOINTS.deleteGift;
|
||||
return apiRequest<GiftDto>(apiEndpointPath(API_OPERATIONS.deleteGift, { gift_id: giftId }), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function enableGift(giftId: string): Promise<GiftDto> {
|
||||
const endpoint = API_ENDPOINTS.enableGift;
|
||||
return apiRequest<GiftDto>(apiEndpointPath(API_OPERATIONS.enableGift, { gift_id: giftId }), {
|
||||
|
||||
@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createEmojiPack,
|
||||
createGift,
|
||||
createResource,
|
||||
createResourceGroup,
|
||||
deleteGift as requestDeleteGift,
|
||||
disableResourceShopItem,
|
||||
disableGift,
|
||||
disableResource,
|
||||
@ -886,6 +888,7 @@ export function useResourceGroupListPage() {
|
||||
|
||||
export function useGiftListPage() {
|
||||
const abilities = useResourceAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
@ -1143,6 +1146,41 @@ export function useGiftListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGifts = async (gifts = []) => {
|
||||
const targetGifts = gifts.filter((gift) => gift?.giftId);
|
||||
if (!abilities.canDeleteGift || !targetGifts.length) {
|
||||
return false;
|
||||
}
|
||||
const title = targetGifts.length > 1 ? "批量删除礼物" : "删除礼物";
|
||||
const message =
|
||||
targetGifts.length > 1
|
||||
? `确认删除选中的 ${targetGifts.length} 个礼物?只删除礼物列表数据,不删除资源列表数据。`
|
||||
: `确认删除礼物 ${targetGifts[0].name || targetGifts[0].giftId}?只删除礼物列表数据,不删除资源列表数据。`;
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message,
|
||||
title,
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
setLoadingAction(targetGifts.length > 1 ? "gift-delete-batch" : `gift-delete-${targetGifts[0].giftId}`);
|
||||
try {
|
||||
for (const gift of targetGifts) {
|
||||
await requestDeleteGift(gift.giftId);
|
||||
}
|
||||
showToast(targetGifts.length > 1 ? `已删除 ${targetGifts.length} 个礼物` : "礼物已删除", "success");
|
||||
await result.reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除礼物失败", "error");
|
||||
return false;
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const saveGiftSortOrder = async (gift, rawSortOrder) => {
|
||||
if (!abilities.canUpdateGift || !gift?.giftId) {
|
||||
return false;
|
||||
@ -1200,6 +1238,7 @@ export function useGiftListPage() {
|
||||
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
|
||||
closeAction,
|
||||
closeGiftTypeDialog,
|
||||
deleteGifts,
|
||||
form,
|
||||
giftSortSavingIds,
|
||||
giftTypeCode,
|
||||
|
||||
@ -3,16 +3,19 @@ import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
|
||||
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Popover from "@mui/material/Popover";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
@ -128,7 +131,7 @@ const baseColumns = (giftTypeOptions) => [
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(76px, 0.4fr)",
|
||||
width: "minmax(112px, 0.5fr)",
|
||||
},
|
||||
];
|
||||
|
||||
@ -136,75 +139,148 @@ export function GiftListPage() {
|
||||
const page = useGiftListPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const [selectedGiftIds, setSelectedGiftIds] = useState([]);
|
||||
const isEditing = page.activeAction === "edit";
|
||||
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
|
||||
const regionLabelById = buildRegionLabelMap(giftRegionOptions);
|
||||
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
|
||||
const tableColumns = baseColumns(giftTypeOptions).map((column) => {
|
||||
if (column.key === "regions") {
|
||||
return {
|
||||
...column,
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: giftRegionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId,
|
||||
}),
|
||||
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
|
||||
};
|
||||
const selectedGiftIdSet = useMemo(() => new Set(selectedGiftIds), [selectedGiftIds]);
|
||||
const selectedGifts = useMemo(
|
||||
() => items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))),
|
||||
[items, selectedGiftIdSet],
|
||||
);
|
||||
const allChecked = items.length > 0 && items.every((gift) => selectedGiftIdSet.has(String(gift.giftId)));
|
||||
const selectedOnPageCount = items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))).length;
|
||||
const someChecked = selectedOnPageCount > 0 && !allChecked;
|
||||
const deletingBatch = page.loadingAction === "gift-delete-batch";
|
||||
|
||||
useEffect(() => {
|
||||
const itemIds = new Set(items.map((gift) => String(gift.giftId)));
|
||||
setSelectedGiftIds((current) => {
|
||||
const next = current.filter((giftId) => itemIds.has(giftId));
|
||||
return next.length === current.length ? current : next;
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const toggleAllGifts = (checked) => {
|
||||
const pageIds = items.map((gift) => String(gift.giftId));
|
||||
setSelectedGiftIds((current) => {
|
||||
if (!checked) {
|
||||
return current.filter((giftId) => !pageIds.includes(giftId));
|
||||
}
|
||||
return Array.from(new Set([...current, ...pageIds]));
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGiftSelection = (gift, checked) => {
|
||||
const giftId = String(gift.giftId);
|
||||
setSelectedGiftIds((current) =>
|
||||
checked ? Array.from(new Set([...current, giftId])) : current.filter((selectedId) => selectedId !== giftId),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteSelectedGifts = async () => {
|
||||
const deleted = await page.deleteGifts(selectedGifts);
|
||||
if (deleted) {
|
||||
setSelectedGiftIds([]);
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "type") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
emptyLabel: "全部类型",
|
||||
loading: page.giftTypesLoading,
|
||||
options: giftTypeOptions.map((item) => ({
|
||||
label: item.displayName || item.tabName || item.tabKey,
|
||||
value: item.tabKey,
|
||||
})),
|
||||
placeholder: "搜索类型",
|
||||
value: page.giftTypeCode,
|
||||
onChange: page.changeGiftTypeCode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "sortOrder") {
|
||||
return {
|
||||
...column,
|
||||
render: (gift) => <GiftSortOrderCell gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "gift") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索礼物名称、礼物 ID",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
render: (gift) => <GiftRowActions gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
};
|
||||
|
||||
const selectionColumn = {
|
||||
key: "select",
|
||||
className: styles.selectColumn,
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
disabled={!page.abilities.canDeleteGift || items.length === 0 || deletingBatch}
|
||||
indeterminate={someChecked}
|
||||
size="small"
|
||||
onChange={(event) => toggleAllGifts(event.target.checked)}
|
||||
/>
|
||||
),
|
||||
resizable: false,
|
||||
width: "56px",
|
||||
render: (gift) => {
|
||||
const giftId = String(gift.giftId);
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selectedGiftIdSet.has(giftId)}
|
||||
disabled={!page.abilities.canDeleteGift || deletingBatch}
|
||||
inputProps={{ "aria-label": `选择 ${gift.name || gift.giftId}` }}
|
||||
size="small"
|
||||
onChange={(event) => toggleGiftSelection(gift, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
const tableColumns = [
|
||||
selectionColumn,
|
||||
...baseColumns(giftTypeOptions).map((column) => {
|
||||
if (column.key === "regions") {
|
||||
return {
|
||||
...column,
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: giftRegionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId,
|
||||
}),
|
||||
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: resourceStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "type") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
emptyLabel: "全部类型",
|
||||
loading: page.giftTypesLoading,
|
||||
options: giftTypeOptions.map((item) => ({
|
||||
label: item.displayName || item.tabName || item.tabKey,
|
||||
value: item.tabKey,
|
||||
})),
|
||||
placeholder: "搜索类型",
|
||||
value: page.giftTypeCode,
|
||||
onChange: page.changeGiftTypeCode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "sortOrder") {
|
||||
return {
|
||||
...column,
|
||||
render: (gift) => <GiftSortOrderCell gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "gift") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索礼物名称、礼物 ID",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
render: (gift) => <GiftRowActions gift={gift} page={page} />,
|
||||
};
|
||||
}
|
||||
return column;
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
@ -216,6 +292,19 @@ export function GiftListPage() {
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canDeleteGift ? (
|
||||
<AdminActionIconButton
|
||||
disabled={!selectedGifts.length || deletingBatch}
|
||||
label={deletingBatch ? "批量删除中" : `批量删除礼物(${selectedGifts.length})`}
|
||||
onClick={deleteSelectedGifts}
|
||||
>
|
||||
{deletingBatch ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
)}
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canCreateGift ? (
|
||||
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
|
||||
<Add fontSize="small" />
|
||||
@ -229,7 +318,7 @@ export function GiftListPage() {
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1440px"
|
||||
minWidth="1500px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -270,6 +359,9 @@ export function GiftListPage() {
|
||||
}
|
||||
|
||||
function GiftRowActions({ gift, page }) {
|
||||
const deleting = page.loadingAction === `gift-delete-${gift.giftId}`;
|
||||
const deletingBatch = page.loadingAction === "gift-delete-batch";
|
||||
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
@ -279,6 +371,15 @@ function GiftRowActions({ gift, page }) {
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
{page.abilities.canDeleteGift ? (
|
||||
<AdminActionIconButton
|
||||
disabled={deleting || deletingBatch}
|
||||
label={deleting ? "删除中" : "删除礼物"}
|
||||
onClick={() => page.deleteGifts([gift])}
|
||||
>
|
||||
{deleting ? <CircularProgress size={16} /> : <DeleteOutlineOutlined fontSize="small" />}
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ export function useResourceAbilities() {
|
||||
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||
canDeleteGift: can(PERMISSIONS.giftDelete),
|
||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
|
||||
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
|
||||
|
||||
@ -43,6 +43,7 @@ const permissionGroupDefinitions = [
|
||||
"cumulative-recharge-reward",
|
||||
"daily-task",
|
||||
"first-recharge-reward",
|
||||
"invite-activity-reward",
|
||||
"lucky-gift",
|
||||
"red-packet",
|
||||
"registration-reward",
|
||||
|
||||
@ -45,6 +45,7 @@ export const API_OPERATIONS = {
|
||||
createMenu: "createMenu",
|
||||
createPermission: "createPermission",
|
||||
createPlatform: "createPlatform",
|
||||
createPopup: "createPopup",
|
||||
createPrettyIdPool: "createPrettyIdPool",
|
||||
createRechargeProduct: "createRechargeProduct",
|
||||
createRegion: "createRegion",
|
||||
@ -68,10 +69,12 @@ export const API_OPERATIONS = {
|
||||
deleteCountry: "deleteCountry",
|
||||
deleteDiceRobot: "deleteDiceRobot",
|
||||
deleteExploreTab: "deleteExploreTab",
|
||||
deleteGift: "deleteGift",
|
||||
deleteH5Link: "deleteH5Link",
|
||||
deleteHostAgencyPolicy: "deleteHostAgencyPolicy",
|
||||
deleteMenu: "deleteMenu",
|
||||
deletePermission: "deletePermission",
|
||||
deletePopup: "deletePopup",
|
||||
deleteRechargeProduct: "deleteRechargeProduct",
|
||||
deleteRole: "deleteRole",
|
||||
deleteRoom: "deleteRoom",
|
||||
@ -151,6 +154,7 @@ export const API_OPERATIONS = {
|
||||
listOperationLogs: "listOperationLogs",
|
||||
listPermissions: "listPermissions",
|
||||
listPlatforms: "listPlatforms",
|
||||
listPopups: "listPopups",
|
||||
listPrettyIdPools: "listPrettyIdPools",
|
||||
listPrettyIds: "listPrettyIds",
|
||||
listRechargeBills: "listRechargeBills",
|
||||
@ -230,6 +234,7 @@ export const API_OPERATIONS = {
|
||||
updateMenuVisible: "updateMenuVisible",
|
||||
updatePermission: "updatePermission",
|
||||
updatePlatform: "updatePlatform",
|
||||
updatePopup: "updatePopup",
|
||||
updatePrettyIdPool: "updatePrettyIdPool",
|
||||
updateRechargeProduct: "updateRechargeProduct",
|
||||
updateRedPacketConfig: "updateRedPacketConfig",
|
||||
@ -486,6 +491,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"]
|
||||
},
|
||||
createPopup: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createPopup,
|
||||
path: "/v1/admin/app-config/popups",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
createPrettyIdPool: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createPrettyIdPool,
|
||||
@ -647,6 +659,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
deleteGift: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteGift,
|
||||
path: "/v1/admin/gifts/{gift_id}",
|
||||
permission: "gift:delete",
|
||||
permissions: ["gift:delete"]
|
||||
},
|
||||
deleteH5Link: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteH5Link,
|
||||
@ -675,6 +694,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "permission:delete",
|
||||
permissions: ["permission:delete"]
|
||||
},
|
||||
deletePopup: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deletePopup,
|
||||
path: "/v1/admin/app-config/popups/{popup_id}",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
deleteRechargeProduct: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteRechargeProduct,
|
||||
@ -1226,6 +1252,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:view",
|
||||
permissions: ["game:view"]
|
||||
},
|
||||
listPopups: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listPopups,
|
||||
path: "/v1/admin/app-config/popups",
|
||||
permission: "app-config:view",
|
||||
permissions: ["app-config:view"]
|
||||
},
|
||||
listPrettyIdPools: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listPrettyIdPools,
|
||||
@ -1767,6 +1800,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"]
|
||||
},
|
||||
updatePopup: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updatePopup,
|
||||
path: "/v1/admin/app-config/popups/{popup_id}",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
updatePrettyIdPool: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updatePrettyIdPool,
|
||||
|
||||
100
src/shared/api/generated/schema.d.ts
vendored
100
src/shared/api/generated/schema.d.ts
vendored
@ -628,6 +628,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-config/popups": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listPopups"];
|
||||
put?: never;
|
||||
post: operations["createPopup"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-config/popups/{popup_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["updatePopup"];
|
||||
post?: never;
|
||||
delete: operations["deletePopup"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-config/h5-links": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1342,7 +1374,7 @@ export interface paths {
|
||||
get?: never;
|
||||
put: operations["updateGift"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["deleteGift"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@ -3994,6 +4026,58 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listPopups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createPopup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updatePopup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
popup_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
deletePopup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
popup_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listH5Links: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4861,6 +4945,20 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
deleteGift: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
gift_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
disableGift: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -980,6 +980,36 @@ export interface AppSplashScreenPayload {
|
||||
status: "active" | "disabled" | "expired";
|
||||
}
|
||||
|
||||
export interface AppPopupDto {
|
||||
appCode?: string;
|
||||
code: string;
|
||||
createdAtMs?: number;
|
||||
displayPeriodDays?: number;
|
||||
endsAtMs?: number;
|
||||
id: number;
|
||||
imageUrl?: string;
|
||||
jumpType: "h5" | "app" | string;
|
||||
jumpUrl: string;
|
||||
name: string;
|
||||
sortOrder?: number;
|
||||
startsAtMs?: number;
|
||||
status: "active" | "disabled" | "expired" | string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface AppPopupPayload {
|
||||
code: string;
|
||||
displayPeriodDays?: number;
|
||||
endsAtMs?: number;
|
||||
imageUrl?: string;
|
||||
jumpType: "h5" | "app";
|
||||
jumpUrl: string;
|
||||
name: string;
|
||||
sortOrder?: number;
|
||||
startsAtMs?: number;
|
||||
status: "active" | "disabled" | "expired";
|
||||
}
|
||||
|
||||
export interface AgencyMembershipDto {
|
||||
agencyId?: number;
|
||||
createdAtMs?: number;
|
||||
|
||||
175
src/shared/app-jump/appJumpConfig.js
Normal file
175
src/shared/app-jump/appJumpConfig.js
Normal file
@ -0,0 +1,175 @@
|
||||
export const appJumpTargetOptions = [
|
||||
["wallet", "钱包"],
|
||||
["room", "指定房间"],
|
||||
["room_random", "随机房间"],
|
||||
["room_window", "指定房内拉起窗口"],
|
||||
["room_random_window", "随机房内拉起窗口"],
|
||||
["room_game", "指定房内拉起游戏"],
|
||||
["room_random_game", "随机房内拉起游戏"],
|
||||
["explore_game", "Explore 游戏 H5"],
|
||||
["custom", "自定义 JSON"],
|
||||
];
|
||||
|
||||
export const appJumpWindowOptions = [
|
||||
["game_window", "游戏窗口"],
|
||||
["gift_panel", "礼物面板"],
|
||||
["red_packet_claim", "领红包弹窗"],
|
||||
["wheel", "转盘窗口"],
|
||||
];
|
||||
|
||||
export const appJumpExploreGameCodeOptions = [
|
||||
["game_dice", "Dice Battle"],
|
||||
["game_rock", "Rock Paper Scissors"],
|
||||
];
|
||||
|
||||
const knownTargets = new Set(appJumpTargetOptions.map(([value]) => value).filter((value) => value !== "custom"));
|
||||
const knownWindows = new Set(appJumpWindowOptions.map(([value]) => value));
|
||||
const knownExploreGameCodes = new Set(appJumpExploreGameCodeOptions.map(([value]) => value));
|
||||
|
||||
export function normalizeJumpConfigType(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase() === "app"
|
||||
? "app"
|
||||
: "h5";
|
||||
}
|
||||
|
||||
export function parseAppJumpParam(rawParam) {
|
||||
const raw = String(rawParam || "").trim();
|
||||
const data = parseObject(raw);
|
||||
if (!data) {
|
||||
return defaultParsedJump(raw);
|
||||
}
|
||||
|
||||
const type = readString(data.type);
|
||||
const target = knownTargets.has(type) ? type : type ? "custom" : "wallet";
|
||||
const window = normalizeWindow(data.window);
|
||||
|
||||
return {
|
||||
gameCode: normalizeExploreGameCode(data.game_code ?? data.gameCode),
|
||||
gameId: readString(data.game_id ?? data.gameId),
|
||||
giftId: readString(data.gift_id ?? data.giftId),
|
||||
raw,
|
||||
redPacketNo: readString(data.red_packet_no ?? data.redPacketNo),
|
||||
roomId: readString(data.room_id ?? data.roomId),
|
||||
target,
|
||||
window,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAppJumpParam(values) {
|
||||
const target = knownTargets.has(values?.target) ? values.target : "wallet";
|
||||
const payload = { type: target };
|
||||
const roomId = readString(values?.roomId);
|
||||
const gameId = readString(values?.gameId);
|
||||
const gameCode = normalizeExploreGameCode(values?.gameCode);
|
||||
const giftId = readString(values?.giftId);
|
||||
const redPacketNo = readString(values?.redPacketNo);
|
||||
const window = normalizeWindow(values?.window);
|
||||
|
||||
if (target === "room" || target === "room_window" || target === "room_game") {
|
||||
payload.room_id = roomId;
|
||||
}
|
||||
if (target === "room_window" || target === "room_random_window") {
|
||||
payload.window = window;
|
||||
appendWindowParam(payload, window, { gameId, giftId, redPacketNo });
|
||||
}
|
||||
if (target === "room_game" || target === "room_random_game") {
|
||||
payload.game_id = gameId;
|
||||
}
|
||||
if (target === "explore_game") {
|
||||
payload.game_code = gameCode;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function validatePublicAppJumpParam(rawParam) {
|
||||
const raw = String(rawParam || "").trim();
|
||||
if (!raw) {
|
||||
return { message: "请填写 APP 跳转参数", valid: false };
|
||||
}
|
||||
const data = parseObject(raw);
|
||||
if (!data) {
|
||||
return { message: "APP 跳转参数必须是 JSON 对象", valid: false };
|
||||
}
|
||||
|
||||
const target = readString(data.type);
|
||||
if (!knownTargets.has(target)) {
|
||||
return { message: "APP 跳转 type 不在公共跳转方案内", valid: false };
|
||||
}
|
||||
|
||||
const roomId = readString(data.room_id ?? data.roomId);
|
||||
const gameId = readString(data.game_id ?? data.gameId);
|
||||
const gameCode = readString(data.game_code ?? data.gameCode);
|
||||
const window = readString(data.window);
|
||||
|
||||
if ((target === "room" || target === "room_window" || target === "room_game") && !roomId) {
|
||||
return { message: "该 APP 跳转需要填写 room_id", valid: false };
|
||||
}
|
||||
if ((target === "room_game" || target === "room_random_game") && !gameId) {
|
||||
return { message: "该 APP 跳转需要填写 game_id", valid: false };
|
||||
}
|
||||
if ((target === "room_window" || target === "room_random_window") && !knownWindows.has(window)) {
|
||||
return { message: "该 APP 跳转需要填写有效 window", valid: false };
|
||||
}
|
||||
if (target === "explore_game" && !knownExploreGameCodes.has(gameCode)) {
|
||||
return { message: "Explore 游戏 H5 需要选择有效 game_code", valid: false };
|
||||
}
|
||||
|
||||
return { message: "", valid: true };
|
||||
}
|
||||
|
||||
function defaultParsedJump(raw = "") {
|
||||
return {
|
||||
gameCode: "game_dice",
|
||||
gameId: "",
|
||||
giftId: "",
|
||||
raw,
|
||||
redPacketNo: "",
|
||||
roomId: "",
|
||||
target: raw ? "custom" : "wallet",
|
||||
window: "gift_panel",
|
||||
};
|
||||
}
|
||||
|
||||
function appendWindowParam(payload, window, values) {
|
||||
if (window === "game_window" && values.gameId) {
|
||||
payload.game_id = values.gameId;
|
||||
}
|
||||
if (window === "gift_panel" && values.giftId) {
|
||||
payload.gift_id = values.giftId;
|
||||
}
|
||||
if (window === "red_packet_claim" && values.redPacketNo) {
|
||||
payload.red_packet_no = values.redPacketNo;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindow(value) {
|
||||
const window = readString(value);
|
||||
return knownWindows.has(window) ? window : "gift_panel";
|
||||
}
|
||||
|
||||
function normalizeExploreGameCode(value) {
|
||||
const gameCode = readString(value);
|
||||
return knownExploreGameCodes.has(gameCode) ? gameCode : "game_dice";
|
||||
}
|
||||
|
||||
function parseObject(raw) {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && !Array.isArray(parsed) && typeof parsed === "object") {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readString(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
50
src/shared/app-jump/appJumpConfig.test.js
Normal file
50
src/shared/app-jump/appJumpConfig.test.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildAppJumpParam, parseAppJumpParam, validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
|
||||
|
||||
describe("public app jump config", () => {
|
||||
test("builds and parses room window jump", () => {
|
||||
const param = buildAppJumpParam({
|
||||
giftId: "gift_9",
|
||||
roomId: "room_1",
|
||||
target: "room_window",
|
||||
window: "gift_panel",
|
||||
});
|
||||
|
||||
expect(param).toBe('{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_9"}');
|
||||
expect(parseAppJumpParam(param)).toMatchObject({
|
||||
giftId: "gift_9",
|
||||
roomId: "room_1",
|
||||
target: "room_window",
|
||||
window: "gift_panel",
|
||||
});
|
||||
});
|
||||
|
||||
test("validates required fields for public app jumps", () => {
|
||||
expect(validatePublicAppJumpParam('{"type":"wallet"}').valid).toBe(true);
|
||||
expect(validatePublicAppJumpParam('{"type":"room_game","room_id":"room_1","game_id":"dice"}').valid).toBe(true);
|
||||
expect(validatePublicAppJumpParam('{"type":"explore_game","game_code":"game_rock"}').valid).toBe(true);
|
||||
expect(validatePublicAppJumpParam('{"type":"room_game","room_id":"room_1"}')).toMatchObject({
|
||||
valid: false,
|
||||
});
|
||||
expect(validatePublicAppJumpParam('{"type":"room_window","room_id":"room_1"}')).toMatchObject({
|
||||
valid: false,
|
||||
});
|
||||
expect(validatePublicAppJumpParam('{"type":"explore_game","game_code":"unknown"}')).toMatchObject({
|
||||
valid: false,
|
||||
});
|
||||
expect(validatePublicAppJumpParam("not json")).toMatchObject({ valid: false });
|
||||
});
|
||||
|
||||
test("builds and parses explore game jump", () => {
|
||||
const param = buildAppJumpParam({
|
||||
gameCode: "game_dice",
|
||||
target: "explore_game",
|
||||
});
|
||||
|
||||
expect(param).toBe('{"type":"explore_game","game_code":"game_dice"}');
|
||||
expect(parseAppJumpParam(param)).toMatchObject({
|
||||
gameCode: "game_dice",
|
||||
target: "explore_game",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -38,7 +38,7 @@ export function AdminUserIdentity({
|
||||
user: normalized.detailUser,
|
||||
});
|
||||
const visibleRows = Array.isArray(rows)
|
||||
? rows.map((row) => String(row || "").trim()).filter(Boolean)
|
||||
? resolveVisibleRows(rows, normalized)
|
||||
: [normalized.shortId].filter(Boolean);
|
||||
|
||||
return (
|
||||
@ -175,6 +175,7 @@ function normalizeUserIdentity({
|
||||
userId: resolvedUserId,
|
||||
username: firstNonEmpty(name, source.username, source.name, source.account),
|
||||
},
|
||||
displayUserId: resolvedDisplayUserId,
|
||||
displayName,
|
||||
hasPretty,
|
||||
prettyDisplayUserId: resolvedPrettyDisplayUserId,
|
||||
@ -184,6 +185,20 @@ function normalizeUserIdentity({
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVisibleRows(rows, normalized) {
|
||||
const visibleRows = rows.map((row) => String(row || "").trim()).filter(Boolean);
|
||||
if (
|
||||
normalized.hasPretty &&
|
||||
normalized.shortId &&
|
||||
normalized.displayUserId &&
|
||||
visibleRows[0] === normalized.displayUserId &&
|
||||
normalized.shortId !== normalized.displayUserId
|
||||
) {
|
||||
return [normalized.shortId, ...visibleRows.slice(1)];
|
||||
}
|
||||
return visibleRows;
|
||||
}
|
||||
|
||||
function resolveRootProps({ displayName, onClick, openAppUserDetail, shouldOpenAppUserDetail, user }) {
|
||||
if (onClick) {
|
||||
return {
|
||||
|
||||
@ -44,6 +44,26 @@ test("clickable identity opens detail handler", async () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("uses original id before pretty id when rows pass current display id", () => {
|
||||
render(
|
||||
<AdminUserIdentity
|
||||
rows={["VIP2026", "10001"]}
|
||||
user={{
|
||||
defaultDisplayUserId: "123456",
|
||||
displayUserId: "VIP2026",
|
||||
prettyDisplayUserId: "VIP2026",
|
||||
prettyId: "pretty-2026",
|
||||
userId: "10001",
|
||||
username: "tester",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("123456")).toBeInTheDocument();
|
||||
expect(screen.getByText("VIP2026")).toBeInTheDocument();
|
||||
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("open detail uses provider instead of app user list link", async () => {
|
||||
const user = userEvent.setup();
|
||||
const openAppUserDetail = vi.fn();
|
||||
|
||||
203
src/shared/ui/AppJumpConfigField.jsx
Normal file
203
src/shared/ui/AppJumpConfigField.jsx
Normal file
@ -0,0 +1,203 @@
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
appJumpExploreGameCodeOptions,
|
||||
appJumpTargetOptions,
|
||||
appJumpWindowOptions,
|
||||
buildAppJumpParam,
|
||||
normalizeJumpConfigType,
|
||||
parseAppJumpParam,
|
||||
} from "@/shared/app-jump/appJumpConfig.js";
|
||||
import { AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import styles from "@/shared/ui/AppJumpConfigField.module.css";
|
||||
|
||||
export function AppJumpConfigField({
|
||||
appParamLabel = "APP 参数 JSON",
|
||||
className = "",
|
||||
disabled = false,
|
||||
h5Label = "H5 链接",
|
||||
onChange,
|
||||
paramValue,
|
||||
required = false,
|
||||
typeLabel = "跳转类型",
|
||||
typeValue,
|
||||
}) {
|
||||
const type = normalizeJumpConfigType(typeValue);
|
||||
const appJump = useMemo(() => parseAppJumpParam(paramValue), [paramValue]);
|
||||
const setType = (nextType) => {
|
||||
if (nextType === "app") {
|
||||
onChange({ param: buildAppJumpParam({ target: "wallet" }), type: "app" });
|
||||
return;
|
||||
}
|
||||
onChange({ param: "", type: "h5" });
|
||||
};
|
||||
const setAppJump = (patch) => {
|
||||
const next = { ...appJump, ...patch };
|
||||
if (next.target === "custom") {
|
||||
onChange({ param: next.raw, type: "app" });
|
||||
return;
|
||||
}
|
||||
onChange({ param: buildAppJumpParam(next), type: "app" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={[styles.root, className].filter(Boolean).join(" ")}>
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label={typeLabel}
|
||||
required={required}
|
||||
select
|
||||
value={type}
|
||||
onChange={(event) => setType(event.target.value)}
|
||||
>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
{type === "h5" ? (
|
||||
<TextField
|
||||
className={styles.wide}
|
||||
disabled={disabled}
|
||||
label={h5Label}
|
||||
required={required}
|
||||
value={paramValue}
|
||||
onChange={(event) => onChange({ param: event.target.value, type: "h5" })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="APP 目标"
|
||||
required
|
||||
select
|
||||
value={appJump.target}
|
||||
onChange={(event) => setAppJump({ target: event.target.value })}
|
||||
>
|
||||
{appJumpTargetOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<AppJumpStructuredFields appJump={appJump} disabled={disabled} onChange={setAppJump} />
|
||||
<TextField
|
||||
className={styles.wide}
|
||||
disabled={disabled}
|
||||
label={appParamLabel}
|
||||
minRows={2}
|
||||
multiline
|
||||
required
|
||||
value={paramValue}
|
||||
onChange={(event) => onChange({ param: event.target.value, type: "app" })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AdminFormFieldGrid>
|
||||
{type === "app" ? (
|
||||
<p className={styles.hint}>APP 参数会按公共跳转方案生成 JSON;需要特殊参数时可直接编辑 JSON。</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppJumpStructuredFields({ appJump, disabled, onChange }) {
|
||||
if (appJump.target === "custom" || appJump.target === "wallet" || appJump.target === "room_random") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsRoomId(appJump.target) ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="房间 ID"
|
||||
required
|
||||
value={appJump.roomId}
|
||||
onChange={(event) => onChange({ roomId: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
{needsWindow(appJump.target) ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="房内窗口"
|
||||
required
|
||||
select
|
||||
value={appJump.window}
|
||||
onChange={(event) => onChange({ window: event.target.value })}
|
||||
>
|
||||
{appJumpWindowOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
{needsGameId(appJump.target, appJump.window) ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="游戏 ID"
|
||||
required={appJump.target === "room_game" || appJump.target === "room_random_game"}
|
||||
value={appJump.gameId}
|
||||
onChange={(event) => onChange({ gameId: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
{needsExploreGameCode(appJump.target) ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="Game Code"
|
||||
required
|
||||
select
|
||||
value={appJump.gameCode}
|
||||
onChange={(event) => onChange({ gameCode: event.target.value })}
|
||||
>
|
||||
{appJumpExploreGameCodeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
{needsGiftId(appJump.target, appJump.window) ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="礼物 ID"
|
||||
value={appJump.giftId}
|
||||
onChange={(event) => onChange({ giftId: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
{needsRedPacketNo(appJump.target, appJump.window) ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="红包编号"
|
||||
value={appJump.redPacketNo}
|
||||
onChange={(event) => onChange({ redPacketNo: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function needsRoomId(target) {
|
||||
return target === "room" || target === "room_window" || target === "room_game";
|
||||
}
|
||||
|
||||
function needsWindow(target) {
|
||||
return target === "room_window" || target === "room_random_window";
|
||||
}
|
||||
|
||||
function needsGameId(target, window) {
|
||||
return target === "room_game" || target === "room_random_game" || window === "game_window";
|
||||
}
|
||||
|
||||
function needsExploreGameCode(target) {
|
||||
return target === "explore_game";
|
||||
}
|
||||
|
||||
function needsGiftId(target, window) {
|
||||
return needsWindow(target) && window === "gift_panel";
|
||||
}
|
||||
|
||||
function needsRedPacketNo(target, window) {
|
||||
return needsWindow(target) && window === "red_packet_claim";
|
||||
}
|
||||
16
src/shared/ui/AppJumpConfigField.module.css
Normal file
16
src/shared/ui/AppJumpConfigField.module.css
Normal file
@ -0,0 +1,16 @@
|
||||
.root {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user