2026-07-16 11:48:12 +08:00

321 lines
14 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 AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useEffect, useMemo, useRef, useState } from "react";
import {
adapterCatalogRows,
adapterCommonFields,
adapterConfigFieldValue,
adapterDefinition,
formatAdapterConfigJson,
readAdapterConfig,
updateAdapterCatalog,
updateAdapterConfigField,
} from "@/features/games/adapterConfigModel.js";
import { Button } from "@/shared/ui/Button.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import styles from "@/features/games/components/AdapterConfigFields.module.css";
export function AdapterConfigFields({ adapterType, disabled = false, value, onChange }) {
const definition = adapterDefinition(adapterType);
const configValid = Boolean(readAdapterConfig(value));
const visualFieldsDisabled = disabled || !configValid;
return (
<div className={styles.root}>
{!configValid ? <div className={styles.invalid}>请先在高级配置中修正 JSON 格式</div> : null}
<ConfigFieldGroup
disabled={visualFieldsDisabled}
fields={adapterCommonFields()}
title="会话参数"
value={value}
onChange={onChange}
/>
{(definition.fields || []).length > 0 ? (
<ConfigFieldGroup
disabled={visualFieldsDisabled}
fields={definition.fields}
title="厂商参数"
value={value}
onChange={onChange}
/>
) : null}
{definition.catalog ? (
<GameCatalogEditor
adapterType={adapterType}
disabled={visualFieldsDisabled}
value={value}
onChange={onChange}
/>
) : null}
{(definition.advancedFields || []).length > 0 ? (
<details className={styles.details}>
<summary>可选厂商参数</summary>
<ConfigFieldGrid
disabled={visualFieldsDisabled}
fields={definition.advancedFields}
value={value}
onChange={onChange}
/>
</details>
) : null}
<details className={styles.details} open={configValid ? undefined : true}>
<summary>高级配置 · 完整 JSON</summary>
<div className={styles.advancedEditor}>
<JsonEditorField
disabled={disabled}
formatter={formatAdapterConfigJson}
label="完整配置 JSON"
minRows={5}
value={value}
onChange={onChange}
/>
</div>
</details>
</div>
);
}
function ConfigFieldGroup({ disabled, fields, title, value, onChange }) {
return (
<section className={styles.group}>
<div className={styles.groupTitle}>{title}</div>
<ConfigFieldGrid disabled={disabled} fields={fields} value={value} onChange={onChange} />
</section>
);
}
function ConfigFieldGrid({ disabled, fields, value, onChange }) {
return (
<div className={styles.fieldGrid}>
{fields.map((field) => (
<AdapterField
disabled={disabled}
field={field}
key={field.key}
value={adapterConfigFieldValue(value, field)}
onChange={(inputValue) => onChange(updateAdapterConfigField(value, field, inputValue))}
/>
))}
</div>
);
}
function AdapterField({ disabled, field, value, onChange }) {
const className = field.className === "wide" ? styles.wideField : undefined;
const commonProps = {
className,
disabled,
label: field.label,
placeholder: field.placeholder,
required: field.required,
value,
onChange: (event) => onChange(event.target.value),
};
if (field.type === "select" || field.type === "selectNumber" || field.type === "selectBoolean") {
return (
<TextField {...commonProps} select>
{(field.options || []).map(([optionValue, optionLabel]) => (
<MenuItem key={`${field.key}-${optionValue}`} value={optionValue}>
{optionLabel}
</MenuItem>
))}
</TextField>
);
}
return (
<TextField
{...commonProps}
slotProps={{
htmlInput: {
min: field.min,
step: field.type === "integer" ? 1 : undefined,
},
input: field.unit
? { endAdornment: <InputAdornment position="end">{field.unit}</InputAdornment> }
: undefined,
}}
type={field.type === "integer" ? "number" : "text"}
/>
);
}
function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
const definition = adapterDefinition(adapterType);
const externalRows = useMemo(() => adapterCatalogRows(value), [value]);
const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]);
const lastEmittedSignature = useRef(externalSignature);
const adapterTypeRef = useRef(adapterType);
const rowSequence = useRef(externalRows.length);
const [rows, setRows] = useState(() => withRowKeys(externalRows, "initial"));
useEffect(() => {
const adapterChanged = adapterTypeRef.current !== adapterType;
if (adapterChanged || externalSignature !== lastEmittedSignature.current) {
rowSequence.current += externalRows.length;
setRows(withRowKeys(externalRows, `sync-${rowSequence.current}`));
}
adapterTypeRef.current = adapterType;
lastEmittedSignature.current = externalSignature;
}, [adapterType, externalRows, externalSignature]);
const emitRows = (nextRows) => {
setRows(nextRows);
const nextValue = updateAdapterCatalog(value, nextRows);
lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue));
onChange(nextValue);
};
const addRow = () => {
rowSequence.current += 1;
setRows((current) => [
...current,
{
rowKey: `draft-${rowSequence.current}`,
providerGameId: "",
gameName: "",
gameUrl: "",
iconUrl: "",
coverUrl: "",
gameLevelUid: "",
},
]);
};
const changeRow = (rowKey, patch) => {
emitRows(rows.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
};
const removeRow = (rowKey) => {
emitRows(rows.filter((row) => row.rowKey !== rowKey));
};
return (
<section className={styles.catalog}>
<div className={styles.catalogHeader}>
<div className={styles.groupTitle}>游戏目录 · {rows.length}</div>
<Button disabled={disabled} onClick={addRow}>
<AddOutlined fontSize="small" />
添加游戏
</Button>
</div>
{rows.length > 0 ? (
<div className={styles.catalogRows}>
{rows.map((row, index) => {
const duplicate =
row.providerGameId.trim() !== "" &&
rows.some(
(candidate, candidateIndex) =>
candidateIndex !== index &&
candidate.providerGameId.trim() === row.providerGameId.trim(),
);
return (
<div className={styles.catalogRow} key={row.rowKey}>
<div className={styles.catalogMain}>
<TextField
disabled={disabled}
error={duplicate}
helperText={duplicate ? "游戏 ID 重复" : undefined}
label={definition.numericGameID ? "游戏 ID正整数" : "游戏 ID"}
required
slotProps={{
// 重复 ID 无法同时写进 JSON 对象;用原生 pattern 阻止提交,并保留当前行供用户修正。
htmlInput: {
inputMode: definition.numericGameID ? "numeric" : undefined,
pattern: duplicate
? "a^"
: definition.numericGameID
? "[1-9][0-9]*"
: undefined,
},
}}
value={row.providerGameId}
onChange={(event) =>
changeRow(row.rowKey, { providerGameId: event.target.value })
}
/>
<TextField
disabled={disabled}
label="游戏名称"
required={definition.catalogPrimary === "name"}
value={row.gameName}
onChange={(event) => changeRow(row.rowKey, { gameName: event.target.value })}
/>
<TextField
disabled={disabled}
label="H5 URL"
required={definition.catalogPrimary !== "name"}
value={row.gameUrl}
onChange={(event) => changeRow(row.rowKey, { gameUrl: event.target.value })}
/>
<Button
aria-label={`删除第 ${index + 1} 个游戏`}
disabled={disabled}
onClick={() => removeRow(row.rowKey)}
>
<DeleteOutlineOutlined fontSize="small" />
</Button>
</div>
<details className={styles.rowDetails}>
<summary>素材与游戏参数</summary>
<div className={styles.rowExtra}>
<TextField
disabled={disabled}
label="图标 URL"
value={row.iconUrl}
onChange={(event) => changeRow(row.rowKey, { iconUrl: event.target.value })}
/>
<TextField
disabled={disabled}
label="封面 URL"
value={row.coverUrl}
onChange={(event) =>
changeRow(row.rowKey, { coverUrl: event.target.value })
}
/>
{adapterType === "yomi_v4" ? (
<TextField
disabled={disabled}
label="场次 ID"
slotProps={{ htmlInput: { min: 1, step: 1 } }}
type="number"
value={row.gameLevelUid}
onChange={(event) =>
changeRow(row.rowKey, { gameLevelUid: event.target.value })
}
/>
) : null}
</div>
</details>
</div>
);
})}
</div>
) : (
<div className={styles.empty}>当前无游戏目录</div>
)}
</section>
);
}
function withRowKeys(rows, prefix) {
return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` }));
}
function catalogSignature(rows) {
return JSON.stringify(
(rows || []).map(({ providerGameId, gameName, gameUrl, iconUrl, coverUrl, gameLevelUid }) => ({
providerGameId,
gameName,
gameUrl,
iconUrl,
coverUrl,
gameLevelUid,
})),
);
}