资源发放

This commit is contained in:
zhx 2026-06-26 12:58:27 +08:00
parent c33fb469d9
commit 0112303b88
3 changed files with 318 additions and 39 deletions

View File

@ -1,4 +1,5 @@
import Add from "@mui/icons-material/Add";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import UndoOutlined from "@mui/icons-material/UndoOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
@ -17,9 +18,11 @@ import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/t
import { formatMillis } from "@/shared/utils/time.js";
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
import {
resourceGroupAssetLabels,
resourceGrantStatusFilters,
resourceGrantStatusLabels,
resourceGrantSubjectLabels,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { useResourceGrantListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
@ -27,12 +30,11 @@ import styles from "@/features/resources/resources.module.css";
const grantColumns = [
{
key: "grant",
label: "赠送记录",
width: "minmax(240px, 1.2fr)",
label: "来源",
width: "minmax(220px, 1fr)",
render: (grant) => (
<div className={styles.stack}>
<span className={styles.name}>{grant.grantId || "-"}</span>
<span className={styles.meta}>{grant.commandId || "-"}</span>
<span className={styles.name}>{grantSourceLabel(grant.grantSource)}</span>
</div>
),
},
@ -45,24 +47,19 @@ const grantColumns = [
{
key: "operator",
label: "操作人",
width: "minmax(180px, 1fr)",
width: "minmax(190px, 0.9fr)",
render: (grant) => <GrantOperator grant={grant} />,
},
{
key: "subject",
label: "对象",
width: "minmax(160px, 0.8fr)",
render: (grant) => (
<div className={styles.stack}>
<span>{resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "-"}</span>
<span className={styles.meta}>{grant.grantSubjectId || "-"}</span>
</div>
),
label: "发放方式",
width: "minmax(170px, 0.75fr)",
render: (grant) => <GrantSubject grant={grant} />,
},
{
key: "items",
label: "发放内容",
width: "minmax(180px, 1fr)",
width: "minmax(320px, 1.5fr)",
render: (grant) => <GrantItems grant={grant} />,
},
{
@ -79,8 +76,8 @@ const grantColumns = [
{
key: "reason",
label: "原因",
width: "minmax(160px, 1fr)",
render: (grant) => grant.reason || "-",
width: "minmax(190px, 0.95fr)",
render: (grant) => <GrantReason grant={grant} />,
},
{
key: "time",
@ -143,7 +140,7 @@ export function ResourceGrantListPage() {
<DataTable
columns={columns}
items={items}
minWidth="1460px"
minWidth="1560px"
pagination={
total > 0
? {
@ -347,14 +344,53 @@ function GrantItems({ grant }) {
return "-";
}
return (
<div className={styles.stack}>
{items.slice(0, 2).map((item) => (
<span key={item.grantItemId || `${grant.grantId}-${item.resourceId}`}>
{item.resourceId || "-"} / {item.quantity || 0}
{item.durationMs ? ` / ${formatDuration(item.durationMs)}` : ""}
</span>
<div className={styles.grantMediaScroller}>
{items.map((item) => (
<GrantItemMedia item={item} key={item.grantItemId || `${grant.grantId}-${item.resourceId}`} />
))}
{items.length > 2 ? <span className={styles.meta}>+{items.length - 2}</span> : null}
</div>
);
}
function GrantItemMedia({ item }) {
const snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson);
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
if (walletAssetType) {
const label = resourceGroupAssetLabels[walletAssetType] || walletAssetType;
const amount = snapshotValue(snapshot, "walletAssetAmount", "wallet_asset_amount", "WalletAssetAmount", "quantity");
return <span className={styles.grantAssetPill}>{`${label} ${formatNumber(amount || item.quantity)}`}</span>;
}
const label = grantItemLabel(item, snapshot);
const imageUrl = grantItemImageUrl(snapshot);
return (
<span className={styles.grantMediaCard} title={label}>
<span className={styles.grantMediaThumb}>
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
</span>
<span className={styles.grantMediaName}>{label}</span>
</span>
);
}
function GrantSubject({ grant }) {
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
const sourceLabel = grantSourceLabel(grant.grantSource);
return (
<div className={styles.stack}>
<span className={styles.name}>{sourceLabel === "-" ? subjectLabel : `${subjectLabel} · ${sourceLabel}`}</span>
<span className={styles.meta}>{subjectMeta(grant)}</span>
</div>
);
}
function GrantReason({ grant }) {
const label = reasonLabel(grant.reason);
const raw = String(grant.reason || "").trim();
return (
<div className={styles.stack}>
<span className={styles.name}>{label}</span>
{raw && raw !== label ? <span className={styles.meta}>{raw}</span> : null}
</div>
);
}
@ -384,26 +420,115 @@ function GrantOperator({ grant }) {
/>
);
}
if (source === "admin") {
const name = operator.name || operator.username || (operatorUserId ? `Admin ${operatorUserId}` : "-");
return (
<div className={styles.stack}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>{operator.username || operatorUserId || "-"}</span>
</div>
);
return "-";
}
const grantSourceLabels = {
achievement: "成就奖励",
admin: "后台发放",
agency_opening: "Agency 开通",
cp_weekly_rank: "CP 周榜奖励",
cumulative_recharge_reward: "累充奖励",
first_recharge_reward: "首充奖励",
game_robot_init: "机器人初始化",
growth_level: "成长等级奖励",
manager_center: "经理中心",
registration_reward: "注册奖励",
resource_shop: "道具商店",
room_rocket: "房间火箭",
seven_day_checkin: "七日签到",
weekly_star: "周星奖励",
wheel_reward: "转盘奖励",
};
const grantReasonLabels = {
...grantSourceLabels,
growth_level_registration_badge: "注册成长等级徽章",
"resource shop purchase": "道具商店购买",
};
function grantSourceLabel(source) {
const key = String(source || "").trim();
return grantSourceLabels[key] || key || "-";
}
function reasonLabel(reason) {
const key = String(reason || "").trim();
return grantReasonLabels[key] || key || "-";
}
function subjectMeta(grant) {
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "对象";
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
}
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
if (walletAssetType) {
const label = resourceGroupAssetLabels[walletAssetType] || walletAssetType;
const amount = snapshotValue(snapshot, "walletAssetAmount", "wallet_asset_amount", "WalletAssetAmount", "quantity");
return `${label} ${formatNumber(amount || item.quantity)}`;
}
if (!operatorUserId) {
return "-";
const resourceName = snapshotValue(snapshot, "name", "Name", "resourceName", "resource_name") || `资源 ${item.resourceId || "-"}`;
const resourceType = snapshotValue(snapshot, "resourceType", "resource_type", "ResourceType");
const typeLabel = resourceTypeLabels[resourceType] || resourceType || "资源";
const quantity = Number(item.quantity || 0);
const parts = [`${resourceName} x${formatNumber(quantity)}`, typeLabel];
if (item.durationMs) {
parts.push(formatDuration(item.durationMs));
}
return (
<div className={styles.stack}>
<span className={styles.name}>{operatorUserId}</span>
<span className={styles.meta}>{source || "-"}</span>
</div>
return parts.join(" · ");
}
function grantItemImageUrl(snapshot) {
return imageURL(
snapshotValue(
snapshot,
"previewUrl",
"preview_url",
"PreviewURL",
"assetUrl",
"asset_url",
"AssetURL",
"animationUrl",
"animation_url",
"AnimationURL",
),
);
}
function parseGrantItemSnapshot(value) {
if (!value || typeof value !== "string") {
return {};
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function snapshotValue(snapshot, ...keys) {
for (const key of keys) {
const value = snapshot?.[key];
if (value !== undefined && value !== null && value !== "") {
return value;
}
}
return "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function imageURL(value) {
const url = String(value || "").trim();
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatDuration(durationMs) {
const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000);
return Number.isInteger(days) ? `${days}` : `${days.toFixed(1)}`;

View File

@ -65,6 +65,91 @@ test("resource grant list hides revoke without permission", () => {
expect(screen.queryByLabelText("撤销")).not.toBeInTheDocument();
});
test("resource grant list renders readable source, reason, and granted items", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
data: {
items: [
grantFixture({
grantId: "rgr_1234567890abcdef",
commandId: "seven_day_checkin_1782448934991_9b39c38",
grantSource: "seven_day_checkin",
grantSubjectId: "18",
grantSubjectType: "resource_group",
items: [
{
grantItemId: 1,
quantity: 100,
resourceId: 0,
resourceSnapshotJson: JSON.stringify({
wallet_asset_type: "COIN",
wallet_asset_amount: 100,
}),
},
{
durationMs: 7 * 24 * 60 * 60 * 1000,
grantItemId: 2,
quantity: 1,
resourceId: 385,
resourceSnapshotJson: JSON.stringify({
Name: "星光头像框",
PreviewURL: "https://media.haiyihy.com/star-frame.png",
ResourceType: "avatar_frame",
}),
},
],
operator: { name: "后台用户", source: "admin", userId: "7" },
reason: "seven_day_checkin",
}),
],
page: 1,
pageSize: 50,
total: 1,
},
}),
);
render(<ResourceGrantListPage />);
expect(screen.getAllByText("七日签到")).toHaveLength(2);
expect(screen.getByText("资源组 · 七日签到")).toBeInTheDocument();
expect(screen.getByText("资源组 ID 18")).toBeInTheDocument();
expect(screen.getByText("金币 100")).toBeInTheDocument();
expect(screen.getByText("星光头像框 x1 · 头像框 · 7天")).toBeInTheDocument();
expect(screen.queryByText("0 / 100")).not.toBeInTheDocument();
expect(screen.queryByText("rgr_1234567890abcdef")).not.toBeInTheDocument();
expect(screen.queryByText("seven_day_checkin_1782448934991_9b39c38")).not.toBeInTheDocument();
expect(screen.queryByText("后台用户")).not.toBeInTheDocument();
expect(document.querySelector('img[src="https://media.haiyihy.com/star-frame.png"]')).toBeInTheDocument();
});
test("resource grant list keeps operator only for user initiated manager grants", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
data: {
items: [
grantFixture({
grantId: "rgr_manager",
grantSource: "manager_center",
operator: {
source: "manager_center",
userId: "328151620022083584",
username: "Manager A",
},
}),
],
page: 1,
pageSize: 50,
total: 1,
},
}),
);
render(<ResourceGrantListPage />);
expect(screen.getByText("Manager A")).toBeInTheDocument();
});
function pageFixture(patch = {}) {
return {
abilities: {

View File

@ -100,6 +100,75 @@
white-space: nowrap;
}
.grantMediaScroller {
display: flex;
min-width: 0;
max-width: 100%;
align-items: stretch;
gap: var(--space-2);
overflow-x: auto;
overscroll-behavior-x: contain;
padding-bottom: 2px;
scrollbar-width: thin;
}
.grantMediaCard {
display: grid;
width: 72px;
flex: 0 0 72px;
min-width: 0;
justify-items: center;
gap: 4px;
}
.grantMediaThumb {
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.grantMediaThumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.grantMediaName {
width: 100%;
min-width: 0;
overflow: hidden;
color: var(--text-secondary);
font-size: 12px;
font-weight: 650;
line-height: 1.25;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.grantAssetPill {
display: inline-flex;
height: var(--admin-tag-height);
flex: 0 0 auto;
align-items: center;
border: 1px solid var(--admin-tag-border);
border-radius: var(--admin-tag-radius);
background: var(--admin-tag-bg);
color: var(--admin-tag-color);
font-size: var(--admin-tag-font-size);
font-weight: var(--admin-tag-font-weight);
line-height: 1;
padding: 0 var(--admin-tag-padding-x);
white-space: nowrap;
}
.identityGrantLabel {
display: inline-flex;
min-height: var(--control-height);