From a7509e1f9a188351787e55a3bb04c0efc79be816 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 8 Jun 2026 14:08:14 +0800 Subject: [PATCH] =?UTF-8?q?cp=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.test.jsx | 86 ++++++ src/app/navigation/menu.js | 2 + src/app/permissions.ts | 3 + src/app/router/routeConfig.ts | 2 + src/features/cp-config/api.ts | 122 ++++++++ src/features/cp-config/cp-config.module.css | 136 +++++++++ .../cp-config/hooks/useCPConfigPage.js | 249 ++++++++++++++++ src/features/cp-config/pages/CPConfigPage.jsx | 282 ++++++++++++++++++ src/features/cp-config/permissions.js | 11 + src/features/cp-config/routes.js | 12 + src/features/resources/constants.js | 8 + .../resources/hooks/useResourcePages.js | 145 +++++++-- .../resources/hooks/useResourcePages.test.js | 38 ++- src/features/resources/pages/GiftListPage.jsx | 40 ++- src/features/resources/schema.js | 10 + 15 files changed, 1123 insertions(+), 23 deletions(-) create mode 100644 src/features/cp-config/api.ts create mode 100644 src/features/cp-config/cp-config.module.css create mode 100644 src/features/cp-config/hooks/useCPConfigPage.js create mode 100644 src/features/cp-config/pages/CPConfigPage.jsx create mode 100644 src/features/cp-config/permissions.js create mode 100644 src/features/cp-config/routes.js diff --git a/src/App.test.jsx b/src/App.test.jsx index 63c19e5..d9ffa96 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -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) { ); } + +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", + })), + }; +} diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 9ea63a7..aa91287 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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 }), ], }, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 88b7255..4237f34 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index d9e1c67..34ec1c3 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -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, diff --git a/src/features/cp-config/api.ts b/src/features/cp-config/api.ts new file mode 100644 index 0000000..cc20a60 --- /dev/null +++ b/src/features/cp-config/api.ts @@ -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 { + return apiRequest(path).then(normalizeConfig); +} + +export function updateCPConfig(payload: CPConfigPayload): Promise { + return apiRequest(path, { body: payload, method: "PUT" }).then(normalizeConfig); +} + +type RawConfig = CPConfigDto & Record; +type RawRelation = CPRelationConfigDto & Record; +type RawLevel = CPLevelDto & Record; + +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 { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +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; +} diff --git a/src/features/cp-config/cp-config.module.css b/src/features/cp-config/cp-config.module.css new file mode 100644 index 0000000..41a6e14 --- /dev/null +++ b/src/features/cp-config/cp-config.module.css @@ -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; + } +} diff --git a/src/features/cp-config/hooks/useCPConfigPage.js b/src/features/cp-config/hooks/useCPConfigPage.js new file mode 100644 index 0000000..b74c04f --- /dev/null +++ b/src/features/cp-config/hooks/useCPConfigPage.js @@ -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", + }; + }); +} diff --git a/src/features/cp-config/pages/CPConfigPage.jsx b/src/features/cp-config/pages/CPConfigPage.jsx new file mode 100644 index 0000000..2c39eb1 --- /dev/null +++ b/src/features/cp-config/pages/CPConfigPage.jsx @@ -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 ( + +
+
+ {relations.length || 3} + {page.activeRelationCount} + {relationMax(relations, "brother")} + {relationMax(relations, "sister")} +
+
+ + {page.abilities.canUpdate ? ( + + ) : null} +
+
+ + + item.relationType} /> + + + +
+ ); +} + +function CPConfigDrawer({ page }) { + const disabled = !page.abilities.canUpdate || page.saving || page.loading; + return ( + +
+

CP配置

+
+
关系与等级
+
+ {(page.form.relations || []).map((relation) => ( + + ))} +
+
+
+ + +
+
+
+ ); +} + +function RelationEditor({ disabled, relation, updateLevel, updateRelation }) { + const relationType = relation.relationType; + const maxCountDisabled = disabled || relationType === "cp"; + + return ( +
+
+ {relation.displayName || cpRelationLabels[relationType]} + + updateRelation(relationType, { status: event.target.checked ? "active" : "disabled" }) + } + /> +
+
+ updateRelation(relationType, { maxCountPerUser: event.target.value })} + /> + updateRelation(relationType, { applicationExpireHours: event.target.value })} + /> +
+
+ {(relation.levels || []).map((level) => ( + + ))} +
+
+ ); +} + +function LevelEditor({ disabled, level, relationType, updateLevel }) { + return ( +
+
+ {level.level}级 + + updateLevel(relationType, level.level, { + status: event.target.checked ? "active" : "disabled", + }) + } + /> +
+
+ + updateLevel(relationType, level.level, { intimacyThreshold: event.target.value }) + } + /> + + updateLevel(relationType, level.level, { rewardResourceGroupId: event.target.value }) + } + /> + updateLevel(relationType, level.level, { levelIconUrl: event.target.value })} + /> +
+
+ ); +} + +function cpConfigColumns() { + return [ + { + key: "relation", + label: "关系", + width: "minmax(140px, 0.45fr)", + render: (item) => ( +
+ {item.displayName || cpRelationLabels[item.relationType]} + {item.relationType} +
+ ), + }, + { + 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) => , + }, + { + key: "status", + label: "状态", + width: "minmax(110px, 0.4fr)", + render: (item) => , + }, + { + key: "updatedAt", + label: "更新时间", + width: "minmax(180px, 0.6fr)", + render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"), + }, + ]; +} + +function LevelPreview({ levels }) { + return ( +
+ {levels.map((level) => ( + + {level.level}级 {formatNumber(level.intimacyThreshold)} + + ))} +
+ ); +} + +function StatusBadge({ status }) { + const active = status === "active"; + return ( + + {active ? "启用" : "停用"} + + ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +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"); +} diff --git a/src/features/cp-config/permissions.js b/src/features/cp-config/permissions.js new file mode 100644 index 0000000..4920d10 --- /dev/null +++ b/src/features/cp-config/permissions.js @@ -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), + }; +} diff --git a/src/features/cp-config/routes.js b/src/features/cp-config/routes.js new file mode 100644 index 0000000..ebe78f4 --- /dev/null +++ b/src/features/cp-config/routes.js @@ -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, + }, +]; diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 3ea3354..258676d 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -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", "成功"], diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 61aeda5..d10f865 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -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), diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js index 9ac8218..6dbda4c 100644 --- a/src/features/resources/hooks/useResourcePages.test.js +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -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 }); +}); diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index f506ecc..99d9a6a 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -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) => , }, { 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 ( page.setForm({ ...form, giftTypeCode: event.target.value })} + onChange={(event) => page.changeGiftFormTypeCode(event.target.value)} > {page.giftTypeOptions.map((item) => ( @@ -353,6 +359,22 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open ))} + {isCPGiftType ? ( + page.changeGiftCPRelationType(event.target.value)} + > + {cpRelationTypeOptions.map(([value, label]) => ( + + {label} + + ))} + + ) : null} @@ -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 ( +
+ {giftTypeLabel(gift.giftTypeCode, giftTypeOptions)} + {relationType ? {cpRelationTypeLabels[relationType] || relationType} : null} +
+ ); +} + function giftPriceLabel(gift) { return `金币 · ${formatNumber(gift.coinPrice)}`; } diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 28ab24a..42ab543 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -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",