729 lines
29 KiB
JavaScript
729 lines
29 KiB
JavaScript
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");
|
||
}
|