相关活动
This commit is contained in:
parent
7c4bdf8d77
commit
f5d06e1e52
@ -132,6 +132,28 @@
|
||||
"x-permissions": ["user-leaderboard:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/cp/config": {
|
||||
"get": {
|
||||
"operationId": "getCPConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "cp-config:view",
|
||||
"x-permissions": ["cp-config:view"]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateCPConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "cp-config:update",
|
||||
"x-permissions": ["cp-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/red-packets": {
|
||||
"get": {
|
||||
"operationId": "listRedPackets",
|
||||
@ -2382,6 +2404,59 @@
|
||||
"x-permissions": ["coin-seller-ledger:view"]
|
||||
}
|
||||
},
|
||||
"/admin/operations/full-server-notices/fanout": {
|
||||
"post": {
|
||||
"operationId": "createFullServerNoticeFanout",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["message_type", "target_scope", "title", "summary"],
|
||||
"properties": {
|
||||
"action_param": { "type": "string" },
|
||||
"action_type": { "type": "string" },
|
||||
"aggregate_id": { "type": "string" },
|
||||
"aggregate_type": { "type": "string" },
|
||||
"batch_size": { "type": "integer" },
|
||||
"body": { "type": "string" },
|
||||
"command_id": { "type": "string" },
|
||||
"country": { "type": "string" },
|
||||
"expire_at_ms": { "type": "integer", "format": "int64" },
|
||||
"icon_url": { "type": "string" },
|
||||
"image_url": { "type": "string" },
|
||||
"message_type": { "type": "string", "enum": ["system", "activity"] },
|
||||
"metadata_json": { "type": "string" },
|
||||
"priority": { "type": "integer" },
|
||||
"producer_event_type": { "type": "string" },
|
||||
"region_id": { "type": "integer", "format": "int64" },
|
||||
"sent_at_ms": { "type": "integer", "format": "int64" },
|
||||
"summary": { "type": "string" },
|
||||
"target_scope": {
|
||||
"type": "string",
|
||||
"enum": ["all_active_users", "single_user", "user_ids", "region", "country"]
|
||||
},
|
||||
"target_user_id": { "type": "integer", "format": "int64" },
|
||||
"title": { "type": "string" },
|
||||
"user_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer", "format": "int64" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "full-server-notice:send",
|
||||
"x-permissions": ["full-server-notice:send"]
|
||||
}
|
||||
},
|
||||
"/admin/operations/reports": {
|
||||
"get": {
|
||||
"operationId": "listReports",
|
||||
|
||||
@ -184,6 +184,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
||||
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
||||
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
||||
routeNavItem("operation-full-server-notice", { icon: CampaignOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -212,6 +213,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
||||
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
||||
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
||||
routeNavItem("agency-opening", { icon: StorefrontOutlined }),
|
||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
||||
|
||||
@ -45,6 +45,8 @@ export const PERMISSIONS = {
|
||||
reportView: "report:view",
|
||||
giftDiamondView: "gift-diamond:view",
|
||||
giftDiamondUpdate: "gift-diamond:update",
|
||||
fullServerNoticeView: "full-server-notice:view",
|
||||
fullServerNoticeSend: "full-server-notice:send",
|
||||
paymentBillView: "payment-bill:view",
|
||||
paymentProductView: "payment-product:view",
|
||||
paymentProductCreate: "payment-product:create",
|
||||
@ -159,6 +161,9 @@ export const PERMISSIONS = {
|
||||
weeklyStarCreate: "weekly-star:create",
|
||||
weeklyStarUpdate: "weekly-star:update",
|
||||
weeklyStarSettle: "weekly-star:settle",
|
||||
agencyOpeningView: "agency-opening:view",
|
||||
agencyOpeningCreate: "agency-opening:create",
|
||||
agencyOpeningUpdate: "agency-opening:update",
|
||||
uploadCreate: "upload:create",
|
||||
} as const;
|
||||
|
||||
@ -207,6 +212,7 @@ export const MENU_CODES = {
|
||||
operationCoinAdjustment: "operation-coin-adjustment",
|
||||
operationReports: "operation-reports",
|
||||
operationGiftDiamond: "operation-gift-diamond",
|
||||
operationFullServerNotice: "operation-full-server-notice",
|
||||
luckyGift: "lucky-gift",
|
||||
activities: "activities",
|
||||
dailyTaskList: "daily-task-list",
|
||||
@ -223,6 +229,7 @@ export const MENU_CODES = {
|
||||
cpConfig: "cp-config",
|
||||
vipConfig: "vip-config",
|
||||
weeklyStar: "weekly-star",
|
||||
agencyOpening: "agency-opening",
|
||||
geo: "geo",
|
||||
hostOrg: "host-org",
|
||||
hostOrgCountries: "host-org-countries",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { authRoutes } from "@/features/auth/routes.js";
|
||||
import { agencyOpeningRoutes } from "@/features/agency-opening/routes.js";
|
||||
import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||
@ -52,6 +53,7 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...roomRocketRoutes,
|
||||
...roomTurnoverRewardRoutes,
|
||||
...weeklyStarRoutes,
|
||||
...agencyOpeningRoutes,
|
||||
...userLeaderboardRoutes,
|
||||
...redPacketRoutes,
|
||||
...cpConfigRoutes,
|
||||
|
||||
153
src/features/agency-opening/api.ts
Normal file
153
src/features/agency-opening/api.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { apiRequest, type QueryParams } from "@/shared/api/request";
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
export interface AgencyOpeningRewardDto {
|
||||
rankNo: number;
|
||||
tierNo: number;
|
||||
thresholdCoinSpent: number;
|
||||
rewardCoinAmount: number;
|
||||
}
|
||||
|
||||
export interface AgencyOpeningCycleDto {
|
||||
cycleId: string;
|
||||
activityCode: string;
|
||||
title: string;
|
||||
status: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
minHostCount: number;
|
||||
maxAgencyAgeDays: number;
|
||||
rewards: AgencyOpeningRewardDto[];
|
||||
settledAtMs: number;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface AgencyOpeningCyclePayload {
|
||||
cycle_id?: string;
|
||||
title: string;
|
||||
status: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
min_host_count: number;
|
||||
max_agency_age_days: number;
|
||||
rewards: Array<{ rank_no: number; tier_no?: number; threshold_coin_spent: number; reward_coin_amount: number }>;
|
||||
}
|
||||
|
||||
export interface AgencyOpeningApplicationDto {
|
||||
applicationId: string;
|
||||
cycleId: string;
|
||||
agencyId: string;
|
||||
agencyOwnerUserId: string;
|
||||
agencyName: string;
|
||||
hostCount: number;
|
||||
agencyCreatedAtMs: number;
|
||||
status: string;
|
||||
scoreCoinAmount: number;
|
||||
rewardRankNo: number;
|
||||
rewardThresholdCoinSpent: number;
|
||||
rewardCoinAmount: number;
|
||||
walletCommandId: string;
|
||||
walletTransactionId: string;
|
||||
failureReason: string;
|
||||
appliedAtMs: number;
|
||||
grantedAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
export function listAgencyOpeningCycles(query: QueryParams = {}) {
|
||||
return apiRequest<{ items?: Raw[]; total?: number; page?: number; page_size?: number }>(
|
||||
"/v1/admin/activity/agency-opening/cycles",
|
||||
{ method: "GET", query },
|
||||
).then((page) => ({ ...page, items: (page.items || []).map(normalizeCycle) }));
|
||||
}
|
||||
|
||||
export function createAgencyOpeningCycle(payload: AgencyOpeningCyclePayload) {
|
||||
return apiRequest<Raw, AgencyOpeningCyclePayload>("/v1/admin/activity/agency-opening/cycles", {
|
||||
body: payload,
|
||||
method: "POST",
|
||||
}).then(normalizeCycle);
|
||||
}
|
||||
|
||||
export function updateAgencyOpeningCycle(cycleId: string, payload: AgencyOpeningCyclePayload) {
|
||||
return apiRequest<Raw, AgencyOpeningCyclePayload>(
|
||||
`/v1/admin/activity/agency-opening/cycles/${encodeURIComponent(cycleId)}`,
|
||||
{
|
||||
body: payload,
|
||||
method: "PUT",
|
||||
},
|
||||
).then(normalizeCycle);
|
||||
}
|
||||
|
||||
export function setAgencyOpeningCycleStatus(cycleId: string, status: string) {
|
||||
return apiRequest<Raw, { status: string }>(
|
||||
`/v1/admin/activity/agency-opening/cycles/${encodeURIComponent(cycleId)}/status`,
|
||||
{ body: { status }, method: "PATCH" },
|
||||
).then(normalizeCycle);
|
||||
}
|
||||
|
||||
export function listAgencyOpeningApplications(query: QueryParams = {}) {
|
||||
return apiRequest<{ items?: Raw[]; total?: number; page?: number; page_size?: number }>(
|
||||
"/v1/admin/activity/agency-opening/applications",
|
||||
{ method: "GET", query },
|
||||
).then((page) => ({ ...page, items: (page.items || []).map(normalizeApplication) }));
|
||||
}
|
||||
|
||||
function normalizeCycle(item: Raw): AgencyOpeningCycleDto {
|
||||
return {
|
||||
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||
activityCode: stringValue(item.activityCode ?? item.activity_code),
|
||||
title: stringValue(item.title),
|
||||
status: stringValue(item.status),
|
||||
startMs: numberValue(item.startMs ?? item.start_ms),
|
||||
endMs: numberValue(item.endMs ?? item.end_ms),
|
||||
minHostCount: numberValue(item.minHostCount ?? item.min_host_count),
|
||||
maxAgencyAgeDays: numberValue(item.maxAgencyAgeDays ?? item.max_agency_age_days),
|
||||
rewards: Array.isArray(item.rewards) ? item.rewards.map((reward) => normalizeReward(reward as Raw)) : [],
|
||||
settledAtMs: numberValue(item.settledAtMs ?? item.settled_at_ms),
|
||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReward(item: Raw): AgencyOpeningRewardDto {
|
||||
return {
|
||||
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||
tierNo: numberValue(item.tierNo ?? item.tier_no ?? item.rankNo ?? item.rank_no),
|
||||
thresholdCoinSpent: numberValue(item.thresholdCoinSpent ?? item.threshold_coin_spent),
|
||||
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApplication(item: Raw): AgencyOpeningApplicationDto {
|
||||
return {
|
||||
applicationId: stringValue(item.applicationId ?? item.application_id),
|
||||
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||
agencyId: stringValue(item.agencyId ?? item.agency_id),
|
||||
agencyOwnerUserId: stringValue(item.agencyOwnerUserId ?? item.agency_owner_user_id),
|
||||
agencyName: stringValue(item.agencyName ?? item.agency_name),
|
||||
hostCount: numberValue(item.hostCount ?? item.host_count),
|
||||
agencyCreatedAtMs: numberValue(item.agencyCreatedAtMs ?? item.agency_created_at_ms),
|
||||
status: stringValue(item.status),
|
||||
scoreCoinAmount: numberValue(item.scoreCoinAmount ?? item.score_coin_amount),
|
||||
rewardRankNo: numberValue(item.rewardRankNo ?? item.reward_rank_no),
|
||||
rewardThresholdCoinSpent: numberValue(item.rewardThresholdCoinSpent ?? item.reward_threshold_coin_spent),
|
||||
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||
appliedAtMs: numberValue(item.appliedAtMs ?? item.applied_at_ms),
|
||||
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
591
src/features/agency-opening/pages/AgencyOpeningPage.jsx
Normal file
591
src/features/agency-opening/pages/AgencyOpeningPage.jsx
Normal file
@ -0,0 +1,591 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||
import Box from "@mui/material/Box";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
createAgencyOpeningCycle,
|
||||
listAgencyOpeningApplications,
|
||||
listAgencyOpeningCycles,
|
||||
setAgencyOpeningCycleStatus,
|
||||
updateAgencyOpeningCycle,
|
||||
} from "@/features/agency-opening/api";
|
||||
import { useAgencyOpeningAbilities } from "@/features/agency-opening/permissions.js";
|
||||
import { Button } from "@/shared/ui/Button.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 { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
|
||||
const statusOptions = [
|
||||
["draft", "草稿"],
|
||||
["active", "启用"],
|
||||
["disabled", "停用"],
|
||||
["settling", "结算中"],
|
||||
["settled", "已结算"],
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
cycleId: "",
|
||||
title: "代理开业活动",
|
||||
status: "draft",
|
||||
startMs: "",
|
||||
endMs: "",
|
||||
minHostCount: "10",
|
||||
maxAgencyAgeDays: "30",
|
||||
rewards: [
|
||||
{ rankNo: 1, thresholdCoinSpent: "500000", rewardCoinAmount: "50000" },
|
||||
{ rankNo: 2, thresholdCoinSpent: "1000000", rewardCoinAmount: "100000" },
|
||||
{ rankNo: 3, thresholdCoinSpent: "3000000", rewardCoinAmount: "300000" },
|
||||
{ rankNo: 4, thresholdCoinSpent: "5000000", rewardCoinAmount: "500000" },
|
||||
{ rankNo: 5, thresholdCoinSpent: "10000000", rewardCoinAmount: "500000" },
|
||||
],
|
||||
};
|
||||
|
||||
export function AgencyOpeningPage() {
|
||||
const abilities = useAgencyOpeningAbilities();
|
||||
const [cycles, setCycles] = useState([]);
|
||||
const [applications, setApplications] = useState([]);
|
||||
const [selectedCycleId, setSelectedCycleId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [applicationsLoading, setApplicationsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
reloadCycles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reloadApplications(selectedCycleId);
|
||||
}, [selectedCycleId]);
|
||||
|
||||
async function reloadCycles() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await listAgencyOpeningCycles({ page_size: 100 });
|
||||
const items = result.items || [];
|
||||
setCycles(items);
|
||||
setSelectedCycleId((current) => current || items[0]?.cycleId || "");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadApplications(cycleId) {
|
||||
setApplicationsLoading(true);
|
||||
try {
|
||||
const result = await listAgencyOpeningApplications({
|
||||
cycle_id: cycleId || undefined,
|
||||
page_size: 100,
|
||||
});
|
||||
setApplications(result.items || []);
|
||||
} catch {
|
||||
setApplications([]);
|
||||
} finally {
|
||||
setApplicationsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setForm(emptyForm);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(cycle) {
|
||||
setForm({
|
||||
cycleId: cycle.cycleId,
|
||||
title: cycle.title,
|
||||
status: cycle.status || "draft",
|
||||
startMs: millisToInput(cycle.startMs),
|
||||
endMs: millisToInput(cycle.endMs),
|
||||
minHostCount: String(cycle.minHostCount || 10),
|
||||
maxAgencyAgeDays: String(cycle.maxAgencyAgeDays || 0),
|
||||
rewards: (cycle.rewards?.length ? cycle.rewards : emptyForm.rewards).map((reward) => ({
|
||||
rankNo: reward.rankNo,
|
||||
thresholdCoinSpent: String(reward.thresholdCoinSpent || ""),
|
||||
rewardCoinAmount: String(reward.rewardCoinAmount || ""),
|
||||
})),
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function submitForm(event) {
|
||||
event.preventDefault();
|
||||
if (!abilities.canCreate && !form.cycleId) return;
|
||||
if (!abilities.canUpdate && form.cycleId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
cycle_id: form.cycleId || undefined,
|
||||
title: form.title.trim(),
|
||||
status: form.status,
|
||||
start_ms: inputToMillis(form.startMs),
|
||||
end_ms: inputToMillis(form.endMs),
|
||||
min_host_count: Number(form.minHostCount || 0),
|
||||
max_agency_age_days: Number(form.maxAgencyAgeDays || 0),
|
||||
rewards: normalizeRewardPayload(form.rewards),
|
||||
};
|
||||
if (form.cycleId) {
|
||||
await updateAgencyOpeningCycle(form.cycleId, payload);
|
||||
} else {
|
||||
await createAgencyOpeningCycle(payload);
|
||||
}
|
||||
setToast("已保存代理开业活动周期");
|
||||
setDrawerOpen(false);
|
||||
await reloadCycles();
|
||||
} catch (err) {
|
||||
setToast(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(cycle) {
|
||||
if (!abilities.canUpdate) return;
|
||||
const nextStatus = cycle.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
await setAgencyOpeningCycleStatus(cycle.cycleId, nextStatus);
|
||||
setToast(nextStatus === "active" ? "已启用" : "已停用");
|
||||
await reloadCycles();
|
||||
} catch (err) {
|
||||
setToast(err instanceof Error ? err.message : "状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
function updateReward(index, field, value) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rewards: current.rewards.map((reward, itemIndex) =>
|
||||
itemIndex === index ? { ...reward, [field]: value } : reward,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function addRewardTier() {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rewards: [
|
||||
...current.rewards,
|
||||
{
|
||||
rankNo: current.rewards.length + 1,
|
||||
thresholdCoinSpent: "",
|
||||
rewardCoinAmount: "",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function removeRewardTier(index) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rewards: current.rewards
|
||||
.filter((_, itemIndex) => itemIndex !== index)
|
||||
.map((reward, itemIndex) => ({ ...reward, rankNo: itemIndex + 1 })),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={reloadCycles}>
|
||||
刷新
|
||||
</Button>
|
||||
{abilities.canCreate ? (
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={openCreate}>
|
||||
新增周期
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={error} loading={loading} onRetry={reloadCycles}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期",
|
||||
width: "minmax(260px, 1fr)",
|
||||
render: (cycle) => (
|
||||
<div className="weekly-star-list-stack">
|
||||
<span className="weekly-star-list-title">{cycle.title || "-"}</span>
|
||||
<span className="weekly-star-list-meta">{cycle.cycleId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "时间",
|
||||
width: "minmax(240px, 0.9fr)",
|
||||
render: (cycle) => (
|
||||
<div className="weekly-star-list-stack">
|
||||
<TimeText value={cycle.startMs} />
|
||||
<span className="weekly-star-list-meta">
|
||||
至 <TimeText value={cycle.endMs} />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "policy",
|
||||
label: "规则",
|
||||
width: "minmax(200px, 0.8fr)",
|
||||
render: (cycle) => (
|
||||
<div className="weekly-star-list-stack">
|
||||
<span>主播数 > {cycle.minHostCount}</span>
|
||||
<span className="weekly-star-list-meta">
|
||||
新代理 {cycle.maxAgencyAgeDays || 0} 天内
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rewards",
|
||||
label: "金币奖励",
|
||||
width: "minmax(260px, 1fr)",
|
||||
render: (cycle) => (
|
||||
<div className="weekly-star-list-chip-list">
|
||||
{(cycle.rewards || []).map((reward) => (
|
||||
<Chip
|
||||
className="weekly-star-list-chip"
|
||||
key={reward.rankNo}
|
||||
label={`流水≥${formatNumber(reward.thresholdCoinSpent)}: ${formatNumber(reward.rewardCoinAmount)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "112px",
|
||||
render: (cycle) => (
|
||||
<Chip
|
||||
color={statusColor(cycle.status)}
|
||||
label={statusLabel(cycle.status)}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "132px",
|
||||
fixed: "right",
|
||||
resizable: false,
|
||||
render: (cycle) => (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
icon={<VisibilityOutlined fontSize="small" />}
|
||||
label="申请记录"
|
||||
onClick={() => setSelectedCycleId(cycle.cycleId)}
|
||||
/>
|
||||
{abilities.canUpdate ? (
|
||||
<AdminActionIconButton
|
||||
icon={<EditOutlined fontSize="small" />}
|
||||
label="编辑"
|
||||
onClick={() => openEdit(cycle)}
|
||||
/>
|
||||
) : null}
|
||||
{abilities.canUpdate ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant={cycle.status === "active" ? "secondary" : "primary"}
|
||||
onClick={() => toggleStatus(cycle)}
|
||||
>
|
||||
{cycle.status === "active" ? "停用" : "启用"}
|
||||
</Button>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyLabel="暂无代理开业活动周期"
|
||||
items={cycles}
|
||||
minWidth="1220px"
|
||||
rowKey={(cycle) => cycle.cycleId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<AdminListBody>
|
||||
<Typography component="h2" variant="h6">
|
||||
申请记录
|
||||
</Typography>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: "agency",
|
||||
label: "Agency",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (item) => `${item.agencyName || "-"} / ${item.agencyId || "-"}`,
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
label: "Owner",
|
||||
width: "160px",
|
||||
render: (item) => item.agencyOwnerUserId || "-",
|
||||
},
|
||||
{ key: "hosts", label: "主播数", width: "100px", render: (item) => item.hostCount },
|
||||
{
|
||||
key: "score",
|
||||
label: "开业流水",
|
||||
width: "120px",
|
||||
render: (item) => formatNumber(item.scoreCoinAmount),
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "奖励",
|
||||
width: "180px",
|
||||
render: (item) =>
|
||||
item.rewardCoinAmount
|
||||
? `流水≥${formatNumber(item.rewardThresholdCoinSpent)} / ${formatNumber(item.rewardCoinAmount)}`
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "112px",
|
||||
render: (item) => <Chip label={applicationStatusLabel(item.status)} size="small" />,
|
||||
},
|
||||
{
|
||||
key: "applied",
|
||||
label: "申请时间",
|
||||
width: "180px",
|
||||
render: (item) => <TimeText value={item.appliedAtMs} />,
|
||||
},
|
||||
{
|
||||
key: "granted",
|
||||
label: "发奖时间",
|
||||
width: "180px",
|
||||
render: (item) => (item.grantedAtMs ? <TimeText value={item.grantedAtMs} /> : "-"),
|
||||
},
|
||||
]}
|
||||
emptyLabel={applicationsLoading ? "加载中..." : "暂无申请记录"}
|
||||
items={applications}
|
||||
minWidth="1210px"
|
||||
rowKey={(item) => item.applicationId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
|
||||
<Drawer anchor="right" open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
<Box className="weekly-star-config-drawer" component="form" onSubmit={submitForm}>
|
||||
<div className="weekly-star-config-drawer__header">
|
||||
<h2 className="weekly-star-config-drawer__title">
|
||||
{form.cycleId ? "编辑代理开业活动" : "新增代理开业活动"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="weekly-star-config-drawer__body">
|
||||
<section className="weekly-star-config-section">
|
||||
<div className="weekly-star-config-grid weekly-star-config-grid--basic">
|
||||
<TextField
|
||||
label="标题"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="状态"
|
||||
select
|
||||
value={form.status}
|
||||
onChange={(event) => setForm({ ...form, status: event.target.value })}
|
||||
>
|
||||
{statusOptions.slice(0, 3).map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="开始时间"
|
||||
required
|
||||
type="datetime-local"
|
||||
value={form.startMs}
|
||||
onChange={(event) => setForm({ ...form, startMs: event.target.value })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="结束时间"
|
||||
required
|
||||
type="datetime-local"
|
||||
value={form.endMs}
|
||||
onChange={(event) => setForm({ ...form, endMs: event.target.value })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="主播数必须大于"
|
||||
required
|
||||
type="number"
|
||||
value={form.minHostCount}
|
||||
onChange={(event) => setForm({ ...form, minHostCount: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="代理创建天数上限"
|
||||
type="number"
|
||||
value={form.maxAgencyAgeDays}
|
||||
onChange={(event) => setForm({ ...form, maxAgencyAgeDays: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="weekly-star-config-section">
|
||||
<Typography component="h3" variant="subtitle1">
|
||||
开业流水档位奖励
|
||||
</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addRewardTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</Box>
|
||||
<div className="weekly-star-config-card-grid">
|
||||
{form.rewards.map((reward, index) => (
|
||||
<div className="weekly-star-config-card" key={reward.rankNo}>
|
||||
<div className="weekly-star-config-card__header">
|
||||
<span
|
||||
className={`weekly-star-rank-badge weekly-star-rank-badge--${index + 1}`}
|
||||
>
|
||||
档位 {index + 1}
|
||||
</span>
|
||||
{form.rewards.length > 1 ? (
|
||||
<Button
|
||||
startIcon={<DeleteOutlineOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={() => removeRewardTier(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="开业流水"
|
||||
required
|
||||
type="number"
|
||||
value={reward.thresholdCoinSpent}
|
||||
onChange={(event) =>
|
||||
updateReward(index, "thresholdCoinSpent", event.target.value)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="奖励金币"
|
||||
required
|
||||
type="number"
|
||||
value={reward.rewardCoinAmount}
|
||||
onChange={(event) =>
|
||||
updateReward(index, "rewardCoinAmount", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="weekly-star-config-drawer__actions">
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button disabled={saving} type="submit" variant="primary">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
<Snackbar autoHideDuration={2600} message={toast} open={Boolean(toast)} onClose={() => setToast("")} />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
if (status === "active") return "success";
|
||||
if (status === "settled") return "info";
|
||||
if (status === "settling") return "warning";
|
||||
if (status === "disabled") return "default";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function applicationStatusLabel(status) {
|
||||
return (
|
||||
{
|
||||
applied: "已申请",
|
||||
pending: "待发奖",
|
||||
granted: "已发奖",
|
||||
failed: "发奖失败",
|
||||
}[status] ||
|
||||
status ||
|
||||
"-"
|
||||
);
|
||||
}
|
||||
|
||||
function millisToInput(ms) {
|
||||
if (!ms) return "";
|
||||
const local = new Date(ms - new Date(ms).getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function inputToMillis(value) {
|
||||
return value ? new Date(value).getTime() : 0;
|
||||
}
|
||||
|
||||
function normalizeRewardPayload(rewards) {
|
||||
const payload = (rewards || []).map((reward, index) => {
|
||||
const thresholdCoinSpent = Number(reward.thresholdCoinSpent || 0);
|
||||
const rewardCoinAmount = Number(reward.rewardCoinAmount || 0);
|
||||
if (!Number.isInteger(thresholdCoinSpent) || thresholdCoinSpent <= 0) {
|
||||
throw new Error("开业流水必须是大于 0 的整数");
|
||||
}
|
||||
if (!Number.isInteger(rewardCoinAmount) || rewardCoinAmount < 0) {
|
||||
throw new Error("奖励金币不能小于 0");
|
||||
}
|
||||
return {
|
||||
rank_no: index + 1,
|
||||
tier_no: index + 1,
|
||||
threshold_coin_spent: thresholdCoinSpent,
|
||||
reward_coin_amount: rewardCoinAmount,
|
||||
};
|
||||
});
|
||||
for (let index = 1; index < payload.length; index += 1) {
|
||||
if (payload[index].threshold_coin_spent <= payload[index - 1].threshold_coin_spent) {
|
||||
throw new Error("开业流水档位必须递增");
|
||||
}
|
||||
}
|
||||
if (!payload.length) {
|
||||
throw new Error("至少需要配置一个开业流水档位");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString();
|
||||
}
|
||||
11
src/features/agency-opening/permissions.js
Normal file
11
src/features/agency-opening/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
|
||||
export function useAgencyOpeningAbilities() {
|
||||
const { can } = useAuth();
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.agencyOpeningCreate),
|
||||
canUpdate: can(PERMISSIONS.agencyOpeningUpdate),
|
||||
canView: can(PERMISSIONS.agencyOpeningView),
|
||||
};
|
||||
}
|
||||
12
src/features/agency-opening/routes.js
Normal file
12
src/features/agency-opening/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const agencyOpeningRoutes = [
|
||||
{
|
||||
label: "代理开业活动",
|
||||
loader: () => import("./pages/AgencyOpeningPage.jsx").then((module) => module.AgencyOpeningPage),
|
||||
menuCode: MENU_CODES.agencyOpening,
|
||||
pageKey: "agency-opening",
|
||||
path: "/activities/agency-opening",
|
||||
permission: PERMISSIONS.agencyOpeningView,
|
||||
},
|
||||
];
|
||||
@ -25,9 +25,25 @@ export interface CPRelationConfigDto {
|
||||
export interface CPConfigDto {
|
||||
appCode: string;
|
||||
relations: CPRelationConfigDto[];
|
||||
weeklyRank: CPWeeklyRankDto;
|
||||
serverTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface CPWeeklyRankRewardDto {
|
||||
rankNo: number;
|
||||
resourceGroupId: number;
|
||||
}
|
||||
|
||||
export interface CPWeeklyRankDto {
|
||||
enabled: boolean;
|
||||
relationType: CPRelationType;
|
||||
topCount: number;
|
||||
rewards: CPWeeklyRankRewardDto[];
|
||||
updatedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface CPConfigPayload {
|
||||
relations: Array<{
|
||||
relationType: CPRelationType;
|
||||
@ -43,6 +59,11 @@ export interface CPConfigPayload {
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
weeklyRank: {
|
||||
enabled: boolean;
|
||||
topCount: number;
|
||||
rewards: CPWeeklyRankRewardDto[];
|
||||
};
|
||||
}
|
||||
|
||||
const path = "/v1/admin/activity/cp/config";
|
||||
@ -58,12 +79,14 @@ export function updateCPConfig(payload: CPConfigPayload): Promise<CPConfigDto> {
|
||||
type RawConfig = CPConfigDto & Record<string, unknown>;
|
||||
type RawRelation = CPRelationConfigDto & Record<string, unknown>;
|
||||
type RawLevel = CPLevelDto & Record<string, unknown>;
|
||||
type RawWeeklyRank = CPWeeklyRankDto & Record<string, unknown>;
|
||||
|
||||
function normalizeConfig(raw: RawConfig): CPConfigDto {
|
||||
const item = asRecord(raw) as RawConfig;
|
||||
return {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
relations: arrayValue(item.relations).map(normalizeRelation),
|
||||
weeklyRank: normalizeWeeklyRank(item.weeklyRank ?? item.weekly_rank),
|
||||
serverTimeMs: numberValue(item.serverTimeMs ?? item.server_time_ms),
|
||||
};
|
||||
}
|
||||
@ -83,6 +106,25 @@ function normalizeRelation(raw: unknown): CPRelationConfigDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWeeklyRank(raw: unknown): CPWeeklyRankDto {
|
||||
const item = asRecord(raw) as RawWeeklyRank;
|
||||
return {
|
||||
enabled: item.enabled !== false,
|
||||
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||
topCount: numberValue(item.topCount ?? item.top_count) || 3,
|
||||
rewards: arrayValue(item.rewards).map((reward) => {
|
||||
const rawReward = asRecord(reward);
|
||||
return {
|
||||
rankNo: numberValue(rawReward.rankNo ?? rawReward.rank_no),
|
||||
resourceGroupId: numberValue(rawReward.resourceGroupId ?? rawReward.resource_group_id),
|
||||
};
|
||||
}),
|
||||
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 normalizeLevel(raw: unknown): CPLevelDto {
|
||||
const item = asRecord(raw) as RawLevel;
|
||||
return {
|
||||
|
||||
@ -10,6 +10,7 @@ export const cpRelationLabels = {
|
||||
brother: "兄弟",
|
||||
sister: "姐妹",
|
||||
};
|
||||
export const cpWeeklyRankRanks = [1, 2, 3];
|
||||
|
||||
const defaultExpireHours = 24;
|
||||
const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
||||
@ -17,8 +18,11 @@ const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
||||
export function useCPConfigPage() {
|
||||
const abilities = useCPConfigAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [config, setConfig] = useState({ relations: defaultRelations() });
|
||||
const [form, setForm] = useState({ relations: defaultRelations().map(relationToForm) });
|
||||
const [config, setConfig] = useState({ relations: defaultRelations(), weeklyRank: defaultWeeklyRank() });
|
||||
const [form, setForm] = useState({
|
||||
relations: defaultRelations().map(relationToForm),
|
||||
weeklyRank: weeklyRankToForm(defaultWeeklyRank()),
|
||||
});
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
@ -39,8 +43,9 @@ export function useCPConfigPage() {
|
||||
if (configResult.status === "fulfilled") {
|
||||
const remoteConfig = configResult.value;
|
||||
const relations = completeRelations(remoteConfig.relations || []);
|
||||
setConfig({ ...remoteConfig, relations });
|
||||
setForm({ relations: relations.map(relationToForm) });
|
||||
const weeklyRank = completeWeeklyRank(remoteConfig.weeklyRank);
|
||||
setConfig({ ...remoteConfig, relations, weeklyRank });
|
||||
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
||||
} else {
|
||||
showToast(configResult.reason?.message || "加载 CP 配置失败", "error");
|
||||
}
|
||||
@ -61,7 +66,10 @@ export function useCPConfigPage() {
|
||||
}, [reload]);
|
||||
|
||||
const openDrawer = () => {
|
||||
setForm({ relations: completeRelations(config.relations || []).map(relationToForm) });
|
||||
setForm({
|
||||
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
@ -69,7 +77,10 @@ export function useCPConfigPage() {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setForm({ relations: completeRelations(config.relations || []).map(relationToForm) });
|
||||
setForm({
|
||||
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
||||
});
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
@ -98,6 +109,25 @@ export function useCPConfigPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const updateWeeklyRank = (patch) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
weeklyRank: { ...(current.weeklyRank || weeklyRankToForm(defaultWeeklyRank())), ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateWeeklyRankReward = (rankNo, patch) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
weeklyRank: {
|
||||
...(current.weeklyRank || weeklyRankToForm(defaultWeeklyRank())),
|
||||
rewards: completeWeeklyRankRewardForms(current.weeklyRank?.rewards || []).map((reward) =>
|
||||
Number(reward.rankNo) === Number(rankNo) ? { ...reward, ...patch } : reward,
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
@ -114,8 +144,9 @@ export function useCPConfigPage() {
|
||||
try {
|
||||
const saved = await updateCPConfig(payload);
|
||||
const relations = completeRelations(saved.relations || []);
|
||||
setConfig({ ...saved, relations });
|
||||
setForm({ relations: relations.map(relationToForm) });
|
||||
const weeklyRank = completeWeeklyRank(saved.weeklyRank);
|
||||
setConfig({ ...saved, relations, weeklyRank });
|
||||
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
||||
setDrawerOpen(false);
|
||||
showToast("CP 配置已保存", "success");
|
||||
} catch (err) {
|
||||
@ -140,6 +171,8 @@ export function useCPConfigPage() {
|
||||
submit,
|
||||
updateLevel,
|
||||
updateRelation,
|
||||
updateWeeklyRank,
|
||||
updateWeeklyRankReward,
|
||||
};
|
||||
}
|
||||
|
||||
@ -167,6 +200,16 @@ function defaultLevels() {
|
||||
}));
|
||||
}
|
||||
|
||||
function defaultWeeklyRank() {
|
||||
return {
|
||||
enabled: true,
|
||||
relationType: "cp",
|
||||
topCount: 3,
|
||||
rewards: cpWeeklyRankRanks.map((rankNo) => ({ rankNo, resourceGroupId: 0 })),
|
||||
updatedAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function completeRelations(relations) {
|
||||
const byType = new Map((relations || []).map((relation) => [relation.relationType, relation]));
|
||||
return defaultRelations().map((fallback) => {
|
||||
@ -211,6 +254,44 @@ function levelToForm(level) {
|
||||
};
|
||||
}
|
||||
|
||||
function completeWeeklyRank(weeklyRank) {
|
||||
const fallback = defaultWeeklyRank();
|
||||
const normalized = { ...fallback, ...(weeklyRank || {}) };
|
||||
normalized.topCount = 3;
|
||||
normalized.relationType = "cp";
|
||||
normalized.rewards = completeWeeklyRankRewards(normalized.rewards || []);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function completeWeeklyRankRewards(rewards) {
|
||||
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
||||
return cpWeeklyRankRanks.map((rankNo) => ({
|
||||
rankNo,
|
||||
resourceGroupId: Number(byRank.get(rankNo)?.resourceGroupId || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function weeklyRankToForm(weeklyRank) {
|
||||
const completed = completeWeeklyRank(weeklyRank);
|
||||
return {
|
||||
enabled: completed.enabled !== false,
|
||||
relationType: "cp",
|
||||
topCount: "3",
|
||||
rewards: completeWeeklyRankRewards(completed.rewards).map((reward) => ({
|
||||
rankNo: reward.rankNo,
|
||||
resourceGroupId: reward.resourceGroupId ? String(reward.resourceGroupId) : "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function completeWeeklyRankRewardForms(rewards) {
|
||||
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
||||
return cpWeeklyRankRanks.map((rankNo) => ({
|
||||
rankNo,
|
||||
resourceGroupId: byRank.get(rankNo)?.resourceGroupId || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
const relations = completeFormRelations(form.relations || []).map((relation) => {
|
||||
const relationType = relation.relationType;
|
||||
@ -236,7 +317,29 @@ function payloadFromForm(form) {
|
||||
levels: completeFormLevels(relation.levels || [], relationType),
|
||||
};
|
||||
});
|
||||
return { relations };
|
||||
const weeklyRank = weeklyRankPayloadFromForm(form.weeklyRank || weeklyRankToForm(defaultWeeklyRank()));
|
||||
return { relations, weeklyRank };
|
||||
}
|
||||
|
||||
function weeklyRankPayloadFromForm(weeklyRank) {
|
||||
const enabled = weeklyRank.enabled !== false;
|
||||
const rewards = completeWeeklyRankRewardForms(weeklyRank.rewards || []).map((reward) => ({
|
||||
rankNo: Number(reward.rankNo),
|
||||
resourceGroupId: Number(reward.resourceGroupId || 0),
|
||||
}));
|
||||
if (enabled) {
|
||||
const missing = rewards.find(
|
||||
(reward) => !Number.isInteger(reward.resourceGroupId) || reward.resourceGroupId <= 0,
|
||||
);
|
||||
if (missing) {
|
||||
throw new Error(`CP周榜 Top${missing.rankNo} 奖励资源组必须配置`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
topCount: 3,
|
||||
rewards,
|
||||
};
|
||||
}
|
||||
|
||||
function completeFormRelations(relations) {
|
||||
|
||||
@ -4,7 +4,7 @@ import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { cpRelationLabels, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
||||
import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
||||
import styles from "@/features/cp-config/cp-config.module.css";
|
||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
@ -28,6 +28,9 @@ export function CPConfigPage() {
|
||||
<SummaryItem label="兄弟上限">{relationMax(relations, "brother")}</SummaryItem>
|
||||
<SummaryItem label="姐妹上限">{relationMax(relations, "sister")}</SummaryItem>
|
||||
<SummaryItem label="CP解除费">{relationBreakupCost(relations, "cp")}</SummaryItem>
|
||||
<SummaryItem label="CP周榜">
|
||||
{page.config.weeklyRank?.enabled === false ? "停用" : "启用"}
|
||||
</SummaryItem>
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<Button
|
||||
@ -92,6 +95,16 @@ function CPConfigDrawer({ page }) {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">CP周榜奖励</div>
|
||||
<WeeklyRankEditor
|
||||
disabled={disabled}
|
||||
resourceGroups={page.resourceGroups}
|
||||
weeklyRank={page.form.weeklyRank}
|
||||
updateWeeklyRank={page.updateWeeklyRank}
|
||||
updateWeeklyRankReward={page.updateWeeklyRankReward}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className={["form-drawer__actions", styles.configDrawerActions].join(" ")}>
|
||||
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||
@ -111,6 +124,64 @@ function CPConfigDrawer({ page }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WeeklyRankEditor({ disabled, resourceGroups, weeklyRank, updateWeeklyRank, updateWeeklyRankReward }) {
|
||||
const enabled = weeklyRank?.enabled !== false;
|
||||
const rewards = weeklyRank?.rewards || [];
|
||||
return (
|
||||
<div className={styles.relationEditor}>
|
||||
<div className={styles.relationEditorHeader}>
|
||||
<div className={styles.relationTitleGroup}>
|
||||
<span className={styles.relationTitle}>Last Weekly CP</span>
|
||||
<span className={styles.relationSubtitle}>Top 1-3 资源组会发给获奖 CP 双方</span>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={enabled}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="CP周榜状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) => updateWeeklyRank({ enabled: event.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.levelEditorList}>
|
||||
{cpWeeklyRankRanks.map((rankNo) => {
|
||||
const reward = rewards.find((item) => Number(item.rankNo) === Number(rankNo)) || {
|
||||
rankNo,
|
||||
resourceGroupId: "",
|
||||
};
|
||||
const selectedGroup = resourceGroups.find(
|
||||
(group) => String(group.groupId) === String(reward.resourceGroupId),
|
||||
);
|
||||
return (
|
||||
<div className={styles.levelEditor} key={rankNo}>
|
||||
<div className={styles.levelEditorHeader}>
|
||||
<div className={styles.levelTitleGroup}>
|
||||
<span className={styles.levelTitle}>Top {rankNo}</span>
|
||||
<span className={styles.levelMeta}>
|
||||
{resourceGroupLabel(selectedGroup, reward.resourceGroupId)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.levelFields}>
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled || !enabled}
|
||||
drawerTitle={`CP周榜 Top ${rankNo} 奖励资源组`}
|
||||
emptyLabel="未配置奖励"
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
placeholder="点击选择奖励资源组"
|
||||
value={reward.resourceGroupId}
|
||||
onChange={(value) => updateWeeklyRankReward(rankNo, { resourceGroupId: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationEditor({ disabled, relation, resourceGroups, updateLevel, updateRelation }) {
|
||||
const relationType = relation.relationType;
|
||||
const maxCountDisabled = disabled || relationType === "cp";
|
||||
|
||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import {
|
||||
createCoinAdjustment,
|
||||
createFullServerNoticeFanout,
|
||||
exportCoinSellerLedger,
|
||||
getGiftDiamondRatios,
|
||||
listCoinAdjustments,
|
||||
@ -41,6 +42,52 @@ test("gift diamond ratio APIs use operations paths", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("full server notice fanout API uses operations path", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
command_id: "admin_notice_test",
|
||||
created: true,
|
||||
job_id: "mfan_test",
|
||||
message_type: "system",
|
||||
status: "pending",
|
||||
target_scope: "all_active_users",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await createFullServerNoticeFanout({
|
||||
action_type: "app_h5",
|
||||
action_param: "https://example.com",
|
||||
batch_size: 500,
|
||||
command_id: "admin_notice_test",
|
||||
message_type: "system",
|
||||
summary: "今晚 23:00 维护",
|
||||
target_scope: "all_active_users",
|
||||
title: "维护通知",
|
||||
});
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||
|
||||
expect(String(url)).toContain("/api/v1/admin/operations/full-server-notices/fanout");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
action_param: "https://example.com",
|
||||
action_type: "app_h5",
|
||||
command_id: "admin_notice_test",
|
||||
message_type: "system",
|
||||
target_scope: "all_active_users",
|
||||
title: "维护通知",
|
||||
});
|
||||
});
|
||||
|
||||
test("coin adjustment APIs use generated admin paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@ -96,7 +143,10 @@ test("coin seller ledger API uses generated admin path and filters", async () =>
|
||||
});
|
||||
|
||||
test("coin seller ledger export API sends current filters without pagination", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("seller,amount\n", { status: 200 })));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("seller,amount\n", { status: 200 })),
|
||||
);
|
||||
|
||||
await exportCoinSellerLedger({
|
||||
end_at_ms: "2000",
|
||||
|
||||
@ -26,6 +26,40 @@ export interface GiftDiamondRatioResponse {
|
||||
items: GiftDiamondRatioItem[];
|
||||
}
|
||||
|
||||
export interface FullServerNoticeFanoutPayload {
|
||||
action_param?: string;
|
||||
action_type?: string;
|
||||
aggregate_id?: string;
|
||||
aggregate_type?: string;
|
||||
batch_size?: number;
|
||||
body?: string;
|
||||
command_id?: string;
|
||||
country?: string;
|
||||
expire_at_ms?: number;
|
||||
icon_url?: string;
|
||||
image_url?: string;
|
||||
message_type: "system" | "activity";
|
||||
metadata_json?: string;
|
||||
priority?: number;
|
||||
producer_event_type?: string;
|
||||
region_id?: number;
|
||||
sent_at_ms?: number;
|
||||
summary: string;
|
||||
target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
||||
target_user_id?: number;
|
||||
title: string;
|
||||
user_ids?: number[];
|
||||
}
|
||||
|
||||
export interface FullServerNoticeFanoutResponse {
|
||||
command_id: string;
|
||||
created: boolean;
|
||||
job_id: string;
|
||||
message_type: string;
|
||||
status: string;
|
||||
target_scope: string;
|
||||
}
|
||||
|
||||
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
|
||||
const endpoint = API_ENDPOINTS.listCoinLedger;
|
||||
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
|
||||
@ -101,3 +135,16 @@ export function updateGiftDiamondRatios(payload: {
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
export function createFullServerNoticeFanout(
|
||||
payload: FullServerNoticeFanoutPayload,
|
||||
): Promise<FullServerNoticeFanoutResponse> {
|
||||
const endpoint = API_ENDPOINTS.createFullServerNoticeFanout;
|
||||
return apiRequest<FullServerNoticeFanoutResponse, FullServerNoticeFanoutPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.createFullServerNoticeFanout),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -235,10 +235,105 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.noticeBody {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.noticePanel {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.noticeHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.noticeHeader h2,
|
||||
.noticePreview h3 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.noticeGrid,
|
||||
.noticeContentGrid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.noticeContentGrid {
|
||||
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.noticeWideField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.noticePreview {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.noticePreview p {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.noticePreviewMeta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.noticePreviewMeta span {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ratioGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.noticeBody,
|
||||
.noticeGrid,
|
||||
.noticeContentGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.reportTargetType {
|
||||
|
||||
398
src/features/operations/pages/FullServerNoticePage.jsx
Normal file
398
src/features/operations/pages/FullServerNoticePage.jsx
Normal file
@ -0,0 +1,398 @@
|
||||
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo, useState } from "react";
|
||||
import { createFullServerNoticeFanout } from "@/features/operations/api";
|
||||
import { useFullServerNoticeAbilities } from "@/features/operations/permissions.js";
|
||||
import styles from "@/features/operations/operations.module.css";
|
||||
import { AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const messageTypes = [
|
||||
{ label: "系统消息", value: "system" },
|
||||
{ label: "活动消息", value: "activity" },
|
||||
];
|
||||
|
||||
const targetScopes = [
|
||||
{ label: "全量活跃用户", value: "all_active_users" },
|
||||
{ label: "指定用户", value: "single_user" },
|
||||
{ label: "用户列表", value: "user_ids" },
|
||||
{ label: "指定区域", value: "region" },
|
||||
{ label: "指定国家", value: "country" },
|
||||
];
|
||||
|
||||
const actionTypes = [
|
||||
{ label: "无跳转", value: "" },
|
||||
{ label: "活动详情", value: "activity_detail" },
|
||||
{ label: "H5 页面", value: "app_h5" },
|
||||
{ label: "房间详情", value: "room_detail" },
|
||||
{ label: "用户主页", value: "user_profile" },
|
||||
{ label: "提现详情", value: "wallet_withdraw_detail" },
|
||||
{ label: "主播申请", value: "host_application_detail" },
|
||||
{ label: "币商转账", value: "coin_seller_transfer_detail" },
|
||||
];
|
||||
|
||||
const initialForm = {
|
||||
actionParam: "",
|
||||
actionType: "",
|
||||
aggregateId: "",
|
||||
aggregateType: "admin_notice",
|
||||
batchSize: "500",
|
||||
body: "",
|
||||
commandId: "",
|
||||
country: "",
|
||||
expireAt: "",
|
||||
messageType: "system",
|
||||
metadataJson: "",
|
||||
priority: "0",
|
||||
producerEventType: "admin_full_server_notice",
|
||||
regionId: "",
|
||||
summary: "",
|
||||
targetScope: "all_active_users",
|
||||
targetUserId: "",
|
||||
title: "",
|
||||
userIds: "",
|
||||
};
|
||||
|
||||
export function FullServerNoticePage() {
|
||||
const abilities = useFullServerNoticeAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [result, setResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const targetLabel = useMemo(
|
||||
() => targetScopes.find((item) => item.value === form.targetScope)?.label || form.targetScope,
|
||||
[form.targetScope],
|
||||
);
|
||||
|
||||
const updateField = (field) => (event) => {
|
||||
setForm((current) => ({ ...current, [field]: event.target.value }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = buildPayload(form);
|
||||
if (!payload.ok) {
|
||||
showToast(payload.error, "error");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const next = await createFullServerNoticeFanout(payload.value);
|
||||
setResult(next);
|
||||
showToast(next.created ? "全服通知任务已创建" : "已返回现有通知任务", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "创建全服通知失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
abilities.canSend ? (
|
||||
<Button disabled={saving} startIcon={<SendOutlined />} variant="primary" onClick={submit}>
|
||||
发送
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
filters={null}
|
||||
/>
|
||||
<div className={styles.noticeBody}>
|
||||
<section className={styles.noticePanel}>
|
||||
<div className={styles.noticeHeader}>
|
||||
<CampaignOutlined fontSize="small" />
|
||||
<h2>全服通知</h2>
|
||||
</div>
|
||||
<div className={styles.noticeGrid}>
|
||||
<TextField
|
||||
select
|
||||
label="消息类型"
|
||||
size="small"
|
||||
value={form.messageType}
|
||||
onChange={updateField("messageType")}
|
||||
>
|
||||
{messageTypes.map((item) => (
|
||||
<MenuItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="发送范围"
|
||||
size="small"
|
||||
value={form.targetScope}
|
||||
onChange={updateField("targetScope")}
|
||||
>
|
||||
{targetScopes.map((item) => (
|
||||
<MenuItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{form.targetScope === "single_user" ? (
|
||||
<TextField
|
||||
label="用户 ID"
|
||||
size="small"
|
||||
value={form.targetUserId}
|
||||
onChange={updateField("targetUserId")}
|
||||
/>
|
||||
) : null}
|
||||
{form.targetScope === "user_ids" ? (
|
||||
<TextField
|
||||
label="用户 ID 列表"
|
||||
size="small"
|
||||
value={form.userIds}
|
||||
onChange={updateField("userIds")}
|
||||
/>
|
||||
) : null}
|
||||
{form.targetScope === "region" ? (
|
||||
<TextField
|
||||
label="区域 ID"
|
||||
size="small"
|
||||
value={form.regionId}
|
||||
onChange={updateField("regionId")}
|
||||
/>
|
||||
) : null}
|
||||
{form.targetScope === "country" ? (
|
||||
<TextField
|
||||
label="国家"
|
||||
size="small"
|
||||
value={form.country}
|
||||
onChange={updateField("country")}
|
||||
/>
|
||||
) : null}
|
||||
<TextField
|
||||
label="幂等命令 ID"
|
||||
size="small"
|
||||
value={form.commandId}
|
||||
onChange={updateField("commandId")}
|
||||
/>
|
||||
<TextField
|
||||
label="批量大小"
|
||||
size="small"
|
||||
type="number"
|
||||
value={form.batchSize}
|
||||
onChange={updateField("batchSize")}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.noticeContentGrid}>
|
||||
<TextField
|
||||
required
|
||||
label="标题"
|
||||
size="small"
|
||||
value={form.title}
|
||||
onChange={updateField("title")}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
label="摘要"
|
||||
size="small"
|
||||
value={form.summary}
|
||||
onChange={updateField("summary")}
|
||||
/>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={4}
|
||||
className={styles.noticeWideField}
|
||||
label="正文"
|
||||
value={form.body}
|
||||
onChange={updateField("body")}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.noticeGrid}>
|
||||
<TextField
|
||||
select
|
||||
label="跳转类型"
|
||||
size="small"
|
||||
value={form.actionType}
|
||||
onChange={updateField("actionType")}
|
||||
>
|
||||
{actionTypes.map((item) => (
|
||||
<MenuItem key={item.value || "none"} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="跳转参数"
|
||||
size="small"
|
||||
value={form.actionParam}
|
||||
onChange={updateField("actionParam")}
|
||||
/>
|
||||
<TextField
|
||||
label="关联类型"
|
||||
size="small"
|
||||
value={form.aggregateType}
|
||||
onChange={updateField("aggregateType")}
|
||||
/>
|
||||
<TextField
|
||||
label="关联 ID"
|
||||
size="small"
|
||||
value={form.aggregateId}
|
||||
onChange={updateField("aggregateId")}
|
||||
/>
|
||||
<TextField
|
||||
label="事件类型"
|
||||
size="small"
|
||||
value={form.producerEventType}
|
||||
onChange={updateField("producerEventType")}
|
||||
/>
|
||||
<TextField
|
||||
label="优先级"
|
||||
size="small"
|
||||
type="number"
|
||||
value={form.priority}
|
||||
onChange={updateField("priority")}
|
||||
/>
|
||||
<TextField
|
||||
label="过期时间"
|
||||
size="small"
|
||||
type="datetime-local"
|
||||
value={form.expireAt}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
onChange={updateField("expireAt")}
|
||||
/>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
label="Metadata JSON"
|
||||
value={form.metadataJson}
|
||||
onChange={updateField("metadataJson")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.noticePreview}>
|
||||
<h3>{form.title || "标题"}</h3>
|
||||
<p>{form.summary || "摘要"}</p>
|
||||
<div className={styles.noticePreviewMeta}>
|
||||
<span>{messageTypes.find((item) => item.value === form.messageType)?.label}</span>
|
||||
<span>{targetLabel}</span>
|
||||
<span>{form.actionType || "无跳转"}</span>
|
||||
</div>
|
||||
{result ? (
|
||||
<Alert severity={result.created ? "success" : "info"}>
|
||||
{result.status} · {result.job_id} · {result.command_id}
|
||||
</Alert>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPayload(form) {
|
||||
const title = form.title.trim();
|
||||
const summary = form.summary.trim();
|
||||
if (!title || !summary) {
|
||||
return { error: "标题和摘要不能为空", ok: false };
|
||||
}
|
||||
const batchSize = parsePositiveNumber(form.batchSize, "批量大小");
|
||||
if (!batchSize.ok) {
|
||||
return batchSize;
|
||||
}
|
||||
const priority = parseOptionalNumber(form.priority, "优先级");
|
||||
if (!priority.ok) {
|
||||
return priority;
|
||||
}
|
||||
const expireAtMS = datetimeLocalToMS(form.expireAt);
|
||||
if (!expireAtMS.ok) {
|
||||
return expireAtMS;
|
||||
}
|
||||
const target = buildTargetPayload(form);
|
||||
if (!target.ok) {
|
||||
return target;
|
||||
}
|
||||
const metadataJson = form.metadataJson.trim();
|
||||
if (metadataJson) {
|
||||
try {
|
||||
JSON.parse(metadataJson);
|
||||
} catch {
|
||||
return { error: "Metadata JSON 不正确", ok: false };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...target.value,
|
||||
action_param: form.actionParam.trim() || undefined,
|
||||
action_type: form.actionType,
|
||||
aggregate_id: form.aggregateId.trim() || undefined,
|
||||
aggregate_type: form.aggregateType.trim() || undefined,
|
||||
batch_size: batchSize.value,
|
||||
body: form.body.trim() || undefined,
|
||||
command_id: form.commandId.trim() || undefined,
|
||||
expire_at_ms: expireAtMS.value || undefined,
|
||||
message_type: form.messageType,
|
||||
metadata_json: metadataJson || undefined,
|
||||
priority: priority.value,
|
||||
producer_event_type: form.producerEventType.trim() || undefined,
|
||||
summary,
|
||||
target_scope: form.targetScope,
|
||||
title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTargetPayload(form) {
|
||||
if (form.targetScope === "single_user") {
|
||||
const targetUserID = parsePositiveNumber(form.targetUserId, "用户 ID");
|
||||
return targetUserID.ok ? { ok: true, value: { target_user_id: targetUserID.value } } : targetUserID;
|
||||
}
|
||||
if (form.targetScope === "user_ids") {
|
||||
const ids = form.userIds
|
||||
.split(/[\s,,]+/)
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((value) => Number.isInteger(value) && value > 0);
|
||||
if (!ids.length) {
|
||||
return { error: "用户 ID 列表不能为空", ok: false };
|
||||
}
|
||||
return { ok: true, value: { user_ids: Array.from(new Set(ids)) } };
|
||||
}
|
||||
if (form.targetScope === "region") {
|
||||
const regionID = parsePositiveNumber(form.regionId, "区域 ID");
|
||||
return regionID.ok ? { ok: true, value: { region_id: regionID.value } } : regionID;
|
||||
}
|
||||
if (form.targetScope === "country") {
|
||||
const country = form.country.trim();
|
||||
return country ? { ok: true, value: { country } } : { error: "国家不能为空", ok: false };
|
||||
}
|
||||
return { ok: true, value: {} };
|
||||
}
|
||||
|
||||
function parsePositiveNumber(value, label) {
|
||||
const number = Number(String(value || "").trim());
|
||||
if (!Number.isInteger(number) || number <= 0) {
|
||||
return { error: `${label} 必须是正整数`, ok: false };
|
||||
}
|
||||
return { ok: true, value: number };
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value, label) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return { ok: true, value: 0 };
|
||||
}
|
||||
const number = Number(raw);
|
||||
if (!Number.isInteger(number)) {
|
||||
return { error: `${label} 必须是整数`, ok: false };
|
||||
}
|
||||
return { ok: true, value: number };
|
||||
}
|
||||
|
||||
function datetimeLocalToMS(value) {
|
||||
if (!value) {
|
||||
return { ok: true, value: 0 };
|
||||
}
|
||||
const ms = new Date(value).getTime();
|
||||
if (!Number.isFinite(ms)) {
|
||||
return { error: "过期时间不正确", ok: false };
|
||||
}
|
||||
return { ok: true, value: ms };
|
||||
}
|
||||
@ -18,3 +18,12 @@ export function useGiftDiamondAbilities() {
|
||||
canView: can(PERMISSIONS.giftDiamondView),
|
||||
};
|
||||
}
|
||||
|
||||
export function useFullServerNoticeAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canSend: can(PERMISSIONS.fullServerNoticeSend),
|
||||
canView: can(PERMISSIONS.fullServerNoticeView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -41,4 +41,12 @@ export const operationsRoutes = [
|
||||
path: "/operations/gift-diamonds",
|
||||
permission: PERMISSIONS.giftDiamondView,
|
||||
},
|
||||
{
|
||||
label: "全服通知",
|
||||
loader: () => import("./pages/FullServerNoticePage.jsx").then((module) => module.FullServerNoticePage),
|
||||
menuCode: MENU_CODES.operationFullServerNotice,
|
||||
pageKey: "operation-full-server-notice",
|
||||
path: "/operations/full-server-notices",
|
||||
permission: PERMISSIONS.fullServerNoticeView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -39,6 +39,7 @@ const permissionGroupDefinitions = [
|
||||
key: "activities",
|
||||
prefixes: [
|
||||
"achievement",
|
||||
"agency-opening",
|
||||
"cp-config",
|
||||
"cumulative-recharge-reward",
|
||||
"daily-task",
|
||||
|
||||
@ -39,6 +39,7 @@ export const API_OPERATIONS = {
|
||||
createCountry: "createCountry",
|
||||
createEmojiPack: "createEmojiPack",
|
||||
createExploreTab: "createExploreTab",
|
||||
createFullServerNoticeFanout: "createFullServerNoticeFanout",
|
||||
createGift: "createGift",
|
||||
createH5Link: "createH5Link",
|
||||
createHostAgencyPolicy: "createHostAgencyPolicy",
|
||||
@ -102,6 +103,7 @@ export const API_OPERATIONS = {
|
||||
generatePrettyIds: "generatePrettyIds",
|
||||
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
||||
getCountry: "getCountry",
|
||||
getCPConfig: "getCPConfig",
|
||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
||||
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
|
||||
@ -230,6 +232,7 @@ export const API_OPERATIONS = {
|
||||
updateBanner: "updateBanner",
|
||||
updateCatalog: "updateCatalog",
|
||||
updateCountry: "updateCountry",
|
||||
updateCPConfig: "updateCPConfig",
|
||||
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||
updateDiceConfig: "updateDiceConfig",
|
||||
updateExploreTab: "updateExploreTab",
|
||||
@ -460,6 +463,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
createFullServerNoticeFanout: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createFullServerNoticeFanout,
|
||||
path: "/v1/admin/operations/full-server-notices/fanout",
|
||||
permission: "full-server-notice:send",
|
||||
permissions: ["full-server-notice:send"]
|
||||
},
|
||||
createGift: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createGift,
|
||||
@ -901,6 +911,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "country:view",
|
||||
permissions: ["country:view"]
|
||||
},
|
||||
getCPConfig: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getCPConfig,
|
||||
path: "/v1/admin/activity/cp/config",
|
||||
permission: "cp-config:view",
|
||||
permissions: ["cp-config:view"]
|
||||
},
|
||||
getCumulativeRechargeRewardConfig: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getCumulativeRechargeRewardConfig,
|
||||
@ -1783,6 +1800,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "country:update",
|
||||
permissions: ["country:update"]
|
||||
},
|
||||
updateCPConfig: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateCPConfig,
|
||||
path: "/v1/admin/activity/cp/config",
|
||||
permission: "cp-config:update",
|
||||
permissions: ["cp-config:update"]
|
||||
},
|
||||
updateCumulativeRechargeRewardConfig: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateCumulativeRechargeRewardConfig,
|
||||
|
||||
101
src/shared/api/generated/schema.d.ts
vendored
101
src/shared/api/generated/schema.d.ts
vendored
@ -100,6 +100,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/cp/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCPConfig"];
|
||||
put: operations["updateCPConfig"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/red-packets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1636,6 +1652,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/operations/full-server-notices/fanout": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["createFullServerNoticeFanout"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/operations/reports": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3597,6 +3629,30 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getCPConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateCPConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRedPackets: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -5443,6 +5499,51 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createFullServerNoticeFanout: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
action_param?: string;
|
||||
action_type?: string;
|
||||
aggregate_id?: string;
|
||||
aggregate_type?: string;
|
||||
batch_size?: number;
|
||||
body?: string;
|
||||
command_id?: string;
|
||||
country?: string;
|
||||
/** Format: int64 */
|
||||
expire_at_ms?: number;
|
||||
icon_url?: string;
|
||||
image_url?: string;
|
||||
/** @enum {string} */
|
||||
message_type: "system" | "activity";
|
||||
metadata_json?: string;
|
||||
priority?: number;
|
||||
producer_event_type?: string;
|
||||
/** Format: int64 */
|
||||
region_id?: number;
|
||||
/** Format: int64 */
|
||||
sent_at_ms?: number;
|
||||
summary: string;
|
||||
/** @enum {string} */
|
||||
target_scope: "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
||||
/** Format: int64 */
|
||||
target_user_id?: number;
|
||||
title: string;
|
||||
user_ids?: number[];
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
201: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listReports: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user