This commit is contained in:
zhx 2026-06-08 14:08:14 +08:00
parent 4b4054a4d0
commit a7509e1f9a
15 changed files with 1123 additions and 23 deletions

View File

@ -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",
})),
};
}

View File

@ -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 }),
],
},

View File

@ -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",

View File

@ -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,

View 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;
}

View 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;
}
}

View 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",
};
});
}

View 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");
}

View 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),
};
}

View 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,
},
];

View File

@ -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", "成功"],

View File

@ -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),

View File

@ -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 });
});

View File

@ -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)}`;
}

View File

@ -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",