cp相关
This commit is contained in:
parent
4b4054a4d0
commit
a7509e1f9a
@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom";
|
|||||||
import App from "@/app/App.jsx";
|
import App from "@/app/App.jsx";
|
||||||
import { AppProviders } from "@/app/providers.jsx";
|
import { AppProviders } from "@/app/providers.jsx";
|
||||||
import { adminRoutes } from "@/app/router/routeConfig";
|
import { adminRoutes } from "@/app/router/routeConfig";
|
||||||
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@ -29,6 +30,70 @@ test("redirects protected route to login when unauthenticated", async () => {
|
|||||||
expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
|
expect(await screen.findByText("HYApp 管理平台")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders resource list 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: ["resource: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/resources")) {
|
||||||
|
return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
}
|
||||||
|
return jsonResponse(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRoute("/resources");
|
||||||
|
|
||||||
|
expect(await screen.findByText("资源")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders cp config 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-config: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/activity/cp/config")) {
|
||||||
|
return jsonResponse({
|
||||||
|
appCode: "lalu",
|
||||||
|
relations: [
|
||||||
|
cpRelationFixture("cp", "CP", 1),
|
||||||
|
cpRelationFixture("brother", "兄弟", 5),
|
||||||
|
cpRelationFixture("sister", "姐妹", 5),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRoute("/activities/cp-config");
|
||||||
|
|
||||||
|
expect((await screen.findAllByText("CP配置")).length).toBeGreaterThan(0);
|
||||||
|
expect(await screen.findByText("兄弟上限")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("admin routes declare menu code and permission", () => {
|
test("admin routes declare menu code and permission", () => {
|
||||||
expect(adminRoutes.length).toBeGreaterThan(0);
|
expect(adminRoutes.length).toBeGreaterThan(0);
|
||||||
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
||||||
@ -43,3 +108,24 @@ function renderWithRoute(route) {
|
|||||||
</AppProviders>
|
</AppProviders>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonResponse(data) {
|
||||||
|
return new Response(JSON.stringify({ code: 0, data }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cpRelationFixture(relationType, displayName, maxCountPerUser) {
|
||||||
|
return {
|
||||||
|
relationType,
|
||||||
|
displayName,
|
||||||
|
maxCountPerUser,
|
||||||
|
applicationExpireHours: 24,
|
||||||
|
status: "active",
|
||||||
|
levels: Array.from({ length: 5 }, (_, index) => ({
|
||||||
|
level: index + 1,
|
||||||
|
intimacyThreshold: index * 10000,
|
||||||
|
rewardResourceGroupId: 0,
|
||||||
|
levelIconUrl: "",
|
||||||
|
status: "active",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
|||||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||||
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||||
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
||||||
|
import FavoriteBorderOutlined from "@mui/icons-material/FavoriteBorderOutlined";
|
||||||
import FlagOutlined from "@mui/icons-material/FlagOutlined";
|
import FlagOutlined from "@mui/icons-material/FlagOutlined";
|
||||||
import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
||||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||||
@ -201,6 +202,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
||||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||||
|
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
||||||
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -138,6 +138,8 @@ export const PERMISSIONS = {
|
|||||||
userLeaderboardView: "user-leaderboard:view",
|
userLeaderboardView: "user-leaderboard:view",
|
||||||
redPacketView: "red-packet:view",
|
redPacketView: "red-packet:view",
|
||||||
redPacketUpdate: "red-packet:update",
|
redPacketUpdate: "red-packet:update",
|
||||||
|
cpConfigView: "cp-config:view",
|
||||||
|
cpConfigUpdate: "cp-config:update",
|
||||||
vipConfigView: "vip-config:view",
|
vipConfigView: "vip-config:view",
|
||||||
vipConfigUpdate: "vip-config:update",
|
vipConfigUpdate: "vip-config:update",
|
||||||
vipConfigGrant: "vip-config:grant",
|
vipConfigGrant: "vip-config:grant",
|
||||||
@ -201,6 +203,7 @@ export const MENU_CODES = {
|
|||||||
roomTurnoverReward: "room-turnover-reward",
|
roomTurnoverReward: "room-turnover-reward",
|
||||||
userLeaderboard: "user-leaderboard",
|
userLeaderboard: "user-leaderboard",
|
||||||
redPacket: "red-packet",
|
redPacket: "red-packet",
|
||||||
|
cpConfig: "cp-config",
|
||||||
vipConfig: "vip-config",
|
vipConfig: "vip-config",
|
||||||
weeklyStar: "weekly-star",
|
weeklyStar: "weekly-star",
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { achievementRoutes } from "@/features/achievements/routes.js";
|
|||||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
||||||
|
import { cpConfigRoutes } from "@/features/cp-config/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||||
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||||
@ -51,6 +52,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...weeklyStarRoutes,
|
...weeklyStarRoutes,
|
||||||
...userLeaderboardRoutes,
|
...userLeaderboardRoutes,
|
||||||
...redPacketRoutes,
|
...redPacketRoutes,
|
||||||
|
...cpConfigRoutes,
|
||||||
...vipConfigRoutes,
|
...vipConfigRoutes,
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
...operationsRoutes,
|
...operationsRoutes,
|
||||||
|
|||||||
122
src/features/cp-config/api.ts
Normal file
122
src/features/cp-config/api.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
|
||||||
|
export type CPRelationType = "cp" | "brother" | "sister";
|
||||||
|
|
||||||
|
export interface CPLevelDto {
|
||||||
|
level: number;
|
||||||
|
intimacyThreshold: number;
|
||||||
|
rewardResourceGroupId: number;
|
||||||
|
levelIconUrl: string;
|
||||||
|
status: string;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CPRelationConfigDto {
|
||||||
|
relationType: CPRelationType;
|
||||||
|
displayName: string;
|
||||||
|
maxCountPerUser: number;
|
||||||
|
applicationExpireHours: number;
|
||||||
|
status: string;
|
||||||
|
levels: CPLevelDto[];
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CPConfigDto {
|
||||||
|
appCode: string;
|
||||||
|
relations: CPRelationConfigDto[];
|
||||||
|
serverTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CPConfigPayload {
|
||||||
|
relations: Array<{
|
||||||
|
relationType: CPRelationType;
|
||||||
|
maxCountPerUser: number;
|
||||||
|
applicationExpireHours: number;
|
||||||
|
status: string;
|
||||||
|
levels: Array<{
|
||||||
|
level: number;
|
||||||
|
intimacyThreshold: number;
|
||||||
|
rewardResourceGroupId: number;
|
||||||
|
levelIconUrl: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = "/v1/admin/activity/cp/config";
|
||||||
|
|
||||||
|
export function getCPConfig(): Promise<CPConfigDto> {
|
||||||
|
return apiRequest<RawConfig>(path).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCPConfig(payload: CPConfigPayload): Promise<CPConfigDto> {
|
||||||
|
return apiRequest<RawConfig, CPConfigPayload>(path, { body: payload, method: "PUT" }).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawConfig = CPConfigDto & Record<string, unknown>;
|
||||||
|
type RawRelation = CPRelationConfigDto & Record<string, unknown>;
|
||||||
|
type RawLevel = CPLevelDto & 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),
|
||||||
|
serverTimeMs: numberValue(item.serverTimeMs ?? item.server_time_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelation(raw: unknown): CPRelationConfigDto {
|
||||||
|
const item = asRecord(raw) as RawRelation;
|
||||||
|
const relationType = normalizeRelationType(item.relationType ?? item.relation_type);
|
||||||
|
return {
|
||||||
|
relationType,
|
||||||
|
displayName: stringValue(item.displayName ?? item.display_name) || relationLabel(relationType),
|
||||||
|
maxCountPerUser: numberValue(item.maxCountPerUser ?? item.max_count_per_user),
|
||||||
|
applicationExpireHours: numberValue(item.applicationExpireHours ?? item.application_expire_hours),
|
||||||
|
status: stringValue(item.status) || "active",
|
||||||
|
levels: arrayValue(item.levels).map(normalizeLevel),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLevel(raw: unknown): CPLevelDto {
|
||||||
|
const item = asRecord(raw) as RawLevel;
|
||||||
|
return {
|
||||||
|
level: numberValue(item.level),
|
||||||
|
intimacyThreshold: numberValue(item.intimacyThreshold ?? item.intimacy_threshold),
|
||||||
|
rewardResourceGroupId: numberValue(item.rewardResourceGroupId ?? item.reward_resource_group_id),
|
||||||
|
levelIconUrl: stringValue(item.levelIconUrl ?? item.level_icon_url),
|
||||||
|
status: stringValue(item.status) || "active",
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelationType(value: unknown): CPRelationType {
|
||||||
|
const relationType = stringValue(value).toLowerCase();
|
||||||
|
if (relationType === "brother" || relationType === "sister") {
|
||||||
|
return relationType;
|
||||||
|
}
|
||||||
|
return "cp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationLabel(relationType: CPRelationType) {
|
||||||
|
return relationType === "brother" ? "兄弟" : relationType === "sister" ? "姐妹" : "CP";
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
136
src/features/cp-config/cp-config.module.css
Normal file
136
src/features/cp-config/cp-config.module.css
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
.summaryPanel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItem {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel,
|
||||||
|
.meta {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryValue {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusActive {
|
||||||
|
color: #12603a;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDisabled {
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelPreview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelPill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationEditorList,
|
||||||
|
.levelEditorList {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationEditor,
|
||||||
|
.levelEditor {
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationEditor {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelEditor {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationEditorHeader,
|
||||||
|
.levelEditorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelEditorList {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.summaryPanel {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/features/cp-config/hooks/useCPConfigPage.js
Normal file
249
src/features/cp-config/hooks/useCPConfigPage.js
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { getCPConfig, updateCPConfig } from "@/features/cp-config/api";
|
||||||
|
import { useCPConfigAbilities } from "@/features/cp-config/permissions.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
export const cpRelationTypes = ["cp", "brother", "sister"];
|
||||||
|
export const cpRelationLabels = {
|
||||||
|
cp: "CP",
|
||||||
|
brother: "兄弟",
|
||||||
|
sister: "姐妹",
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultExpireHours = 24;
|
||||||
|
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 [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const activeRelationCount = useMemo(
|
||||||
|
() => (config.relations || []).filter((relation) => relation.status === "active").length,
|
||||||
|
[config.relations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const remoteConfig = await getCPConfig();
|
||||||
|
const relations = completeRelations(remoteConfig.relations || []);
|
||||||
|
setConfig({ ...remoteConfig, relations });
|
||||||
|
setForm({ relations: relations.map(relationToForm) });
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "加载 CP 配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const openDrawer = () => {
|
||||||
|
setForm({ relations: completeRelations(config.relations || []).map(relationToForm) });
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDrawer = () => {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm({ relations: completeRelations(config.relations || []).map(relationToForm) });
|
||||||
|
setDrawerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRelation = (relationType, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
relations: (current.relations || []).map((relation) =>
|
||||||
|
relation.relationType === relationType ? { ...relation, ...patch } : relation,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLevel = (relationType, levelNumber, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
relations: (current.relations || []).map((relation) =>
|
||||||
|
relation.relationType === relationType
|
||||||
|
? {
|
||||||
|
...relation,
|
||||||
|
levels: (relation.levels || []).map((level) =>
|
||||||
|
Number(level.level) === Number(levelNumber) ? { ...level, ...patch } : level,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: relation,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 updateCPConfig(payload);
|
||||||
|
const relations = completeRelations(saved.relations || []);
|
||||||
|
setConfig({ ...saved, relations });
|
||||||
|
setForm({ relations: relations.map(relationToForm) });
|
||||||
|
setDrawerOpen(false);
|
||||||
|
showToast("CP 配置已保存", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存 CP 配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
activeRelationCount,
|
||||||
|
closeDrawer,
|
||||||
|
config,
|
||||||
|
drawerOpen,
|
||||||
|
form,
|
||||||
|
loading,
|
||||||
|
openDrawer,
|
||||||
|
reload,
|
||||||
|
saving,
|
||||||
|
submit,
|
||||||
|
updateLevel,
|
||||||
|
updateRelation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultRelations() {
|
||||||
|
return cpRelationTypes.map((relationType) => ({
|
||||||
|
relationType,
|
||||||
|
displayName: cpRelationLabels[relationType],
|
||||||
|
maxCountPerUser: relationType === "cp" ? 1 : 5,
|
||||||
|
applicationExpireHours: defaultExpireHours,
|
||||||
|
status: "active",
|
||||||
|
levels: defaultLevels(),
|
||||||
|
updatedAtMs: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLevels() {
|
||||||
|
return defaultThresholds.map((threshold, index) => ({
|
||||||
|
level: index + 1,
|
||||||
|
intimacyThreshold: threshold,
|
||||||
|
rewardResourceGroupId: 0,
|
||||||
|
levelIconUrl: "",
|
||||||
|
status: "active",
|
||||||
|
updatedAtMs: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeRelations(relations) {
|
||||||
|
const byType = new Map((relations || []).map((relation) => [relation.relationType, relation]));
|
||||||
|
return defaultRelations().map((fallback) => {
|
||||||
|
const relation = { ...fallback, ...(byType.get(fallback.relationType) || {}) };
|
||||||
|
if (relation.relationType === "cp") {
|
||||||
|
relation.maxCountPerUser = 1;
|
||||||
|
}
|
||||||
|
relation.displayName = relation.displayName || cpRelationLabels[relation.relationType];
|
||||||
|
relation.levels = completeLevels(relation.levels || []);
|
||||||
|
return relation;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeLevels(levels) {
|
||||||
|
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
|
||||||
|
return defaultLevels().map((fallback) => ({
|
||||||
|
...fallback,
|
||||||
|
...(byLevel.get(fallback.level) || {}),
|
||||||
|
level: fallback.level,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationToForm(relation) {
|
||||||
|
return {
|
||||||
|
relationType: relation.relationType,
|
||||||
|
displayName: relation.displayName || cpRelationLabels[relation.relationType],
|
||||||
|
maxCountPerUser: relation.relationType === "cp" ? "1" : String(relation.maxCountPerUser || 5),
|
||||||
|
applicationExpireHours: String(relation.applicationExpireHours || defaultExpireHours),
|
||||||
|
status: relation.status || "active",
|
||||||
|
levels: completeLevels(relation.levels || []).map(levelToForm),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelToForm(level) {
|
||||||
|
return {
|
||||||
|
level: Number(level.level || 0),
|
||||||
|
intimacyThreshold: String(level.intimacyThreshold || 0),
|
||||||
|
rewardResourceGroupId: level.rewardResourceGroupId ? String(level.rewardResourceGroupId) : "",
|
||||||
|
levelIconUrl: level.levelIconUrl || "",
|
||||||
|
status: level.status || "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFromForm(form) {
|
||||||
|
const relations = completeFormRelations(form.relations || []).map((relation) => {
|
||||||
|
const relationType = relation.relationType;
|
||||||
|
const maxCountPerUser = relationType === "cp" ? 1 : Number(relation.maxCountPerUser || 0);
|
||||||
|
const applicationExpireHours = Number(relation.applicationExpireHours || 0);
|
||||||
|
const status = relation.status === "disabled" ? "disabled" : "active";
|
||||||
|
if (!Number.isInteger(maxCountPerUser) || maxCountPerUser <= 0) {
|
||||||
|
throw new Error(`${cpRelationLabels[relationType]}数量必须大于 0`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(applicationExpireHours) || applicationExpireHours <= 0) {
|
||||||
|
throw new Error(`${cpRelationLabels[relationType]}申请过期小时必须大于 0`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
relationType,
|
||||||
|
maxCountPerUser,
|
||||||
|
applicationExpireHours,
|
||||||
|
status,
|
||||||
|
levels: completeFormLevels(relation.levels || [], relationType),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { relations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeFormRelations(relations) {
|
||||||
|
const byType = new Map((relations || []).map((relation) => [relation.relationType, relation]));
|
||||||
|
return defaultRelations().map((fallback) => ({ ...relationToForm(fallback), ...(byType.get(fallback.relationType) || {}) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeFormLevels(levels, relationType) {
|
||||||
|
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
|
||||||
|
let previous = -1;
|
||||||
|
return defaultLevels().map((fallback) => {
|
||||||
|
const level = { ...levelToForm(fallback), ...(byLevel.get(fallback.level) || {}) };
|
||||||
|
const threshold = Number(level.intimacyThreshold || 0);
|
||||||
|
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
|
||||||
|
if (!Number.isInteger(threshold) || threshold < 0) {
|
||||||
|
throw new Error(`${cpRelationLabels[relationType]}${fallback.level}级亲密值不能小于 0`);
|
||||||
|
}
|
||||||
|
if (threshold <= previous) {
|
||||||
|
throw new Error(`${cpRelationLabels[relationType]}等级亲密值必须递增`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(rewardResourceGroupId) || rewardResourceGroupId < 0) {
|
||||||
|
throw new Error(`${cpRelationLabels[relationType]}${fallback.level}级奖励资源组不正确`);
|
||||||
|
}
|
||||||
|
previous = threshold;
|
||||||
|
return {
|
||||||
|
level: fallback.level,
|
||||||
|
intimacyThreshold: threshold,
|
||||||
|
rewardResourceGroupId,
|
||||||
|
levelIconUrl: String(level.levelIconUrl || "").trim(),
|
||||||
|
status: level.status === "disabled" ? "disabled" : "active",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
282
src/features/cp-config/pages/CPConfigPage.jsx
Normal file
282
src/features/cp-config/pages/CPConfigPage.jsx
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
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 styles from "@/features/cp-config/cp-config.module.css";
|
||||||
|
import { AdminListBody, AdminListPage } 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 { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
export function CPConfigPage() {
|
||||||
|
const page = useCPConfigPage();
|
||||||
|
const relations = page.config.relations || [];
|
||||||
|
const columns = useMemo(() => cpConfigColumns(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<div className={styles.summaryPanel}>
|
||||||
|
<div className={styles.summaryItems}>
|
||||||
|
<SummaryItem label="关系类型">{relations.length || 3}</SummaryItem>
|
||||||
|
<SummaryItem label="启用类型">{page.activeRelationCount}</SummaryItem>
|
||||||
|
<SummaryItem label="兄弟上限">{relationMax(relations, "brother")}</SummaryItem>
|
||||||
|
<SummaryItem label="姐妹上限">{relationMax(relations, "sister")}</SummaryItem>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryActions}>
|
||||||
|
<Button disabled={page.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={page.reload}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{page.abilities.canUpdate ? (
|
||||||
|
<Button
|
||||||
|
disabled={page.loading}
|
||||||
|
startIcon={<EditOutlined fontSize="small" />}
|
||||||
|
variant="primary"
|
||||||
|
onClick={page.openDrawer}
|
||||||
|
>
|
||||||
|
编辑配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataState loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable columns={columns} items={relations} minWidth="1120px" rowKey={(item) => item.relationType} />
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<CPConfigDrawer page={page} />
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CPConfigDrawer({ page }) {
|
||||||
|
const disabled = !page.abilities.canUpdate || page.saving || page.loading;
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={page.drawerOpen} onClose={page.saving ? undefined : page.closeDrawer}>
|
||||||
|
<form className="form-drawer form-drawer--wide" onSubmit={page.submit}>
|
||||||
|
<h2>CP配置</h2>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className="form-drawer__section-title">关系与等级</div>
|
||||||
|
<div className={styles.relationEditorList}>
|
||||||
|
{(page.form.relations || []).map((relation) => (
|
||||||
|
<RelationEditor
|
||||||
|
disabled={disabled}
|
||||||
|
key={relation.relationType}
|
||||||
|
relation={relation}
|
||||||
|
updateLevel={page.updateLevel}
|
||||||
|
updateRelation={page.updateRelation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="form-drawer__actions">
|
||||||
|
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<SaveOutlined fontSize="small" />}
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelationEditor({ disabled, relation, updateLevel, updateRelation }) {
|
||||||
|
const relationType = relation.relationType;
|
||||||
|
const maxCountDisabled = disabled || relationType === "cp";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.relationEditor}>
|
||||||
|
<div className={styles.relationEditorHeader}>
|
||||||
|
<span>{relation.displayName || cpRelationLabels[relationType]}</span>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={relation.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label={`${cpRelationLabels[relationType]}状态`}
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) =>
|
||||||
|
updateRelation(relationType, { status: event.target.checked ? "active" : "disabled" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-drawer__grid">
|
||||||
|
<TextField
|
||||||
|
disabled={maxCountDisabled}
|
||||||
|
inputProps={{ min: 1, step: 1 }}
|
||||||
|
label="数量上限"
|
||||||
|
type="number"
|
||||||
|
value={relationType === "cp" ? "1" : relation.maxCountPerUser}
|
||||||
|
onChange={(event) => updateRelation(relationType, { maxCountPerUser: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 1, step: 1 }}
|
||||||
|
label="申请过期小时"
|
||||||
|
type="number"
|
||||||
|
value={relation.applicationExpireHours}
|
||||||
|
onChange={(event) => updateRelation(relationType, { applicationExpireHours: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelEditorList}>
|
||||||
|
{(relation.levels || []).map((level) => (
|
||||||
|
<LevelEditor
|
||||||
|
disabled={disabled}
|
||||||
|
key={`${relationType}-${level.level}`}
|
||||||
|
level={level}
|
||||||
|
relationType={relationType}
|
||||||
|
updateLevel={updateLevel}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelEditor({ disabled, level, relationType, updateLevel }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.levelEditor}>
|
||||||
|
<div className={styles.levelEditorHeader}>
|
||||||
|
<span>{level.level}级</span>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={level.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label={`${cpRelationLabels[relationType]}${level.level}级状态`}
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLevel(relationType, level.level, {
|
||||||
|
status: event.target.checked ? "active" : "disabled",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-drawer__grid">
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1 }}
|
||||||
|
label="亲密值"
|
||||||
|
type="number"
|
||||||
|
value={level.intimacyThreshold}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLevel(relationType, level.level, { intimacyThreshold: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1 }}
|
||||||
|
label="奖励资源组ID"
|
||||||
|
type="number"
|
||||||
|
value={level.rewardResourceGroupId}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLevel(relationType, level.level, { rewardResourceGroupId: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="等级图标URL"
|
||||||
|
value={level.levelIconUrl}
|
||||||
|
onChange={(event) => updateLevel(relationType, level.level, { levelIconUrl: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cpConfigColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "relation",
|
||||||
|
label: "关系",
|
||||||
|
width: "minmax(140px, 0.45fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{item.displayName || cpRelationLabels[item.relationType]}</span>
|
||||||
|
<span className={styles.meta}>{item.relationType}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxCount",
|
||||||
|
label: "数量上限",
|
||||||
|
width: "minmax(120px, 0.45fr)",
|
||||||
|
render: (item) => (item.relationType === "cp" ? "1(固定)" : formatNumber(item.maxCountPerUser)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "expire",
|
||||||
|
label: "申请过期",
|
||||||
|
width: "minmax(130px, 0.45fr)",
|
||||||
|
render: (item) => `${formatNumber(item.applicationExpireHours)} 小时`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "levels",
|
||||||
|
label: "等级亲密值",
|
||||||
|
width: "minmax(360px, 1.3fr)",
|
||||||
|
render: (item) => <LevelPreview levels={item.levels || []} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(110px, 0.4fr)",
|
||||||
|
render: (item) => <StatusBadge status={item.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "minmax(180px, 0.6fr)",
|
||||||
|
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelPreview({ levels }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.levelPreview}>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<span className={styles.levelPill} key={level.level}>
|
||||||
|
{level.level}级 {formatNumber(level.intimacyThreshold)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const active = status === "active";
|
||||||
|
return (
|
||||||
|
<span className={[styles.statusBadge, active ? styles.statusActive : styles.statusDisabled].join(" ")}>
|
||||||
|
{active ? "启用" : "停用"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryItem({ children, label }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.summaryItem}>
|
||||||
|
<span className={styles.summaryLabel}>{label}</span>
|
||||||
|
<span className={styles.summaryValue}>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationMax(relations, relationType) {
|
||||||
|
const relation = relations.find((item) => item.relationType === relationType);
|
||||||
|
return relation ? formatNumber(relation.maxCountPerUser) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
11
src/features/cp-config/permissions.js
Normal file
11
src/features/cp-config/permissions.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useCPConfigAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canView: can(PERMISSIONS.cpConfigView),
|
||||||
|
canUpdate: can(PERMISSIONS.cpConfigUpdate),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/cp-config/routes.js
Normal file
12
src/features/cp-config/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const cpConfigRoutes = [
|
||||||
|
{
|
||||||
|
label: "CP配置",
|
||||||
|
loader: () => import("./pages/CPConfigPage.jsx").then((module) => module.CPConfigPage),
|
||||||
|
menuCode: MENU_CODES.cpConfig,
|
||||||
|
pageKey: "cp-config",
|
||||||
|
path: "/activities/cp-config",
|
||||||
|
permission: PERMISSIONS.cpConfigView,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -22,6 +22,14 @@ export const defaultGiftTypeOptions = [
|
|||||||
{ displayName: "定制礼物", sortOrder: 100, status: "active", tabKey: "custom", tabName: "Custom" },
|
{ displayName: "定制礼物", sortOrder: 100, status: "active", tabKey: "custom", tabName: "Custom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const cpRelationTypeOptions = [
|
||||||
|
["cp", "CP礼物"],
|
||||||
|
["brother", "兄弟礼物"],
|
||||||
|
["sister", "姐妹礼物"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const cpRelationTypeLabels = Object.fromEntries(cpRelationTypeOptions);
|
||||||
|
|
||||||
export const resourceGrantStatusFilters = [
|
export const resourceGrantStatusFilters = [
|
||||||
["", "全部状态"],
|
["", "全部状态"],
|
||||||
["succeeded", "成功"],
|
["succeeded", "成功"],
|
||||||
|
|||||||
@ -33,7 +33,12 @@ import {
|
|||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
upsertResourceShopItems,
|
upsertResourceShopItems,
|
||||||
} from "@/features/resources/api";
|
} from "@/features/resources/api";
|
||||||
import { defaultGiftTypeOptions, resourceShopSellableTypes } from "@/features/resources/constants.js";
|
import {
|
||||||
|
cpRelationTypeLabels,
|
||||||
|
cpRelationTypeOptions,
|
||||||
|
defaultGiftTypeOptions,
|
||||||
|
resourceShopSellableTypes,
|
||||||
|
} from "@/features/resources/constants.js";
|
||||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||||
import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
|
import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
|
||||||
import {
|
import {
|
||||||
@ -50,6 +55,11 @@ const pageSize = 50;
|
|||||||
const optionPageSize = 100;
|
const optionPageSize = 100;
|
||||||
const optionMaxPages = 200;
|
const optionMaxPages = 200;
|
||||||
const dayMillis = 24 * 60 * 60 * 1000;
|
const dayMillis = 24 * 60 * 60 * 1000;
|
||||||
|
const cpGiftTypeCode = "cp";
|
||||||
|
const defaultCPRelationType = cpRelationTypeOptions[0][0];
|
||||||
|
const cpRelationTypeValues = new Set(cpRelationTypeOptions.map(([value]) => value));
|
||||||
|
const cpPresentationRelationKeys = ["cp_relation_type", "cpRelationType"];
|
||||||
|
const cpPresentationLabelKeys = ["cp_relation_label", "cpRelationLabel"];
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyResourceForm = (resource = {}) => ({
|
const emptyResourceForm = (resource = {}) => ({
|
||||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||||
@ -105,22 +115,31 @@ const emptyGroupWalletAssetItem = (walletAssetType) => ({
|
|||||||
walletAssetAmount: "",
|
walletAssetAmount: "",
|
||||||
walletAssetType,
|
walletAssetType,
|
||||||
});
|
});
|
||||||
const emptyGiftForm = (gift = {}) => ({
|
const emptyGiftForm = (gift = {}) => {
|
||||||
chargeAssetType: gift.chargeAssetType || "COIN",
|
const giftTypeCode = gift.giftTypeCode || "normal";
|
||||||
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
|
const presentationJson = gift.presentationJson || "{}";
|
||||||
effectTypes: gift.effectTypes || [],
|
const cpRelationType =
|
||||||
effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
|
giftTypeCode === cpGiftTypeCode
|
||||||
effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
|
? cpRelationTypeFromPresentationJson(presentationJson) || defaultCPRelationType
|
||||||
enabled: gift.status ? gift.status === "active" : true,
|
: "";
|
||||||
giftId: gift.giftId || "",
|
return {
|
||||||
giftTypeCode: gift.giftTypeCode || "normal",
|
chargeAssetType: gift.chargeAssetType || "COIN",
|
||||||
name: gift.name || "",
|
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
|
||||||
presentationJson: gift.presentationJson || "{}",
|
cpRelationType,
|
||||||
priceVersion: gift.priceVersion || "default",
|
effectTypes: gift.effectTypes || [],
|
||||||
regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String),
|
effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
|
||||||
resourceId: gift.resourceId ? String(gift.resourceId) : "",
|
effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
|
||||||
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
enabled: gift.status ? gift.status === "active" : true,
|
||||||
});
|
giftId: gift.giftId || "",
|
||||||
|
giftTypeCode,
|
||||||
|
name: gift.name || "",
|
||||||
|
presentationJson: normalizeGiftPresentationJson(presentationJson, giftTypeCode, cpRelationType),
|
||||||
|
priceVersion: gift.priceVersion || "default",
|
||||||
|
regionIds: (gift.regionIds?.length ? gift.regionIds : [0]).map(String),
|
||||||
|
resourceId: gift.resourceId ? String(gift.resourceId) : "",
|
||||||
|
sortOrder: gift.sortOrder === 0 || gift.sortOrder ? String(gift.sortOrder) : "0",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function applyGiftResourceSelection(form, resource, fillIdentity = true) {
|
export function applyGiftResourceSelection(form, resource, fillIdentity = true) {
|
||||||
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
|
const resourceId = resource?.resourceId ? String(resource.resourceId) : "";
|
||||||
@ -143,6 +162,36 @@ export function applyGiftPriceDefaults(form, coinPrice) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cpRelationTypeFromPresentationJson(presentationJson) {
|
||||||
|
const presentation = parseGiftPresentationObject(presentationJson);
|
||||||
|
const relationType = cpPresentationRelationKeys
|
||||||
|
.map((key) => String(presentation?.[key] || "").trim().toLowerCase())
|
||||||
|
.find((value) => cpRelationTypeValues.has(value));
|
||||||
|
return relationType || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGiftPresentationJson(presentationJson, giftTypeCode, cpRelationType) {
|
||||||
|
const rawValue = String(presentationJson || "").trim();
|
||||||
|
const isCPGift = giftTypeCode === cpGiftTypeCode;
|
||||||
|
let parsedValue;
|
||||||
|
|
||||||
|
// 礼物展示配置由后端透传给 room/wallet 链路;这里只对对象型 JSON 做 CP 字段收敛,避免误改旧的非对象配置。
|
||||||
|
try {
|
||||||
|
parsedValue = rawValue ? JSON.parse(rawValue) : {};
|
||||||
|
} catch {
|
||||||
|
return rawValue || "{}";
|
||||||
|
}
|
||||||
|
if (!parsedValue || typeof parsedValue !== "object" || Array.isArray(parsedValue)) {
|
||||||
|
return isCPGift ? cpPresentationJson({}, cpRelationType) : rawValue || "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPresentation = { ...parsedValue };
|
||||||
|
[...cpPresentationRelationKeys, ...cpPresentationLabelKeys].forEach((key) => {
|
||||||
|
delete nextPresentation[key];
|
||||||
|
});
|
||||||
|
return isCPGift ? cpPresentationJson(nextPresentation, cpRelationType) : JSON.stringify(nextPresentation);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize) {
|
export async function fetchAllOptionPages(fetcher, query = {}, pageSize = optionPageSize) {
|
||||||
const items = [];
|
const items = [];
|
||||||
let total = Number.POSITIVE_INFINITY;
|
let total = Number.POSITIVE_INFINITY;
|
||||||
@ -172,6 +221,33 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option
|
|||||||
throw new Error("资源选项分页过多,请收窄筛选条件");
|
throw new Error("资源选项分页过多,请收窄筛选条件");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGiftPresentationObject(presentationJson) {
|
||||||
|
const rawValue = String(presentationJson || "").trim();
|
||||||
|
if (!rawValue) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsedValue = JSON.parse(rawValue);
|
||||||
|
return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) ? parsedValue : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedCPRelationType(value) {
|
||||||
|
const relationType = String(value || "").trim().toLowerCase();
|
||||||
|
return cpRelationTypeValues.has(relationType) ? relationType : defaultCPRelationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cpPresentationJson(basePresentation, cpRelationType) {
|
||||||
|
const relationType = normalizedCPRelationType(cpRelationType);
|
||||||
|
return JSON.stringify({
|
||||||
|
...basePresentation,
|
||||||
|
cp_relation_label: cpRelationTypeLabels[relationType] || relationType,
|
||||||
|
cp_relation_type: relationType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function applyGiftResourcePrice(form, resource) {
|
function applyGiftResourcePrice(form, resource) {
|
||||||
const priceType = resource?.priceType || (resource?.coinPrice > 0 ? "coin" : "");
|
const priceType = resource?.priceType || (resource?.coinPrice > 0 ? "coin" : "");
|
||||||
if (priceType === "free") {
|
if (priceType === "free") {
|
||||||
@ -899,6 +975,37 @@ export function useGiftListPage() {
|
|||||||
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
|
setForm((currentForm) => applyGiftPriceDefaults(currentForm, coinPrice));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeGiftFormTypeCode = (nextGiftTypeCode) => {
|
||||||
|
setForm((currentForm) => {
|
||||||
|
const cpRelationType =
|
||||||
|
nextGiftTypeCode === cpGiftTypeCode
|
||||||
|
? currentForm.cpRelationType || cpRelationTypeFromPresentationJson(currentForm.presentationJson)
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
...currentForm,
|
||||||
|
cpRelationType: cpRelationType || (nextGiftTypeCode === cpGiftTypeCode ? defaultCPRelationType : ""),
|
||||||
|
giftTypeCode: nextGiftTypeCode,
|
||||||
|
presentationJson: normalizeGiftPresentationJson(
|
||||||
|
currentForm.presentationJson,
|
||||||
|
nextGiftTypeCode,
|
||||||
|
cpRelationType,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeGiftCPRelationType = (nextCPRelationType) => {
|
||||||
|
setForm((currentForm) => ({
|
||||||
|
...currentForm,
|
||||||
|
cpRelationType: nextCPRelationType,
|
||||||
|
presentationJson: normalizeGiftPresentationJson(
|
||||||
|
currentForm.presentationJson,
|
||||||
|
currentForm.giftTypeCode,
|
||||||
|
nextCPRelationType,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const openGiftTypeDialog = () => {
|
const openGiftTypeDialog = () => {
|
||||||
setGiftTypeForm({ items: giftTypes.map(giftTypeToForm) });
|
setGiftTypeForm({ items: giftTypes.map(giftTypeToForm) });
|
||||||
setGiftTypeDialogOpen(true);
|
setGiftTypeDialogOpen(true);
|
||||||
@ -1037,7 +1144,9 @@ export function useGiftListPage() {
|
|||||||
data: giftData,
|
data: giftData,
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
|
changeGiftCPRelationType,
|
||||||
changeGiftPrice,
|
changeGiftPrice,
|
||||||
|
changeGiftFormTypeCode,
|
||||||
changeRegionId: resetSetter(setRegionId, setPage),
|
changeRegionId: resetSetter(setRegionId, setPage),
|
||||||
changeStatus: resetSetter(setStatus, setPage),
|
changeStatus: resetSetter(setStatus, setPage),
|
||||||
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
|
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
|
||||||
@ -1503,7 +1612,7 @@ function buildGiftPayload(form) {
|
|||||||
giftId: form.giftId.trim(),
|
giftId: form.giftId.trim(),
|
||||||
giftTypeCode: form.giftTypeCode,
|
giftTypeCode: form.giftTypeCode,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
presentationJson: form.presentationJson?.trim() || "{}",
|
presentationJson: normalizeGiftPresentationJson(form.presentationJson, form.giftTypeCode, form.cpRelationType),
|
||||||
priceVersion: form.priceVersion.trim(),
|
priceVersion: form.priceVersion.trim(),
|
||||||
regionIds: form.regionIds.map(Number),
|
regionIds: form.regionIds.map(Number),
|
||||||
resourceId: Number(form.resourceId),
|
resourceId: Number(form.resourceId),
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { applyGiftPriceDefaults, applyGiftResourceSelection, fetchAllOptionPages } from "./useResourcePages.js";
|
import {
|
||||||
|
applyGiftPriceDefaults,
|
||||||
|
applyGiftResourceSelection,
|
||||||
|
cpRelationTypeFromPresentationJson,
|
||||||
|
fetchAllOptionPages,
|
||||||
|
normalizeGiftPresentationJson,
|
||||||
|
} from "./useResourcePages.js";
|
||||||
|
|
||||||
test("fills gift name and id from selected gift resource", () => {
|
test("fills gift name and id from selected gift resource", () => {
|
||||||
const form = {
|
const form = {
|
||||||
@ -54,3 +60,33 @@ test("fetches all option pages instead of only the first resource page", async (
|
|||||||
expect(result.items.map((item) => item.resourceId)).toEqual([1, 2, 3, 4, 5]);
|
expect(result.items.map((item) => item.resourceId)).toEqual([1, 2, 3, 4, 5]);
|
||||||
expect(result.total).toBe(5);
|
expect(result.total).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reads CP relation type from gift presentation json", () => {
|
||||||
|
expect(cpRelationTypeFromPresentationJson('{"cp_relation_type":"brother"}')).toBe("brother");
|
||||||
|
expect(cpRelationTypeFromPresentationJson('{"cpRelationType":"sister"}')).toBe("sister");
|
||||||
|
expect(cpRelationTypeFromPresentationJson('{"cp_relation_type":"unknown"}')).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes CP gift presentation json for submit", () => {
|
||||||
|
const nextJson = normalizeGiftPresentationJson(
|
||||||
|
'{"other":1,"cp_relation_type":"brother","cpRelationLabel":"old"}',
|
||||||
|
"cp",
|
||||||
|
"sister",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(JSON.parse(nextJson)).toEqual({
|
||||||
|
cp_relation_label: "姐妹礼物",
|
||||||
|
cp_relation_type: "sister",
|
||||||
|
other: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes CP relation metadata from non-CP gifts", () => {
|
||||||
|
const nextJson = normalizeGiftPresentationJson(
|
||||||
|
'{"other":1,"cp_relation_type":"cp","cp_relation_label":"CP礼物"}',
|
||||||
|
"normal",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(JSON.parse(nextJson)).toEqual({ other: 1 });
|
||||||
|
});
|
||||||
|
|||||||
@ -33,10 +33,15 @@ import {
|
|||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
|
import {
|
||||||
|
cpRelationTypeLabels,
|
||||||
|
cpRelationTypeOptions,
|
||||||
|
defaultGiftTypeOptions,
|
||||||
|
resourceStatusFilters,
|
||||||
|
} from "@/features/resources/constants.js";
|
||||||
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
|
||||||
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
||||||
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
import { cpRelationTypeFromPresentationJson, useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
|
|
||||||
const effectOptions = [
|
const effectOptions = [
|
||||||
@ -64,7 +69,7 @@ const baseColumns = (giftTypeOptions) => [
|
|||||||
key: "type",
|
key: "type",
|
||||||
label: "类型",
|
label: "类型",
|
||||||
width: "minmax(130px, 0.6fr)",
|
width: "minmax(130px, 0.6fr)",
|
||||||
render: (gift) => giftTypeLabel(gift.giftTypeCode, giftTypeOptions),
|
render: (gift) => <GiftTypeIdentity gift={gift} giftTypeOptions={giftTypeOptions} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "price",
|
key: "price",
|
||||||
@ -300,6 +305,7 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
const submitDisabled =
|
const submitDisabled =
|
||||||
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
|
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
|
||||||
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
|
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
|
||||||
|
const isCPGiftType = form.giftTypeCode === "cp";
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@ -345,7 +351,7 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
required
|
required
|
||||||
select
|
select
|
||||||
value={form.giftTypeCode}
|
value={form.giftTypeCode}
|
||||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
onChange={(event) => page.changeGiftFormTypeCode(event.target.value)}
|
||||||
>
|
>
|
||||||
{page.giftTypeOptions.map((item) => (
|
{page.giftTypeOptions.map((item) => (
|
||||||
<MenuItem key={item.tabKey} value={item.tabKey}>
|
<MenuItem key={item.tabKey} value={item.tabKey}>
|
||||||
@ -353,6 +359,22 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
{isCPGiftType ? (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="关系类型"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.cpRelationType || "cp"}
|
||||||
|
onChange={(event) => page.changeGiftCPRelationType(event.target.value)}
|
||||||
|
>
|
||||||
|
{cpRelationTypeOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="价格与排序">
|
<AdminFormSection title="价格与排序">
|
||||||
@ -644,6 +666,16 @@ function giftTypeLabel(value, options = defaultGiftTypeOptions) {
|
|||||||
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
|
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GiftTypeIdentity({ gift, giftTypeOptions }) {
|
||||||
|
const relationType = gift.giftTypeCode === "cp" ? cpRelationTypeFromPresentationJson(gift.presentationJson) : "";
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
|
||||||
|
{relationType ? <span className="admin-tag">{cpRelationTypeLabels[relationType] || relationType}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function giftPriceLabel(gift) {
|
function giftPriceLabel(gift) {
|
||||||
return `金币 · ${formatNumber(gift.coinPrice)}`;
|
return `金币 · ${formatNumber(gift.coinPrice)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { cpRelationTypeOptions } from "@/features/resources/constants.js";
|
||||||
|
|
||||||
const resourceTypes = [
|
const resourceTypes = [
|
||||||
"avatar_frame",
|
"avatar_frame",
|
||||||
@ -19,6 +20,7 @@ const badgeForms = ["strip", "tile"];
|
|||||||
const badgeKinds = ["normal", "level"];
|
const badgeKinds = ["normal", "level"];
|
||||||
const badgeLevelTracks = ["wealth", "game", "charm"];
|
const badgeLevelTracks = ["wealth", "game", "charm"];
|
||||||
const resourceShopDurations = ["1", "3", "7"];
|
const resourceShopDurations = ["1", "3", "7"];
|
||||||
|
const cpRelationTypes = cpRelationTypeOptions.map(([value]) => value);
|
||||||
const optionalWalletAssetTypeSchema = z
|
const optionalWalletAssetTypeSchema = z
|
||||||
.preprocess((value) => {
|
.preprocess((value) => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@ -170,6 +172,7 @@ export const giftFormSchema = z
|
|||||||
.object({
|
.object({
|
||||||
chargeAssetType: z.enum(["COIN"]),
|
chargeAssetType: z.enum(["COIN"]),
|
||||||
coinPrice: z.union([z.string(), z.number()]),
|
coinPrice: z.union([z.string(), z.number()]),
|
||||||
|
cpRelationType: z.string().trim().optional(),
|
||||||
effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
|
effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
|
||||||
effectiveFrom: z.string().optional(),
|
effectiveFrom: z.string().optional(),
|
||||||
effectiveTo: z.string().optional(),
|
effectiveTo: z.string().optional(),
|
||||||
@ -208,6 +211,13 @@ export const giftFormSchema = z
|
|||||||
path: ["coinPrice"],
|
path: ["coinPrice"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (value.giftTypeCode === "cp" && !cpRelationTypes.includes(value.cpRelationType)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请选择关系类型",
|
||||||
|
path: ["cpRelationType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (effectiveFromMs < 0) {
|
if (effectiveFromMs < 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user