diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index e336b77..a9700f1 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -48,6 +48,17 @@ const defaultPlatformForm = { sortOrder: 0, }; +const defaultBridgeScriptForm = { + bridgeScriptUrl: "", + bridgeScriptVersion: "", + bridgeScriptSha256: "", +}; + +const defaultBridgeHashState = { + loading: false, + error: "", +}; + const emptyCatalog = { items: [], pageSize: 50 }; const emptyPlatforms = { items: [] }; const defaultPageSize = 50; @@ -69,6 +80,8 @@ export function useGamesPage() { const [editingPlatformCode, setEditingPlatformCode] = useState(""); const [gameForm, setGameForm] = useState(defaultGameForm); const [platformForm, setPlatformForm] = useState(defaultPlatformForm); + const [bridgeScriptForm, setBridgeScriptForm] = useState(defaultBridgeScriptForm); + const [bridgeHashState, setBridgeHashState] = useState(defaultBridgeHashState); const [loadingAction, setLoadingAction] = useState(""); const [syncPlatform, setSyncPlatform] = useState(null); const [syncCandidates, setSyncCandidates] = useState([]); @@ -104,7 +117,9 @@ export function useGamesPage() { return result; } setCatalogMeta(result); - setCatalogItems((current) => (append ? mergeCatalogItems(current, result.items || []) : result.items || [])); + setCatalogItems((current) => + append ? mergeCatalogItems(current, result.items || []) : result.items || [], + ); return result; } catch (err) { if (catalogRequestRef.current === requestId) { @@ -200,6 +215,12 @@ export function useGamesPage() { setActiveAction("manage-platforms"); }; + const openBridgeScript = () => { + setBridgeScriptForm(bridgeScriptFormFromPlatforms(platformData.items || [])); + setBridgeHashState(defaultBridgeHashState); + setActiveAction("bridge-script"); + }; + const openEditPlatform = (platform) => { setEditingPlatformCode(platform.platformCode); setPlatformForm(platformToForm(platform)); @@ -212,11 +233,80 @@ export function useGamesPage() { setSyncCandidates([]); setSelectedSyncGameIds([]); } + if (activeAction === "bridge-script") { + setBridgeScriptForm(defaultBridgeScriptForm); + setBridgeHashState(defaultBridgeHashState); + } setActiveAction(""); setEditingGameId(""); setEditingPlatformCode(""); }; + const refreshBridgeScriptHash = useCallback(async () => { + const url = bridgeScriptForm.bridgeScriptUrl.trim(); + if (activeAction !== "bridge-script" || url === "") { + return ""; + } + setBridgeHashState({ loading: true, error: "" }); + try { + const hash = await calculateBridgeScriptSha256(url); + setBridgeScriptForm((current) => { + if (current.bridgeScriptUrl.trim() !== url) { + return current; + } + return { ...current, bridgeScriptSha256: hash }; + }); + setBridgeHashState(defaultBridgeHashState); + return hash; + } catch (err) { + const message = err.message || "生成 SHA256 失败"; + setBridgeHashState({ loading: false, error: message }); + showToast(message, "error"); + return ""; + } + }, [activeAction, bridgeScriptForm.bridgeScriptUrl, showToast]); + + useEffect(() => { + if (activeAction !== "bridge-script") { + return undefined; + } + const url = bridgeScriptForm.bridgeScriptUrl.trim(); + const version = bridgeScriptForm.bridgeScriptVersion.trim(); + if (!url || !version) { + setBridgeScriptForm((current) => + current.bridgeScriptSha256 ? { ...current, bridgeScriptSha256: "" } : current, + ); + setBridgeHashState(defaultBridgeHashState); + return undefined; + } + let cancelled = false; + const timer = window.setTimeout(async () => { + setBridgeHashState({ loading: true, error: "" }); + try { + const hash = await calculateBridgeScriptSha256(url); + if (cancelled) { + return; + } + setBridgeScriptForm((current) => { + if (current.bridgeScriptUrl.trim() !== url || current.bridgeScriptVersion.trim() !== version) { + return current; + } + return { ...current, bridgeScriptSha256: hash }; + }); + setBridgeHashState(defaultBridgeHashState); + } catch (err) { + if (cancelled) { + return; + } + setBridgeHashState({ loading: false, error: err.message || "生成 SHA256 失败" }); + } + }, 500); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [activeAction, bridgeScriptForm.bridgeScriptUrl, bridgeScriptForm.bridgeScriptVersion]); + const submitGame = async (event) => { event.preventDefault(); const payload = gamePayload(gameForm); @@ -258,6 +348,54 @@ export function useGamesPage() { } }; + const submitBridgeScript = async (event) => { + event.preventDefault(); + const platforms = platformData.items || []; + if (platforms.length === 0) { + showToast("暂无游戏平台可写入", "error"); + return; + } + const bridgeScriptUrl = bridgeScriptForm.bridgeScriptUrl.trim(); + const bridgeScriptVersion = bridgeScriptForm.bridgeScriptVersion.trim(); + if (!bridgeScriptUrl || !bridgeScriptVersion) { + showToast("请填写 bridge_script_url 和 bridge_script_version", "error"); + return; + } + let bridgeScriptSha256 = bridgeScriptForm.bridgeScriptSha256.trim(); + if (!bridgeScriptSha256) { + bridgeScriptSha256 = await refreshBridgeScriptHash(); + } + if (!bridgeScriptSha256) { + return; + } + setLoadingAction("bridge-script"); + try { + await Promise.all( + platforms.map((platform) => { + const config = parseJsonObject(platform.adapterConfigJson); + config.bridge_script_url = bridgeScriptUrl; + config.bridge_script_version = bridgeScriptVersion; + config.bridge_script_sha256 = bridgeScriptSha256; + delete config.bridgeScriptUrl; + delete config.bridgeScriptVersion; + delete config.bridgeScriptSha256; + return upsertGamePlatform( + platformUpsertPayload(platform, JSON.stringify(config, null, 2)), + platform.platformCode, + ); + }), + ); + await reloadPlatforms(); + await reloadCatalog(); + showToast(`JS 桥接配置已同步 ${platforms.length} 个平台`, "success"); + closeAction(); + } catch (err) { + showToast(err.message || "保存 JS 桥接配置失败", "error"); + } finally { + setLoadingAction(""); + } + }; + const changeGameStatus = async (game, checked) => { const nextStatus = checked ? "active" : "maintenance"; setLoadingAction(`status-${game.gameId}`); @@ -380,6 +518,8 @@ export function useGamesPage() { abilities, activeAction, allSyncGamesSelected, + bridgeHashState, + bridgeScriptForm, changeGameStatus, deleteGame, closeAction, @@ -400,6 +540,7 @@ export function useGamesPage() { importSingleSyncGame, openCreateGame, openCreatePlatform, + openBridgeScript, openEditGame, openEditPlatform, openManagePlatforms, @@ -409,15 +550,18 @@ export function useGamesPage() { platformForm, platformOptions, loadNextPage, + refreshBridgeScriptHash, reload: reloadCatalog, resetFilters, resolveGameURL, changePageSize, setGameForm, + setBridgeScriptForm, setPlatformCode: changePlatformCode, setPlatformForm, setStatus: changeStatus, status, + submitBridgeScript, submitGame, submitPlatform, syncCandidates, @@ -440,6 +584,66 @@ function mergeCatalogItems(current, nextItems) { }); } +function bridgeScriptFormFromPlatform(platform) { + if (!platform) { + return defaultBridgeScriptForm; + } + const config = parseJsonObject(platform.adapterConfigJson); + return { + bridgeScriptUrl: readConfigString(config.bridge_script_url ?? config.bridgeScriptUrl), + bridgeScriptVersion: readConfigString(config.bridge_script_version ?? config.bridgeScriptVersion), + bridgeScriptSha256: readConfigString( + config.bridge_script_sha256 ?? config.bridgeScriptSha256 ?? config.bridge_script_hash, + ), + }; +} + +function bridgeScriptFormFromPlatforms(platforms) { + for (const platform of platforms) { + const form = bridgeScriptFormFromPlatform(platform); + if (form.bridgeScriptUrl || form.bridgeScriptVersion || form.bridgeScriptSha256) { + return form; + } + } + return defaultBridgeScriptForm; +} + +function readConfigString(value) { + if (value === null || value === undefined) { + return ""; + } + return String(value).trim(); +} + +function platformUpsertPayload(platform, adapterConfigJson) { + return { + platformCode: platform.platformCode, + platformName: platform.platformName, + status: platform.status, + apiBaseUrl: platform.apiBaseUrl, + adapterType: platform.adapterType, + callbackSecret: "", + callbackIpWhitelist: platform.callbackIpWhitelist || [], + adapterConfigJson, + sortOrder: Number(platform.sortOrder || 0), + }; +} + +async function calculateBridgeScriptSha256(url) { + if (!window.crypto?.subtle) { + throw new Error("当前浏览器不支持 SHA256 计算"); + } + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`拉取 JS 失败:HTTP ${response.status}`); + } + const buffer = await response.arrayBuffer(); + const digest = await window.crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + async function saveGameUrl(platforms, game, gameUrl) { const platform = platforms.find((item) => item.platformCode === game.platformCode); if (!platform) { @@ -452,9 +656,10 @@ async function saveGameUrl(platforms, game, gameUrl) { } const config = parseJsonObject(platform.adapterConfigJson); - const gameURLs = config.game_urls && typeof config.game_urls === "object" && !Array.isArray(config.game_urls) - ? { ...config.game_urls } - : {}; + const gameURLs = + config.game_urls && typeof config.game_urls === "object" && !Array.isArray(config.game_urls) + ? { ...config.game_urls } + : {}; const keys = gameUrlKeys(game); for (const key of keys) { delete gameURLs[key]; @@ -468,20 +673,7 @@ async function saveGameUrl(platforms, game, gameUrl) { delete config.game_urls; } - await upsertGamePlatform( - { - platformCode: platform.platformCode, - platformName: platform.platformName, - status: platform.status, - apiBaseUrl: platform.apiBaseUrl, - adapterType: platform.adapterType, - callbackSecret: "", - callbackIpWhitelist: platform.callbackIpWhitelist || [], - adapterConfigJson: JSON.stringify(config, null, 2), - sortOrder: Number(platform.sortOrder || 0), - }, - platform.platformCode, - ); + await upsertGamePlatform(platformUpsertPayload(platform, JSON.stringify(config, null, 2)), platform.platformCode); } function gameToForm(game, platforms = []) { diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 176cd2d..5cb78d3 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -1,4 +1,5 @@ import Add from "@mui/icons-material/Add"; +import CodeOutlined from "@mui/icons-material/CodeOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; @@ -148,6 +149,11 @@ export function GameListPage() { + {page.abilities.canUpdate ? ( + + + + ) : null} {page.abilities.canUpdate ? ( @@ -209,6 +215,14 @@ export function GameListPage() { onClose={page.closeAction} onSubmit={page.submitPlatform} /> + + + {hashState.loading ? "生成中..." : "重新生成 SHA256"} + + } + > + + + page.setBridgeScriptForm({ ...form, bridgeScriptVersion: event.target.value }) + } + /> + page.setBridgeScriptForm({ ...form, bridgeScriptUrl: event.target.value })} + /> + + page.setBridgeScriptForm({ ...form, bridgeScriptSha256: event.target.value }) + } + /> + + + + ); +} + function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { const isLeaderCC = form.adapterType === "leadercc_v1"; return ( diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 37b4bc3..0b10654 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -51,6 +51,7 @@ test("resource APIs use generated admin paths", async () => { badgeKind: "level", coinPrice: 10, levelTrack: "wealth", + managerGrantEnabled: true, metadataJson: '{"profile_card_layout":{"content_top":117,"content_height":1550}}', name: "VIP Badge", previewUrl: "https://media.haiyihy.com/resource/cover.png", @@ -66,6 +67,7 @@ test("resource APIs use generated admin paths", async () => { badgeForm: "tile", badgeKind: "normal", coinPrice: 20, + managerGrantEnabled: false, metadataJson: '{"profile_card_layout":{"content_top":120,"content_height":1500}}', name: "VIP Badge Updated", previewUrl: "https://media.haiyihy.com/resource/cover-updated.png", diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index c4d14eb..30b3633 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -10,6 +10,7 @@ export interface ResourceDto { name?: string; status?: string; grantable?: boolean; + managerGrantEnabled?: boolean; grantStrategy?: string; walletAssetType?: string; walletAssetAmount?: number; @@ -36,6 +37,7 @@ export interface ResourcePayload { badgeKind?: string; coinPrice: number; levelTrack?: string; + managerGrantEnabled: boolean; metadataJson?: string; name: string; previewUrl: string; diff --git a/src/features/resources/batchUpload.js b/src/features/resources/batchUpload.js index 1feabc6..b59f61e 100644 --- a/src/features/resources/batchUpload.js +++ b/src/features/resources/batchUpload.js @@ -108,6 +108,7 @@ export function resourcePlanToPayload(item) { badgeForm: item.resourceType === "badge" ? item.badgeForm || "tile" : undefined, badgeKind: item.resourceType === "badge" ? "normal" : undefined, coinPrice, + managerGrantEnabled: true, metadataJson: item.resourceType === "profile_card" ? profileCardMetadataJSON(item.metadataJson) : undefined, name: item.name, previewUrl: item.coverUrl, diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index d10f865..67e1039 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -74,6 +74,10 @@ const emptyResourceForm = (resource = {}) => ({ coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "", enabled: resource.status ? resource.status === "active" : true, levelTrack: resource.resourceType === "badge" ? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || "" : "", + managerGrantEnabled: + resource.managerGrantEnabled === undefined || resource.managerGrantEnabled === null + ? true + : Boolean(resource.managerGrantEnabled), metadataJson: resource.metadataJson || "", name: resource.name || "", previewUrl: resource.previewUrl || "", @@ -384,6 +388,27 @@ export function useResourceListPage() { ); }; + const toggleManagerGrant = async (resource, nextEnabled = !resource.managerGrantEnabled) => { + if (!abilities.canUpdate || !resource?.resourceId) { + return; + } + if (Boolean(resource.managerGrantEnabled) === nextEnabled) { + return; + } + await runAction( + `resource-manager-grant-${resource.resourceId}`, + nextEnabled ? "经理赠送已开启" : "经理赠送已关闭", + async () => { + const formValue = parseForm(resourceFormSchema, { + ...emptyResourceForm(resource), + managerGrantEnabled: nextEnabled, + }); + await updateResource(resource.resourceId, buildResourcePayload(formValue)); + await result.reload(); + }, + ); + }; + const runAction = async (action, successMessage, submitter) => { setLoadingAction(action); try { @@ -420,6 +445,7 @@ export function useResourceListPage() { setForm, status, submitResource, + toggleManagerGrant, toggleResource, }; } @@ -1479,6 +1505,7 @@ function buildResourcePayload(form) { badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined, coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0, levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined, + managerGrantEnabled: Boolean(form.managerGrantEnabled), metadataJson: resourceType === "profile_card" ? profileCardMetadataJSON(form.metadataJson) : undefined, name: form.name.trim(), previewUrl: form.previewUrl.trim(), diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 34a7efd..687d3d7 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -92,6 +92,11 @@ const baseColumns = [ label: "状态", width: "minmax(92px, 0.55fr)", }, + { + key: "managerGrant", + label: "经理赠送", + width: "minmax(112px, 0.6fr)", + }, { key: "time", label: "更新时间", @@ -146,6 +151,11 @@ export function ResourceListPage() { }), render: (resource) => , } + : column.key === "managerGrant" + ? { + ...column, + render: (resource) => , + } : column.key === "actions" ? { ...column, @@ -281,7 +291,7 @@ export function ResourceListPage() { 0 ? { @@ -656,6 +666,14 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, uncheckedLabel="禁用" onChange={(checked) => setForm({ ...form, enabled: checked })} /> + setForm({ ...form, managerGrantEnabled: checked })} + /> ); @@ -688,6 +706,20 @@ function ResourceStatusSwitch({ page, resource }) { ); } +function ManagerGrantSwitch({ page, resource }) { + const checked = Boolean(resource.managerGrantEnabled); + return ( + page.toggleManagerGrant(resource, event.target.checked)} + /> + ); +} + function resourcePriceLabel(resource) { if (resource.priceType === "free") { return resourcePriceTypeLabels.free; diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index 42ab543..a8bd26f 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -38,6 +38,7 @@ export const resourceCreateFormSchema = z badgeKind: z.enum(badgeKinds).optional(), enabled: z.boolean(), levelTrack: z.enum(badgeLevelTracks).optional().or(z.literal("")), + managerGrantEnabled: z.boolean(), metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(), name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"), coinPrice: z.union([z.string(), z.number()]).optional(), diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index bf2dc18..56f9268 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -15,6 +15,7 @@ describe("resource form schema", () => { animationUrl: "https://media.haiyihy.com/resource/profile-card.pag", badgeForm: "tile", enabled: true, + managerGrantEnabled: true, metadataJson: '{"profile_card_layout":{"source_width":1136,"source_height":1680,"color_content_width":750,"content_top":117,"content_bottom":1666,"content_height":1550,"content_top_ratio":0.069643,"content_height_ratio":0.922619,"detect_version":1}}', name: "Profile Card", @@ -36,6 +37,7 @@ describe("resource form schema", () => { badgeKind: "level", enabled: true, levelTrack: "wealth", + managerGrantEnabled: true, name: "VIP Badge", previewUrl: "https://media.haiyihy.com/resource/badge.png", priceType: "free", @@ -51,6 +53,7 @@ describe("resource form schema", () => { parseForm(resourceFormSchema, { animationUrl: "https://media.haiyihy.com/resource/badge.pag", enabled: true, + managerGrantEnabled: true, name: "VIP Badge", previewUrl: "https://media.haiyihy.com/resource/badge.png", priceType: "free", @@ -65,6 +68,7 @@ describe("resource form schema", () => { badgeForm: "strip", badgeKind: "level", enabled: true, + managerGrantEnabled: true, name: "VIP Badge", previewUrl: "https://media.haiyihy.com/resource/badge.png", priceType: "free",