hyapp-admin-platform/src/features/resources/components/GiftResourceSelectDrawer.jsx
2026-07-05 01:31:45 +08:00

399 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import Checkbox from "@mui/material/Checkbox";
import InputAdornment from "@mui/material/InputAdornment";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import {
resourcePriceTypeLabels,
resourceTypeFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { Button } from "@/shared/ui/Button.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import styles from "@/features/resources/components/GiftResourceSelectDrawer.module.css";
const drawerZIndex = 1600;
export function GiftResourceSelectField({
disabled = false,
drawerTitle = "选择资源",
excludedResourceIds = [],
giftTypes = [],
gifts = [],
label = "资源",
loading = false,
onChange = () => {},
placeholder = "请选择资源",
required = true,
resources = [],
selectedResourceIds = [],
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeType, setActiveType] = useState("");
const selectedIds = useMemo(() => selectedResourceIds.map((id) => String(id)), [selectedResourceIds]);
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const excludedIds = useMemo(() => excludedResourceIds.map((id) => String(id)), [excludedResourceIds]);
const excludedIdSet = useMemo(() => new Set(excludedIds), [excludedIds]);
const giftsByResourceId = useMemo(() => {
const items = new Map();
gifts.forEach((gift) => {
if (gift?.resourceId) {
items.set(String(gift.resourceId), gift);
}
});
return items;
}, [gifts]);
const resourceItems = useMemo(
() => buildResourceItems(resources, giftsByResourceId),
[giftsByResourceId, resources],
);
const visibleResourceItems = useMemo(
() =>
resourceItems.filter((item) => {
const resourceId = String(item.resource.resourceId);
return !excludedIdSet.has(resourceId) || selectedIdSet.has(resourceId);
}),
[excludedIdSet, resourceItems, selectedIdSet],
);
const visibleResources = useMemo(() => visibleResourceItems.map((item) => item.resource), [visibleResourceItems]);
const itemCounts = useMemo(() => countResourceItems(visibleResourceItems), [visibleResourceItems]);
const categoryOptions = useMemo(
() => buildCategoryOptions(giftTypes, visibleResources, visibleResourceItems),
[giftTypes, visibleResourceItems, visibleResources],
);
const activeCategory = categoryOptions.some((item) => item.value === activeType)
? activeType
: categoryOptions.find((item) => itemCounts.get(item.value))?.value || categoryOptions[0]?.value || "";
const resourcesById = useMemo(() => {
const items = new Map();
resourceItems.forEach((item) => {
items.set(String(item.resource.resourceId), item);
});
return items;
}, [resourceItems]);
const displayValue = useMemo(() => {
if (!selectedIds.length) {
return "";
}
const labels = selectedIds.map((resourceId) => resourceName(resourcesById.get(resourceId), resourceId));
if (labels.length <= 3) {
return labels.join("");
}
return `${labels.slice(0, 3).join("")}${labels.length} 个资源`;
}, [resourcesById, selectedIds]);
const filteredResources = useMemo(() => {
const keyword = query.trim().toLowerCase();
return visibleResourceItems.filter((item) => {
if (activeCategory && item.categoryValue !== activeCategory) {
return false;
}
if (!keyword) {
return true;
}
const resource = item.resource || {};
const gift = item.gift || {};
const haystack = [
resource.name,
resource.resourceCode,
resource.resourceId,
resource.resourceType,
gift.name,
gift.giftId,
gift.giftTypeCode,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(keyword);
});
}, [activeCategory, query, visibleResourceItems]);
const openDrawer = () => {
if (!disabled && !loading) {
setOpen(true);
}
};
const toggleResource = (item) => {
if (!item?.resource?.resourceId || disabled) {
return;
}
const resourceId = String(item.resource.resourceId);
if (selectedIdSet.has(resourceId)) {
onChange(selectedIds.filter((selectedId) => selectedId !== resourceId));
return;
}
onChange([...selectedIds, resourceId]);
};
return (
<>
<TextField
fullWidth
className={styles.field}
disabled={disabled || loading}
label={label}
placeholder={loading ? "资源加载中" : placeholder}
required={required}
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
value={displayValue}
onClick={openDrawer}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer();
}
}}
/>
<SideDrawer
actions={
<Button type="button" variant="primary" onClick={() => setOpen(false)}>
完成
</Button>
}
className={styles.drawer}
contentClassName={styles.drawerBody}
drawerProps={{ sx: { zIndex: drawerZIndex } }}
open={open}
title={drawerTitle}
width="wide"
onClose={() => setOpen(false)}
>
<div className={styles.filters}>
<TextField
className={styles.search}
placeholder="搜索资源名称、ID"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchOutlined fontSize="small" />
</InputAdornment>
),
},
}}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{categoryOptions.length ? (
<Tabs
aria-label="资源分类"
className={styles.tabs}
value={activeCategory}
variant="scrollable"
onChange={(_, value) => setActiveType(value)}
>
{categoryOptions.map((item) => (
<Tab key={item.value} label={item.label} value={item.value} />
))}
</Tabs>
) : null}
</div>
<div className={styles.grid}>
{filteredResources.map((item) => {
const resourceId = String(item.resource.resourceId);
const selected = selectedIdSet.has(resourceId);
const name = resourceName(item);
return (
<button
aria-pressed={selected}
className={[styles.card, selected ? styles.cardSelected : ""]
.filter(Boolean)
.join(" ")}
key={resourceId}
type="button"
onClick={() => toggleResource(item)}
>
<Checkbox
checked={selected}
className={styles.checkbox}
inputProps={{
"aria-label": `${selected ? "取消选择" : "选择"} ${name}`,
readOnly: true,
}}
tabIndex={-1}
/>
<ResourceThumb item={item} />
<span className={styles.name}>{name}</span>
<span className={styles.meta}>
{item.resource.resourceCode || `资源 ${item.resource.resourceId || "-"}`}
</span>
<span className={styles.tags}>
<span className={styles.tag}>{categoryLabel(item, categoryOptions)}</span>
<span className={styles.tag}>{resourcePriceLabel(item)}</span>
</span>
</button>
);
})}
{!filteredResources.length ? <div className={styles.empty}>当前无可选资源</div> : null}
</div>
</SideDrawer>
</>
);
}
function buildResourceItems(resources, giftsByResourceId) {
const seen = new Set();
return resources
.filter((resource) => resource?.resourceId)
.filter((resource) => {
const resourceId = String(resource.resourceId);
if (seen.has(resourceId)) {
return false;
}
seen.add(resourceId);
return true;
})
.map((resource) => {
const gift = giftsByResourceId.get(String(resource.resourceId));
const category = categoryForResource(resource, gift);
return {
categoryLabel: category.label,
categoryValue: category.value,
gift,
resource,
};
});
}
function buildCategoryOptions(giftTypes, resources, resourceItems) {
const options = [];
const resourceTypes = new Set(resources.map((resource) => resource?.resourceType).filter(Boolean));
resourceTypeFilters.forEach(([type, label], index) => {
if (!type) {
return;
}
if (type === "gift") {
options.push(...buildGiftCategoryOptions(giftTypes, resourceItems, index));
if (resourceItems.some((item) => item.categoryValue === "resource:gift")) {
options.push({ label: resourceTypeLabels.gift || "礼物", order: index * 100, value: "resource:gift" });
}
return;
}
options.push({ label, order: index * 100, value: `resource:${type}` });
resourceTypes.delete(type);
});
resourceTypes.forEach((type) => {
options.push({
label: resourceTypeLabels[type] || type,
order: options.length * 100,
value: `resource:${type}`,
});
});
return dedupeCategories(options);
}
function buildGiftCategoryOptions(giftTypes, resourceItems, resourceTypeOrder) {
const options = [];
const knownGiftTypes = new Set();
giftTypes
.filter((item) => item?.status !== "disabled")
.forEach((item) => {
const value = String(item.tabKey || item.typeCode || "").trim();
if (!value) {
return;
}
knownGiftTypes.add(value);
options.push({
label: item.displayName || item.tabName || value,
order: resourceTypeOrder * 100 + Number(item.sortOrder || 0),
value: `gift:${value}`,
});
});
resourceItems.forEach((item) => {
const value = String(item.gift?.giftTypeCode || "").trim();
if (value && !knownGiftTypes.has(value)) {
knownGiftTypes.add(value);
options.push({
label: value,
order: resourceTypeOrder * 100 + 1000 + options.length,
value: `gift:${value}`,
});
}
});
return options;
}
function dedupeCategories(options) {
const byValue = new Map();
options.forEach((item) => {
if (!byValue.has(item.value)) {
byValue.set(item.value, item);
}
});
return Array.from(byValue.values()).sort((left, right) => left.order - right.order);
}
function categoryForResource(resource, gift) {
if (resource.resourceType === "gift" && gift?.giftTypeCode) {
return { value: `gift:${gift.giftTypeCode}` };
}
const type = resource.resourceType || "unknown";
return {
label: resourceTypeLabels[type] || type,
value: `resource:${type}`,
};
}
function categoryLabel(item, categoryOptions) {
return categoryOptions.find((option) => option.value === item.categoryValue)?.label || item.categoryLabel || "资源";
}
function countResourceItems(resourceItems) {
const counts = new Map();
resourceItems.forEach((item) => {
counts.set(item.categoryValue, (counts.get(item.categoryValue) || 0) + 1);
});
return counts;
}
function ResourceThumb({ item }) {
const imageUrl = imageURL(item.resource.previewUrl || item.resource.assetUrl || item.resource.animationUrl);
return (
<span className={styles.thumb}>
{imageUrl ? (
<img alt="" src={imageUrl} />
) : item.resource.resourceType === "gift" ? (
<CardGiftcardOutlined fontSize="small" />
) : (
<Inventory2Outlined fontSize="small" />
)}
</span>
);
}
function resourceName(item, fallbackResourceId = "") {
const resource = item?.resource || {};
const gift = item?.gift || {};
return gift.name || resource.name || resource.resourceCode || `资源 #${fallbackResourceId || resource.resourceId || "-"}`;
}
function resourcePriceLabel(item = {}) {
const resource = item.resource || {};
const gift = item.gift || {};
if (resource.resourceType === "gift" && (gift.chargeAssetType === "COIN" || Number(gift.coinPrice || 0) > 0)) {
return `${formatNumber(gift.coinPrice)}金币`;
}
if (resource.priceType === "coin") {
return `${formatNumber(resource.coinPrice)}金币`;
}
if (resource.priceType === "free") {
return resourcePriceTypeLabels.free;
}
return "未配置价格";
}
function imageURL(value) {
const url = String(value || "").trim();
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}