321 lines
14 KiB
JavaScript
321 lines
14 KiB
JavaScript
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,
|
||
})),
|
||
);
|
||
}
|