cp和充值
This commit is contained in:
parent
e51a4401ef
commit
88ef541b95
@ -178,6 +178,40 @@
|
||||
"x-permissions": ["cp-config:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/cp-weekly-rank/config": {
|
||||
"get": {
|
||||
"operationId": "getCPWeeklyRankConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "cp-weekly-rank:view",
|
||||
"x-permissions": ["cp-weekly-rank:view"]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateCPWeeklyRankConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "cp-weekly-rank:update",
|
||||
"x-permissions": ["cp-weekly-rank:update"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/cp-weekly-rank/settlements": {
|
||||
"get": {
|
||||
"operationId": "listCPWeeklyRankSettlements",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "cp-weekly-rank:view",
|
||||
"x-permissions": ["cp-weekly-rank:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/red-packets": {
|
||||
"get": {
|
||||
"operationId": "listRedPackets",
|
||||
|
||||
@ -122,6 +122,65 @@ test("renders cp config route with an authenticated session", async () => {
|
||||
expect(screen.queryByText("共 1 条")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders cp weekly rank route with an authenticated session", async () => {
|
||||
setAccessToken("test-token");
|
||||
vi.mocked(fetch).mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) {
|
||||
return jsonResponse({
|
||||
accessToken: "test-token",
|
||||
permissions: ["cp-weekly-rank:view"],
|
||||
user: { userId: 1, username: "admin" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/v1/admin/apps")) {
|
||||
return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 });
|
||||
}
|
||||
if (url.includes("/v1/admin/navigation/menus")) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
if (url.includes("/v1/admin/resource-groups")) {
|
||||
return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
}
|
||||
if (url.includes("/v1/admin/activity/cp-weekly-rank/config")) {
|
||||
return jsonResponse({
|
||||
activityCode: "cp_weekly_rank",
|
||||
enabled: true,
|
||||
relationType: "cp",
|
||||
rewards: [
|
||||
{ rankNo: 1, resourceGroupId: 1001 },
|
||||
{ rankNo: 2, resourceGroupId: 1002 },
|
||||
{ rankNo: 3, resourceGroupId: 1003 },
|
||||
],
|
||||
topCount: 3,
|
||||
});
|
||||
}
|
||||
if (url.includes("/v1/admin/activity/cp-weekly-rank/settlements")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{
|
||||
periodStartMs: 1760918400000,
|
||||
periodEndMs: 1761523200000,
|
||||
rankNo: 1,
|
||||
relationshipId: "cp_1001_1002",
|
||||
score: 12000,
|
||||
settlementId: "settlement_1",
|
||||
status: "granted",
|
||||
userId: "1001",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
}
|
||||
return jsonResponse(null);
|
||||
});
|
||||
|
||||
renderWithRoute("/activities/cp-weekly-rank");
|
||||
|
||||
expect((await screen.findAllByText("CP排行活动")).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("已发放")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("admin routes declare menu code and permission", () => {
|
||||
expect(adminRoutes.length).toBeGreaterThan(0);
|
||||
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
||||
|
||||
@ -223,6 +223,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
||||
routeNavItem("cp-weekly-rank", { icon: LeaderboardOutlined }),
|
||||
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
||||
],
|
||||
},
|
||||
|
||||
@ -162,6 +162,8 @@ export const PERMISSIONS = {
|
||||
redPacketUpdate: "red-packet:update",
|
||||
cpConfigView: "cp-config:view",
|
||||
cpConfigUpdate: "cp-config:update",
|
||||
cpWeeklyRankView: "cp-weekly-rank:view",
|
||||
cpWeeklyRankUpdate: "cp-weekly-rank:update",
|
||||
vipConfigView: "vip-config:view",
|
||||
vipConfigUpdate: "vip-config:update",
|
||||
vipConfigGrant: "vip-config:grant",
|
||||
@ -238,6 +240,7 @@ export const MENU_CODES = {
|
||||
userLeaderboard: "user-leaderboard",
|
||||
redPacket: "red-packet",
|
||||
cpConfig: "cp-config",
|
||||
cpWeeklyRank: "cp-weekly-rank",
|
||||
vipConfig: "vip-config",
|
||||
weeklyStar: "weekly-star",
|
||||
agencyOpening: "agency-opening",
|
||||
|
||||
@ -5,6 +5,7 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
||||
import { cpConfigRoutes } from "@/features/cp-config/routes.js";
|
||||
import { cpWeeklyRankRoutes } from "@/features/cp-weekly-rank/routes.js";
|
||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||
@ -59,6 +60,7 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...userLeaderboardRoutes,
|
||||
...redPacketRoutes,
|
||||
...cpConfigRoutes,
|
||||
...cpWeeklyRankRoutes,
|
||||
...vipConfigRoutes,
|
||||
...resourceRoutes,
|
||||
...operationsRoutes,
|
||||
|
||||
@ -118,11 +118,6 @@ export interface CPConfigPayload {
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
weeklyRank: {
|
||||
enabled: boolean;
|
||||
topCount: number;
|
||||
rewards: CPWeeklyRankRewardDto[];
|
||||
};
|
||||
}
|
||||
|
||||
export function getCPConfig(): Promise<CPConfigDto> {
|
||||
@ -259,14 +254,18 @@ function normalizeRelationship(raw: unknown): CPRelationshipDto {
|
||||
const item = asRecord(raw) as RawRelationship;
|
||||
const userA = normalizeUser(item.userA ?? item.user_a ?? item.me ?? item.requester);
|
||||
const userB = normalizeUser(item.userB ?? item.user_b ?? item.partner ?? item.target);
|
||||
const gift = normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift ?? item.applicationGift ?? item.application_gift);
|
||||
const gift = normalizeGift(
|
||||
item.gift ?? item.sourceGift ?? item.source_gift ?? item.applicationGift ?? item.application_gift,
|
||||
);
|
||||
return {
|
||||
relationshipId: stringValue(item.relationshipId ?? item.relationship_id),
|
||||
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||
status: stringValue(item.status) || "active",
|
||||
userA,
|
||||
userB,
|
||||
intimacyValue: numberValue(item.intimacyValue ?? item.intimacy_value ?? item.experienceValue ?? item.experience_value),
|
||||
intimacyValue: numberValue(
|
||||
item.intimacyValue ?? item.intimacy_value ?? item.experienceValue ?? item.experience_value,
|
||||
),
|
||||
level: numberValue(item.level ?? item.levelNo ?? item.level_no),
|
||||
durationDays: numberValue(item.durationDays ?? item.duration_days ?? item.days),
|
||||
gift: gift.giftId || gift.giftName || gift.giftValue ? gift : undefined,
|
||||
@ -282,7 +281,9 @@ function normalizeApplication(raw: unknown): CPApplicationDto {
|
||||
applicationId: stringValue(item.applicationId ?? item.application_id),
|
||||
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||
status: stringValue(item.status) || "pending",
|
||||
requester: normalizeUser(item.requester ?? item.requesterUser ?? item.requester_user ?? item.userA ?? item.user_a),
|
||||
requester: normalizeUser(
|
||||
item.requester ?? item.requesterUser ?? item.requester_user ?? item.userA ?? item.user_a,
|
||||
),
|
||||
target: normalizeUser(item.target ?? item.targetUser ?? item.target_user ?? item.userB ?? item.user_b),
|
||||
gift: normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift),
|
||||
roomId: stringValue(item.roomId ?? item.room_id),
|
||||
|
||||
@ -10,7 +10,6 @@ export const cpRelationLabels = {
|
||||
brother: "兄弟",
|
||||
sister: "姐妹",
|
||||
};
|
||||
export const cpWeeklyRankRanks = [1, 2, 3];
|
||||
|
||||
const defaultExpireHours = 24;
|
||||
const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
||||
@ -28,10 +27,9 @@ export function useCPConfigPage() {
|
||||
const [relationshipsError, setRelationshipsError] = useState("");
|
||||
const [applicationsError, setApplicationsError] = useState("");
|
||||
const [applicationsLoaded, setApplicationsLoaded] = useState(false);
|
||||
const [config, setConfig] = useState({ relations: defaultRelations(), weeklyRank: defaultWeeklyRank() });
|
||||
const [config, setConfig] = useState({ relations: defaultRelations() });
|
||||
const [form, setForm] = useState({
|
||||
relations: defaultRelations().map(relationToForm),
|
||||
weeklyRank: weeklyRankToForm(defaultWeeklyRank()),
|
||||
});
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -53,9 +51,8 @@ export function useCPConfigPage() {
|
||||
if (configResult.status === "fulfilled") {
|
||||
const remoteConfig = configResult.value;
|
||||
const relations = completeRelations(remoteConfig.relations || []);
|
||||
const weeklyRank = completeWeeklyRank(remoteConfig.weeklyRank);
|
||||
setConfig({ ...remoteConfig, relations, weeklyRank });
|
||||
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
||||
setConfig({ ...remoteConfig, relations });
|
||||
setForm({ relations: relations.map(relationToForm) });
|
||||
} else {
|
||||
showToast(configResult.reason?.message || "加载 CP 配置失败", "error");
|
||||
}
|
||||
@ -168,7 +165,6 @@ export function useCPConfigPage() {
|
||||
const openDrawer = () => {
|
||||
setForm({
|
||||
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
@ -179,7 +175,6 @@ export function useCPConfigPage() {
|
||||
}
|
||||
setForm({
|
||||
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
||||
});
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
@ -209,25 +204,6 @@ 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) {
|
||||
@ -244,9 +220,8 @@ export function useCPConfigPage() {
|
||||
try {
|
||||
const saved = await updateCPConfig(payload);
|
||||
const relations = completeRelations(saved.relations || []);
|
||||
const weeklyRank = completeWeeklyRank(saved.weeklyRank);
|
||||
setConfig({ ...saved, relations, weeklyRank });
|
||||
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
||||
setConfig({ ...saved, relations });
|
||||
setForm({ relations: relations.map(relationToForm) });
|
||||
setDrawerOpen(false);
|
||||
showToast("CP 配置已保存", "success");
|
||||
} catch (err) {
|
||||
@ -282,8 +257,6 @@ export function useCPConfigPage() {
|
||||
submit,
|
||||
updateLevel,
|
||||
updateRelation,
|
||||
updateWeeklyRank,
|
||||
updateWeeklyRankReward,
|
||||
};
|
||||
}
|
||||
|
||||
@ -311,16 +284,6 @@ 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) => {
|
||||
@ -365,44 +328,6 @@ 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;
|
||||
@ -428,29 +353,7 @@ function payloadFromForm(form) {
|
||||
levels: completeFormLevels(relation.levels || [], relationType),
|
||||
};
|
||||
});
|
||||
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,
|
||||
};
|
||||
return { relations };
|
||||
}
|
||||
|
||||
function completeFormRelations(relations) {
|
||||
|
||||
@ -11,7 +11,7 @@ import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
||||
import { cpRelationLabels, 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";
|
||||
@ -19,8 +19,8 @@ import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
|
||||
const tabLabels = {
|
||||
relationships: "用户关系列表",
|
||||
@ -49,9 +49,6 @@ 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
|
||||
@ -63,7 +60,13 @@ export function CPConfigPage() {
|
||||
</Button>
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
startIcon={page.abilities.canUpdate ? <EditOutlined fontSize="small" /> : <SettingsOutlined fontSize="small" />}
|
||||
startIcon={
|
||||
page.abilities.canUpdate ? (
|
||||
<EditOutlined fontSize="small" />
|
||||
) : (
|
||||
<SettingsOutlined fontSize="small" />
|
||||
)
|
||||
}
|
||||
variant="primary"
|
||||
onClick={page.openDrawer}
|
||||
>
|
||||
@ -91,7 +94,9 @@ export function CPConfigPage() {
|
||||
onPageChange: isApplicationsTab ? page.loadApplications : page.loadRelationships,
|
||||
}}
|
||||
rowKey={(item) =>
|
||||
isApplicationsTab ? item.applicationId : item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
|
||||
isApplicationsTab
|
||||
? item.applicationId
|
||||
: item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
|
||||
}
|
||||
/>
|
||||
</AdminListBody>
|
||||
@ -116,7 +121,7 @@ function CPConfigDialog({ page }) {
|
||||
<DialogTitle className={styles.configDialogTitle}>
|
||||
<div>
|
||||
<h2>CP配置</h2>
|
||||
<span>关系等级、解除费用和 CP 周榜奖励</span>
|
||||
<span>关系等级、关系数量和解除费用</span>
|
||||
</div>
|
||||
<div className={styles.configDrawerStats}>
|
||||
<span>{(page.form.relations || []).length} 个关系</span>
|
||||
@ -139,16 +144,6 @@ function CPConfigDialog({ 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>
|
||||
</DialogContent>
|
||||
<DialogActions className={styles.configDialogActions}>
|
||||
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||
@ -157,7 +152,13 @@ function CPConfigDialog({ page }) {
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={page.saving ? <CircularProgress color="inherit" size={16} /> : <SaveOutlined fontSize="small" />}
|
||||
startIcon={
|
||||
page.saving ? (
|
||||
<CircularProgress color="inherit" size={16} />
|
||||
) : (
|
||||
<SaveOutlined fontSize="small" />
|
||||
)
|
||||
}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
@ -170,64 +171,6 @@ function CPConfigDialog({ 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";
|
||||
|
||||
128
src/features/cp-weekly-rank/api.ts
Normal file
128
src/features/cp-weekly-rank/api.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { apiRequest, type QueryParams } from "@/shared/api/request";
|
||||
import { API_ENDPOINTS } from "@/shared/api/generated/endpoints";
|
||||
|
||||
export interface CPWeeklyRankRewardDto {
|
||||
rankNo: number;
|
||||
resourceGroupId: number;
|
||||
}
|
||||
|
||||
export interface CPWeeklyRankConfigDto {
|
||||
appCode: string;
|
||||
activityCode: string;
|
||||
enabled: boolean;
|
||||
relationType: string;
|
||||
topCount: number;
|
||||
rewards: CPWeeklyRankRewardDto[];
|
||||
updatedByAdminId?: number;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface CPWeeklyRankConfigPayload {
|
||||
enabled: boolean;
|
||||
top_count: number;
|
||||
rewards: Array<{
|
||||
rank_no: number;
|
||||
resource_group_id: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CPWeeklyRankSettlementDto {
|
||||
settlementId: string;
|
||||
periodStartMs: number;
|
||||
periodEndMs: number;
|
||||
rankNo: number;
|
||||
relationshipId: string;
|
||||
userId: string;
|
||||
score: number;
|
||||
resourceGroupId: number;
|
||||
walletCommandId: string;
|
||||
walletGrantId: string;
|
||||
status: string;
|
||||
failureReason: string;
|
||||
attemptCount: number;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
export function getCPWeeklyRankConfig(): Promise<CPWeeklyRankConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getCPWeeklyRankConfig;
|
||||
return apiRequest<Raw>(endpoint.path, { method: endpoint.method }).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function updateCPWeeklyRankConfig(payload: CPWeeklyRankConfigPayload): Promise<CPWeeklyRankConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.updateCPWeeklyRankConfig;
|
||||
return apiRequest<Raw, CPWeeklyRankConfigPayload>(endpoint.path, {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
}).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function listCPWeeklyRankSettlements(query: QueryParams = {}) {
|
||||
const endpoint = API_ENDPOINTS.listCPWeeklyRankSettlements;
|
||||
return apiRequest<{ items?: Raw[]; total?: number }>(endpoint.path, { method: endpoint.method, query }).then(
|
||||
(page) => ({
|
||||
items: (page.items || []).map(normalizeSettlement),
|
||||
total: numberValue(page.total),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeConfig(raw: Raw): CPWeeklyRankConfigDto {
|
||||
return {
|
||||
appCode: stringValue(raw.appCode ?? raw.app_code),
|
||||
activityCode: stringValue(raw.activityCode ?? raw.activity_code) || "cp_weekly_rank",
|
||||
enabled: raw.enabled !== false,
|
||||
relationType: stringValue(raw.relationType ?? raw.relation_type) || "cp",
|
||||
topCount: numberValue(raw.topCount ?? raw.top_count) || 3,
|
||||
rewards: arrayValue(raw.rewards).map((reward) => {
|
||||
const item = asRecord(reward);
|
||||
return {
|
||||
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||
};
|
||||
}),
|
||||
updatedByAdminId: numberValue(raw.updatedByAdminId ?? raw.updated_by_admin_id),
|
||||
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||
updatedAtMs: numberValue(raw.updatedAtMs ?? raw.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSettlement(raw: Raw): CPWeeklyRankSettlementDto {
|
||||
return {
|
||||
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
|
||||
periodStartMs: numberValue(raw.periodStartMs ?? raw.period_start_ms),
|
||||
periodEndMs: numberValue(raw.periodEndMs ?? raw.period_end_ms),
|
||||
rankNo: numberValue(raw.rankNo ?? raw.rank_no),
|
||||
relationshipId: stringValue(raw.relationshipId ?? raw.relationship_id),
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
score: numberValue(raw.score),
|
||||
resourceGroupId: numberValue(raw.resourceGroupId ?? raw.resource_group_id),
|
||||
walletCommandId: stringValue(raw.walletCommandId ?? raw.wallet_command_id),
|
||||
walletGrantId: stringValue(raw.walletGrantId ?? raw.wallet_grant_id),
|
||||
status: stringValue(raw.status),
|
||||
failureReason: stringValue(raw.failureReason ?? raw.failure_reason),
|
||||
attemptCount: numberValue(raw.attemptCount ?? raw.attempt_count),
|
||||
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||
updatedAtMs: numberValue(raw.updatedAtMs ?? raw.updated_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Raw {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Raw) : {};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
213
src/features/cp-weekly-rank/cp-weekly-rank.module.css
Normal file
213
src/features/cp-weekly-rank/cp-weekly-rank.module.css
Normal file
@ -0,0 +1,213 @@
|
||||
.summaryPanel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.summaryItems {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rewardStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.rewardItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.rewardRank {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rewardGroup {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configDialog {
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.configDialogPaper {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.configDialogForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.configDialogTitle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 14px;
|
||||
}
|
||||
|
||||
.configDialogTitle h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.configDialogTitle span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.configDialogContent {
|
||||
padding: 0 24px 8px;
|
||||
}
|
||||
|
||||
.configDialogActions {
|
||||
padding: 14px 24px 22px;
|
||||
}
|
||||
|
||||
.rewardEditorList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rewardEditor {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.rewardEditorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rewardEditorHeader span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.periodCell {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 64px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.statusActive {
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.statusPending {
|
||||
background: color-mix(in srgb, var(--warning) 14%, transparent);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.statusDanger {
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.statusDisabled {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.summaryPanel {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summaryItems,
|
||||
.rewardStrip,
|
||||
.rewardEditorList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summaryActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
228
src/features/cp-weekly-rank/hooks/useCPWeeklyRankPage.js
Normal file
228
src/features/cp-weekly-rank/hooks/useCPWeeklyRankPage.js
Normal file
@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getCPWeeklyRankConfig,
|
||||
listCPWeeklyRankSettlements,
|
||||
updateCPWeeklyRankConfig,
|
||||
} from "@/features/cp-weekly-rank/api";
|
||||
import { useCPWeeklyRankAbilities } from "@/features/cp-weekly-rank/permissions.js";
|
||||
import { listResourceGroups } from "@/features/resources/api";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
export const cpWeeklyRankRanks = [1, 2, 3];
|
||||
|
||||
export function useCPWeeklyRankPage() {
|
||||
const abilities = useCPWeeklyRankAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [config, setConfig] = useState(defaultConfig());
|
||||
const [form, setForm] = useState(configToForm(defaultConfig()));
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
const [settlementRange, setSettlementRange] = useState(currentUTCWeekRange());
|
||||
const [settlements, setSettlements] = useState({ items: [], total: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settlementsLoading, setSettlementsLoading] = useState(false);
|
||||
const [settlementsError, setSettlementsError] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const configuredRewardCount = useMemo(
|
||||
() => completeRewards(config.rewards || []).filter((reward) => Number(reward.resourceGroupId) > 0).length,
|
||||
[config.rewards],
|
||||
);
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [remoteConfig, groups] = await Promise.all([
|
||||
getCPWeeklyRankConfig(),
|
||||
listResourceGroups({ page: 1, page_size: 200, status: "active" }),
|
||||
]);
|
||||
const completed = completeConfig(remoteConfig);
|
||||
setConfig(completed);
|
||||
setForm(configToForm(completed));
|
||||
setResourceGroups(groups.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载 CP 排行活动配置失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const reloadSettlements = useCallback(
|
||||
async (range = settlementRange) => {
|
||||
setSettlementsLoading(true);
|
||||
setSettlementsError("");
|
||||
try {
|
||||
const page = await listCPWeeklyRankSettlements({
|
||||
period_start_ms: Number(range.startMs || 0),
|
||||
period_end_ms: Number(range.endMs || 0),
|
||||
});
|
||||
setSettlements(page);
|
||||
} catch (err) {
|
||||
setSettlementsError(err.message || "加载 CP 排行活动发奖记录失败");
|
||||
} finally {
|
||||
setSettlementsLoading(false);
|
||||
}
|
||||
},
|
||||
[settlementRange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadSettlements(settlementRange);
|
||||
}, [reloadSettlements, settlementRange]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
await Promise.allSettled([reloadConfig(), reloadSettlements(settlementRange)]);
|
||||
}, [reloadConfig, reloadSettlements, settlementRange]);
|
||||
|
||||
const openDrawer = () => {
|
||||
setForm(configToForm(config));
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setForm(configToForm(config));
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
const updateForm = (patch) => {
|
||||
setForm((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const updateReward = (rankNo, patch) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rewards: completeRewardForms(current.rewards || []).map((reward) =>
|
||||
Number(reward.rankNo) === Number(rankNo) ? { ...reward, ...patch } : reward,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
return;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = payloadFromForm(form);
|
||||
} catch (err) {
|
||||
showToast(err.message || "CP 排行活动配置参数不正确", "error");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await updateCPWeeklyRankConfig(payload);
|
||||
const completed = completeConfig(saved);
|
||||
setConfig(completed);
|
||||
setForm(configToForm(completed));
|
||||
setDrawerOpen(false);
|
||||
showToast("CP 排行活动配置已保存", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存 CP 排行活动配置失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
closeDrawer,
|
||||
config,
|
||||
configuredRewardCount,
|
||||
drawerOpen,
|
||||
form,
|
||||
loading,
|
||||
openDrawer,
|
||||
reload,
|
||||
reloadSettlements,
|
||||
resourceGroups,
|
||||
saving,
|
||||
settlementRange,
|
||||
settlements,
|
||||
settlementsError,
|
||||
settlementsLoading,
|
||||
setSettlementRange,
|
||||
submit,
|
||||
updateForm,
|
||||
updateReward,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultConfig() {
|
||||
return {
|
||||
activityCode: "cp_weekly_rank",
|
||||
enabled: true,
|
||||
relationType: "cp",
|
||||
topCount: 3,
|
||||
rewards: cpWeeklyRankRanks.map((rankNo) => ({ rankNo, resourceGroupId: 0 })),
|
||||
};
|
||||
}
|
||||
|
||||
function completeConfig(config) {
|
||||
const completed = { ...defaultConfig(), ...(config || {}) };
|
||||
completed.topCount = 3;
|
||||
completed.relationType = "cp";
|
||||
completed.rewards = completeRewards(completed.rewards || []);
|
||||
return completed;
|
||||
}
|
||||
|
||||
function completeRewards(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 configToForm(config) {
|
||||
const completed = completeConfig(config);
|
||||
return {
|
||||
enabled: completed.enabled !== false,
|
||||
rewards: completeRewards(completed.rewards).map((reward) => ({
|
||||
rankNo: reward.rankNo,
|
||||
resourceGroupId: reward.resourceGroupId ? String(reward.resourceGroupId) : "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function completeRewardForms(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 rewards = completeRewardForms(form.rewards || []).map((reward) => ({
|
||||
rank_no: Number(reward.rankNo),
|
||||
resource_group_id: Number(reward.resourceGroupId || 0),
|
||||
}));
|
||||
if (form.enabled !== false) {
|
||||
const missing = rewards.find(
|
||||
(reward) => !Number.isInteger(reward.resource_group_id) || reward.resource_group_id <= 0,
|
||||
);
|
||||
if (missing) {
|
||||
throw new Error(`Top${missing.rank_no} 奖励资源组必须配置`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
enabled: form.enabled !== false,
|
||||
top_count: 3,
|
||||
rewards,
|
||||
};
|
||||
}
|
||||
|
||||
function currentUTCWeekRange(now = new Date()) {
|
||||
const utcDayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const weekday = now.getUTCDay() || 7;
|
||||
const startMs = utcDayStart - (weekday - 1) * 86400000;
|
||||
return { endMs: startMs + 7 * 86400000, startMs };
|
||||
}
|
||||
339
src/features/cp-weekly-rank/pages/CPWeeklyRankPage.jsx
Normal file
339
src/features/cp-weekly-rank/pages/CPWeeklyRankPage.jsx
Normal file
@ -0,0 +1,339 @@
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import { useMemo } from "react";
|
||||
import { cpWeeklyRankRanks, useCPWeeklyRankPage } from "@/features/cp-weekly-rank/hooks/useCPWeeklyRankPage.js";
|
||||
import styles from "@/features/cp-weekly-rank/cp-weekly-rank.module.css";
|
||||
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
|
||||
export function CPWeeklyRankPage() {
|
||||
const page = useCPWeeklyRankPage();
|
||||
const resourceLabels = useMemo(() => groupLabelMap(page.resourceGroups), [page.resourceGroups]);
|
||||
const columns = useMemo(() => settlementColumns(resourceLabels), [resourceLabels]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<div className={styles.summaryPanel}>
|
||||
<div className={styles.summaryItems}>
|
||||
<SummaryItem label="活动编码">{page.config.activityCode || "cp_weekly_rank"}</SummaryItem>
|
||||
<SummaryItem label="活动状态">{page.config.enabled === false ? "停用" : "启用"}</SummaryItem>
|
||||
<SummaryItem label="奖励名次">Top {page.config.topCount || 3}</SummaryItem>
|
||||
<SummaryItem label="已配置奖励">{page.configuredRewardCount} / 3</SummaryItem>
|
||||
<SummaryItem label="最近更新">
|
||||
{page.config.updatedAtMs ? <TimeText value={page.config.updatedAtMs} /> : "-"}
|
||||
</SummaryItem>
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
onClick={page.reload}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
startIcon={
|
||||
page.abilities.canUpdate ? (
|
||||
<EditOutlined fontSize="small" />
|
||||
) : (
|
||||
<SettingsOutlined fontSize="small" />
|
||||
)
|
||||
}
|
||||
variant="primary"
|
||||
onClick={page.openDrawer}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<RewardPreview config={page.config} resourceLabels={resourceLabels} />
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button
|
||||
disabled={page.settlementsLoading}
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
onClick={() => page.reloadSettlements(page.settlementRange)}
|
||||
>
|
||||
刷新记录
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<TimeRangeFilter label="结算周期" value={page.settlementRange} onChange={page.setSettlementRange} />
|
||||
}
|
||||
/>
|
||||
<DataState
|
||||
error={page.settlementsError}
|
||||
loading={page.settlementsLoading}
|
||||
onRetry={() => page.reloadSettlements(page.settlementRange)}
|
||||
>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
emptyLabel="当前无数据"
|
||||
items={page.settlements.items}
|
||||
minWidth="1320px"
|
||||
rowKey={(item) => item.settlementId || `${item.relationshipId}-${item.userId}`}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<CPWeeklyRankDialog page={page} resourceLabels={resourceLabels} />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function RewardPreview({ config, resourceLabels }) {
|
||||
const rewards = completeRewards(config.rewards || []);
|
||||
return (
|
||||
<div className={styles.rewardStrip}>
|
||||
{rewards.map((reward) => (
|
||||
<div className={styles.rewardItem} key={reward.rankNo}>
|
||||
<span className={styles.rewardRank}>Top {reward.rankNo}</span>
|
||||
<span className={styles.rewardGroup}>{resourceLabel(resourceLabels, reward.resourceGroupId)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CPWeeklyRankDialog({ page, resourceLabels }) {
|
||||
const canUpdate = page.abilities.canUpdate;
|
||||
const disabled = !canUpdate || page.saving || page.loading;
|
||||
const enabled = page.form.enabled !== false;
|
||||
return (
|
||||
<Dialog
|
||||
className={styles.configDialog}
|
||||
maxWidth={false}
|
||||
open={page.drawerOpen}
|
||||
slotProps={{ paper: { className: styles.configDialogPaper } }}
|
||||
onClose={page.saving ? undefined : page.closeDrawer}
|
||||
>
|
||||
<form className={styles.configDialogForm} onSubmit={page.submit}>
|
||||
<DialogTitle className={styles.configDialogTitle}>
|
||||
<div>
|
||||
<h2>CP排行活动</h2>
|
||||
<span>Top 1-3 资源组会发给获奖 CP 双方</span>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={enabled}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label="CP排行活动状态"
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) => page.updateForm({ enabled: event.target.checked })}
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent className={styles.configDialogContent}>
|
||||
<div className={styles.rewardEditorList}>
|
||||
{cpWeeklyRankRanks.map((rankNo) => {
|
||||
const reward = (page.form.rewards || []).find(
|
||||
(item) => Number(item.rankNo) === Number(rankNo),
|
||||
) || {
|
||||
rankNo,
|
||||
resourceGroupId: "",
|
||||
};
|
||||
return (
|
||||
<div className={styles.rewardEditor} key={rankNo}>
|
||||
<div className={styles.rewardEditorHeader}>
|
||||
<span>Top {rankNo}</span>
|
||||
<span>{resourceLabel(resourceLabels, reward.resourceGroupId)}</span>
|
||||
</div>
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled || !enabled}
|
||||
drawerTitle={`CP排行活动 Top ${rankNo} 奖励资源组`}
|
||||
emptyLabel="未配置奖励"
|
||||
groups={page.resourceGroups}
|
||||
label="奖励资源组"
|
||||
placeholder="点击选择奖励资源组"
|
||||
value={reward.resourceGroupId}
|
||||
onChange={(value) =>
|
||||
page.updateReward(rankNo, {
|
||||
resourceGroupId: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions className={styles.configDialogActions}>
|
||||
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||
{canUpdate ? "取消" : "关闭"}
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={
|
||||
page.saving ? (
|
||||
<CircularProgress color="inherit" size={16} />
|
||||
) : (
|
||||
<SaveOutlined fontSize="small" />
|
||||
)
|
||||
}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function settlementColumns(resourceLabels) {
|
||||
return [
|
||||
{
|
||||
key: "period",
|
||||
label: "结算周期",
|
||||
width: "minmax(240px, 0.9fr)",
|
||||
render: (item) => (
|
||||
<span className={styles.periodCell}>
|
||||
<TimeText value={item.periodStartMs} />
|
||||
<span>至</span>
|
||||
<TimeText value={item.periodEndMs} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rankNo",
|
||||
label: "名次",
|
||||
width: "minmax(90px, 0.3fr)",
|
||||
render: (item) => `Top ${item.rankNo || "-"}`,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "用户ID",
|
||||
width: "minmax(140px, 0.45fr)",
|
||||
render: (item) => item.userId || "-",
|
||||
},
|
||||
{
|
||||
key: "relationshipId",
|
||||
label: "关系ID",
|
||||
width: "minmax(220px, 0.75fr)",
|
||||
render: (item) => item.relationshipId || "-",
|
||||
},
|
||||
{
|
||||
key: "score",
|
||||
label: "周积分",
|
||||
width: "minmax(110px, 0.35fr)",
|
||||
render: (item) => formatNumber(item.score),
|
||||
},
|
||||
{
|
||||
key: "resourceGroupId",
|
||||
label: "奖励资源组",
|
||||
width: "minmax(180px, 0.55fr)",
|
||||
render: (item) => resourceLabel(resourceLabels, item.resourceGroupId),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(110px, 0.35fr)",
|
||||
render: (item) => <StatusBadge status={item.status} />,
|
||||
},
|
||||
{
|
||||
key: "walletGrantId",
|
||||
label: "发放流水",
|
||||
width: "minmax(180px, 0.55fr)",
|
||||
render: (item) => item.walletGrantId || item.walletCommandId || "-",
|
||||
},
|
||||
{
|
||||
key: "attemptCount",
|
||||
label: "重试",
|
||||
width: "minmax(90px, 0.3fr)",
|
||||
render: (item) => formatNumber(item.attemptCount),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.55fr)",
|
||||
render: (item) => (item.updatedAtMs ? <TimeText value={item.updatedAtMs} /> : "-"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
return (
|
||||
<span className={[styles.statusBadge, statusClassName(normalized)].filter(Boolean).join(" ")}>
|
||||
{statusLabel(normalized)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ children, label }) {
|
||||
return (
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>{label}</span>
|
||||
<span className={styles.summaryValue}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function completeRewards(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 groupLabelMap(groups) {
|
||||
return new Map(
|
||||
(groups || []).map((group) => [
|
||||
String(group.groupId),
|
||||
group.name || group.groupCode || `资源组 #${group.groupId}`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function resourceLabel(resourceLabels, value) {
|
||||
const normalized = String(value || "");
|
||||
return normalized && resourceLabels.has(normalized)
|
||||
? resourceLabels.get(normalized)
|
||||
: normalized
|
||||
? `资源组 #${normalized}`
|
||||
: "未配置奖励";
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
const labels = {
|
||||
failed: "失败",
|
||||
granted: "已发放",
|
||||
pending: "待发放",
|
||||
running: "发放中",
|
||||
};
|
||||
return labels[status] || status || "-";
|
||||
}
|
||||
|
||||
function statusClassName(status) {
|
||||
if (status === "granted") {
|
||||
return styles.statusActive;
|
||||
}
|
||||
if (status === "failed") {
|
||||
return styles.statusDanger;
|
||||
}
|
||||
if (status === "running") {
|
||||
return styles.statusPending;
|
||||
}
|
||||
return styles.statusDisabled;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
11
src/features/cp-weekly-rank/permissions.js
Normal file
11
src/features/cp-weekly-rank/permissions.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useCPWeeklyRankAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canUpdate: can(PERMISSIONS.cpWeeklyRankUpdate),
|
||||
canView: can(PERMISSIONS.cpWeeklyRankView),
|
||||
};
|
||||
}
|
||||
12
src/features/cp-weekly-rank/routes.js
Normal file
12
src/features/cp-weekly-rank/routes.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const cpWeeklyRankRoutes = [
|
||||
{
|
||||
label: "CP排行活动",
|
||||
loader: () => import("./pages/CPWeeklyRankPage.jsx").then((module) => module.CPWeeklyRankPage),
|
||||
menuCode: MENU_CODES.cpWeeklyRank,
|
||||
pageKey: "cp-weekly-rank",
|
||||
path: "/activities/cp-weekly-rank",
|
||||
permission: PERMISSIONS.cpWeeklyRankView,
|
||||
},
|
||||
];
|
||||
@ -100,7 +100,9 @@ function temporaryPaymentLinkColumns(page) {
|
||||
value: page.keyword,
|
||||
onChange: page.setKeyword,
|
||||
}),
|
||||
render: (item) => <CopyableText label="复制订单号" value={item.orderId} />,
|
||||
render: (item) => (
|
||||
<Stack primary={<CopyableText label="复制订单号" value={item.orderId} />} secondary={item.appCode ? `App: ${item.appCode}` : "-"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
@ -121,11 +123,9 @@ function temporaryPaymentLinkColumns(page) {
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "金币/美元",
|
||||
label: "收款金额",
|
||||
width: "minmax(140px, 0.65fr)",
|
||||
render: (item) => (
|
||||
<Stack primary={`${formatNumber(item.coinAmount)} 金币`} secondary={formatMoney(item.usdMinorAmount, "USD")} />
|
||||
),
|
||||
render: (item) => <Stack primary={formatMoney(item.usdMinorAmount, "USD")} secondary="仅收款,不发金币" />,
|
||||
},
|
||||
{
|
||||
key: "providerAmount",
|
||||
@ -257,13 +257,6 @@ function providerLabel(value) {
|
||||
return providerOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === 0 || value) {
|
||||
return Number(value).toLocaleString("zh-CN");
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function formatMoney(value, currency = "USD") {
|
||||
if (!(value === 0 || value)) {
|
||||
return "-";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
68
src/shared/api/generated/schema.d.ts
vendored
68
src/shared/api/generated/schema.d.ts
vendored
@ -148,6 +148,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/cp-weekly-rank/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCPWeeklyRankConfig"];
|
||||
put: operations["updateCPWeeklyRankConfig"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/cp-weekly-rank/settlements": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listCPWeeklyRankSettlements"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/red-packets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3944,6 +3976,42 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getCPWeeklyRankConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateCPWeeklyRankConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listCPWeeklyRankSettlements: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRedPackets: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user