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 { AppProviders } from "@/app/providers.jsx";
|
||||
import { adminRoutes } from "@/app/router/routeConfig";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
@ -29,6 +30,70 @@ test("redirects protected route to login when unauthenticated", async () => {
|
||||
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", () => {
|
||||
expect(adminRoutes.length).toBeGreaterThan(0);
|
||||
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
||||
@ -43,3 +108,24 @@ function renderWithRoute(route) {
|
||||
</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 DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
||||
import FavoriteBorderOutlined from "@mui/icons-material/FavoriteBorderOutlined";
|
||||
import FlagOutlined from "@mui/icons-material/FlagOutlined";
|
||||
import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
|
||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||
@ -201,6 +202,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
||||
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
||||
],
|
||||
},
|
||||
|
||||
@ -138,6 +138,8 @@ export const PERMISSIONS = {
|
||||
userLeaderboardView: "user-leaderboard:view",
|
||||
redPacketView: "red-packet:view",
|
||||
redPacketUpdate: "red-packet:update",
|
||||
cpConfigView: "cp-config:view",
|
||||
cpConfigUpdate: "cp-config:update",
|
||||
vipConfigView: "vip-config:view",
|
||||
vipConfigUpdate: "vip-config:update",
|
||||
vipConfigGrant: "vip-config:grant",
|
||||
@ -201,6 +203,7 @@ export const MENU_CODES = {
|
||||
roomTurnoverReward: "room-turnover-reward",
|
||||
userLeaderboard: "user-leaderboard",
|
||||
redPacket: "red-packet",
|
||||
cpConfig: "cp-config",
|
||||
vipConfig: "vip-config",
|
||||
weeklyStar: "weekly-star",
|
||||
geo: "geo",
|
||||
|
||||
@ -3,6 +3,7 @@ import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||
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 { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||
@ -51,6 +52,7 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...weeklyStarRoutes,
|
||||
...userLeaderboardRoutes,
|
||||
...redPacketRoutes,
|
||||
...cpConfigRoutes,
|
||||
...vipConfigRoutes,
|
||||
...resourceRoutes,
|
||||
...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" },
|
||||
];
|
||||
|
||||
export const cpRelationTypeOptions = [
|
||||
["cp", "CP礼物"],
|
||||
["brother", "兄弟礼物"],
|
||||
["sister", "姐妹礼物"],
|
||||
];
|
||||
|
||||
export const cpRelationTypeLabels = Object.fromEntries(cpRelationTypeOptions);
|
||||
|
||||
export const resourceGrantStatusFilters = [
|
||||
["", "全部状态"],
|
||||
["succeeded", "成功"],
|
||||
|
||||
@ -33,7 +33,12 @@ import {
|
||||
updateResourceGroup,
|
||||
upsertResourceShopItems,
|
||||
} 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 { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
|
||||
import {
|
||||
@ -50,6 +55,11 @@ const pageSize = 50;
|
||||
const optionPageSize = 100;
|
||||
const optionMaxPages = 200;
|
||||
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 emptyResourceForm = (resource = {}) => ({
|
||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||
@ -105,22 +115,31 @@ const emptyGroupWalletAssetItem = (walletAssetType) => ({
|
||||
walletAssetAmount: "",
|
||||
walletAssetType,
|
||||
});
|
||||
const emptyGiftForm = (gift = {}) => ({
|
||||
chargeAssetType: gift.chargeAssetType || "COIN",
|
||||
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
|
||||
effectTypes: gift.effectTypes || [],
|
||||
effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
|
||||
effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
|
||||
enabled: gift.status ? gift.status === "active" : true,
|
||||
giftId: gift.giftId || "",
|
||||
giftTypeCode: gift.giftTypeCode || "normal",
|
||||
name: gift.name || "",
|
||||
presentationJson: gift.presentationJson || "{}",
|
||||
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",
|
||||
});
|
||||
const emptyGiftForm = (gift = {}) => {
|
||||
const giftTypeCode = gift.giftTypeCode || "normal";
|
||||
const presentationJson = gift.presentationJson || "{}";
|
||||
const cpRelationType =
|
||||
giftTypeCode === cpGiftTypeCode
|
||||
? cpRelationTypeFromPresentationJson(presentationJson) || defaultCPRelationType
|
||||
: "";
|
||||
return {
|
||||
chargeAssetType: gift.chargeAssetType || "COIN",
|
||||
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
|
||||
cpRelationType,
|
||||
effectTypes: gift.effectTypes || [],
|
||||
effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
|
||||
effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
|
||||
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) {
|
||||
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) {
|
||||
const items = [];
|
||||
let total = Number.POSITIVE_INFINITY;
|
||||
@ -172,6 +221,33 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option
|
||||
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) {
|
||||
const priceType = resource?.priceType || (resource?.coinPrice > 0 ? "coin" : "");
|
||||
if (priceType === "free") {
|
||||
@ -899,6 +975,37 @@ export function useGiftListPage() {
|
||||
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 = () => {
|
||||
setGiftTypeForm({ items: giftTypes.map(giftTypeToForm) });
|
||||
setGiftTypeDialogOpen(true);
|
||||
@ -1037,7 +1144,9 @@ export function useGiftListPage() {
|
||||
data: giftData,
|
||||
abilities,
|
||||
activeAction,
|
||||
changeGiftCPRelationType,
|
||||
changeGiftPrice,
|
||||
changeGiftFormTypeCode,
|
||||
changeRegionId: resetSetter(setRegionId, setPage),
|
||||
changeStatus: resetSetter(setStatus, setPage),
|
||||
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
|
||||
@ -1503,7 +1612,7 @@ function buildGiftPayload(form) {
|
||||
giftId: form.giftId.trim(),
|
||||
giftTypeCode: form.giftTypeCode,
|
||||
name: form.name.trim(),
|
||||
presentationJson: form.presentationJson?.trim() || "{}",
|
||||
presentationJson: normalizeGiftPresentationJson(form.presentationJson, form.giftTypeCode, form.cpRelationType),
|
||||
priceVersion: form.priceVersion.trim(),
|
||||
regionIds: form.regionIds.map(Number),
|
||||
resourceId: Number(form.resourceId),
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
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", () => {
|
||||
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.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 { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.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 { 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";
|
||||
|
||||
const effectOptions = [
|
||||
@ -64,7 +69,7 @@ const baseColumns = (giftTypeOptions) => [
|
||||
key: "type",
|
||||
label: "类型",
|
||||
width: "minmax(130px, 0.6fr)",
|
||||
render: (gift) => giftTypeLabel(gift.giftTypeCode, giftTypeOptions),
|
||||
render: (gift) => <GiftTypeIdentity gift={gift} giftTypeOptions={giftTypeOptions} />,
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
@ -300,6 +305,7 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
const submitDisabled =
|
||||
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
|
||||
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
|
||||
const isCPGiftType = form.giftTypeCode === "cp";
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
@ -345,7 +351,7 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
required
|
||||
select
|
||||
value={form.giftTypeCode}
|
||||
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
|
||||
onChange={(event) => page.changeGiftFormTypeCode(event.target.value)}
|
||||
>
|
||||
{page.giftTypeOptions.map((item) => (
|
||||
<MenuItem key={item.tabKey} value={item.tabKey}>
|
||||
@ -353,6 +359,22 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
|
||||
</MenuItem>
|
||||
))}
|
||||
</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>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格与排序">
|
||||
@ -644,6 +666,16 @@ function giftTypeLabel(value, options = defaultGiftTypeOptions) {
|
||||
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) {
|
||||
return `金币 · ${formatNumber(gift.coinPrice)}`;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { cpRelationTypeOptions } from "@/features/resources/constants.js";
|
||||
|
||||
const resourceTypes = [
|
||||
"avatar_frame",
|
||||
@ -19,6 +20,7 @@ const badgeForms = ["strip", "tile"];
|
||||
const badgeKinds = ["normal", "level"];
|
||||
const badgeLevelTracks = ["wealth", "game", "charm"];
|
||||
const resourceShopDurations = ["1", "3", "7"];
|
||||
const cpRelationTypes = cpRelationTypeOptions.map(([value]) => value);
|
||||
const optionalWalletAssetTypeSchema = z
|
||||
.preprocess((value) => {
|
||||
if (typeof value !== "string") {
|
||||
@ -170,6 +172,7 @@ export const giftFormSchema = z
|
||||
.object({
|
||||
chargeAssetType: z.enum(["COIN"]),
|
||||
coinPrice: z.union([z.string(), z.number()]),
|
||||
cpRelationType: z.string().trim().optional(),
|
||||
effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
|
||||
effectiveFrom: z.string().optional(),
|
||||
effectiveTo: z.string().optional(),
|
||||
@ -208,6 +211,13 @@ export const giftFormSchema = z
|
||||
path: ["coinPrice"],
|
||||
});
|
||||
}
|
||||
if (value.giftTypeCode === "cp" && !cpRelationTypes.includes(value.cpRelationType)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "请选择关系类型",
|
||||
path: ["cpRelationType"],
|
||||
});
|
||||
}
|
||||
if (effectiveFromMs < 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user