百顺游戏
This commit is contained in:
parent
d06718cc49
commit
f59c374eae
@ -185,7 +185,7 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
|
|||||||
category: stringValue(game.category),
|
category: stringValue(game.category),
|
||||||
iconUrl: stringValue(game.iconUrl),
|
iconUrl: stringValue(game.iconUrl),
|
||||||
coverUrl: stringValue(game.coverUrl),
|
coverUrl: stringValue(game.coverUrl),
|
||||||
launchMode: stringValue(game.launchMode || "h5_popup"),
|
launchMode: normalizeLaunchMode(game.launchMode),
|
||||||
orientation: stringValue(game.orientation || "portrait"),
|
orientation: stringValue(game.orientation || "portrait"),
|
||||||
minCoin: numberValue(game.minCoin),
|
minCoin: numberValue(game.minCoin),
|
||||||
status: stringValue(game.status || "active"),
|
status: stringValue(game.status || "active"),
|
||||||
@ -204,3 +204,8 @@ function numberValue(value: unknown) {
|
|||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number : 0;
|
return Number.isFinite(number) ? number : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLaunchMode(value: unknown) {
|
||||||
|
const mode = stringValue(value);
|
||||||
|
return mode === "half_screen" || mode === "three_quarter_screen" || mode === "full_screen" ? mode : "full_screen";
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/features/games/api";
|
} from "@/features/games/api";
|
||||||
import { useGameAbilities } from "@/features/games/permissions.js";
|
import { useGameAbilities } from "@/features/games/permissions.js";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const defaultGameForm = {
|
const defaultGameForm = {
|
||||||
@ -19,7 +20,7 @@ const defaultGameForm = {
|
|||||||
category: "casual",
|
category: "casual",
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
coverUrl: "",
|
coverUrl: "",
|
||||||
launchMode: "h5_popup",
|
launchMode: "full_screen",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
minCoin: 0,
|
minCoin: 0,
|
||||||
status: "active",
|
status: "active",
|
||||||
@ -201,7 +202,13 @@ export function useGamesPage() {
|
|||||||
|
|
||||||
const submitPlatform = async (event) => {
|
const submitPlatform = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const payload = platformPayload(platformForm);
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = platformPayload(platformForm);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "适配器配置 JSON 不正确", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingAction(activeAction);
|
setLoadingAction(activeAction);
|
||||||
try {
|
try {
|
||||||
await upsertGamePlatform(payload, editingPlatformCode);
|
await upsertGamePlatform(payload, editingPlatformCode);
|
||||||
@ -365,7 +372,7 @@ function gameToForm(game) {
|
|||||||
category: game.category || "casual",
|
category: game.category || "casual",
|
||||||
iconUrl: game.iconUrl || "",
|
iconUrl: game.iconUrl || "",
|
||||||
coverUrl: game.coverUrl || "",
|
coverUrl: game.coverUrl || "",
|
||||||
launchMode: game.launchMode || "h5_popup",
|
launchMode: normalizeLaunchMode(game.launchMode),
|
||||||
orientation: game.orientation || "portrait",
|
orientation: game.orientation || "portrait",
|
||||||
minCoin: Number(game.minCoin || 0),
|
minCoin: Number(game.minCoin || 0),
|
||||||
status: game.status || "active",
|
status: game.status || "active",
|
||||||
@ -399,7 +406,7 @@ function gamePayload(form) {
|
|||||||
category: form.category.trim(),
|
category: form.category.trim(),
|
||||||
iconUrl: form.iconUrl.trim(),
|
iconUrl: form.iconUrl.trim(),
|
||||||
coverUrl: form.coverUrl.trim(),
|
coverUrl: form.coverUrl.trim(),
|
||||||
launchMode: form.launchMode.trim() || "h5_popup",
|
launchMode: normalizeLaunchMode(form.launchMode),
|
||||||
orientation: form.orientation.trim() || "portrait",
|
orientation: form.orientation.trim() || "portrait",
|
||||||
minCoin: Number(form.minCoin || 0),
|
minCoin: Number(form.minCoin || 0),
|
||||||
status: form.status.trim() || "active",
|
status: form.status.trim() || "active",
|
||||||
@ -411,6 +418,14 @@ function gamePayload(form) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLaunchMode(value) {
|
||||||
|
const mode = String(value || "").trim();
|
||||||
|
if (mode === "half_screen" || mode === "three_quarter_screen" || mode === "full_screen") {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return "full_screen";
|
||||||
|
}
|
||||||
|
|
||||||
function platformPayload(form) {
|
function platformPayload(form) {
|
||||||
const isLeaderCC = form.adapterType.trim() === "leadercc_v1";
|
const isLeaderCC = form.adapterType.trim() === "leadercc_v1";
|
||||||
return {
|
return {
|
||||||
@ -428,8 +443,7 @@ function platformPayload(form) {
|
|||||||
.split(/[\n,]/)
|
.split(/[\n,]/)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
// JSON 不在前端强解析,避免前端版本限制后端新增厂商字段。
|
adapterConfigJson: normalizeJsonObjectString(form.adapterConfigJson),
|
||||||
adapterConfigJson: form.adapterConfigJson.trim() || "{}",
|
|
||||||
sortOrder: Number(form.sortOrder || 0),
|
sortOrder: Number(form.sortOrder || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/
|
|||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
@ -35,7 +36,11 @@ const orientationOptions = [
|
|||||||
["portrait", "Portrait"],
|
["portrait", "Portrait"],
|
||||||
["landscape", "Landscape"],
|
["landscape", "Landscape"],
|
||||||
];
|
];
|
||||||
const launchModeOptions = [["h5_popup", "H5 弹窗"]];
|
const launchModeOptions = [
|
||||||
|
["full_screen", "全屏"],
|
||||||
|
["half_screen", "半屏"],
|
||||||
|
["three_quarter_screen", "3/4屏"],
|
||||||
|
];
|
||||||
// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。
|
// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。
|
||||||
const adapterTypeOptions = [
|
const adapterTypeOptions = [
|
||||||
["demo", "Demo"],
|
["demo", "Demo"],
|
||||||
@ -69,7 +74,7 @@ const baseColumns = [
|
|||||||
width: "minmax(150px, 0.75fr)",
|
width: "minmax(150px, 0.75fr)",
|
||||||
render: (game) => (
|
render: (game) => (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{game.launchMode}</span>
|
<span>{launchModeLabel(game.launchMode)}</span>
|
||||||
<span className={styles.meta}>{game.orientation}</span>
|
<span className={styles.meta}>{game.orientation}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -223,14 +228,15 @@ function GameIdentity({ game }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GameStatusSwitch({ page, game }) {
|
function GameStatusSwitch({ page, game }) {
|
||||||
const disabled =
|
const disabled = !page.abilities.canStatus || page.loadingAction === `status-${game.gameId}`;
|
||||||
!page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`;
|
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={game.status === "active"}
|
checked={game.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label={`${game.gameName || game.gameId} 状态`}
|
label={`${game.gameName || game.gameId} 状态`}
|
||||||
size="small"
|
size="small"
|
||||||
|
uncheckedLabel={game.status === "disabled" ? "下架" : "维护"}
|
||||||
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
|
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -252,6 +258,7 @@ function GameRowActions({ page, game }) {
|
|||||||
|
|
||||||
function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||||
const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate;
|
const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate;
|
||||||
|
const gameURL = resolveGameURL(page, form);
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -330,6 +337,14 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="游戏 URL"
|
||||||
|
className={styles.fullField}
|
||||||
|
minRows={2}
|
||||||
|
multiline
|
||||||
|
slotProps={{ input: { readOnly: true } }}
|
||||||
|
value={gameURL || "未配置"}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="最低金币"
|
label="最低金币"
|
||||||
type="number"
|
type="number"
|
||||||
@ -472,13 +487,12 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
|
{/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
|
||||||
<TextField
|
<JsonEditorField
|
||||||
className={styles.fullField}
|
className={styles.fullField}
|
||||||
label="适配器配置 JSON"
|
label="适配器配置 JSON"
|
||||||
minRows={5}
|
minRows={5}
|
||||||
multiline
|
|
||||||
value={form.adapterConfigJson}
|
value={form.adapterConfigJson}
|
||||||
onChange={(event) => page.setPlatformForm({ ...form, adapterConfigJson: event.target.value })}
|
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
|
||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
@ -615,3 +629,32 @@ function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, unc
|
|||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return Number(value || 0).toLocaleString();
|
return Number(value || 0).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function launchModeLabel(value) {
|
||||||
|
return launchModeOptions.find(([mode]) => mode === value)?.[1] || "全屏";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGameURL(page, form) {
|
||||||
|
const platform = (page.platformData.items || []).find((item) => item.platformCode === form.platformCode);
|
||||||
|
if (!platform) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const config = parseAdapterConfig(platform.adapterConfigJson);
|
||||||
|
const gameURLs = config?.game_urls && typeof config.game_urls === "object" ? config.game_urls : {};
|
||||||
|
const candidates = [form.providerGameId, form.gameId, String(form.gameId || "").toLowerCase()].filter(Boolean);
|
||||||
|
for (const key of candidates) {
|
||||||
|
const value = gameURLs[key];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return platform.apiBaseUrl || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdapterConfig(raw) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw || "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,87 +1,87 @@
|
|||||||
import Switch from "@mui/material/Switch";
|
import Switch from "@mui/material/Switch";
|
||||||
|
|
||||||
const baseSwitchSx = {
|
const baseSwitchSx = {
|
||||||
"--switch-width": "40px",
|
"--switch-width": "40px",
|
||||||
"--switch-height": "24px",
|
"--switch-height": "24px",
|
||||||
"--switch-thumb-size": "18px",
|
"--switch-thumb-size": "18px",
|
||||||
"--switch-padding": "3px",
|
"--switch-padding": "3px",
|
||||||
"--switch-translate-x": "16px",
|
"--switch-translate-x": "16px",
|
||||||
margin: 0
|
margin: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function cssContent(value) {
|
function cssContent(value) {
|
||||||
return `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
return `"${String(value ?? "")
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function labeledSwitchSx(checkedLabel, uncheckedLabel) {
|
function labeledSwitchSx(checkedLabel, uncheckedLabel) {
|
||||||
const labelLength = Math.max(Array.from(String(checkedLabel || "")).length, Array.from(String(uncheckedLabel || "")).length);
|
const labelLength = Math.max(
|
||||||
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
|
Array.from(String(checkedLabel || "")).length,
|
||||||
|
Array.from(String(uncheckedLabel || "")).length,
|
||||||
|
);
|
||||||
|
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"--switch-width": `${width}px`,
|
"--switch-width": `${width}px`,
|
||||||
"--switch-height": "26px",
|
"--switch-height": "26px",
|
||||||
"--switch-thumb-size": "20px",
|
"--switch-thumb-size": "20px",
|
||||||
"--switch-padding": "3px",
|
"--switch-padding": "3px",
|
||||||
"--switch-translate-x": `${width - 26}px`,
|
"--switch-translate-x": `${width - 26}px`,
|
||||||
"& .MuiSwitch-track": {
|
"& .MuiSwitch-track": {
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
"&::before, &::after": {
|
"&::before, &::after": {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
fontSize: 10.5,
|
fontSize: 10,
|
||||||
fontWeight: 760,
|
fontWeight: 720,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
pointerEvents: "none",
|
whiteSpace: "nowrap",
|
||||||
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)"
|
pointerEvents: "none",
|
||||||
},
|
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)",
|
||||||
"&::before": {
|
},
|
||||||
right: 26,
|
"&::before": {
|
||||||
left: 7,
|
right: 24,
|
||||||
justifyContent: "flex-start",
|
left: 5,
|
||||||
color: "var(--active-contrast)",
|
justifyContent: "flex-start",
|
||||||
content: cssContent(checkedLabel),
|
color: "var(--active-contrast)",
|
||||||
opacity: 0
|
content: cssContent(checkedLabel),
|
||||||
},
|
opacity: 0,
|
||||||
"&::after": {
|
},
|
||||||
right: 7,
|
"&::after": {
|
||||||
left: 26,
|
right: 5,
|
||||||
justifyContent: "flex-end",
|
left: 24,
|
||||||
color: "var(--text-tertiary)",
|
justifyContent: "flex-end",
|
||||||
content: cssContent(uncheckedLabel),
|
color: "var(--text-tertiary)",
|
||||||
opacity: 1
|
content: cssContent(uncheckedLabel),
|
||||||
}
|
opacity: 1,
|
||||||
},
|
},
|
||||||
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::before": {
|
},
|
||||||
opacity: 1
|
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::before": {
|
||||||
},
|
opacity: 1,
|
||||||
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::after": {
|
},
|
||||||
opacity: 0
|
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::after": {
|
||||||
}
|
opacity: 0,
|
||||||
};
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSwitch({ checkedLabel, inputProps, label, sx, uncheckedLabel, ...props }) {
|
export function AdminSwitch({ checkedLabel, inputProps, label, sx, uncheckedLabel, ...props }) {
|
||||||
const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel);
|
const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel);
|
||||||
const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null;
|
const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null;
|
||||||
const mergedSx = Array.isArray(sx)
|
const mergedSx = Array.isArray(sx)
|
||||||
? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean)
|
? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean)
|
||||||
: [baseSwitchSx, switchLabelSx, sx].filter(Boolean);
|
: [baseSwitchSx, switchLabelSx, sx].filter(Boolean);
|
||||||
const mergedInputProps = label
|
const mergedInputProps = label
|
||||||
? {
|
? {
|
||||||
"aria-label": label,
|
"aria-label": label,
|
||||||
...inputProps
|
...inputProps,
|
||||||
}
|
}
|
||||||
: inputProps;
|
: inputProps;
|
||||||
|
|
||||||
return (
|
return <Switch {...props} inputProps={mergedInputProps} sx={mergedSx} />;
|
||||||
<Switch
|
|
||||||
{...props}
|
|
||||||
inputProps={mergedInputProps}
|
|
||||||
sx={mergedSx}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/shared/ui/JsonEditorField.jsx
Normal file
114
src/shared/ui/JsonEditorField.jsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import styles from "@/shared/ui/JsonEditorField.module.css";
|
||||||
|
|
||||||
|
export function JsonEditorField({
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
minRows = 6,
|
||||||
|
onChange,
|
||||||
|
required = false,
|
||||||
|
value,
|
||||||
|
}) {
|
||||||
|
const [validated, setValidated] = useState(false);
|
||||||
|
const validation = useMemo(() => validateJsonObject(value), [value]);
|
||||||
|
const showError = validated && !validation.valid;
|
||||||
|
const helperText = showError
|
||||||
|
? validation.message
|
||||||
|
: validated
|
||||||
|
? "JSON 格式正确"
|
||||||
|
: "支持格式化和校验,内容必须是 JSON 对象";
|
||||||
|
const lineCount = Math.max(1, String(value || "").split("\n").length);
|
||||||
|
|
||||||
|
const formatJson = () => {
|
||||||
|
const result = parseJsonObject(value);
|
||||||
|
setValidated(true);
|
||||||
|
if (!result.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(JSON.stringify(result.value, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.root, className].filter(Boolean).join(" ")}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{required ? <span className={styles.required}>*</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button disabled={disabled} onClick={() => setValidated(true)}>
|
||||||
|
校验 JSON
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled} onClick={formatJson}>
|
||||||
|
格式化 JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={[styles.editor, showError ? styles.editorInvalid : ""].filter(Boolean).join(" ")}>
|
||||||
|
<pre className={styles.gutter} aria-hidden="true">
|
||||||
|
{Array.from({ length: lineCount }, (_, index) => index + 1).join("\n")}
|
||||||
|
</pre>
|
||||||
|
<TextField
|
||||||
|
className={styles.editorField}
|
||||||
|
disabled={disabled}
|
||||||
|
minRows={minRows}
|
||||||
|
multiline
|
||||||
|
slotProps={{
|
||||||
|
input: { disableUnderline: true },
|
||||||
|
htmlInput: {
|
||||||
|
"aria-label": label,
|
||||||
|
autoCapitalize: "off",
|
||||||
|
autoCorrect: "off",
|
||||||
|
spellCheck: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={value}
|
||||||
|
variant="standard"
|
||||||
|
onBlur={() => setValidated(true)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValidated(false);
|
||||||
|
onChange(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[styles.helper, showError ? styles.helperInvalid : validated ? styles.helperValid : ""].join(
|
||||||
|
" ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{helperText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJsonObjectString(raw) {
|
||||||
|
const result = parseJsonObject(raw);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
return JSON.stringify(result.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateJsonObject(raw) {
|
||||||
|
return parseJsonObject(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw) {
|
||||||
|
const value = String(raw || "").trim();
|
||||||
|
if (!value) {
|
||||||
|
return { valid: true, value: {} };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return { valid: false, message: '配置 JSON 必须是对象,例如 {"app_id": 123}' };
|
||||||
|
}
|
||||||
|
return { valid: true, value: parsed };
|
||||||
|
} catch (err) {
|
||||||
|
return { valid: false, message: `JSON 格式错误:${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/shared/ui/JsonEditorField.module.css
Normal file
105
src/shared/ui/JsonEditorField.module.css
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(90deg, var(--bg-card-strong) 0 44px, transparent 44px), var(--bg-card);
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor:focus-within {
|
||||||
|
border-color: var(--primary-border-strong);
|
||||||
|
box-shadow: 0 0 0 3px var(--primary-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorInvalid {
|
||||||
|
border-color: var(--danger-border-strong);
|
||||||
|
box-shadow: 0 0 0 3px var(--danger-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 10px 14px 0;
|
||||||
|
border-right: 1px solid var(--border-soft);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField :global(.MuiInputBase-root) {
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField :global(textarea) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px 14px 14px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorField :global(textarea::placeholder) {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helperInvalid {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helperValid {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
12
src/shared/ui/JsonEditorField.test.jsx
Normal file
12
src/shared/ui/JsonEditorField.test.jsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
|
|
||||||
|
test("normalizes valid json object strings", () => {
|
||||||
|
expect(normalizeJsonObjectString(`{"app_id":307715}`)).toBe(`{"app_id":307715}`);
|
||||||
|
expect(normalizeJsonObjectString("")).toBe("{}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid json and non-object json", () => {
|
||||||
|
expect(() => normalizeJsonObjectString("{")).toThrow("JSON 格式错误");
|
||||||
|
expect(() => normalizeJsonObjectString("[]")).toThrow("配置 JSON 必须是对象");
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user