diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 3f16d28..9c9f7f4 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -7,6 +7,7 @@ import CategoryOutlined from "@mui/icons-material/CategoryOutlined"; import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined"; import FlagOutlined from "@mui/icons-material/FlagOutlined"; +import DiamondOutlined from "@mui/icons-material/DiamondOutlined"; import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; import HistoryOutlined from "@mui/icons-material/HistoryOutlined"; import HubOutlined from "@mui/icons-material/HubOutlined"; @@ -147,6 +148,7 @@ export const fallbackNavigation = [ routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }), routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }), routeNavItem("operation-reports", { icon: FlagOutlined }), + routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }), routeNavItem("lucky-gift", { icon: RedeemOutlined }), ], }, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index a39d24c..0b8dcd9 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -40,6 +40,8 @@ export const PERMISSIONS = { coinAdjustmentView: "coin-adjustment:view", coinAdjustmentCreate: "coin-adjustment:create", reportView: "report:view", + giftDiamondView: "gift-diamond:view", + giftDiamondUpdate: "gift-diamond:update", paymentBillView: "payment-bill:view", paymentProductView: "payment-product:view", paymentProductCreate: "payment-product:create", @@ -173,6 +175,7 @@ export const MENU_CODES = { operationCoinLedger: "operation-coin-ledger", operationCoinAdjustment: "operation-coin-adjustment", operationReports: "operation-reports", + operationGiftDiamond: "operation-gift-diamond", luckyGift: "lucky-gift", activities: "activities", dailyTaskList: "daily-task-list", diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index 497dccc..b09b92d 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -26,7 +26,6 @@ const columns = [ render: (user) => , }, { key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) }, - { key: "diamond", label: "钻石", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.diamond) }, { key: "status", label: "状态", diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts index 8347194..40d0caa 100644 --- a/src/features/operations/api.test.ts +++ b/src/features/operations/api.test.ts @@ -1,12 +1,44 @@ import { afterEach, expect, test, vi } from "vitest"; import { setAccessToken } from "@/shared/api/request"; -import { createCoinAdjustment, listCoinAdjustments, lookupCoinAdjustmentTarget } from "./api"; +import { + createCoinAdjustment, + getGiftDiamondRatios, + listCoinAdjustments, + lookupCoinAdjustmentTarget, + updateGiftDiamondRatios, +} from "./api"; afterEach(() => { setAccessToken(""); vi.unstubAllGlobals(); }); +test("gift diamond ratio APIs use operations paths", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { regionId: 1001, items: [] } }))), + ); + + await getGiftDiamondRatios(1001); + await updateGiftDiamondRatios({ + regionId: 1001, + ratios: { lucky: "40.00", normal: "30.00", super_lucky: "50.00" }, + }); + + const [getUrl, getInit] = vi.mocked(fetch).mock.calls[0]; + const [putUrl, putInit] = vi.mocked(fetch).mock.calls[1]; + + expect(String(getUrl)).toContain("/api/v1/admin/operations/gift-diamond-ratios?"); + expect(String(getUrl)).toContain("region_id=1001"); + expect(getInit?.method).toBe("GET"); + expect(String(putUrl)).toContain("/api/v1/admin/operations/gift-diamond-ratios"); + expect(putInit?.method).toBe("PUT"); + expect(JSON.parse(String(putInit?.body))).toMatchObject({ + regionId: 1001, + ratios: { lucky: "40.00", normal: "30.00", super_lucky: "50.00" }, + }); +}); + test("coin adjustment APIs use generated admin paths", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts index 66ffa16..c44ed7b 100644 --- a/src/features/operations/api.ts +++ b/src/features/operations/api.ts @@ -11,6 +11,20 @@ import type { ReportDto, } from "@/shared/api/types"; +export interface GiftDiamondRatioItem { + effectiveRegionId: number; + giftTypeCode: string; + ratioPercent: string; + regionId: number; + status: string; + updatedAtMs?: number; +} + +export interface GiftDiamondRatioResponse { + regionId: number; + items: GiftDiamondRatioItem[]; +} + export function listCoinLedger(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listCoinLedger; return apiRequest>(apiEndpointPath(API_OPERATIONS.listCoinLedger), { @@ -53,3 +67,20 @@ export function createCoinAdjustment(payload: CoinAdjustmentPayload): Promise { + return apiRequest("/api/v1/admin/operations/gift-diamond-ratios", { + method: "GET", + query: { region_id: regionId }, + }); +} + +export function updateGiftDiamondRatios(payload: { + regionId: number; + ratios: Record; +}): Promise { + return apiRequest("/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 }} + > + 全局默认 · 0 + {regionOptions.map((option) => ( + + {option.label} + + ))} + + } + /> + + +
+
+ +
+

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