资源发放
This commit is contained in:
parent
c33fb469d9
commit
0112303b88
@ -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)}天`;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user