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]) => ( {label} ))} {form.resourceType === "coin" ? ( setForm({ ...form, walletAssetAmount: event.target.value })} /> ) : null} {form.resourceType === "badge" ? ( <> setForm({ ...form, badgeForm: event.target.value })} > {badgeFormOptions.map(([value, label]) => ( {label} ))} setForm({ ...form, badgeKind: event.target.value, levelTrack: event.target.value === "level" ? form.levelTrack : "", }) } > {badgeKindOptions.map(([value, label]) => ( {label} ))} {form.badgeKind === "level" ? ( setForm({ ...form, levelTrack: event.target.value })} > {badgeLevelTrackOptions.map(([value, label]) => ( {label} ))} ) : null} ) : null} setForm({ ...form, coinPrice: event.target.value === "free" ? "0" : form.coinPrice, priceType: event.target.value, }) } > {resourcePriceTypeOptions.map(([value, label]) => ( {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"); }