131 lines
12 KiB
TypeScript
131 lines
12 KiB
TypeScript
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
|
|
|
|
export type ReactLike = {
|
|
createElement: (...args: any[]) => any;
|
|
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
|
|
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
|
|
};
|
|
|
|
export type SCUMPageContext = {
|
|
pageKey?: string;
|
|
pageTitle?: string;
|
|
serverInstanceId?: string;
|
|
permissions: string[];
|
|
availability: { available: boolean; reason?: string };
|
|
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
|
|
workspaceActions?: SCUMWorkspaceActions;
|
|
};
|
|
|
|
type SCUMWorkspaceActions = {
|
|
listSCUMPlayers?: () => Promise<unknown>;
|
|
listSCUMSquads?: () => Promise<unknown>;
|
|
listSCUMSquadMembers?: () => Promise<unknown>;
|
|
listSCUMVehicles?: () => Promise<unknown>;
|
|
listSCUMFlags?: () => Promise<unknown>;
|
|
listSCUMPositions?: () => Promise<unknown>;
|
|
};
|
|
|
|
type RecordMap = Record<string, unknown>;
|
|
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
|
|
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[] };
|
|
|
|
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [] };
|
|
|
|
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
|
|
const e = react.createElement;
|
|
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
|
|
const pageKey = normalizePageKey(input.pageKey);
|
|
|
|
const refresh = () => {
|
|
const actions = input.workspaceActions;
|
|
if (!input.serverInstanceId || !actions) {
|
|
setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取本地 SCUM 记录。" });
|
|
return;
|
|
}
|
|
setState({ status: "loading" });
|
|
void Promise.all([
|
|
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles),
|
|
safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions)
|
|
]).then(([players, squads, members, vehicles, flags, positions]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions } }))
|
|
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 本地记录读取失败。" }));
|
|
};
|
|
|
|
if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]);
|
|
|
|
const data = state.status === "ready" ? state.data : emptyData;
|
|
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
|
|
e("div", { className: "panel-header" },
|
|
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
|
|
e("div", { className: "console-row-actions" },
|
|
e("span", { className: "page-status" }, input.availability.available ? "本地数据通道可读" : input.availability.reason ?? "等待本地数据通道"),
|
|
e("button", { type: "button", className: "icon-command", onClick: refresh }, "重试本地读取")
|
|
)
|
|
),
|
|
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 记录…") : null,
|
|
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
|
|
state.status === "ready" ? renderSurfaceBody(e, pageKey, data) : null
|
|
);
|
|
}
|
|
|
|
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData) {
|
|
switch (pageKey) {
|
|
case "players": return playersSurface(e, data);
|
|
case "squads": return squadsSurface(e, data);
|
|
case "live-map": return mapSurface(e, data);
|
|
case "gifts": return giftsSurface(e, data);
|
|
default: return playersSurface(e, data);
|
|
}
|
|
}
|
|
|
|
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
|
return e("div", { className: "console-record-list" },
|
|
statsStrip(e, [["玩家记录", data.players.length], ["当前在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标记录", data.positions.length]]),
|
|
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
|
|
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
|
|
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, sourceStatus(player))),
|
|
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`)
|
|
)) : e("p", { className: "page-status" }, "暂无玩家记录。登录日志或已完成的本地同步产生记录后会显示。")
|
|
);
|
|
}
|
|
|
|
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
|
return e("div", { className: "overview-two-col" },
|
|
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, sourceStatus(squad)]),
|
|
tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", sourceStatus(item)])
|
|
);
|
|
}
|
|
|
|
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
|
const overlays = [...data.positions.filter(hasUsableCoordinate), ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(hasUsableCoordinate), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(hasUsableCoordinate)];
|
|
return e("div", { className: "console-record-list" },
|
|
statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
|
|
overlays.length ? e("div", { className: "map-local-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-local-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))) : e("p", { className: "page-status" }, "暂无可渲染坐标。地图只显示已验证且位于边界内的坐标。"),
|
|
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), sourceStatus(point)])
|
|
);
|
|
}
|
|
|
|
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
|
return e("div", { className: "console-record-list" },
|
|
statsStrip(e, [["可选玩家", data.players.length], ["礼包包", 0], ["发放记录", 0]]),
|
|
e("p", { className: "page-status" }, "礼包包和发放记录会从本地礼包接口读取;在接口启用前不展示固定礼包或模拟发放。")
|
|
);
|
|
}
|
|
|
|
function normalizePageKey(pageKey?: string): string { return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "players" ? pageKey : "players"; }
|
|
function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : "用户管理"; }
|
|
function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标只来自已验证的本地当前数据。" : pageKey === "gifts" ? "礼包包、资格和发放历史只来自本地礼包记录。" : pageKey === "squads" ? "队伍、成员和旗帜关系只显示已验证的当前服务事实。" : "玩家列表由登录日志和已验证同步结果创建,不显示样例数据。"; }
|
|
|
|
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("dl", { className: "console-stat-strip" }, items.map(([label, value]) => e("div", { key: label, className: "server-card-stat" }, e("dt", null, label), e("dd", null, String(value))))); }
|
|
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无本地记录。"))); }
|
|
function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x")); const y = Number(field(point, "Y", "y")); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
|
|
function safeList(fn?: () => Promise<unknown>): Promise<RecordMap[]> { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); }
|
|
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
|
|
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
|
|
function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); }
|
|
function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; }
|
|
function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); }
|
|
function idOf(row: RecordMap): string { return textField(row, "ID", "id", "GamePlayerID", "gamePlayerId", "SquadID", "squadId", "VehicleID", "vehicleId", "FlagID", "flagId", "StepKey", "stepKey") || Math.random().toString(36).slice(2); }
|
|
function sourceStatus(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || textField(row, "CollectedAt", "collectedAt") || "unknown"; }
|
|
function coords(row?: RecordMap): string { if (!row) return "坐标 unknown"; const ok = field(row, "HasCoordinates", "hasCoordinates"); return ok === false ? "坐标 unknown" : `X ${numField(row, "X", "x")} / Y ${numField(row, "Y", "y")} / Z ${numField(row, "Z", "z")}`; }
|
|
function hasUsableCoordinate(row: RecordMap | undefined): row is RecordMap { if (!row) return false; if (field(row, "HasCoordinates", "hasCoordinates") === false) return false; const x = Number(field(row, "X", "x")); const y = Number(field(row, "Y", "y")); return Number.isFinite(x) && Number.isFinite(y); }
|