去掉普通用户的钻石钱包

This commit is contained in:
zhx 2026-06-02 17:24:22 +08:00
parent 38a417a8bd
commit 79ed201975
18 changed files with 290 additions and 31 deletions

View File

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

View File

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

View File

@ -26,7 +26,6 @@ const columns = [
render: (user) => <UserIdentity user={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: "状态",

View File

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

View File

@ -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<ApiPage<CoinLedgerEntryDto>> {
const endpoint = API_ENDPOINTS.listCoinLedger;
return apiRequest<ApiPage<CoinLedgerEntryDto>>(apiEndpointPath(API_OPERATIONS.listCoinLedger), {
@ -53,3 +67,20 @@ export function createCoinAdjustment(payload: CoinAdjustmentPayload): Promise<Co
},
);
}
export function getGiftDiamondRatios(regionId: string | number): Promise<GiftDiamondRatioResponse> {
return apiRequest<GiftDiamondRatioResponse>("/api/v1/admin/operations/gift-diamond-ratios", {
method: "GET",
query: { region_id: regionId },
});
}
export function updateGiftDiamondRatios(payload: {
regionId: number;
ratios: Record<string, string>;
}): Promise<GiftDiamondRatioResponse> {
return apiRequest<GiftDiamondRatioResponse, typeof payload>("/api/v1/admin/operations/gift-diamond-ratios", {
body: payload,
method: "PUT",
});
}

View File

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

View File

@ -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 (
<AdminListPage>
<AdminListToolbar
actions={
abilities.canUpdate ? (
<Button disabled={saving || query.loading} startIcon={<SaveOutlined />} variant="primary" onClick={submit}>
保存
</Button>
) : null
}
filters={
<TextField
select
label="区域"
size="small"
value={regionId}
onChange={(event) => setRegionId(event.target.value)}
sx={{ minWidth: 260 }}
>
<MenuItem value="0">全局默认 · 0</MenuItem>
{regionOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
}
/>
<DataState error={query.error} loading={query.loading || loadingRegions} onRetry={query.reload}>
<AdminListBody>
<section className={styles.ratioPanel}>
<div className={styles.ratioHeader}>
<PercentOutlined fontSize="small" />
<div>
<h2>{selectedRegionLabel}</h2>
</div>
</div>
<div className={styles.ratioGrid}>
{giftTypes.map((giftType) => (
<TextField
key={giftType.value}
label={giftType.label}
type="number"
size="small"
value={form[giftType.value] || ""}
inputProps={{ max: 100, min: 0, step: "0.01" }}
disabled={!abilities.canUpdate || saving}
onChange={(event) => changeRatio(giftType.value, event.target.value)}
helperText="%"
/>
))}
</div>
</section>
</AdminListBody>
</DataState>
</AdminListPage>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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", () => {

View File

@ -100,7 +100,6 @@ export const resourceShopTypeFilters = [
export const resourceGroupAssetOptions = [
["COIN", "金币"],
["DIAMOND", "钻石"],
];
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);

View File

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

View File

@ -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
>
金币
</Button>
<Button
disabled={disabled || loading}
onClick={() => page.addWalletAssetItem("DIAMOND")}
startIcon={<DiamondOutlined fontSize="small" />}
>
钻石
</Button>
</AdminFormInlineActions>
}
>

View File

@ -339,6 +339,7 @@ function ResourceBatchRules() {
<div>只读取符合命名规则且同时存在 cover/animation 的资源其他文件会被忽略</div>
<div>头像框_名称_价格_天数_cover / animation</div>
<div>坐骑_名称_价格_天数_cover / animation</div>
<div>礼物_名称_价格_cover / animation</div>
<div>气泡_名称_cover / animation</div>
<div>勋章_名称_长_cover / animation</div>
<div>飘窗_名称_cover / animation</div>

View File

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

View File

@ -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 (
<span className={styles.groupIcon}>
<DiamondOutlined fontSize="small" />
</span>
);
}
if (item?.itemType === "resource") {
return (
<span className={styles.groupIcon}>
@ -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)}`;
}