2026-06-04 11:04:48 +08:00

729 lines
29 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 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) => <ResourceIdentity resource={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) => <ResourceStatusSwitch page={page} resource={resource} />,
}
: column.key === "actions"
? {
...column,
render: (resource) => <ResourceRowActions page={page} resource={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 (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreate ? (
<>
<AdminActionIconButton
disabled={createDisabled}
label="批量上传资源"
onClick={openBatchDialog}
>
<FileUploadOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton label="添加资源" primary onClick={page.openCreateResource}>
<Add fontSize="small" />
</AdminActionIconButton>
</>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="940px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(resource) => resource.resourceId}
/>
</AdminListBody>
</DataState>
<ResourceFormDialog
disabled={page.activeAction === "edit" ? !page.abilities.canUpdate : createDisabled}
form={page.form}
loading={
page.activeAction === "edit"
? page.loadingAction === "resource-edit"
: page.loadingAction === "resource-create"
}
mode={page.activeAction === "edit" ? "edit" : "create"}
open={page.activeAction === "create" || page.activeAction === "edit"}
setForm={page.setForm}
uploadDisabled={!page.abilities.canUpload}
onClose={page.closeAction}
onSubmit={page.submitResource}
/>
<ResourceBatchUploadDialog
disabled={createDisabled}
fileInputRef={folderInputRef}
open={batchOpen}
plan={batchPlan}
progress={batchProgress}
onClose={closeBatchDialog}
onFileChange={handleFolderChange}
onSubmit={submitBatchUpload}
/>
</AdminListPage>
);
}
function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChange, onSubmit, open, plan, progress }) {
const submitDisabled = disabled || progress.running || !plan.resources.length;
return (
<AdminFormDialog
loading={progress.running}
open={open}
size="wide"
submitDisabled={submitDisabled}
submitLabel="开始上传"
title="批量上传资源"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection
actions={
<div className={styles.batchActions}>
<Tooltip arrow placement="top" title={<ResourceBatchRules />}>
<span className={styles.batchHelp} aria-label="文件规则" role="button" tabIndex={0}>
<HelpOutlineOutlined fontSize="small" />
</span>
</Tooltip>
<button
className={styles.batchPickButton}
disabled={disabled || progress.running}
type="button"
onClick={() => fileInputRef.current?.click()}
>
<FileUploadOutlined fontSize="small" />
选择文件夹
</button>
</div>
}
title="文件夹资源"
>
<input
ref={fileInputRef}
className={styles.batchInput}
directory=""
multiple
type="file"
webkitdirectory=""
onChange={onFileChange}
/>
{progress.running ? (
<div className={styles.batchProgress}>
<CircularProgress color="inherit" size={16} />
{progress.label}
</div>
) : null}
<ResourceBatchPreview resources={plan.resources} />
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceBatchRules() {
return (
<div className={styles.batchRules}>
<div>只读取符合命名规则且同时存在 cover/animation 的资源其他文件会被忽略</div>
<div>头像框_名称_价格_天数_cover / animation</div>
<div>坐骑_名称_价格_天数_cover / animation</div>
<div>礼物_名称_价格_cover / animation</div>
<div>气泡_名称_cover / animation</div>
<div>勋章_名称_长_cover / animation</div>
<div>飘窗_名称_cover / animation</div>
<div>mic声波_名称_cover / animation</div>
<div>背景卡_名称_cover / animation</div>
<div>天数字段会忽略勋章不写时按短徽章处理</div>
<div>服务端单批最多上传 10 个素材前端会自动分批串行上传</div>
</div>
);
}
function ResourceBatchPreview({ resources }) {
if (!resources.length) {
return <div className={styles.batchEmpty}>尚未选择资源文件夹</div>;
}
return (
<div className={styles.batchTable}>
<div className={styles.batchTableHead}>
<span>资源</span>
<span>类型</span>
<span>价格</span>
<span>素材</span>
</div>
{resources.map((resource) => (
<div className={styles.batchTableRow} key={resource.resourceCode}>
<span>{resource.name}</span>
<span>{resourceTypeLabels[resource.resourceType] || resource.resourceType}</span>
<span>{resource.price > 0 ? `金币 ${resource.price}` : "免费"}</span>
<span>
{resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation}
</span>
</div>
))}
</div>
);
}
function ResourceRowActions({ page, resource }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑资源"
onClick={() => page.openEditResource(resource)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
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 (
<AdminFormDialog
loading={loading}
open={open}
submitDisabled={submitDisabled}
title={mode === "edit" ? "编辑资源" : "添加资源"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="资源名称"
required
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
<TextField
disabled={disabled}
label="资源编码"
required
value={form.resourceCode}
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="资源类型"
required
select
value={form.resourceType}
onChange={(event) => {
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]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.resourceType === "coin" ? (
<TextField
disabled={disabled}
label="金币数量"
required
type="number"
value={form.walletAssetAmount}
onChange={(event) => setForm({ ...form, walletAssetAmount: event.target.value })}
/>
) : null}
{form.resourceType === "badge" ? (
<>
<TextField
disabled={disabled}
label="徽章属性"
required
select
value={form.badgeForm}
onChange={(event) => setForm({ ...form, badgeForm: event.target.value })}
>
{badgeFormOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="徽章类型"
required
select
value={form.badgeKind}
onChange={(event) =>
setForm({
...form,
badgeKind: event.target.value,
levelTrack: event.target.value === "level" ? form.levelTrack : "",
})
}
>
{badgeKindOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.badgeKind === "level" ? (
<TextField
disabled={disabled}
label="所属等级"
required
select
value={form.levelTrack}
onChange={(event) => setForm({ ...form, levelTrack: event.target.value })}
>
{badgeLevelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : null}
</>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="价格类型"
required
select
value={form.priceType}
onChange={(event) =>
setForm({
...form,
coinPrice: event.target.value === "free" ? "0" : form.coinPrice,
priceType: event.target.value,
})
}
>
{resourcePriceTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled || form.priceType === "free"}
label="价格"
required={form.priceType === "coin"}
type="number"
value={form.priceType === "free" ? "0" : form.coinPrice}
onChange={(event) => setForm({ ...form, coinPrice: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="素材">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
disabled={disabled || uploadDisabled}
kind="file"
label="资源封面"
value={form.previewUrl}
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
/>
<UploadField
disabled={disabled || uploadDisabled}
kind="file"
label="动效素材"
value={form.animationUrl}
onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))}
onFileSelected={handleProfileCardAnimationSelected}
/>
</AdminFormFieldGrid>
{profileCardLayout ? (
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField disabled label="content_top" value={String(profileCardLayout.content_top)} />
<TextField disabled label="内容高度" value={String(profileCardLayout.content_height)} />
</AdminFormFieldGrid>
) : null}
</AdminFormSection>
<AdminFormSection title="状态">
<AdminFormSwitchField
checked={form.enabled}
checkedLabel="启用"
disabled={disabled}
label="资源状态"
uncheckedLabel="禁用"
onChange={(checked) => setForm({ ...form, enabled: checked })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceIdentity({ resource }) {
const preview = imageURL(resource.previewUrl || resource.assetUrl);
return (
<div className={styles.identity}>
<span className={styles.thumb}>
{preview ? <img src={preview} alt="" /> : <Inventory2Outlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.name}>{resource.name || "-"}</span>
<span className={styles.meta}>{resource.resourceCode || resource.resourceId}</span>
</div>
</div>
);
}
function ResourceStatusSwitch({ page, resource }) {
const checked = resource.status === "active";
return (
<AdminSwitch
checked={checked}
disabled={!page.abilities.canUpdate || page.loadingAction === `resource-status-${resource.resourceId}`}
inputProps={{ "aria-label": checked ? "禁用资源" : "启用资源" }}
onChange={(event) => 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");
}