新增功能

This commit is contained in:
zhx 2026-06-11 17:11:04 +08:00
parent 54fdc867fe
commit 7d6418e43c
43 changed files with 2977 additions and 240 deletions

View File

@ -824,6 +824,70 @@
"x-permissions": ["app-config:update"] "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": { "/admin/app-config/h5-links": {
"get": { "get": {
"operationId": "listH5Links", "operationId": "listH5Links",
@ -1917,6 +1981,26 @@
], ],
"x-permission": "gift:update", "x-permission": "gift:update",
"x-permissions": ["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": { "/admin/gifts/{gift_id}/disable": {

View File

@ -23,6 +23,7 @@ import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined"; import PublicOutlined from "@mui/icons-material/PublicOutlined";
import PushPinOutlined from "@mui/icons-material/PushPinOutlined"; import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
import PaidOutlined from "@mui/icons-material/PaidOutlined"; import PaidOutlined from "@mui/icons-material/PaidOutlined";
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined"; import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import RedeemOutlined from "@mui/icons-material/RedeemOutlined"; import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
import SendOutlined from "@mui/icons-material/SendOutlined"; import SendOutlined from "@mui/icons-material/SendOutlined";
@ -64,6 +65,7 @@ const iconMap = {
menu: MenuOpenOutlined, menu: MenuOpenOutlined,
operations: ReceiptLongOutlined, operations: ReceiptLongOutlined,
paid: PaidOutlined, paid: PaidOutlined,
person_add: PersonAddAltOutlined,
public: PublicOutlined, public: PublicOutlined,
push_pin: PushPinOutlined, push_pin: PushPinOutlined,
receipt: ReceiptLongOutlined, receipt: ReceiptLongOutlined,
@ -150,6 +152,7 @@ export const fallbackNavigation = [
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }), routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
routeNavItem("app-config-banners", { icon: ImageOutlined }), routeNavItem("app-config-banners", { icon: ImageOutlined }),
routeNavItem("app-config-splash-screens", { icon: ImageOutlined }), routeNavItem("app-config-splash-screens", { icon: ImageOutlined }),
routeNavItem("app-config-popups", { icon: ImageOutlined }),
routeNavItem("app-config-explore", { icon: MapOutlined }), routeNavItem("app-config-explore", { icon: MapOutlined }),
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }), routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
], ],
@ -203,6 +206,7 @@ export const fallbackNavigation = [
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }), routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }), routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }), routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
routeNavItem("invite-activity-reward", { icon: PersonAddAltOutlined }),
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }), routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
routeNavItem("room-rocket", { icon: RedeemOutlined }), routeNavItem("room-rocket", { icon: RedeemOutlined }),
routeNavItem("room-turnover-reward", { icon: PaidOutlined }), routeNavItem("room-turnover-reward", { icon: PaidOutlined }),

View File

@ -139,6 +139,12 @@ describe("navigation menu helpers", () => {
label: "Explore配置", label: "Explore配置",
path: "/app-config/explore", path: "/app-config/explore",
}, },
{
code: "app-config-popups",
id: "app-config-popups",
label: "弹窗配置",
path: "/app-config/popups",
},
{ {
code: "app-config-versions", code: "app-config-versions",
id: "app-config-versions", id: "app-config-versions",
@ -160,6 +166,7 @@ describe("navigation menu helpers", () => {
"app-config-h5", "app-config-h5",
"app-config-banners", "app-config-banners",
"app-config-splash-screens", "app-config-splash-screens",
"app-config-popups",
"app-config-explore", "app-config-explore",
"app-config-versions", "app-config-versions",
]); ]);

View File

@ -117,6 +117,7 @@ export const PERMISSIONS = {
giftCreate: "gift:create", giftCreate: "gift:create",
giftUpdate: "gift:update", giftUpdate: "gift:update",
giftStatus: "gift:status", giftStatus: "gift:status",
giftDelete: "gift:delete",
emojiPackView: "emoji-pack:view", emojiPackView: "emoji-pack:view",
emojiPackCreate: "emoji-pack:create", emojiPackCreate: "emoji-pack:create",
dailyTaskView: "daily-task:view", dailyTaskView: "daily-task:view",
@ -132,6 +133,8 @@ export const PERMISSIONS = {
firstRechargeRewardUpdate: "first-recharge-reward:update", firstRechargeRewardUpdate: "first-recharge-reward:update",
cumulativeRechargeRewardView: "cumulative-recharge-reward:view", cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update", cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
inviteActivityRewardView: "invite-activity-reward:view",
inviteActivityRewardUpdate: "invite-activity-reward:update",
sevenDayCheckInView: "seven-day-checkin:view", sevenDayCheckInView: "seven-day-checkin:view",
sevenDayCheckInUpdate: "seven-day-checkin:update", sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view", luckyGiftView: "lucky-gift:view",
@ -184,6 +187,7 @@ export const MENU_CODES = {
appConfigH5: "app-config-h5", appConfigH5: "app-config-h5",
appConfigBanners: "app-config-banners", appConfigBanners: "app-config-banners",
appConfigSplashScreens: "app-config-splash-screens", appConfigSplashScreens: "app-config-splash-screens",
appConfigPopups: "app-config-popups",
appConfigExplore: "app-config-explore", appConfigExplore: "app-config-explore",
appConfigVersions: "app-config-versions", appConfigVersions: "app-config-versions",
resources: "resources", resources: "resources",
@ -205,6 +209,7 @@ export const MENU_CODES = {
registrationReward: "registration-reward", registrationReward: "registration-reward",
firstRechargeReward: "first-recharge-reward", firstRechargeReward: "first-recharge-reward",
cumulativeRechargeReward: "cumulative-recharge-reward", cumulativeRechargeReward: "cumulative-recharge-reward",
inviteActivityReward: "invite-activity-reward",
achievementConfig: "achievement-config", achievementConfig: "achievement-config",
sevenDayCheckIn: "seven-day-checkin", sevenDayCheckIn: "seven-day-checkin",
roomRocket: "room-rocket", roomRocket: "room-rocket",

View File

@ -10,6 +10,7 @@ import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/rout
import { gameRoutes } from "@/features/games/routes.js"; import { gameRoutes } from "@/features/games/routes.js";
import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js"; import { hostAgencyPolicyRoutes } from "@/features/host-agency-policy/routes.js";
import { hostOrgRoutes } from "@/features/host-org/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 { levelConfigRoutes } from "@/features/level-config/routes.js";
import { logsRoutes } from "@/features/logs/routes.js"; import { logsRoutes } from "@/features/logs/routes.js";
import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js"; import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
@ -46,6 +47,7 @@ export const adminRoutes: AdminRoute[] = [
...registrationRewardRoutes, ...registrationRewardRoutes,
...firstRechargeRewardRoutes, ...firstRechargeRewardRoutes,
...cumulativeRechargeRewardRoutes, ...cumulativeRechargeRewardRoutes,
...inviteActivityRewardRoutes,
...sevenDayCheckInRoutes, ...sevenDayCheckInRoutes,
...roomRocketRoutes, ...roomRocketRoutes,
...roomTurnoverRewardRoutes, ...roomTurnoverRewardRoutes,

View File

@ -4,6 +4,8 @@ import type {
ApiList, ApiList,
AppBannerDto, AppBannerDto,
AppBannerPayload, AppBannerPayload,
AppPopupDto,
AppPopupPayload,
AppSplashScreenDto, AppSplashScreenDto,
AppSplashScreenPayload, AppSplashScreenPayload,
AppVersionDto, 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>> { export function listAppVersions(query: PageQuery = {}): Promise<ApiList<AppVersionDto>> {
const endpoint = API_ENDPOINTS.listAppVersions; const endpoint = API_ENDPOINTS.listAppVersions;
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), { return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {

View File

@ -24,6 +24,24 @@
object-fit: cover; 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, .splashCover,
.splashCoverEmpty { .splashCoverEmpty {
display: inline-flex; display: inline-flex;
@ -84,6 +102,40 @@
align-self: center; 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 { .statusCell {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -105,4 +157,20 @@
justify-self: stretch; justify-self: stretch;
width: min(100%, 260px); 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);
}
} }

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

View File

@ -27,6 +27,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
@ -116,17 +117,6 @@ export function BannerConfigPage() {
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="跳转信息"> <AdminFormSection title="跳转信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))"> <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 <TextField
disabled={!page.abilities.canUpdate} disabled={!page.abilities.canUpdate}
label="平台" label="平台"
@ -138,13 +128,16 @@ export function BannerConfigPage() {
<MenuItem value="android">安卓</MenuItem> <MenuItem value="android">安卓</MenuItem>
<MenuItem value="ios">iOS</MenuItem> <MenuItem value="ios">iOS</MenuItem>
</TextField> </TextField>
<TextField <AppJumpConfigField
appParamLabel="参数APP公共跳转JSON"
className={styles.formWideField} className={styles.formWideField}
disabled={!page.abilities.canUpdate} disabled={!page.abilities.canUpdate}
label={page.form.bannerType === "h5" ? "参数H5链接" : "参数"} h5Label="参数H5链接"
required={page.form.bannerType === "h5"} paramValue={page.form.param}
value={page.form.param} required
onChange={(event) => page.setForm({ param: event.target.value })} typeLabel="类型"
typeValue={page.form.bannerType}
onChange={({ param, type }) => page.setForm({ bannerType: type, param })}
/> />
</AdminFormFieldGrid> </AdminFormFieldGrid>
</AdminFormSection> </AdminFormSection>

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

View File

@ -24,6 +24,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
@ -102,17 +103,6 @@ export function SplashScreenConfigPage() {
<div className={styles.splashFieldPane}> <div className={styles.splashFieldPane}>
<AdminFormSection title="跳转信息"> <AdminFormSection title="跳转信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))"> <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 <TextField
disabled={!page.abilities.canUpdate} disabled={!page.abilities.canUpdate}
label="平台" label="平台"
@ -124,13 +114,16 @@ export function SplashScreenConfigPage() {
<MenuItem value="android">安卓</MenuItem> <MenuItem value="android">安卓</MenuItem>
<MenuItem value="ios">iOS</MenuItem> <MenuItem value="ios">iOS</MenuItem>
</TextField> </TextField>
<TextField <AppJumpConfigField
appParamLabel="参数APP公共跳转JSON"
className={styles.formWideField} className={styles.formWideField}
disabled={!page.abilities.canUpdate} disabled={!page.abilities.canUpdate}
label={page.form.splashType === "h5" ? "参数H5链接" : "参数"} h5Label="参数H5链接"
required={page.form.splashType === "h5"} paramValue={page.form.param}
value={page.form.param} required
onChange={(event) => page.setForm({ param: event.target.value })} typeLabel="类型"
typeValue={page.form.splashType}
onChange={({ param, type }) => page.setForm({ param, splashType: type })}
/> />
</AdminFormFieldGrid> </AdminFormFieldGrid>
</AdminFormSection> </AdminFormSection>

View File

@ -25,6 +25,14 @@ export const appConfigRoutes = [
path: "/app-config/splash-screens", path: "/app-config/splash-screens",
permission: PERMISSIONS.appConfigView, 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配置", label: "Explore配置",
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage), loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),

View File

@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
appBannerSchema, appBannerSchema,
appPopupSchema,
appSplashScreenSchema, appSplashScreenSchema,
exploreTabSchema, exploreTabSchema,
h5LinkUpdateSchema, h5LinkUpdateSchema,
@ -39,6 +40,19 @@ const validSplashScreenForm = {
status: "active", 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", () => { describe("app config form schema", () => {
test("validates dynamic h5 config item", () => { test("validates dynamic h5 config item", () => {
const payload = parseForm(h5LinkUpdateSchema, { const payload = parseForm(h5LinkUpdateSchema, {
@ -69,6 +83,27 @@ describe("app config form schema", () => {
expect(payload.endsAtMs).toBe(1800000000000); 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", () => { test("accepts multiple banner display scopes", () => {
const payload = parseForm(appBannerSchema, { const payload = parseForm(appBannerSchema, {
...validBannerForm, ...validBannerForm,
@ -109,6 +144,17 @@ describe("app config form schema", () => {
expect("displayScopes" in payload).toBe(false); 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", () => { test("requires h5 link for splash screen h5 type", () => {
expect(() => expect(() =>
parseForm(appSplashScreenSchema, { parseForm(appSplashScreenSchema, {
@ -137,6 +183,57 @@ describe("app config form schema", () => {
).toThrow(FormValidationError); ).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", () => { test("validates explore tab h5 url and sort order", () => {
const payload = parseForm(exploreTabSchema, { const payload = parseForm(exploreTabSchema, {
enabled: true, enabled: true,

View File

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
const appBannerDisplayScopes = ["home", "room", "recharge"] as const; const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
@ -70,6 +71,12 @@ export const appBannerSchema = z
if (value.bannerType === "h5" && /\s/.test(value.param)) { if (value.bannerType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["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) { if (value.displayScopes.includes("room") && !value.roomSmallImageUrl) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@ -115,6 +122,12 @@ export const appSplashScreenSchema = z
if (value.splashType === "h5" && /\s/.test(value.param)) { if (value.splashType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["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) { if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["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 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({ export const appVersionSchema = z.object({
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"), buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""), description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),

View File

@ -71,3 +71,36 @@
align-items: center; align-items: center;
gap: var(--space-1); 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;
}
}

View File

@ -13,27 +13,29 @@ import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
const pageSize = 50; const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = (task = {}) => ({ const emptyForm = (task = {}) => {
actionParam: task.actionParam || "", const actionType = task.actionType || "none";
actionPayloadJson: task.actionPayloadJson || "{}", const flutterJumpParam = taskFlutterJumpParam(actionType, task.actionParam, task.actionPayloadJson);
actionType: task.actionType || "none",
audienceType: task.audienceType || "all", return {
category: task.category || "", actionParam: task.actionParam || "",
description: task.description || "", actionType,
dimensionFilterJson: task.dimensionFilterJson || "{}", category: task.category || "",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs), description: task.description || "",
effectiveTo: msToDatetimeLocal(task.effectiveToMs), effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
iconKey: task.iconKey || "", effectiveTo: msToDatetimeLocal(task.effectiveToMs),
iconUrl: task.iconUrl || "", flutterJumpParam,
metricType: task.metricType || "", iconUrl: task.iconUrl || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "", metricType: task.metricType || "",
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0", rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
status: task.status || "draft", sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
targetUnit: task.targetUnit || "count", status: task.status || "draft",
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "", targetUnit: task.targetUnit || "count",
taskType: task.taskType || "daily", targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
title: task.title || "", taskType: task.taskType || "daily",
}); title: task.title || "",
};
};
export function useDailyTasksPage() { export function useDailyTasksPage() {
const abilities = useDailyTaskAbilities(); const abilities = useDailyTaskAbilities();
@ -41,7 +43,6 @@ export function useDailyTasksPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [taskType, setTaskType] = useState(""); const [taskType, setTaskType] = useState("");
const [category, setCategory] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const [selectedTask, setSelectedTask] = useState(null); const [selectedTask, setSelectedTask] = useState(null);
@ -49,12 +50,11 @@ export function useDailyTasksPage() {
const [loadingAction, setLoadingAction] = useState(""); const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo( const filters = useMemo(
() => ({ () => ({
category,
keyword: query, keyword: query,
status, status,
task_type: taskType, task_type: taskType,
}), }),
[category, query, status, taskType], [query, status, taskType],
); );
const { const {
data = emptyData, data = emptyData,
@ -73,13 +73,11 @@ export function useDailyTasksPage() {
const changeQuery = resetSetter(setQuery, setPage); const changeQuery = resetSetter(setQuery, setPage);
const changeStatus = resetSetter(setStatus, setPage); const changeStatus = resetSetter(setStatus, setPage);
const changeTaskType = resetSetter(setTaskType, setPage); const changeTaskType = resetSetter(setTaskType, setPage);
const changeCategory = resetSetter(setCategory, setPage);
const resetFilters = () => { const resetFilters = () => {
setQuery(""); setQuery("");
setStatus(""); setStatus("");
setTaskType(""); setTaskType("");
setCategory("");
setPage(1); setPage(1);
}; };
@ -103,6 +101,15 @@ export function useDailyTasksPage() {
setSelectedTask(null); setSelectedTask(null);
}; };
const changeFormActionType = (actionType) => {
setForm((current) => ({
...current,
actionParam: "",
actionType,
flutterJumpParam: defaultFlutterJumpParam(actionType),
}));
};
const submitTask = async (event) => { const submitTask = async (event) => {
event.preventDefault(); event.preventDefault();
const editing = activeAction === "edit"; const editing = activeAction === "edit";
@ -151,8 +158,7 @@ export function useDailyTasksPage() {
return { return {
abilities, abilities,
activeAction, activeAction,
category, changeFormActionType,
changeCategory,
changeQuery, changeQuery,
changeStatus, changeStatus,
changeTaskType, 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 { return {
action_param: form.actionParam.trim(), // room_random_game / gift_panel_specific 的旧 App 合约仍从 action_param 读取 game_id/gift_id。
action_payload_json: normalizedJSONObject(form.actionPayloadJson), // 新表单只让运营维护一份 Flutter 参数,所以提交前从对象里反抽标量,保证新旧字段保持同一含义。
action_param: actionParamFromFlutterJump(form.actionType, actionPayloadJSON, form.actionParam),
action_payload_json: actionPayloadJSON,
action_type: form.actionType, action_type: form.actionType,
audience_type: form.audienceType, // 人群、维度过滤、图标键已从表单移除:任务默认全量用户可见,不做事件维度限制,图标只使用上传 URL。
category: form.category.trim(), audience_type: "all",
category: hiddenTaskCategory(form),
description: form.description.trim(), description: form.description.trim(),
dimension_filter_json: normalizedJSONObject(form.dimensionFilterJson), dimension_filter_json: "{}",
effective_from_ms: datetimeLocalToMs(form.effectiveFrom), effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo), effective_to_ms: datetimeLocalToMs(form.effectiveTo),
icon_key: form.iconKey.trim(), icon_key: "",
icon_url: form.iconUrl.trim(), icon_url: form.iconUrl.trim(),
metric_type: form.metricType.trim(), metric_type: form.metricType.trim(),
reward_coin_amount: Number(form.rewardCoinAmount), 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) { function normalizedJSONObject(value) {
const trimmed = String(value || "").trim(); const trimmed = String(value || "").trim();
if (!trimmed) { 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) { function resetSetter(setter, setPage) {
return (value) => { return (value) => {
setter(value); setter(value);

View File

@ -21,12 +21,11 @@ import {
AdminRowActions, AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx"; } from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { import {
taskActionLabels, taskActionLabels,
taskActionOptions, taskActionOptions,
taskAudienceLabels,
taskAudienceOptions,
taskMetricOptions, taskMetricOptions,
taskStatusLabels, taskStatusLabels,
taskStatusOptions, taskStatusOptions,
@ -51,7 +50,9 @@ const baseColumns = [
render: (task) => ( render: (task) => (
<div className={styles.stack}> <div className={styles.stack}>
<span>{task.metricType}</span> <span>{task.metricType}</span>
<span className={styles.meta}>{task.category}</span> <span className={styles.meta}>
{formatNumber(task.targetValue)} {taskUnitLabel(task.targetUnit)}
</span>
</div> </div>
), ),
}, },
@ -61,11 +62,8 @@ const baseColumns = [
width: "minmax(220px, 1fr)", width: "minmax(220px, 1fr)",
render: (task) => ( render: (task) => (
<div className={styles.stack}> <div className={styles.stack}>
<span>{taskAudienceLabel(task.audienceType)}</span> <span>{taskActionLabel(task.actionType)}</span>
<span className={styles.meta}> <span className={styles.meta}>{task.actionParam || task.actionPayloadJson || "-"}</span>
{taskActionLabel(task.actionType)}
{task.actionParam ? `${task.actionParam}` : ""}
</span>
</div> </div>
), ),
}, },
@ -141,14 +139,7 @@ export function DailyTaskListPage() {
}; };
} }
if (column.key === "metric") { if (column.key === "metric") {
return { return column;
...column,
filter: createTextColumnFilter({
placeholder: "搜索分类",
value: page.category,
onChange: page.changeCategory,
}),
};
} }
if (column.key === "status") { if (column.key === "status") {
return { return {
@ -183,7 +174,7 @@ export function DailyTaskListPage() {
onChange={page.changeTaskType} onChange={page.changeTaskType}
/> />
<AdminFilterResetButton <AdminFilterResetButton
disabled={!page.query && !page.status && !page.taskType && !page.category} disabled={!page.query && !page.status && !page.taskType}
onClick={page.resetFilters} onClick={page.resetFilters}
/> />
</> </>
@ -328,13 +319,6 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
value={form.title} value={form.title}
onChange={(event) => page.setForm({ ...form, title: event.target.value })} 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> </AdminFormFieldGrid>
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="目标与奖励"> <AdminFormSection title="目标与奖励">
@ -393,70 +377,41 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
</AdminFormFieldGrid> </AdminFormFieldGrid>
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="展示与跳转"> <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 <TextField
className={styles.flutterJumpField}
disabled={disabled} 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 multiline
minRows={2} minRows={5}
value={form.dimensionFilterJson} label="Flutter 跳转参数"
onChange={(event) => page.setForm({ ...form, dimensionFilterJson: event.target.value })} value={form.flutterJumpParam}
onChange={(event) => page.setForm({ ...form, flutterJumpParam: event.target.value })}
/> />
<TextField </div>
disabled={disabled}
label="跳转扩展 JSON"
multiline
minRows={2}
value={form.actionPayloadJson}
onChange={(event) => page.setForm({ ...form, actionPayloadJson: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="有效期"> <AdminFormSection title="有效期">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))"> <AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
@ -500,10 +455,6 @@ function taskStatusLabel(value) {
return taskStatusLabels[value] || value || "-"; return taskStatusLabels[value] || value || "-";
} }
function taskAudienceLabel(value) {
return taskAudienceLabels[value] || value || "-";
}
function taskActionLabel(value) { function taskActionLabel(value) {
return taskActionLabels[value] || value || "-"; return taskActionLabels[value] || value || "-";
} }

View File

@ -8,6 +8,7 @@ export function useDailyTaskAbilities() {
canCreate: can(PERMISSIONS.dailyTaskCreate), canCreate: can(PERMISSIONS.dailyTaskCreate),
canStatus: can(PERMISSIONS.dailyTaskStatus), canStatus: can(PERMISSIONS.dailyTaskStatus),
canUpdate: can(PERMISSIONS.dailyTaskUpdate), canUpdate: can(PERMISSIONS.dailyTaskUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.dailyTaskView), canView: can(PERMISSIONS.dailyTaskView),
}; };
} }

View File

@ -2,16 +2,13 @@ import { z } from "zod";
export const dailyTaskFormSchema = z export const dailyTaskFormSchema = z
.object({ .object({
category: z.string().trim().min(1, "请输入任务分类").max(32, "任务分类不能超过 32 个字符"), actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符").optional(),
actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符"),
actionPayloadJson: z.string().trim().optional(),
actionType: z.enum(["none", "room_random", "room_random_game", "wallet", "gift_panel", "gift_panel_specific"]), 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 个字符"), description: z.string().trim().max(512, "描述不能超过 512 个字符"),
dimensionFilterJson: z.string().trim().optional(),
effectiveFrom: z.string().optional(), effectiveFrom: z.string().optional(),
effectiveTo: 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 个字符"), iconUrl: z.string().trim().max(512, "图标 URL 不能超过 512 个字符"),
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"), metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
rewardCoinAmount: z.union([z.string(), z.number()]), rewardCoinAmount: z.union([z.string(), z.number()]),
@ -47,11 +44,12 @@ export const dailyTaskFormSchema = z
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) { if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] }); context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
} }
if (!isJSONObject(value.actionPayloadJson)) { if (!isJSONObject(value.flutterJumpParam)) {
context.addIssue({ code: "custom", message: "跳转扩展必须是 JSON 对象", path: ["actionPayloadJson"] }); context.addIssue({
} code: "custom",
if (!isJSONObject(value.dimensionFilterJson)) { message: "Flutter 跳转参数必须是对象格式",
context.addIssue({ code: "custom", message: "维度过滤必须是 JSON 对象", path: ["dimensionFilterJson"] }); path: ["flutterJumpParam"],
});
} }
}); });

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -9,6 +9,7 @@ import {
disableGift, disableGift,
disableResource, disableResource,
disableResourceGroup, disableResourceGroup,
deleteGift,
enableResourceShopItem, enableResourceShopItem,
enableGift, enableGift,
enableResource, enableResource,
@ -150,6 +151,7 @@ test("resource APIs use generated admin paths", async () => {
}); });
await enableGift("rose"); await enableGift("rose");
await disableGift("rose"); await disableGift("rose");
await deleteGift("rose");
await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" }); await listResourceGrants({ page: 1, page_size: 10, target_user_id: 1001, status: "succeeded" });
await lookupResourceGrantTarget("163000"); await lookupResourceGrantTarget("163000");
await grantResource({ await grantResource({
@ -181,10 +183,11 @@ test("resource APIs use generated admin paths", async () => {
const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11]; const [updateGiftUrl, updateGiftInit] = vi.mocked(fetch).mock.calls[11];
const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12]; const [enableGiftUrl, enableGiftInit] = vi.mocked(fetch).mock.calls[12];
const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13]; const [disableGiftUrl, disableGiftInit] = vi.mocked(fetch).mock.calls[13];
const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[14]; const [deleteGiftUrl, deleteGiftInit] = vi.mocked(fetch).mock.calls[14];
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[15]; const [grantListUrl, grantListInit] = vi.mocked(fetch).mock.calls[15];
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[16]; const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[17]; 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("/api/v1/admin/resources?");
expect(String(listUrl)).toContain("resource_type=gift"); 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(enableGiftInit?.method).toBe("POST");
expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable"); expect(String(disableGiftUrl)).toContain("/api/v1/admin/gifts/rose/disable");
expect(disableGiftInit?.method).toBe("POST"); 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("/api/v1/admin/resource-grants?");
expect(String(grantListUrl)).toContain("target_user_id=1001"); expect(String(grantListUrl)).toContain("target_user_id=1001");
expect(grantListInit?.method).toBe("GET"); 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 () => { 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([ await updateResourceMp4Layouts([
{ {

View File

@ -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> { export function enableGift(giftId: string): Promise<GiftDto> {
const endpoint = API_ENDPOINTS.enableGift; const endpoint = API_ENDPOINTS.enableGift;
return apiRequest<GiftDto>(apiEndpointPath(API_OPERATIONS.enableGift, { gift_id: giftId }), { return apiRequest<GiftDto>(apiEndpointPath(API_OPERATIONS.enableGift, { gift_id: giftId }), {

View File

@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation"; import { parseForm } from "@/shared/forms/validation";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { import {
createEmojiPack, createEmojiPack,
createGift, createGift,
createResource, createResource,
createResourceGroup, createResourceGroup,
deleteGift as requestDeleteGift,
disableResourceShopItem, disableResourceShopItem,
disableGift, disableGift,
disableResource, disableResource,
@ -886,6 +888,7 @@ export function useResourceGroupListPage() {
export function useGiftListPage() { export function useGiftListPage() {
const abilities = useResourceAbilities(); const abilities = useResourceAbilities();
const confirm = useConfirm();
const { showToast } = useToast(); const { showToast } = useToast();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = 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) => { const saveGiftSortOrder = async (gift, rawSortOrder) => {
if (!abilities.canUpdateGift || !gift?.giftId) { if (!abilities.canUpdateGift || !gift?.giftId) {
return false; return false;
@ -1200,6 +1238,7 @@ export function useGiftListPage() {
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage), changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
closeAction, closeAction,
closeGiftTypeDialog, closeGiftTypeDialog,
deleteGifts,
form, form,
giftSortSavingIds, giftSortSavingIds,
giftTypeCode, giftTypeCode,

View File

@ -3,16 +3,19 @@ import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined"; import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined"; import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined"; import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined"; import PublicOutlined from "@mui/icons-material/PublicOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; 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 IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment"; import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import Popover from "@mui/material/Popover"; import Popover from "@mui/material/Popover";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
AdminFormDialog, AdminFormDialog,
AdminFormFieldGrid, AdminFormFieldGrid,
@ -128,7 +131,7 @@ const baseColumns = (giftTypeOptions) => [
{ {
key: "actions", key: "actions",
label: "操作", label: "操作",
width: "minmax(76px, 0.4fr)", width: "minmax(112px, 0.5fr)",
}, },
]; ];
@ -136,75 +139,148 @@ export function GiftListPage() {
const page = useGiftListPage(); const page = useGiftListPage();
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const [selectedGiftIds, setSelectedGiftIds] = useState([]);
const isEditing = page.activeAction === "edit"; const isEditing = page.activeAction === "edit";
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions]; const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
const regionLabelById = buildRegionLabelMap(giftRegionOptions); const regionLabelById = buildRegionLabelMap(giftRegionOptions);
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions; const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
const tableColumns = baseColumns(giftTypeOptions).map((column) => { const selectedGiftIdSet = useMemo(() => new Set(selectedGiftIds), [selectedGiftIds]);
if (column.key === "regions") { const selectedGifts = useMemo(
return { () => items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))),
...column, [items, selectedGiftIdSet],
filter: createRegionColumnFilter({ );
loading: page.loadingRegions, const allChecked = items.length > 0 && items.every((gift) => selectedGiftIdSet.has(String(gift.giftId)));
options: giftRegionOptions, const selectedOnPageCount = items.filter((gift) => selectedGiftIdSet.has(String(gift.giftId))).length;
value: page.regionId, const someChecked = selectedOnPageCount > 0 && !allChecked;
onChange: page.changeRegionId, const deletingBatch = page.loadingAction === "gift-delete-batch";
}),
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />, 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, const selectionColumn = {
filter: createOptionsColumnFilter({ key: "select",
options: resourceStatusFilters, className: styles.selectColumn,
placeholder: "搜索状态", header: (
value: page.status, <Checkbox
onChange: page.changeStatus, checked={allChecked}
}), disabled={!page.abilities.canDeleteGift || items.length === 0 || deletingBatch}
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />, indeterminate={someChecked}
}; size="small"
} onChange={(event) => toggleAllGifts(event.target.checked)}
if (column.key === "type") { />
return { ),
...column, resizable: false,
filter: createOptionsColumnFilter({ width: "56px",
emptyLabel: "全部类型", render: (gift) => {
loading: page.giftTypesLoading, const giftId = String(gift.giftId);
options: giftTypeOptions.map((item) => ({ return (
label: item.displayName || item.tabName || item.tabKey, <Checkbox
value: item.tabKey, checked={selectedGiftIdSet.has(giftId)}
})), disabled={!page.abilities.canDeleteGift || deletingBatch}
placeholder: "搜索类型", inputProps={{ "aria-label": `选择 ${gift.name || gift.giftId}` }}
value: page.giftTypeCode, size="small"
onChange: page.changeGiftTypeCode, onChange={(event) => toggleGiftSelection(gift, event.target.checked)}
}), />
}; );
} },
if (column.key === "sortOrder") { };
return { const tableColumns = [
...column, selectionColumn,
render: (gift) => <GiftSortOrderCell gift={gift} page={page} />, ...baseColumns(giftTypeOptions).map((column) => {
}; if (column.key === "regions") {
} return {
if (column.key === "gift") { ...column,
return { filter: createRegionColumnFilter({
...column, loading: page.loadingRegions,
filter: createTextColumnFilter({ options: giftRegionOptions,
placeholder: "搜索礼物名称、礼物 ID", value: page.regionId,
value: page.query, onChange: page.changeRegionId,
onChange: page.changeQuery, }),
}), render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
}; };
} }
if (column.key === "actions") { if (column.key === "status") {
return { return {
...column, ...column,
render: (gift) => <GiftRowActions gift={gift} page={page} />, filter: createOptionsColumnFilter({
}; options: resourceStatusFilters,
} placeholder: "搜索状态",
return column; 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 ( return (
<AdminListPage> <AdminListPage>
@ -216,6 +292,19 @@ export function GiftListPage() {
<SettingsOutlined fontSize="small" /> <SettingsOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
) : null} ) : 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 ? ( {page.abilities.canCreateGift ? (
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}> <AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
<Add fontSize="small" /> <Add fontSize="small" />
@ -229,7 +318,7 @@ export function GiftListPage() {
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
items={items} items={items}
minWidth="1440px" minWidth="1500px"
pagination={ pagination={
total > 0 total > 0
? { ? {
@ -270,6 +359,9 @@ export function GiftListPage() {
} }
function GiftRowActions({ gift, page }) { function GiftRowActions({ gift, page }) {
const deleting = page.loadingAction === `gift-delete-${gift.giftId}`;
const deletingBatch = page.loadingAction === "gift-delete-batch";
return ( return (
<AdminRowActions> <AdminRowActions>
<AdminActionIconButton <AdminActionIconButton
@ -279,6 +371,15 @@ function GiftRowActions({ gift, page }) {
> >
<EditOutlined fontSize="small" /> <EditOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
{page.abilities.canDeleteGift ? (
<AdminActionIconButton
disabled={deleting || deletingBatch}
label={deleting ? "删除中" : "删除礼物"}
onClick={() => page.deleteGifts([gift])}
>
{deleting ? <CircularProgress size={16} /> : <DeleteOutlineOutlined fontSize="small" />}
</AdminActionIconButton>
) : null}
</AdminRowActions> </AdminRowActions>
); );
} }

View File

@ -10,6 +10,7 @@ export function useResourceAbilities() {
canCreateGrant: can(PERMISSIONS.resourceGrantCreate), canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
canCreateGroup: can(PERMISSIONS.resourceGroupCreate), canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate), canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
canDeleteGift: can(PERMISSIONS.giftDelete),
canStatusGift: can(PERMISSIONS.giftStatus), canStatusGift: can(PERMISSIONS.giftStatus),
canUpdateShop: can(PERMISSIONS.resourceShopUpdate), canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate), canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),

View File

@ -43,6 +43,7 @@ const permissionGroupDefinitions = [
"cumulative-recharge-reward", "cumulative-recharge-reward",
"daily-task", "daily-task",
"first-recharge-reward", "first-recharge-reward",
"invite-activity-reward",
"lucky-gift", "lucky-gift",
"red-packet", "red-packet",
"registration-reward", "registration-reward",

View File

@ -45,6 +45,7 @@ export const API_OPERATIONS = {
createMenu: "createMenu", createMenu: "createMenu",
createPermission: "createPermission", createPermission: "createPermission",
createPlatform: "createPlatform", createPlatform: "createPlatform",
createPopup: "createPopup",
createPrettyIdPool: "createPrettyIdPool", createPrettyIdPool: "createPrettyIdPool",
createRechargeProduct: "createRechargeProduct", createRechargeProduct: "createRechargeProduct",
createRegion: "createRegion", createRegion: "createRegion",
@ -68,10 +69,12 @@ export const API_OPERATIONS = {
deleteCountry: "deleteCountry", deleteCountry: "deleteCountry",
deleteDiceRobot: "deleteDiceRobot", deleteDiceRobot: "deleteDiceRobot",
deleteExploreTab: "deleteExploreTab", deleteExploreTab: "deleteExploreTab",
deleteGift: "deleteGift",
deleteH5Link: "deleteH5Link", deleteH5Link: "deleteH5Link",
deleteHostAgencyPolicy: "deleteHostAgencyPolicy", deleteHostAgencyPolicy: "deleteHostAgencyPolicy",
deleteMenu: "deleteMenu", deleteMenu: "deleteMenu",
deletePermission: "deletePermission", deletePermission: "deletePermission",
deletePopup: "deletePopup",
deleteRechargeProduct: "deleteRechargeProduct", deleteRechargeProduct: "deleteRechargeProduct",
deleteRole: "deleteRole", deleteRole: "deleteRole",
deleteRoom: "deleteRoom", deleteRoom: "deleteRoom",
@ -151,6 +154,7 @@ export const API_OPERATIONS = {
listOperationLogs: "listOperationLogs", listOperationLogs: "listOperationLogs",
listPermissions: "listPermissions", listPermissions: "listPermissions",
listPlatforms: "listPlatforms", listPlatforms: "listPlatforms",
listPopups: "listPopups",
listPrettyIdPools: "listPrettyIdPools", listPrettyIdPools: "listPrettyIdPools",
listPrettyIds: "listPrettyIds", listPrettyIds: "listPrettyIds",
listRechargeBills: "listRechargeBills", listRechargeBills: "listRechargeBills",
@ -230,6 +234,7 @@ export const API_OPERATIONS = {
updateMenuVisible: "updateMenuVisible", updateMenuVisible: "updateMenuVisible",
updatePermission: "updatePermission", updatePermission: "updatePermission",
updatePlatform: "updatePlatform", updatePlatform: "updatePlatform",
updatePopup: "updatePopup",
updatePrettyIdPool: "updatePrettyIdPool", updatePrettyIdPool: "updatePrettyIdPool",
updateRechargeProduct: "updateRechargeProduct", updateRechargeProduct: "updateRechargeProduct",
updateRedPacketConfig: "updateRedPacketConfig", updateRedPacketConfig: "updateRedPacketConfig",
@ -486,6 +491,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:update", permission: "game:update",
permissions: ["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: { createPrettyIdPool: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createPrettyIdPool, operationId: API_OPERATIONS.createPrettyIdPool,
@ -647,6 +659,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "app-config:update", permission: "app-config:update",
permissions: ["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: { deleteH5Link: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteH5Link, operationId: API_OPERATIONS.deleteH5Link,
@ -675,6 +694,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "permission:delete", permission: "permission:delete",
permissions: ["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: { deleteRechargeProduct: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteRechargeProduct, operationId: API_OPERATIONS.deleteRechargeProduct,
@ -1226,6 +1252,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:view", permission: "game:view",
permissions: ["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: { listPrettyIdPools: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listPrettyIdPools, operationId: API_OPERATIONS.listPrettyIdPools,
@ -1767,6 +1800,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:update", permission: "game:update",
permissions: ["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: { updatePrettyIdPool: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updatePrettyIdPool, operationId: API_OPERATIONS.updatePrettyIdPool,

View File

@ -628,6 +628,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/admin/app-config/h5-links": {
parameters: { parameters: {
query?: never; query?: never;
@ -1342,7 +1374,7 @@ export interface paths {
get?: never; get?: never;
put: operations["updateGift"]; put: operations["updateGift"];
post?: never; post?: never;
delete?: never; delete: operations["deleteGift"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
@ -3994,6 +4026,58 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 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: { listH5Links: {
parameters: { parameters: {
query?: never; query?: never;
@ -4861,6 +4945,20 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
deleteGift: {
parameters: {
query?: never;
header?: never;
path: {
gift_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
disableGift: { disableGift: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -980,6 +980,36 @@ export interface AppSplashScreenPayload {
status: "active" | "disabled" | "expired"; 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 { export interface AgencyMembershipDto {
agencyId?: number; agencyId?: number;
createdAtMs?: number; createdAtMs?: number;

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

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

View File

@ -38,7 +38,7 @@ export function AdminUserIdentity({
user: normalized.detailUser, user: normalized.detailUser,
}); });
const visibleRows = Array.isArray(rows) const visibleRows = Array.isArray(rows)
? rows.map((row) => String(row || "").trim()).filter(Boolean) ? resolveVisibleRows(rows, normalized)
: [normalized.shortId].filter(Boolean); : [normalized.shortId].filter(Boolean);
return ( return (
@ -175,6 +175,7 @@ function normalizeUserIdentity({
userId: resolvedUserId, userId: resolvedUserId,
username: firstNonEmpty(name, source.username, source.name, source.account), username: firstNonEmpty(name, source.username, source.name, source.account),
}, },
displayUserId: resolvedDisplayUserId,
displayName, displayName,
hasPretty, hasPretty,
prettyDisplayUserId: resolvedPrettyDisplayUserId, 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 }) { function resolveRootProps({ displayName, onClick, openAppUserDetail, shouldOpenAppUserDetail, user }) {
if (onClick) { if (onClick) {
return { return {

View File

@ -44,6 +44,26 @@ test("clickable identity opens detail handler", async () => {
expect(onClick).toHaveBeenCalledTimes(1); 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 () => { test("open detail uses provider instead of app user list link", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const openAppUserDetail = vi.fn(); const openAppUserDetail = vi.fn();

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

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