("/api/v1/admin/operations/gift-diamond-ratios", {
+ body: payload,
+ method: "PUT",
+ });
+}
diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css
index 8c76bd6..71ab1f4 100644
--- a/src/features/operations/operations.module.css
+++ b/src/features/operations/operations.module.css
@@ -185,6 +185,37 @@
background: var(--bg-card-strong);
}
+.ratioPanel {
+ display: grid;
+ gap: var(--space-5);
+ max-width: 760px;
+}
+
+.ratioHeader {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ color: var(--text-primary);
+}
+
+.ratioHeader h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.ratioGrid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(160px, 1fr));
+ gap: var(--space-4);
+}
+
+@media (max-width: 720px) {
+ .ratioGrid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
.reportTargetType {
display: inline-flex;
align-items: center;
diff --git a/src/features/operations/pages/GiftDiamondRatioPage.jsx b/src/features/operations/pages/GiftDiamondRatioPage.jsx
new file mode 100644
index 0000000..750953e
--- /dev/null
+++ b/src/features/operations/pages/GiftDiamondRatioPage.jsx
@@ -0,0 +1,150 @@
+import PercentOutlined from "@mui/icons-material/PercentOutlined";
+import SaveOutlined from "@mui/icons-material/SaveOutlined";
+import MenuItem from "@mui/material/MenuItem";
+import TextField from "@mui/material/TextField";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { getGiftDiamondRatios, updateGiftDiamondRatios } from "@/features/operations/api";
+import { useGiftDiamondAbilities } from "@/features/operations/permissions.js";
+import styles from "@/features/operations/operations.module.css";
+import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
+import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
+import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
+import { Button } from "@/shared/ui/Button.jsx";
+import { DataState } from "@/shared/ui/DataState.jsx";
+import { useToast } from "@/shared/ui/ToastProvider.jsx";
+
+const giftTypes = [
+ { label: "普通礼物", value: "normal" },
+ { label: "Lucky 礼物", value: "lucky" },
+ { label: "Super Lucky 礼物", value: "super_lucky" },
+];
+
+const defaultRatios = {
+ normal: "100.00",
+ lucky: "100.00",
+ super_lucky: "100.00",
+};
+
+export function GiftDiamondRatioPage() {
+ const abilities = useGiftDiamondAbilities();
+ const { showToast } = useToast();
+ const { loadingRegions, regionOptions } = useRegionOptions();
+ const [regionId, setRegionId] = useState("0");
+ const [form, setForm] = useState(defaultRatios);
+ const [saving, setSaving] = useState(false);
+
+ const queryFn = useCallback(() => getGiftDiamondRatios(regionId), [regionId]);
+ const query = useAdminQuery(queryFn, {
+ errorMessage: "获取礼物钻石比例失败",
+ queryKey: ["gift-diamond-ratios", regionId],
+ });
+
+ useEffect(() => {
+ const next = { ...defaultRatios };
+ for (const item of query.data?.items || []) {
+ if (Object.prototype.hasOwnProperty.call(next, item.giftTypeCode)) {
+ next[item.giftTypeCode] = item.ratioPercent || "100.00";
+ }
+ }
+ setForm(next);
+ }, [query.data]);
+
+ const selectedRegionLabel = useMemo(() => {
+ if (regionId === "0") {
+ return "全局默认";
+ }
+ return regionOptions.find((option) => option.value === regionId)?.label || `区域 ${regionId}`;
+ }, [regionId, regionOptions]);
+
+ const changeRatio = (giftType, value) => {
+ setForm((current) => ({ ...current, [giftType]: value }));
+ };
+
+ const submit = async () => {
+ const payload = {};
+ for (const giftType of giftTypes) {
+ const value = String(form[giftType.value] || "").trim();
+ const numeric = Number(value);
+ if (!value || !Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
+ showToast(`${giftType.label}比例必须在 0-100 之间`, "error");
+ return;
+ }
+ payload[giftType.value] = numeric.toFixed(2);
+ }
+ setSaving(true);
+ try {
+ const result = await updateGiftDiamondRatios({ regionId: Number(regionId), ratios: payload });
+ const next = { ...defaultRatios };
+ for (const item of result.items || []) {
+ if (Object.prototype.hasOwnProperty.call(next, item.giftTypeCode)) {
+ next[item.giftTypeCode] = item.ratioPercent || "100.00";
+ }
+ }
+ setForm(next);
+ showToast("礼物钻石比例已保存", "success");
+ await query.reload();
+ } catch (err) {
+ showToast(err.message || "保存礼物钻石比例失败", "error");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ } variant="primary" onClick={submit}>
+ 保存
+
+ ) : null
+ }
+ filters={
+ setRegionId(event.target.value)}
+ sx={{ minWidth: 260 }}
+ >
+
+ {regionOptions.map((option) => (
+
+ ))}
+
+ }
+ />
+
+
+
+
+
+
+
{selectedRegionLabel}
+
+
+
+ {giftTypes.map((giftType) => (
+ changeRatio(giftType.value, event.target.value)}
+ helperText="%"
+ />
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/features/operations/permissions.js b/src/features/operations/permissions.js
index 569ee27..8906226 100644
--- a/src/features/operations/permissions.js
+++ b/src/features/operations/permissions.js
@@ -9,3 +9,12 @@ export function useCoinAdjustmentAbilities() {
canView: can(PERMISSIONS.coinAdjustmentView),
};
}
+
+export function useGiftDiamondAbilities() {
+ const { can } = useAuth();
+
+ return {
+ canUpdate: can(PERMISSIONS.giftDiamondUpdate),
+ canView: can(PERMISSIONS.giftDiamondView),
+ };
+}
diff --git a/src/features/operations/routes.js b/src/features/operations/routes.js
index e23f83a..19df221 100644
--- a/src/features/operations/routes.js
+++ b/src/features/operations/routes.js
@@ -25,4 +25,12 @@ export const operationsRoutes = [
path: "/operations/reports",
permission: PERMISSIONS.reportView,
},
+ {
+ label: "礼物钻石",
+ loader: () => import("./pages/GiftDiamondRatioPage.jsx").then((module) => module.GiftDiamondRatioPage),
+ menuCode: MENU_CODES.operationGiftDiamond,
+ pageKey: "operation-gift-diamond",
+ path: "/operations/gift-diamonds",
+ permission: PERMISSIONS.giftDiamondView,
+ },
];
diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts
index 1cc5db7..a17e188 100644
--- a/src/features/resources/api.test.ts
+++ b/src/features/resources/api.test.ts
@@ -103,7 +103,7 @@ test("resource APIs use generated admin paths", async () => {
},
{
amount: 1000,
- assetType: "DIAMOND",
+ assetType: "COIN",
itemType: "wallet_asset",
sortOrder: 1,
},
@@ -208,7 +208,7 @@ test("resource APIs use generated admin paths", async () => {
groupCode: "starter_pack_test",
items: [
{ durationDays: 7, itemType: "resource" },
- { amount: 1000, assetType: "DIAMOND" },
+ { amount: 1000, assetType: "COIN" },
],
});
expect(String(updateGroupUrl)).toContain("/api/v1/admin/resource-groups/22");
diff --git a/src/features/resources/batchUpload.js b/src/features/resources/batchUpload.js
index ffb1a04..02830db 100644
--- a/src/features/resources/batchUpload.js
+++ b/src/features/resources/batchUpload.js
@@ -13,9 +13,10 @@ const resourceTypeAliases = new Map([
["麦位动效", "mic_seat_animation"],
["背景卡", "profile_card"],
["资料卡", "profile_card"],
+ ["礼物", "gift"],
]);
-const pricedResourceTypes = new Set(["avatar_frame", "vehicle"]);
+const pricedResourceTypes = new Set(["avatar_frame", "vehicle", "gift"]);
const translationTimeoutMs = 2500;
const localNameTranslations = new Map([
["星光", "starlight"],
diff --git a/src/features/resources/batchUpload.test.js b/src/features/resources/batchUpload.test.js
index 1cf670d..89ca990 100644
--- a/src/features/resources/batchUpload.test.js
+++ b/src/features/resources/batchUpload.test.js
@@ -15,6 +15,8 @@ test("parses folder resource filenames into resource plans", () => {
file("背景卡_夜色_animation.svga"),
file("mic声波_律动_cover.png"),
file("mic声波_律动_animation.svga"),
+ file("礼物_玫瑰_520_cover.png"),
+ file("礼物_玫瑰_520_animation.svga"),
];
const plan = parseResourceFolderFiles(files, 1770000000000);
@@ -25,8 +27,9 @@ test("parses folder resource filenames into resource plans", () => {
["守护", "badge", 0, "strip"],
["夜色", "profile_card", 0, "tile"],
["律动", "mic_seat_animation", 0, "tile"],
+ ["玫瑰", "gift", 520, "tile"],
]);
- expect(plan.resources.map((item) => item.resourceCode)).toEqual(["星光", "守护", "夜色", "律动"]);
+ expect(plan.resources.map((item) => item.resourceCode)).toEqual(["星光", "守护", "夜色", "律动", "玫瑰"]);
expect(resourcePlanToPayload({ ...plan.resources[0], coverUrl: "cover", animationUrl: "animation" })).toMatchObject(
{
@@ -37,6 +40,14 @@ test("parses folder resource filenames into resource plans", () => {
resourceType: "avatar_frame",
},
);
+
+ expect(resourcePlanToPayload({ ...plan.resources[4], coverUrl: "cover", animationUrl: "animation" })).toMatchObject(
+ {
+ coinPrice: 520,
+ priceType: "coin",
+ resourceType: "gift",
+ },
+ );
});
test("silently ignores invalid and unpaired material", () => {
diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js
index f442aa7..17528c4 100644
--- a/src/features/resources/constants.js
+++ b/src/features/resources/constants.js
@@ -100,7 +100,6 @@ export const resourceShopTypeFilters = [
export const resourceGroupAssetOptions = [
["COIN", "金币"],
- ["DIAMOND", "钻石"],
];
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);
diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx
index 0541c11..c9bf145 100644
--- a/src/features/resources/pages/GiftListPage.jsx
+++ b/src/features/resources/pages/GiftListPage.jsx
@@ -625,8 +625,7 @@ function giftPriceLabel(gift) {
if (Number(gift.coinPrice || 0) === 0) {
return "免费";
}
- const chargeAsset = gift.chargeAssetType === "DIAMOND" ? "钻石" : "金币";
- return `${chargeAsset} · ${formatNumber(gift.coinPrice)}`;
+ return `金币 · ${formatNumber(gift.coinPrice)}`;
}
function effectTypeLabel(value) {
diff --git a/src/features/resources/pages/ResourceGroupListPage.jsx b/src/features/resources/pages/ResourceGroupListPage.jsx
index d884b49..2c8f279 100644
--- a/src/features/resources/pages/ResourceGroupListPage.jsx
+++ b/src/features/resources/pages/ResourceGroupListPage.jsx
@@ -1,7 +1,6 @@
import Add from "@mui/icons-material/Add";
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
-import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
@@ -253,13 +252,6 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
>
金币
-
}
>
diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx
index 6510615..3fe6f59 100644
--- a/src/features/resources/pages/ResourceListPage.jsx
+++ b/src/features/resources/pages/ResourceListPage.jsx
@@ -339,6 +339,7 @@ function ResourceBatchRules() {
只读取符合命名规则且同时存在 cover/animation 的资源,其他文件会被忽略。
头像框_名称_价格_天数_cover / animation
坐骑_名称_价格_天数_cover / animation
+ 礼物_名称_价格_cover / animation
气泡_名称_cover / animation
勋章_名称_长_cover / animation
飘窗_名称_cover / animation
diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js
index 4976786..08584c2 100644
--- a/src/features/resources/schema.js
+++ b/src/features/resources/schema.js
@@ -13,7 +13,7 @@ const resourceTypes = [
"mic_seat_animation",
"emoji_pack",
];
-const walletAssetTypes = ["COIN", "DIAMOND"];
+const walletAssetTypes = ["COIN"];
const resourcePriceTypes = ["coin", "free"];
const badgeForms = ["strip", "tile"];
const resourceShopDurations = ["1", "3", "7"];
@@ -25,7 +25,7 @@ const optionalWalletAssetTypeSchema = z
const normalized = value.trim().toUpperCase();
return normalized || undefined;
}, z.string().optional())
- .refine((value) => !value || walletAssetTypes.includes(value), "请选择金币或钻石");
+ .refine((value) => !value || walletAssetTypes.includes(value), "请选择金币");
export const resourceCreateFormSchema = z
.object({
@@ -110,7 +110,7 @@ export const resourceGroupCreateFormSchema = z
if (!item.walletAssetType || !Number.isInteger(amount) || amount <= 0) {
context.addIssue({
code: "custom",
- message: "请输入金币或钻石数量",
+ message: "请输入金币数量",
path: ["items", index, "walletAssetAmount"],
});
}
@@ -156,7 +156,7 @@ export const resourceGroupCreateFormSchema = z
export const giftFormSchema = z
.object({
- chargeAssetType: z.enum(["COIN", "DIAMOND"]),
+ chargeAssetType: z.enum(["COIN"]),
coinPrice: z.union([z.string(), z.number()]),
effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
effectiveFrom: z.string().optional(),
diff --git a/src/shared/ui/ResourceGroupSelectDrawer.jsx b/src/shared/ui/ResourceGroupSelectDrawer.jsx
index 7de22e1..68ea281 100644
--- a/src/shared/ui/ResourceGroupSelectDrawer.jsx
+++ b/src/shared/ui/ResourceGroupSelectDrawer.jsx
@@ -1,5 +1,4 @@
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
-import DiamondOutlined from "@mui/icons-material/DiamondOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import TextField from "@mui/material/TextField";
@@ -130,14 +129,6 @@ function GroupIcon({ group }) {
);
}
- if (item?.itemType === "wallet_asset" && item.walletAssetType === "DIAMOND") {
- return (
-
-
-
- );
- }
-
if (item?.itemType === "resource") {
return (
@@ -182,7 +173,7 @@ function firstGroupItem(group) {
function groupItemLabel(item) {
if (item.itemType === "wallet_asset") {
const label =
- item.walletAssetType === "DIAMOND" ? "钻石" : item.walletAssetType === "COIN" ? "金币" : "钱包资产";
+ item.walletAssetType === "COIN" ? "金币" : "钱包资产";
return `${label} ${formatNumber(item.walletAssetAmount)}`;
}