新增功能

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"]
}
},
"/admin/app-config/popups": {
"get": {
"operationId": "listPopups",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-config:view",
"x-permissions": ["app-config:view"]
},
"post": {
"operationId": "createPopup",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
}
},
"/admin/app-config/popups/{popup_id}": {
"put": {
"operationId": "updatePopup",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "popup_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
},
"delete": {
"operationId": "deletePopup",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "popup_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "app-config:update",
"x-permissions": ["app-config:update"]
}
},
"/admin/app-config/h5-links": {
"get": {
"operationId": "listH5Links",
@ -1917,6 +1981,26 @@
],
"x-permission": "gift:update",
"x-permissions": ["gift:update"]
},
"delete": {
"operationId": "deleteGift",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "gift_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "gift:delete",
"x-permissions": ["gift:delete"]
}
},
"/admin/gifts/{gift_id}/disable": {

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import type {
ApiList,
AppBannerDto,
AppBannerPayload,
AppPopupDto,
AppPopupPayload,
AppSplashScreenDto,
AppSplashScreenPayload,
AppVersionDto,
@ -163,6 +165,40 @@ export function deleteSplashScreen(splashId: EntityId): Promise<{ deleted: boole
);
}
export function listPopups(query: PageQuery = {}): Promise<ApiList<AppPopupDto>> {
const endpoint = API_ENDPOINTS.listPopups;
return apiRequest<ApiList<AppPopupDto>>(apiEndpointPath(API_OPERATIONS.listPopups), {
method: endpoint.method,
query,
});
}
export function createPopup(payload: AppPopupPayload): Promise<AppPopupDto> {
const endpoint = API_ENDPOINTS.createPopup;
return apiRequest<AppPopupDto, AppPopupPayload>(apiEndpointPath(API_OPERATIONS.createPopup), {
body: payload,
method: endpoint.method,
});
}
export function updatePopup(popupId: EntityId, payload: AppPopupPayload): Promise<AppPopupDto> {
const endpoint = API_ENDPOINTS.updatePopup;
return apiRequest<AppPopupDto, AppPopupPayload>(
apiEndpointPath(API_OPERATIONS.updatePopup, { popup_id: popupId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function deletePopup(popupId: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deletePopup;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deletePopup, { popup_id: popupId }), {
method: endpoint.method,
});
}
export function listAppVersions(query: PageQuery = {}): Promise<ApiList<AppVersionDto>> {
const endpoint = API_ENDPOINTS.listAppVersions;
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {

View File

@ -24,6 +24,24 @@
object-fit: cover;
}
.popupCover,
.popupCoverEmpty {
display: inline-flex;
width: 72px;
height: 72px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-tertiary);
}
.popupCover {
object-fit: cover;
}
.splashCover,
.splashCoverEmpty {
display: inline-flex;
@ -84,6 +102,40 @@
align-self: center;
}
.popupFormLayout {
display: grid;
width: 100%;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
align-items: start;
gap: var(--space-5);
}
.popupAssetPane,
.popupFieldPane {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.popupAssetPane {
padding-right: var(--space-1);
}
.popupFieldPane {
padding-left: var(--space-1);
}
.popupUploadField {
width: min(100%, 280px);
justify-self: center;
}
.popupUploadField .popupUploadPreview {
aspect-ratio: 1 / 1;
height: auto;
max-height: min(42vh, 300px);
}
.statusCell {
display: inline-flex;
align-items: center;
@ -105,4 +157,20 @@
justify-self: stretch;
width: min(100%, 260px);
}
.popupFormLayout {
grid-template-columns: 1fr;
gap: var(--space-4);
}
.popupAssetPane,
.popupFieldPane {
padding-right: 0;
padding-left: 0;
}
.popupUploadField {
justify-self: stretch;
width: min(100%, 280px);
}
}

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

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

View File

@ -25,6 +25,14 @@ export const appConfigRoutes = [
path: "/app-config/splash-screens",
permission: PERMISSIONS.appConfigView,
},
{
label: "弹窗配置",
loader: () => import("./pages/PopupConfigPage.jsx").then((module) => module.PopupConfigPage),
menuCode: MENU_CODES.appConfigPopups,
pageKey: "app-config-popups",
path: "/app-config/popups",
permission: PERMISSIONS.appConfigView,
},
{
label: "Explore配置",
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),

View File

@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import {
appBannerSchema,
appPopupSchema,
appSplashScreenSchema,
exploreTabSchema,
h5LinkUpdateSchema,
@ -39,6 +40,19 @@ const validSplashScreenForm = {
status: "active",
};
const validPopupForm = {
code: "new-user-popup",
displayPeriodDays: "2",
endsAtMs: "1800000000000",
imageUrl: "",
jumpType: "h5",
jumpUrl: "https://h5.example.com/popup",
name: "新人活动",
sortOrder: "0",
startsAtMs: "1700000000000",
status: "active",
};
describe("app config form schema", () => {
test("validates dynamic h5 config item", () => {
const payload = parseForm(h5LinkUpdateSchema, {
@ -69,6 +83,27 @@ describe("app config form schema", () => {
expect(payload.endsAtMs).toBe(1800000000000);
});
test("accepts public app jump json for banner", () => {
const payload = parseForm(appBannerSchema, {
...validBannerForm,
bannerType: "app",
param: '{"type":"room_random_game","game_id":"dice"}',
});
expect(payload.bannerType).toBe("app");
expect(payload.param).toBe('{"type":"room_random_game","game_id":"dice"}');
});
test("rejects incomplete public app jump json for banner", () => {
expect(() =>
parseForm(appBannerSchema, {
...validBannerForm,
bannerType: "app",
param: '{"type":"room_game","room_id":"room_1"}',
}),
).toThrow(FormValidationError);
});
test("accepts multiple banner display scopes", () => {
const payload = parseForm(appBannerSchema, {
...validBannerForm,
@ -109,6 +144,17 @@ describe("app config form schema", () => {
expect("displayScopes" in payload).toBe(false);
});
test("accepts public app jump json for splash screen", () => {
const payload = parseForm(appSplashScreenSchema, {
...validSplashScreenForm,
param: '{"type":"wallet"}',
splashType: "app",
});
expect(payload.splashType).toBe("app");
expect(payload.param).toBe('{"type":"wallet"}');
});
test("requires h5 link for splash screen h5 type", () => {
expect(() =>
parseForm(appSplashScreenSchema, {
@ -137,6 +183,57 @@ describe("app config form schema", () => {
).toThrow(FormValidationError);
});
test("keeps popup optional image and display period", () => {
const payload = parseForm(appPopupSchema, validPopupForm);
expect(payload.code).toBe("new-user-popup");
expect(payload.imageUrl).toBe("");
expect(payload.displayPeriodDays).toBe(2);
expect(payload.startsAtMs).toBe(1700000000000);
expect(payload.endsAtMs).toBe(1800000000000);
});
test("accepts public app jump json for popup", () => {
const payload = parseForm(appPopupSchema, {
...validPopupForm,
jumpType: "app",
jumpUrl: '{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}',
});
expect(payload.jumpType).toBe("app");
expect(payload.jumpUrl).toBe(
'{"type":"room_window","room_id":"room_1","window":"gift_panel","gift_id":"gift_1"}',
);
});
test("rejects popup code with spaces", () => {
expect(() =>
parseForm(appPopupSchema, {
...validPopupForm,
code: "new user popup",
}),
).toThrow(FormValidationError);
});
test("requires popup jump address", () => {
expect(() =>
parseForm(appPopupSchema, {
...validPopupForm,
jumpUrl: "",
}),
).toThrow(FormValidationError);
});
test("rejects inverted popup effective time range", () => {
expect(() =>
parseForm(appPopupSchema, {
...validPopupForm,
endsAtMs: "1700000000000",
startsAtMs: "1800000000000",
}),
).toThrow(FormValidationError);
});
test("validates explore tab h5 url and sort order", () => {
const payload = parseForm(exploreTabSchema, {
enabled: true,

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
@ -70,6 +71,12 @@ export const appBannerSchema = z
if (value.bannerType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
}
if (value.bannerType === "app") {
const validation = validatePublicAppJumpParam(value.param);
if (!validation.valid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] });
}
}
if (value.displayScopes.includes("room") && !value.roomSmallImageUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@ -115,6 +122,12 @@ export const appSplashScreenSchema = z
if (value.splashType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
}
if (value.splashType === "app") {
const validation = validatePublicAppJumpParam(value.param);
if (!validation.valid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["param"] });
}
}
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["endsAtMs"] });
}
@ -122,6 +135,45 @@ export const appSplashScreenSchema = z
export type AppSplashScreenForm = z.infer<typeof appSplashScreenSchema>;
export const appPopupSchema = z
.object({
code: z
.string()
.trim()
.min(1, "请填写弹窗code")
.max(80, "弹窗code不能超过 80 个字符")
.regex(/^[A-Za-z0-9_.:-]+$/, "弹窗code只能包含字母、数字、下划线、中横线、点和冒号"),
displayPeriodDays: z.coerce.number().int("展示周期必须是整数天").min(0, "展示周期不能小于 0").default(0),
endsAtMs: z.coerce.number().int().min(0, "生效结束时间不正确").default(0),
imageUrl: z
.string()
.trim()
.max(1024, "图片地址不能超过 1024 个字符")
.refine((value) => !/\s/.test(value), "图片地址不能包含空白字符"),
jumpType: z.enum(["h5", "app"]),
jumpUrl: z.string().trim().min(1, "请填写跳转地址").max(2048, "跳转地址不能超过 2048 个字符"),
name: z.string().trim().min(1, "请填写弹窗名字").max(80, "弹窗名字不能超过 80 个字符"),
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
startsAtMs: z.coerce.number().int().min(0, "生效开始时间不正确").default(0),
status: z.enum(["active", "disabled", "expired"]),
})
.superRefine((value, ctx) => {
if (value.jumpType === "h5" && /\s/.test(value.jumpUrl)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["jumpUrl"] });
}
if (value.jumpType === "app") {
const validation = validatePublicAppJumpParam(value.jumpUrl);
if (!validation.valid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: validation.message, path: ["jumpUrl"] });
}
}
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "生效结束时间必须晚于开始时间", path: ["endsAtMs"] });
}
});
export type AppPopupForm = z.infer<typeof appPopupSchema>;
export const appVersionSchema = z.object({
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),

View File

@ -71,3 +71,36 @@
align-items: center;
gap: var(--space-1);
}
.jumpConfigGrid {
display: grid;
grid-template-columns: minmax(220px, 0.72fr) minmax(320px, 1.28fr);
align-items: start;
gap: var(--space-3);
width: 100%;
}
.jumpConfigSide {
display: grid;
min-width: 0;
gap: var(--space-3);
}
.iconUpload {
min-width: 0;
}
.flutterJumpField {
min-width: 0;
}
.flutterJumpField :global(.MuiInputBase-root) {
min-height: 162px;
align-items: flex-start;
}
@media (max-width: 760px) {
.jumpConfigGrid {
grid-template-columns: 1fr;
}
}

View File

@ -13,27 +13,29 @@ import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = (task = {}) => ({
actionParam: task.actionParam || "",
actionPayloadJson: task.actionPayloadJson || "{}",
actionType: task.actionType || "none",
audienceType: task.audienceType || "all",
category: task.category || "",
description: task.description || "",
dimensionFilterJson: task.dimensionFilterJson || "{}",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
iconKey: task.iconKey || "",
iconUrl: task.iconUrl || "",
metricType: task.metricType || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
status: task.status || "draft",
targetUnit: task.targetUnit || "count",
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
taskType: task.taskType || "daily",
title: task.title || "",
});
const emptyForm = (task = {}) => {
const actionType = task.actionType || "none";
const flutterJumpParam = taskFlutterJumpParam(actionType, task.actionParam, task.actionPayloadJson);
return {
actionParam: task.actionParam || "",
actionType,
category: task.category || "",
description: task.description || "",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
flutterJumpParam,
iconUrl: task.iconUrl || "",
metricType: task.metricType || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
status: task.status || "draft",
targetUnit: task.targetUnit || "count",
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
taskType: task.taskType || "daily",
title: task.title || "",
};
};
export function useDailyTasksPage() {
const abilities = useDailyTaskAbilities();
@ -41,7 +43,6 @@ export function useDailyTasksPage() {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [taskType, setTaskType] = useState("");
const [category, setCategory] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [selectedTask, setSelectedTask] = useState(null);
@ -49,12 +50,11 @@ export function useDailyTasksPage() {
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
category,
keyword: query,
status,
task_type: taskType,
}),
[category, query, status, taskType],
[query, status, taskType],
);
const {
data = emptyData,
@ -73,13 +73,11 @@ export function useDailyTasksPage() {
const changeQuery = resetSetter(setQuery, setPage);
const changeStatus = resetSetter(setStatus, setPage);
const changeTaskType = resetSetter(setTaskType, setPage);
const changeCategory = resetSetter(setCategory, setPage);
const resetFilters = () => {
setQuery("");
setStatus("");
setTaskType("");
setCategory("");
setPage(1);
};
@ -103,6 +101,15 @@ export function useDailyTasksPage() {
setSelectedTask(null);
};
const changeFormActionType = (actionType) => {
setForm((current) => ({
...current,
actionParam: "",
actionType,
flutterJumpParam: defaultFlutterJumpParam(actionType),
}));
};
const submitTask = async (event) => {
event.preventDefault();
const editing = activeAction === "edit";
@ -151,8 +158,7 @@ export function useDailyTasksPage() {
return {
abilities,
activeAction,
category,
changeCategory,
changeFormActionType,
changeQuery,
changeStatus,
changeTaskType,
@ -178,18 +184,25 @@ export function useDailyTasksPage() {
};
}
function buildTaskPayload(form) {
export function buildTaskPayload(form) {
// 表单不再暴露“跳转扩展 JSON”但后端和 App 仍读取 action_payload_json。
// 这里把可编辑的 Flutter 跳转参数压缩成对象 JSON既保留后台配置能力也避免 UI 暴露底层字段名。
const actionPayloadJSON = normalizedJSONObject(form.flutterJumpParam || defaultFlutterJumpParam(form.actionType));
return {
action_param: form.actionParam.trim(),
action_payload_json: normalizedJSONObject(form.actionPayloadJson),
// room_random_game / gift_panel_specific 的旧 App 合约仍从 action_param 读取 game_id/gift_id。
// 新表单只让运营维护一份 Flutter 参数,所以提交前从对象里反抽标量,保证新旧字段保持同一含义。
action_param: actionParamFromFlutterJump(form.actionType, actionPayloadJSON, form.actionParam),
action_payload_json: actionPayloadJSON,
action_type: form.actionType,
audience_type: form.audienceType,
category: form.category.trim(),
// 人群、维度过滤、图标键已从表单移除:任务默认全量用户可见,不做事件维度限制,图标只使用上传 URL。
audience_type: "all",
category: hiddenTaskCategory(form),
description: form.description.trim(),
dimension_filter_json: normalizedJSONObject(form.dimensionFilterJson),
dimension_filter_json: "{}",
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
icon_key: form.iconKey.trim(),
icon_key: "",
icon_url: form.iconUrl.trim(),
metric_type: form.metricType.trim(),
reward_coin_amount: Number(form.rewardCoinAmount),
@ -202,6 +215,52 @@ function buildTaskPayload(form) {
};
}
export function defaultFlutterJumpParam(actionType) {
const template =
{
gift_panel: { route: "gift_panel" },
gift_panel_specific: { gift_id: "", route: "gift_panel" },
none: {},
room_random: { route: "room_random" },
room_random_game: { game_id: "", route: "room_random", show_game: true },
wallet: { route: "wallet" },
}[actionType] || {};
return JSON.stringify(template, null, 2);
}
function taskFlutterJumpParam(actionType, actionParam, actionPayloadJSON) {
const parsed = parseObjectJSON(actionPayloadJSON);
const payload = Object.keys(parsed).length > 0 ? parsed : parseObjectJSON(defaultFlutterJumpParam(actionType));
const scalarParam = String(actionParam || "").trim();
if (actionType === "room_random_game" && scalarParam && !payload.game_id) {
payload.game_id = scalarParam;
}
if (actionType === "gift_panel_specific" && scalarParam && !payload.gift_id) {
payload.gift_id = scalarParam;
}
return JSON.stringify(payload, null, 2);
}
function actionParamFromFlutterJump(actionType, actionPayloadJSON, fallback = "") {
const payload = parseObjectJSON(actionPayloadJSON);
if (actionType === "room_random_game") {
return String(payload.game_id || payload.gameId || fallback || "").trim();
}
if (actionType === "gift_panel_specific") {
return String(payload.gift_id || payload.giftId || fallback || "").trim();
}
return String(fallback || "").trim();
}
function hiddenTaskCategory(form) {
const value = String(form.category || form.metricType || form.title || "task").trim();
return value.slice(0, 32) || "task";
}
function normalizedJSONObject(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {
@ -218,6 +277,16 @@ function normalizedJSONObject(value) {
}
}
function parseObjectJSON(value) {
const normalized = normalizedJSONObject(value);
try {
const parsed = JSON.parse(normalized);
return parsed && !Array.isArray(parsed) && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function resetSetter(setter, setPage) {
return (value) => {
setter(value);

View File

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

View File

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

View File

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

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

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> {
const endpoint = API_ENDPOINTS.enableGift;
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 { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createEmojiPack,
createGift,
createResource,
createResourceGroup,
deleteGift as requestDeleteGift,
disableResourceShopItem,
disableGift,
disableResource,
@ -886,6 +888,7 @@ export function useResourceGroupListPage() {
export function useGiftListPage() {
const abilities = useResourceAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
@ -1143,6 +1146,41 @@ export function useGiftListPage() {
}
};
const deleteGifts = async (gifts = []) => {
const targetGifts = gifts.filter((gift) => gift?.giftId);
if (!abilities.canDeleteGift || !targetGifts.length) {
return false;
}
const title = targetGifts.length > 1 ? "批量删除礼物" : "删除礼物";
const message =
targetGifts.length > 1
? `确认删除选中的 ${targetGifts.length} 个礼物?只删除礼物列表数据,不删除资源列表数据。`
: `确认删除礼物 ${targetGifts[0].name || targetGifts[0].giftId}?只删除礼物列表数据,不删除资源列表数据。`;
const ok = await confirm({
confirmText: "删除",
message,
title,
tone: "danger",
});
if (!ok) {
return false;
}
setLoadingAction(targetGifts.length > 1 ? "gift-delete-batch" : `gift-delete-${targetGifts[0].giftId}`);
try {
for (const gift of targetGifts) {
await requestDeleteGift(gift.giftId);
}
showToast(targetGifts.length > 1 ? `已删除 ${targetGifts.length} 个礼物` : "礼物已删除", "success");
await result.reload();
return true;
} catch (err) {
showToast(err.message || "删除礼物失败", "error");
return false;
} finally {
setLoadingAction("");
}
};
const saveGiftSortOrder = async (gift, rawSortOrder) => {
if (!abilities.canUpdateGift || !gift?.giftId) {
return false;
@ -1200,6 +1238,7 @@ export function useGiftListPage() {
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
closeAction,
closeGiftTypeDialog,
deleteGifts,
form,
giftSortSavingIds,
giftTypeCode,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -980,6 +980,36 @@ export interface AppSplashScreenPayload {
status: "active" | "disabled" | "expired";
}
export interface AppPopupDto {
appCode?: string;
code: string;
createdAtMs?: number;
displayPeriodDays?: number;
endsAtMs?: number;
id: number;
imageUrl?: string;
jumpType: "h5" | "app" | string;
jumpUrl: string;
name: string;
sortOrder?: number;
startsAtMs?: number;
status: "active" | "disabled" | "expired" | string;
updatedAtMs?: number;
}
export interface AppPopupPayload {
code: string;
displayPeriodDays?: number;
endsAtMs?: number;
imageUrl?: string;
jumpType: "h5" | "app";
jumpUrl: string;
name: string;
sortOrder?: number;
startsAtMs?: number;
status: "active" | "disabled" | "expired";
}
export interface AgencyMembershipDto {
agencyId?: number;
createdAtMs?: number;

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

View File

@ -44,6 +44,26 @@ test("clickable identity opens detail handler", async () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
test("uses original id before pretty id when rows pass current display id", () => {
render(
<AdminUserIdentity
rows={["VIP2026", "10001"]}
user={{
defaultDisplayUserId: "123456",
displayUserId: "VIP2026",
prettyDisplayUserId: "VIP2026",
prettyId: "pretty-2026",
userId: "10001",
username: "tester",
}}
/>,
);
expect(screen.getByText("123456")).toBeInTheDocument();
expect(screen.getByText("VIP2026")).toBeInTheDocument();
expect(screen.getByText("10001")).toBeInTheDocument();
});
test("open detail uses provider instead of app user list link", async () => {
const user = userEvent.setup();
const openAppUserDetail = vi.fn();

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