import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import CircularProgress from "@mui/material/CircularProgress";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { useRef, useState } from "react";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminRowActions,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
badgeKindLabels,
badgeKindOptions,
badgeLevelTrackLabels,
badgeLevelTrackOptions,
badgeFormLabels,
badgeFormOptions,
resourceBatchUploadRoleLabels,
resourceStatusFilters,
resourcePriceTypeLabels,
resourcePriceTypeOptions,
resourceTypeFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { createResource } from "@/features/resources/api";
import {
parseResourceFolderFiles,
resourceBatchUploadSize,
resourcePlanToPayload,
translateResourceCodes,
} from "@/features/resources/batchUpload.js";
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
import {
detectProfileCardLayout,
mergeProfileCardLayoutMetadata,
profileCardLayoutFromMetadata,
} from "@/features/resources/profileCardLayout.js";
import { uploadFilesBatch } from "@/shared/api/upload";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import styles from "@/features/resources/resources.module.css";
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
const baseColumns = [
{
key: "resource",
label: "资源",
width: "minmax(260px, 1.5fr)",
render: (resource) => ,
},
{
key: "type",
label: "类型",
width: "minmax(120px, 0.7fr)",
render: (resource) => resourceTypeLabels[resource.resourceType] || resource.resourceType || "-",
},
{
key: "price",
label: "价格",
width: "minmax(120px, 0.65fr)",
render: (resource) => resourcePriceLabel(resource),
},
{
key: "badgeForm",
label: "徽章属性",
width: "minmax(120px, 0.65fr)",
render: (resource) => badgeFormLabel(resource),
},
{
key: "status",
label: "状态",
width: "minmax(92px, 0.55fr)",
},
{
key: "time",
label: "更新时间",
width: "minmax(170px, 0.9fr)",
render: (resource) => formatMillis(resource.updatedAtMs || resource.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.4fr)",
},
];
export function ResourceListPage() {
const page = useResourceListPage();
const { showToast } = useToast();
const folderInputRef = useRef(null);
const [batchOpen, setBatchOpen] = useState(false);
const [batchPlan, setBatchPlan] = useState({ errors: [], resources: [] });
const [batchProgress, setBatchProgress] = useState({ label: "", running: false });
const items = page.data.items || [];
const total = page.data.total || 0;
const createDisabled = !page.abilities.canCreate || !page.abilities.canUpload;
const columns = baseColumns.map((column) =>
column.key === "resource"
? {
...column,
filter: createTextColumnFilter({
placeholder: "搜索资源名称、资源编码",
value: page.query,
onChange: page.changeQuery,
}),
}
: column.key === "type"
? {
...column,
filter: createOptionsColumnFilter({
options: resourceTypeFilters,
placeholder: "搜索类型",
value: page.resourceType,
onChange: page.changeResourceType,
}),
}
: column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (resource) => ,
}
: column.key === "actions"
? {
...column,
render: (resource) => ,
}
: column,
);
const openBatchDialog = () => {
setBatchPlan({ errors: [], resources: [] });
setBatchProgress({ label: "", running: false });
setBatchOpen(true);
};
const closeBatchDialog = () => {
if (!batchProgress.running) {
setBatchOpen(false);
}
};
const handleFolderChange = (event) => {
const plan = parseResourceFolderFiles(event.target.files);
event.target.value = "";
setBatchPlan(plan);
setBatchProgress({ label: "", running: false });
};
const submitBatchUpload = async (event) => {
event.preventDefault();
if (!batchPlan.resources.length || batchProgress.running) {
return;
}
const resources = batchPlan.resources.map((item) => ({ ...item }));
const uploadEntries = resources.flatMap((resource, resourceIndex) => [
{ file: resource.coverFile, resourceIndex, role: "cover" },
{ file: resource.animationFile, resourceIndex, role: "animation" },
]);
setBatchProgress({ label: "解析资料卡内容高度", running: true });
try {
for (const resource of resources) {
if (resource.resourceType !== "profile_card") {
continue;
}
try {
const layout = await detectProfileCardLayout(resource.animationFile);
resource.metadataJson = mergeProfileCardLayoutMetadata(resource.metadataJson, layout);
} catch {
showToast(`${resource.name} 内容高度解析失败`, "error");
}
}
setBatchProgress({ label: "上传素材 0/" + uploadEntries.length, running: true });
for (let index = 0; index < uploadEntries.length; index += resourceBatchUploadSize) {
const chunk = uploadEntries.slice(index, index + resourceBatchUploadSize);
setBatchProgress({
label: `上传素材 ${index + 1}-${index + chunk.length}/${uploadEntries.length}`,
running: true,
});
const results = await uploadFilesBatch(chunk.map((entry) => entry.file));
results.forEach((result, resultIndex) => {
const entry = chunk[resultIndex];
resources[entry.resourceIndex][entry.role === "cover" ? "coverUrl" : "animationUrl"] = result.url;
});
}
setBatchProgress({ label: "翻译资源编码", running: true });
const translatedResources = await translateResourceCodes(resources);
for (let index = 0; index < translatedResources.length; index += 1) {
setBatchProgress({ label: `创建资源 ${index + 1}/${translatedResources.length}`, running: true });
await createResource(resourcePlanToPayload(translatedResources[index]));
}
showToast(`批量上传完成,共创建 ${translatedResources.length} 个资源`, "success");
setBatchOpen(false);
setBatchPlan({ errors: [], resources: [] });
await page.reload();
} catch (err) {
showToast(err.message || "批量上传失败", "error");
} finally {
setBatchProgress({ label: "", running: false });
}
};
return (
>
) : null
}
/>
0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(resource) => resource.resourceId}
/>
);
}
function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChange, onSubmit, open, plan, progress }) {
const submitDisabled = disabled || progress.running || !plan.resources.length;
return (
}>
}
title="文件夹资源"
>
{progress.running ? (
{progress.label}
) : null}
);
}
function ResourceBatchRules() {
return (
只读取符合命名规则且同时存在 cover/animation 的资源,其他文件会被忽略。
头像框_名称_价格_天数_cover / animation
坐骑_名称_价格_天数_cover / animation
礼物_名称_价格_cover / animation
气泡_名称_cover / animation
勋章_名称_长_cover / animation
飘窗_名称_cover / animation
mic声波_名称_cover / animation
背景卡_名称_cover / animation
天数字段会忽略;勋章不写“长”时按短徽章处理。
服务端单批最多上传 10 个素材,前端会自动分批串行上传。
);
}
function ResourceBatchPreview({ resources }) {
if (!resources.length) {
return 尚未选择资源文件夹
;
}
return (
资源
类型
价格
素材
{resources.map((resource) => (
{resource.name}
{resourceTypeLabels[resource.resourceType] || resource.resourceType}
{resource.price > 0 ? `金币 ${resource.price}` : "免费"}
{resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation}
))}
);
}
function ResourceRowActions({ page, resource }) {
return (
page.openEditResource(resource)}
>
);
}
function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) {
const submitDisabled = disabled || loading;
const { showToast } = useToast();
const profileCardLayout =
form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null;
const handleProfileCardAnimationSelected = async (file) => {
if (form.resourceType !== "profile_card") {
return;
}
try {
const layout = await detectProfileCardLayout(file);
setForm((previous) =>
previous.resourceType === "profile_card"
? {
...previous,
metadataJson: mergeProfileCardLayoutMetadata(previous.metadataJson, layout),
}
: previous,
);
} catch (err) {
showToast(err.message || "资料卡内容高度解析失败", "error");
}
};
return (
setForm({ ...form, name: event.target.value })}
/>
setForm({ ...form, resourceCode: event.target.value })}
/>
{
const resourceType = event.target.value;
setForm((previous) => ({
...previous,
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
levelTrack: resourceType === "badge" ? previous.levelTrack || "" : "",
metadataJson: resourceType === "profile_card" ? previous.metadataJson : "",
resourceType,
walletAssetAmount: "",
}));
}}
>
{resourceTypeOptions.map(([value, label]) => (
))}
{form.resourceType === "coin" ? (
setForm({ ...form, walletAssetAmount: event.target.value })}
/>
) : null}
{form.resourceType === "badge" ? (
<>
setForm({ ...form, badgeForm: event.target.value })}
>
{badgeFormOptions.map(([value, label]) => (
))}
setForm({
...form,
badgeKind: event.target.value,
levelTrack: event.target.value === "level" ? form.levelTrack : "",
})
}
>
{badgeKindOptions.map(([value, label]) => (
))}
{form.badgeKind === "level" ? (
setForm({ ...form, levelTrack: event.target.value })}
>
{badgeLevelTrackOptions.map(([value, label]) => (
))}
) : null}
>
) : null}
setForm({
...form,
coinPrice: event.target.value === "free" ? "0" : form.coinPrice,
priceType: event.target.value,
})
}
>
{resourcePriceTypeOptions.map(([value, label]) => (
))}
setForm({ ...form, coinPrice: event.target.value })}
/>
setForm((previous) => ({ ...previous, previewUrl }))}
/>
setForm((previous) => ({ ...previous, animationUrl }))}
onFileSelected={handleProfileCardAnimationSelected}
/>
{profileCardLayout ? (
) : null}
setForm({ ...form, enabled: checked })}
/>
);
}
function ResourceIdentity({ resource }) {
const preview = imageURL(resource.previewUrl || resource.assetUrl);
return (
{preview ?
: }
{resource.name || "-"}
{resource.resourceCode || resource.resourceId}
);
}
function ResourceStatusSwitch({ page, resource }) {
const checked = resource.status === "active";
return (
page.toggleResource(resource, event.target.checked)}
/>
);
}
function resourcePriceLabel(resource) {
if (resource.priceType === "free") {
return resourcePriceTypeLabels.free;
}
if (resource.priceType === "coin") {
return `金币 ${formatNumber(resource.coinPrice)}`;
}
return "未配置";
}
function badgeFormLabel(resource) {
if (resource.resourceType !== "badge") {
return "-";
}
const formLabel = badgeFormLabels[resource.badgeForm || badgeFormFromMetadata(resource.metadataJson)] || "未配置";
const kind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson) || "normal";
const kindLabel = badgeKindLabels[kind] || badgeKindLabels.normal;
const track = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
const trackLabel = kind === "level" && track ? badgeLevelTrackLabels[track] : "";
return [formLabel, kindLabel, trackLabel].filter(Boolean).join(" · ");
}
function badgeFormFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const badgeForm = String(metadata?.badge_form || "")
.trim()
.toLowerCase();
return badgeForm === "strip" || badgeForm === "tile" ? badgeForm : "";
} catch {
return "";
}
}
function badgeKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
return String(metadata?.badge_kind || "").trim().toLowerCase() === "level" ? "level" : "normal";
} catch {
return "";
}
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const levelTrack = String(metadata?.level_track || "")
.trim()
.toLowerCase();
return levelTrack === "wealth" || levelTrack === "game" || levelTrack === "charm" ? levelTrack : "";
} catch {
return "";
}
}
function imageURL(value) {
const url = String(value || "").trim();
if (!url) {
return "";
}
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}